//! 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, ) } /// A screen with one region, holding one node. fn one(title: &str, node: Node) -> Screen { Screen::single(title).with(Slot::new(REGION, RegionKind::Pane).with(node)) } /// 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. fn cover(url: &str) -> Node { let mut picture = Picture::new(url, ""); picture.fit = layout::Fit::Cover; Node::Image(picture) } /// 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, } /// 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. fn buy(view: &ItemView) -> Act { Act::new(&view.button_text, Action::external(&view.purchase_url)) } /// The buy button: cover, title, price, and the control, on one line. /// /// The one embed that is genuinely a [`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. #[must_use] pub fn item_button(view: &ItemView) -> Screen { let mut row = Row::new(""); if let Some(url) = &view.cover_image_url { row = row.part(layout::RowPart::Primary, cover(url)); } let row = row .part(layout::RowPart::Primary, Node::text(&view.title)) .meta(&view.price) .part(layout::RowPart::Actions, Node::Act(buy(view))); one(&view.title, Node::list([row])) } /// 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 fn item_card(view: &ItemView) -> Screen { let mut slot = Slot::new(REGION, RegionKind::Pane); if let Some(url) = &view.cover_image_url { slot = slot.with(cover(url)); } slot = slot.with(Node::section(&view.title)).with(Node::Link { text: format!("by {}", view.creator_display_name), action: Action::external(&view.profile_url), }); if !view.description_excerpt.is_empty() { slot = slot.with(Node::text(&view.description_excerpt)); } let slot = slot .with(Node::text(&view.price)) .with(Node::Act(buy(view))); Screen::single(&view.title).with(slot) } /// The audio player: the card, with the transport in a bespoke region. /// /// **Bespoke for now, widgets 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 [`RegionKind::Bespoke`] is for. /// /// The markup and the script that fills it are [`player_markup`], unchanged from /// the template this replaces. #[must_use] pub fn item_player(view: &ItemView) -> Screen { let mut slot = Slot::new(REGION, RegionKind::Pane); if let Some(url) = &view.cover_image_url { slot = slot.with(cover(url)); } let slot = slot .with(Node::section(&view.title)) .with(Node::text(format!("by {}", view.creator_display_name))) .with(Node::text(&view.price)) .with(Node::Act(buy(view))); Screen::single(&view.title).with(slot).with(Slot::new( PLAYER_REGION, RegionKind::Bespoke { name: "media-transport".into(), }, )) } /// The bespoke 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, } /// The project card: [`item_card`]'s shape, about a project. #[must_use] pub fn project_card(view: &ProjectView) -> Screen { let mut slot = Slot::new(REGION, RegionKind::Pane); if let Some(url) = &view.cover_image_url { slot = slot.with(cover(url)); } slot = slot.with(Node::section(&view.title)).with(Node::Link { text: format!("by {}", view.creator_display_name), action: Action::external(&view.profile_url), }); if !view.description_excerpt.is_empty() { slot = slot.with(Node::text(&view.description_excerpt)); } // The count and the kind read together and neither stands on its own, so // they are one line rather than two nodes. let slot = slot .with(Node::text(format!( "{} {} \u{b7} {}", view.item_count, if view.item_count == 1 { "item" } else { "items" }, view.category_label ))) .with(Node::Act(Act::new( "View project", Action::external(&view.project_url), ))); Screen::single(&view.title).with(slot) } /// 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, } /// The tip button. #[must_use] pub fn tip_button(view: &TipView) -> Screen { let mut row = Row::new(""); if let Some(url) = &view.avatar_url { row = row.part(layout::RowPart::Primary, cover(url)); } let row = row .part( layout::RowPart::Primary, Node::text(format!("Support @{}", view.username)), ) .part( layout::RowPart::Actions, Node::Act(Act::new("Support", Action::external(&view.tip_url))), ); one(&format!("Support {}", view.display_name), Node::list([row])) } #[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("