//! The five public embeds, described. //! //! `54d7f8cf`. These were the last hand-written `", embed_theme_css(), EMBED_GEOMETRY_CSS, EMBED_TYPOGRAPHY_CSS, LAYOUT_CSS, DOCUMENT_CSS, ) } declare! { /// A cover picture, cropped to its box. /// /// `Fit::Cover` because a cover is a fixed square here and the art it holds /// is any shape: the alternative is letterboxing inside a 40-pixel box, /// which is the art unreadable and the box the wrong colour. /// /// The alt text is empty on purpose. A cover repeats the title beside it, so /// a reader who cannot see the bytes is told nothing by a second copy of the /// name -- which is what [`Image::speaks`](quasi_router::Image::speaks) is /// for. shape cover(url: &str) -> Node; picture url "" { fit Cover; } } /// What an item embed is drawn from. /// /// A view rather than the database row, so a screen can be built in a test /// without a connection. The same split every described screen here makes. pub struct ItemView { /// The item's title. pub title: String, /// The price as the canonical formatter writes it. pub price: String, /// What the buy control says: "Buy" or "Get". pub button_text: String, /// Where the buy control goes, on makenot.work. pub purchase_url: String, /// The cover art, when the item has any. pub cover_image_url: Option, /// Who made it. pub creator_display_name: String, /// Their page, on makenot.work. pub profile_url: String, /// The first 150 characters of the description. pub description_excerpt: String, } impl ItemView { /// Whether there is cover art to draw. /// /// A predicate and a reader rather than an `Option` the description reaches /// into: a guard asks one question and the picture is built either way, so /// the absent case hands [`cover`] an empty source and nothing places it. fn has_cover(&self) -> bool { self.cover_image_url.is_some() } /// The cover art's address, or nothing. fn cover_url(&self) -> &str { self.cover_image_url.as_deref().unwrap_or_default() } } declare! { /// The buy control: a link out to makenot.work. /// /// [`Destination::External`](quasi_router::Destination::External), which is /// what makes it an anchor with `rel="noopener noreferrer"` in the webview /// rather than a button that asks a route. An embed's every control is one /// of these. shape buy(view: &ItemView) -> Act; act &view.button_text to external &view.purchase_url; } declare! { /// The buy button: cover, title, price, and the control, on one line. /// /// The one embed that is genuinely a [`Row`](quasi_router::Row): a compact /// strip where the cover is a thumbnail beside the title rather than the /// card's own picture. Drawn with the `embed-button` body class, which is /// what sizes that thumbnail. /// /// The price is a setting and the other three are parts, which is the /// difference between a short trailing fact and something placed by role. #[must_use] pub shape item_button(view: &ItemView) -> Screen; screen single &view.title { region REGION as Pane { list { row "" { beside Primary include cover(view.cover_url()) when view.has_cover(); beside Primary text &view.title; meta &view.price; beside Actions include buy(view); } } } } } declare! { /// The product card: the cover, what it is, who made it, and the control. /// /// Blocks rather than one row, which is the difference between a card and a /// button. A row is an inline run and its parts share a line by role, so a /// card said as a row would read "coverTitle" with the excerpt and the price /// crushed in beside it. What a card actually is -- a picture, a heading, a /// line about who made it, a paragraph, a price and a control, each on its /// own line -- is a region holding six nodes, every one of which the /// vocabulary already names. #[must_use] pub shape item_card(view: &ItemView) -> Screen; screen single &view.title { region REGION as Pane { include cover(view.cover_url()) when view.has_cover(); section &view.title; link "by {view.creator_display_name}" to external &view.profile_url; text &view.description_excerpt unless view.description_excerpt.is_empty(); text &view.price; include buy(view); } } } declare! { /// The audio player: the card, with the transport in a bespoke region. /// /// **A handover for now, a widget eventually.** A play button, a scrub bar /// and an elapsed readout are a media transport, and the vocabulary names /// none of the three on purpose -- describing playback would put scrub, rate /// and chapters into a core two of the three renderers could only degrade. /// So this screen describes the chrome around the player and leaves the /// player alone, which is exactly what a handover region is for: the fill is /// owed, and a renderer without one should say so rather than draw an empty /// box where the transport goes. /// /// The markup and the script that fills it are [`player_markup`], unchanged /// from the template this replaces. /// /// Who made it is text here and a link on the card, which the template had /// too: the player's chrome is a caption over a control, not a place to send /// somebody else. #[must_use] pub shape item_player(view: &ItemView) -> Screen; screen single &view.title { region REGION as Pane { include cover(view.cover_url()) when view.has_cover(); section &view.title; text "by {view.creator_display_name}"; text &view.price; include buy(view); } region PLAYER_REGION as RegionKind::handover("media-transport") {} } } /// The handover region the transport is mounted in. pub const PLAYER_REGION: &str = "transport"; /// The player document: the described chrome, with the transport mounted. /// /// Its own function rather than [`document`] with an argument, because the /// player is the one embed whose renderer carries a fill and whose head carries /// a second sheet. Both are about the same one thing — the island this screen /// deliberately does not describe — so they are named together. #[must_use] pub fn player_document(view: &ItemView, preview_url: &str) -> String { let shell = Shell::default() .without_htmx() .without_hyperscript() .without_clock() .without_fill() .without_reveal() .without_repeat() .without_copy() .without_menu() .without_outline() .with_chrome(Chrome::new()) .with_head_first(format!("{}", head_first())); Webview::new() .with_shell(shell) .with_fill(PLAYER_REGION, player_markup(preview_url)) .screen(&item_player(view).documented(Document::default().classed("embed-player"))) } /// The player island, and the script that drives it. /// /// Verbatim from `templates/embed/item_player.html`, which is the whole point of /// a bespoke region: the behaviour is already implemented once and tested, and /// converting the page around it must not rewrite it. The classes are this /// host's own and are styled by [`PLAYER_CSS`]. /// /// `preview_url` is the one value from outside, and it is escaped here: a /// bespoke fill is markup and nothing downstream escapes it. #[must_use] pub fn player_markup(preview_url: &str) -> String { format!( r#"
0:00
Preview "#, crate::helpers::escape_html(preview_url) ) } /// The transport's own rules, which are about a control the design system does /// not name. /// /// Kept out of [`DOCUMENT_CSS`] because it applies to one embed, and kept in /// this crate because the markup it styles is this crate's. Colour is tokens /// throughout, the same rule the rest of the document keeps. pub const PLAYER_CSS: &str = "\ .transport { display: flex; align-items: center; gap: var(--step-base); } .play-btn { width: 32px; height: 32px; border-radius: 50%; background: var(--action); color: var(--content-on-action); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex: none; } .play-btn:hover { background: var(--action-hover); } .progress-bar { flex: 1; height: 4px; background: var(--surface-sunken); border-radius: 2px; cursor: pointer; position: relative; } .progress-fill { height: 100%; background: var(--action); border-radius: 2px; width: 0%; } .time { font-family: var(--font-mono); color: var(--content-muted); white-space: nowrap; } .preview-label { color: var(--content-muted); } "; /// What a project embed is drawn from. pub struct ProjectView { /// The project's title. pub title: String, /// Who made it. pub creator_display_name: String, /// Their page, on makenot.work. pub profile_url: String, /// The project's page, on makenot.work. pub project_url: String, /// The cover art, when the project has any. pub cover_image_url: Option, /// The first 150 characters of the description. pub description_excerpt: String, /// How many items it holds. pub item_count: usize, /// What kind of project it is. pub category_label: String, } impl ProjectView { /// Whether there is cover art to draw. See [`ItemView::has_cover`]. fn has_cover(&self) -> bool { self.cover_image_url.is_some() } /// The cover art's address, or nothing. fn cover_url(&self) -> &str { self.cover_image_url.as_deref().unwrap_or_default() } /// "item" or "items", for the count line. fn items_word(&self) -> &'static str { if self.item_count == 1 { "item" } else { "items" } } } declare! { /// The project card: [`item_card`]'s shape, about a project. /// /// The count and the kind read together and neither stands on its own, so /// they are one line rather than two nodes. #[must_use] pub shape project_card(view: &ProjectView) -> Screen; screen single &view.title { region REGION as Pane { include cover(view.cover_url()) when view.has_cover(); section &view.title; link "by {view.creator_display_name}" to external &view.profile_url; text &view.description_excerpt unless view.description_excerpt.is_empty(); text "{view.item_count} {view.items_word()} \u{b7} {view.category_label}"; act "View project" to external &view.project_url; } } } /// What a tip embed is drawn from. pub struct TipView { /// The creator's display name, for the document title. pub display_name: String, /// Their handle, which is what the label reads. pub username: String, /// Where the support control goes, on makenot.work. pub tip_url: String, /// Their avatar, when they have one. pub avatar_url: Option, } impl TipView { /// Whether there is an avatar to draw. See [`ItemView::has_cover`]. fn has_avatar(&self) -> bool { self.avatar_url.is_some() } /// The avatar's address, or nothing. fn avatar(&self) -> &str { self.avatar_url.as_deref().unwrap_or_default() } } declare! { /// The tip button. /// /// [`item_button`]'s strip with nothing between the label and the control: /// a tip has no price to trail. #[must_use] pub shape tip_button(view: &TipView) -> Screen; screen single "Support {view.display_name}" { region REGION as Pane { list { row "" { beside Primary include cover(view.avatar()) when view.has_avatar(); beside Primary text "Support @{view.username}"; beside Actions act "Support" to external &view.tip_url; } } } } } #[cfg(test)] mod tests { use super::*; fn item() -> ItemView { ItemView { title: "Item".into(), price: "$9".into(), button_text: "Buy".into(), purchase_url: "https://makenot.work/buy/one".into(), cover_image_url: Some("https://makenot.work/cover.png".into()), creator_display_name: "Creator".into(), profile_url: "https://makenot.work/u/creator".into(), description_excerpt: "About it.".into(), } } fn hex_literals(css: &str) -> Vec { css.split('#') .skip(1) .map(|tail| { tail.chars() .take_while(char::is_ascii_hexdigit) .collect::() }) .filter(|run| run.len() == 3 || run.len() == 6) .map(|run| format!("#{run}")) .collect() } /// The regression guard the templates carried, kept: `#5a4bd6` sat in all /// five of them as a hover violet matching no token in the tree, and nothing /// was looking. What this host still writes by hand is two constants, so /// this is now a check on two strings rather than on five rendered pages. #[test] fn this_host_writes_no_colour_of_its_own() { for (name, css) in [("document", DOCUMENT_CSS), ("player", PLAYER_CSS)] { let found = hex_literals(css); assert!( found.is_empty(), "{name} writes its own colour: {found:?}. Use the token instead; \ a literal here drifts from the theme and nothing will report it.", ); } } /// An embed cannot link a sheet, so every layer has to arrive in the head. #[test] fn an_embed_document_carries_the_whole_design_system() { let html = document(&item_button(&item()).documented(Document::default().classed("embed-button"))); assert!(html.contains("