//! 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#"
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("