Skip to main content

max / makenotwork

server: the five embeds are described 54d7f8cf. They were the last hand-written <style> blocks in the tree and the last documents Askama assembled out of format!-shaped markup. Each is a Screen now, drawn by quasi-webview into a shell that links no sheet, takes no transport and carries no chrome: an embed renders in an iframe on somebody else's page, so the whole design system arrives in the document and every control is an external link. The button and the tip are rows, which is what a compact strip is. The two cards and the player are blocks, because a Row is an inline run whose parts share a line by role, and a card said as a row reads coverTitle with the excerpt crushed in beside it. The player keeps its transport in a bespoke region, unchanged, which is d86122cf's ruling: bespoke for now, widgets eventually. What is left of the hand-written CSS is two constants, both hex-free and checked for it, and the one deliberate override of the design system is named as one: makeover gives a picture width 100%, which is right for a card's cover and wrong for a 40-pixel thumbnail. The dead-vocabulary seal comes down 37 to 36, measured.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-21 15:02 UTC
Signed with PGP, not checked
Commit: f2fdad571cf4bd471c8e78364383ecb03694553b
Parent: e9667c7
15 files changed, +604 insertions, -669 deletions
M server/Cargo.lock +20 -20
@@ -10733,24 +10733,12 @@
10733 10733 ]
10734 10734
10735 10735 [[patch.unused]]
10736 - name = "kberg"
10737 - version = "0.1.0"
10736 + name = "makeover-immediate"
10737 + version = "0.30.0"
10738 10738
10739 10739 [[patch.unused]]
10740 - name = "ops-status"
10741 - version = "0.1.0"
10742 -
10743 - [[patch.unused]]
10744 - name = "painhours"
10745 - version = "0.1.0"
10746 -
10747 - [[patch.unused]]
10748 - name = "synckit-client"
10749 - version = "0.8.0"
10750 -
10751 - [[patch.unused]]
10752 - name = "synckit-config"
10753 - version = "0.2.0"
10740 + name = "makeover-tui"
10741 + version = "0.30.0"
10754 10742
10755 10743 [[patch.unused]]
10756 10744 name = "quasi-immediate"
@@ -10769,9 +10757,21 @@
10769 10757 version = "0.45.0"
10770 10758
10771 10759 [[patch.unused]]
10772 - name = "makeover-immediate"
10773 - version = "0.30.0"
10760 + name = "synckit-client"
10761 + version = "0.8.0"
10774 10762
10775 10763 [[patch.unused]]
10776 - name = "makeover-tui"
10777 - version = "0.30.0"
10764 + name = "synckit-config"
10765 + version = "0.2.0"
10766 +
10767 + [[patch.unused]]
10768 + name = "kberg"
10769 + version = "0.1.0"
10770 +
10771 + [[patch.unused]]
10772 + name = "ops-status"
10773 + version = "0.1.0"
10774 +
10775 + [[patch.unused]]
10776 + name = "painhours"
10777 + version = "0.1.0"
M server/build.rs +6 -1
@@ -437,7 +437,12 @@
437 437 /// discover's search box and its tag typeahead are the two measured sites the
438 438 /// member was designed against, and both are still bare inputs driven from
439 439 /// `page-discover.js`. It tightens when they port, which is B7, not before.
440 - const DEAD_VOCABULARY_HIGH_WATER: usize = 37;
440 + /// 37 to 36, 2026-08-21: the five embeds are described (`54d7f8cf`), and their
441 + /// markup is the renderer's now rather than five hand-written `<style>` blocks.
442 + /// One class the templates had no word for is written by the row they became.
443 + /// Measured rather than predicted; the check asked for it on the build that
444 + /// converted them.
445 + const DEAD_VOCABULARY_HIGH_WATER: usize = 36;
441 446
442 447 /// Every file that can carry a class name.
443 448 ///
@@ -32,6 +32,7 @@
32 32 use crate::auth::SessionUser;
33 33
34 34 pub mod buyer_contacts;
35 + pub mod embeds;
35 36 pub mod forum_memberships;
36 37 pub mod item_tabs;
37 38 pub mod library_contacts;
@@ -1,8 +1,10 @@
1 - //! Askama templates for the public embed widgets (`/embed/*`). Ported from
2 - //! hand-rolled `format!` HTML so user-derived fields (titles, creator names,
3 - //! descriptions, URLs) are autoescaped by Askama rather than per-field by hand.
4 -
5 - use askama::Template;
1 + //! The two design-system sheets the public embeds inline.
2 + //!
3 + //! What used to be here as well were five Askama templates, one per embed, each
4 + //! carrying a hand-written `<style>` block. They are gone: `54d7f8cf`, and the
5 + //! screens they drew are described in `crate::quasi::embeds` now. What survives
6 + //! is the pair of generated sheets, because the reason an embed inlines its
7 + //! design system did not change with how the embed is drawn.
6 8
7 9 /// The spacing layer the embeds inline, written by `build.rs` from
8 10 /// makeover-geometry at Pointer density.
@@ -51,240 +53,3 @@
51 53 pub fn embed_theme_css() -> &'static str {
52 54 crate::theming::theme_css(None)
53 55 }
54 -
55 - #[derive(Template)]
56 - #[template(path = "embed/item_button.html")]
57 - pub struct EmbedItemButtonTemplate {
58 - pub title: String,
59 - pub price_display: String,
60 - pub purchase_url: String,
61 - pub button_text: String,
62 - pub cover_image_url: Option<String>,
63 - /// Design-system layers, inlined because an embed cannot link a sheet.
64 - pub theme_css: &'static str,
65 - pub geometry_css: &'static str,
66 - pub typography_css: &'static str,
67 - }
68 -
69 - #[derive(Template)]
70 - #[template(path = "embed/item_card.html")]
71 - pub struct EmbedItemCardTemplate {
72 - pub title: String,
73 - pub price_display: String,
74 - pub purchase_url: String,
75 - pub button_text: String,
76 - pub cover_image_url: Option<String>,
77 - pub creator_display_name: String,
78 - pub profile_url: String,
79 - /// Empty string = no description block.
80 - pub description_excerpt: String,
81 - pub is_horizontal: bool,
82 - /// Design-system layers, inlined because an embed cannot link a sheet.
83 - pub theme_css: &'static str,
84 - pub geometry_css: &'static str,
85 - pub typography_css: &'static str,
86 - }
87 -
88 - #[derive(Template)]
89 - #[template(path = "embed/item_player.html")]
90 - pub struct EmbedItemPlayerTemplate {
91 - pub title: String,
92 - pub price_display: String,
93 - pub purchase_url: String,
94 - pub button_text: String,
95 - pub creator_display_name: String,
96 - pub cover_image_url: Option<String>,
97 - pub preview_url: String,
98 - /// Design-system layers, inlined because an embed cannot link a sheet.
99 - pub theme_css: &'static str,
100 - pub geometry_css: &'static str,
101 - pub typography_css: &'static str,
102 - }
103 -
104 - #[derive(Template)]
105 - #[template(path = "embed/tip_button.html")]
106 - pub struct EmbedTipButtonTemplate {
107 - pub display_name: String,
108 - pub username: String,
109 - pub tip_url: String,
110 - pub avatar_url: Option<String>,
111 - /// Design-system layers, inlined because an embed cannot link a sheet.
112 - pub theme_css: &'static str,
113 - pub geometry_css: &'static str,
114 - pub typography_css: &'static str,
115 - }
116 -
117 - #[derive(Template)]
118 - #[template(path = "embed/project_card.html")]
119 - pub struct EmbedProjectCardTemplate {
120 - pub title: String,
121 - pub creator_display_name: String,
122 - pub profile_url: String,
123 - pub project_url: String,
124 - pub cover_image_url: Option<String>,
125 - /// Empty string = no description block.
126 - pub description_excerpt: String,
127 - pub item_count: usize,
128 - pub category_label: String,
129 - /// Design-system layers, inlined because an embed cannot link a sheet.
130 - pub theme_css: &'static str,
131 - pub geometry_css: &'static str,
132 - pub typography_css: &'static str,
133 - }
134 -
135 - #[cfg(test)]
136 - mod tests {
137 - use super::*;
138 -
139 - /// Everything a template needs that is not the design system. The values are
140 - /// deliberately hex-free so a literal in the output came from the CSS.
141 - fn sample() -> (
142 - EmbedItemButtonTemplate,
143 - EmbedItemCardTemplate,
144 - EmbedItemPlayerTemplate,
145 - EmbedTipButtonTemplate,
146 - EmbedProjectCardTemplate,
147 - ) {
148 - let url = || "https://makenot.work/i/one".to_string();
149 - (
150 - EmbedItemButtonTemplate {
151 - title: "Item".into(),
152 - price_display: "$9".into(),
153 - purchase_url: url(),
154 - button_text: "Buy".into(),
155 - cover_image_url: Some(url()),
156 - theme_css: embed_theme_css(),
157 - geometry_css: EMBED_GEOMETRY_CSS,
158 - typography_css: EMBED_TYPOGRAPHY_CSS,
159 - },
160 - EmbedItemCardTemplate {
161 - title: "Item".into(),
162 - price_display: "$9".into(),
163 - purchase_url: url(),
164 - button_text: "Buy".into(),
165 - cover_image_url: Some(url()),
166 - creator_display_name: "Creator".into(),
167 - profile_url: url(),
168 - description_excerpt: "About it.".into(),
169 - is_horizontal: true,
170 - theme_css: embed_theme_css(),
171 - geometry_css: EMBED_GEOMETRY_CSS,
172 - typography_css: EMBED_TYPOGRAPHY_CSS,
173 - },
174 - EmbedItemPlayerTemplate {
175 - title: "Item".into(),
176 - price_display: "$9".into(),
177 - purchase_url: url(),
178 - button_text: "Buy".into(),
179 - creator_display_name: "Creator".into(),
180 - cover_image_url: Some(url()),
181 - preview_url: url(),
182 - theme_css: embed_theme_css(),
183 - geometry_css: EMBED_GEOMETRY_CSS,
184 - typography_css: EMBED_TYPOGRAPHY_CSS,
185 - },
186 - EmbedTipButtonTemplate {
187 - display_name: "Creator".into(),
188 - username: "creator".into(),
189 - tip_url: url(),
190 - avatar_url: Some(url()),
191 - theme_css: embed_theme_css(),
192 - geometry_css: EMBED_GEOMETRY_CSS,
193 - typography_css: EMBED_TYPOGRAPHY_CSS,
194 - },
195 - EmbedProjectCardTemplate {
196 - title: "Project".into(),
197 - creator_display_name: "Creator".into(),
198 - profile_url: url(),
199 - project_url: url(),
200 - cover_image_url: Some(url()),
201 - description_excerpt: "About it.".into(),
202 - item_count: 3,
203 - category_label: "Music".into(),
204 - theme_css: embed_theme_css(),
205 - geometry_css: EMBED_GEOMETRY_CSS,
206 - typography_css: EMBED_TYPOGRAPHY_CSS,
207 - },
208 - )
209 - }
210 -
211 - /// Strip the injected `:root { ... }` blocks. What is left is the template's
212 - /// own CSS, which is where a hand-copied value would be.
213 - fn without_injected_roots(rendered: &str) -> String {
214 - let mut out = String::with_capacity(rendered.len());
215 - let mut rest = rendered;
216 - while let Some(start) = rest.find(":root") {
217 - out.push_str(&rest[..start]);
218 - let Some(open) = rest[start..].find('{') else {
219 - break;
220 - };
221 - let Some(close) = rest[start + open..].find('}') else {
222 - break;
223 - };
224 - rest = &rest[start + open + close + 1..];
225 - }
226 - out.push_str(rest);
227 - out
228 - }
229 -
230 - fn hex_literals(css: &str) -> Vec<String> {
231 - css.split('#')
232 - .skip(1)
233 - .map(|tail| {
234 - tail.chars()
235 - .take_while(char::is_ascii_hexdigit)
236 - .collect::<String>()
237 - })
238 - .filter(|run| run.len() == 3 || run.len() == 6)
239 - .map(|run| format!("#{run}"))
240 - .collect()
241 - }
242 -
243 - /// The regression guard the drift got past. `#5a4bd6` sat in all five
244 - /// templates as the button hover violet and matched no token anywhere in the
245 - /// tree: the real `--action-hover` is a different colour. Nothing could have
246 - /// caught it, because nothing was looking.
247 - #[test]
248 - fn no_embed_template_writes_its_own_colour() {
249 - let (button, card, player, tip, project) = sample();
250 - for (name, rendered) in [
251 - ("item_button", button.render().expect("render")),
252 - ("item_card", card.render().expect("render")),
253 - ("item_player", player.render().expect("render")),
254 - ("tip_button", tip.render().expect("render")),
255 - ("project_card", project.render().expect("render")),
256 - ] {
257 - let own = without_injected_roots(&rendered);
258 - let found = hex_literals(&own);
259 - assert!(
260 - found.is_empty(),
261 - "{name} writes its own colour: {found:?}. Use the token instead; \
262 - a literal here drifts from the theme and nothing will report it.",
263 - );
264 - }
265 - }
266 -
267 - /// The stripper only earns its assertion if the blocks it strips were there.
268 - /// Without this, a handler that stopped passing `theme_css` would leave the
269 - /// test above passing on an empty document.
270 - #[test]
271 - fn every_embed_injects_both_design_system_layers() {
272 - let (button, card, player, tip, project) = sample();
273 - for (name, rendered) in [
274 - ("item_button", button.render().expect("render")),
275 - ("item_card", card.render().expect("render")),
276 - ("item_player", player.render().expect("render")),
277 - ("tip_button", tip.render().expect("render")),
278 - ("project_card", project.render().expect("render")),
279 - ] {
280 - assert!(
281 - rendered.contains("--action-hover"),
282 - "{name} did not inject the theme layer",
283 - );
284 - assert!(
285 - rendered.contains("--geometry-base"),
286 - "{name} did not inject the spacing layer",
287 - );
288 - }
289 - }
290 - }
@@ -335,10 +335,4 @@
335 335 WizardItemSectionsTemplate,
336 336 WizardItemPricingTemplate,
337 337 WizardItemPreviewTemplate,
338 - // Embed widgets
339 - EmbedItemButtonTemplate,
340 - EmbedItemCardTemplate,
341 - EmbedItemPlayerTemplate,
342 - EmbedTipButtonTemplate,
343 - EmbedProjectCardTemplate,
344 338 );
@@ -224,10 +224,13 @@
224 224 assert!(resp.text.contains("Card Title"));
225 225 // Canonical formatter renders whole dollars without trailing .00.
226 226 assert!(resp.text.contains("$15"));
227 - // Vertical layout sets flex-direction: column.
227 + // The two layouts are one description in two documents: the row is the same
228 + // row and the body class is what says how this frame lays it out. Vertical
229 + // is the default, so the horizontal marker must be absent.
228 230 assert!(
229 - resp.text.contains("column"),
230 - "Default layout should be vertical (flex-direction: column)"
231 + resp.text.contains(r#"class="embed-card""#),
232 + "Default layout should be the plain card: {}",
233 + resp.text
231 234 );
232 235 assert_embed_headers(&resp, "item card");
233 236 }
@@ -242,10 +245,10 @@
242 245 .get(&format!("/embed/i/{item_id}/card?layout=horizontal"))
243 246 .await;
244 247 assert_eq!(resp.status, 200, "{}", resp.text);
245 - // Horizontal layout switches to flex-direction: row.
246 248 assert!(
247 - resp.text.contains("row"),
248 - "layout=horizontal should set flex-direction: row"
249 + resp.text.contains("embed-card-horizontal"),
250 + "layout=horizontal should mark the document: {}",
251 + resp.text
249 252 );
250 253 }
251 254
@@ -274,12 +277,12 @@
274 277 !resp.text.contains("<script>alert(1)</script>"),
275 278 "Raw script tag must NOT appear in embed output"
276 279 );
277 - // Askama autoescapes to numeric character references (`&#60;`/`&#62;`),
278 - // which are as safe as the old hand-roller's named entities (`&lt;`/`&gt;`).
280 + // The described renderer escapes to named entities, which are as safe as the
281 + // numeric references Askama wrote before the embeds were described.
279 282 assert!(
283 + resp.text.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
284 + "Escaped script tag should appear in embed output: {}",
280 285 resp.text
281 - .contains("&#60;script&#62;alert(1)&#60;/script&#62;"),
282 - "Escaped script tag should appear in embed output"
283 286 );
284 287 }
285 288
@@ -4,10 +4,11 @@
4 4 use axum::{
5 5 extract::{Path, State},
6 6 http::{HeaderValue, header},
7 - response::{IntoResponse, Response},
7 + response::{Html, IntoResponse, Response},
8 8 };
9 9 use serde::Deserialize;
10 10
11 + use crate::quasi::embeds;
11 12 use crate::{
12 13 config::Config,
13 14 db::{self, ItemId},
@@ -31,6 +32,25 @@
31 32 has_audio: bool,
32 33 }
33 34
35 + impl ItemEmbedContext {
36 + /// What the described screens are drawn from.
37 + ///
38 + /// The profile URL is built here rather than fetched, because it is the one
39 + /// address that is derived from a value this context already carries.
40 + fn view(self, config: &Config) -> embeds::ItemView {
41 + embeds::ItemView {
42 + profile_url: format!("{}/u/{}", config.host_url, self.creator_username),
43 + title: self.title,
44 + price: self.price_display,
45 + button_text: self.button_text,
46 + purchase_url: self.purchase_url,
47 + cover_image_url: self.cover_image_url,
48 + creator_display_name: self.creator_display_name,
49 + description_excerpt: self.description_excerpt,
50 + }
51 + }
52 + }
53 +
34 54 async fn fetch_item_embed_context(
35 55 db: &PgPool,
36 56 config: &Config,
@@ -125,16 +145,11 @@
125 145 ) -> Result<Response> {
126 146 let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
127 147
128 - let mut response = crate::templates::EmbedItemButtonTemplate {
129 - title: ctx.title,
130 - price_display: ctx.price_display,
131 - purchase_url: ctx.purchase_url,
132 - button_text: ctx.button_text,
133 - cover_image_url: ctx.cover_image_url,
134 - theme_css: crate::templates::embed_theme_css(),
135 - geometry_css: crate::templates::EMBED_GEOMETRY_CSS,
136 - typography_css: crate::templates::EMBED_TYPOGRAPHY_CSS,
137 - }
148 + let view = ctx.view(&config);
149 + let mut response = Html(embeds::document(
150 + &embeds::item_button(&view),
151 + Some("embed-button"),
152 + ))
138 153 .into_response();
139 154 set_embed_headers(&mut response);
140 155 Ok(response)
@@ -159,22 +174,19 @@
159 174 let layout = query.layout.as_deref().unwrap_or("vertical");
160 175 let is_horizontal = layout == "horizontal";
161 176
162 - let profile_url = format!("{}/u/{}", config.host_url, ctx.creator_username);
163 -
164 - let mut response = crate::templates::EmbedItemCardTemplate {
165 - title: ctx.title,
166 - price_display: ctx.price_display,
167 - purchase_url: ctx.purchase_url,
168 - button_text: ctx.button_text,
169 - cover_image_url: ctx.cover_image_url,
170 - creator_display_name: ctx.creator_display_name,
171 - profile_url,
172 - description_excerpt: ctx.description_excerpt,
173 - is_horizontal,
174 - theme_css: crate::templates::embed_theme_css(),
175 - geometry_css: crate::templates::EMBED_GEOMETRY_CSS,
176 - typography_css: crate::templates::EMBED_TYPOGRAPHY_CSS,
177 - }
177 + let view = ctx.view(&config);
178 + // The two layouts are one description in two documents, which is what
179 + // `Shell::body_class` is: the row is the same row, and how it is laid out in
180 + // this frame is a fact about this host's furniture.
181 + let body_class = if is_horizontal {
182 + "embed-card embed-card-horizontal"
183 + } else {
184 + "embed-card"
185 + };
186 + let mut response = Html(embeds::document(
187 + &embeds::item_card(&view),
188 + Some(body_class),
189 + ))
178 190 .into_response();
179 191 set_embed_headers(&mut response);
180 192 Ok(response)
@@ -201,19 +213,8 @@
201 213 // For now, link to the item page, the player embed is a stub until preview generation ships.
202 214 let preview_url = format!("{}/api/stream/{}", config.host_url, item_id);
203 215
204 - let mut response = crate::templates::EmbedItemPlayerTemplate {
205 - title: ctx.title,
206 - price_display: ctx.price_display,
207 - purchase_url: ctx.purchase_url,
208 - button_text: ctx.button_text,
209 - creator_display_name: ctx.creator_display_name,
210 - cover_image_url: ctx.cover_image_url,
211 - preview_url,
212 - theme_css: crate::templates::embed_theme_css(),
213 - geometry_css: crate::templates::EMBED_GEOMETRY_CSS,
214 - typography_css: crate::templates::EMBED_TYPOGRAPHY_CSS,
215 - }
216 - .into_response();
216 + let view = ctx.view(&config);
217 + let mut response = Html(embeds::player_document(&view, &preview_url)).into_response();
217 218 set_embed_headers(&mut response);
218 219 Ok(response)
219 220 }
@@ -2,9 +2,10 @@
2 2
3 3 use axum::{
4 4 extract::{Path, State},
5 - response::{IntoResponse, Response},
5 + response::{Html, IntoResponse, Response},
6 6 };
7 7
8 + use crate::quasi::embeds;
8 9 use crate::{
9 10 config::Config,
10 11 db::{self},
@@ -49,7 +50,7 @@
49 50 let creator_name = user.display_name.as_deref().unwrap_or(&user.username);
50 51 let category_label = project.project_type.label();
51 52
52 - let mut response = crate::templates::EmbedProjectCardTemplate {
53 + let view = embeds::ProjectView {
53 54 title: project.title.clone(),
54 55 creator_display_name: creator_name.to_string(),
55 56 profile_url,
@@ -58,10 +59,11 @@
58 59 description_excerpt,
59 60 item_count,
60 61 category_label: category_label.to_string(),
61 - theme_css: crate::templates::embed_theme_css(),
62 - geometry_css: crate::templates::EMBED_GEOMETRY_CSS,
63 - typography_css: crate::templates::EMBED_TYPOGRAPHY_CSS,
64 - }
62 + };
63 + let mut response = Html(embeds::document(
64 + &embeds::project_card(&view),
65 + Some("embed-card"),
66 + ))
65 67 .into_response();
66 68 set_embed_headers(&mut response);
67 69 Ok(response)
@@ -2,9 +2,10 @@
2 2
3 3 use axum::{
4 4 extract::{Path, State},
5 - response::{IntoResponse, Response},
5 + response::{Html, IntoResponse, Response},
6 6 };
7 7
8 + use crate::quasi::embeds;
8 9 use crate::{
9 10 config::Config,
10 11 db::{self, Username},
@@ -37,15 +38,16 @@
37 38 .to_string();
38 39 let tip_url = format!("{}/u/{}/tip", config.host_url, user.username);
39 40
40 - let mut response = crate::templates::EmbedTipButtonTemplate {
41 + let view = embeds::TipView {
41 42 display_name,
42 43 username: user.username.to_string(),
43 44 tip_url,
44 45 avatar_url: user.avatar_url,
45 - theme_css: crate::templates::embed_theme_css(),
46 - geometry_css: crate::templates::EMBED_GEOMETRY_CSS,
47 - typography_css: crate::templates::EMBED_TYPOGRAPHY_CSS,
48 - }
46 + };
47 + let mut response = Html(embeds::document(
48 + &embeds::tip_button(&view),
49 + Some("embed-tip"),
50 + ))
49 51 .into_response();
50 52 set_embed_headers(&mut response);
51 53 Ok(response)
@@ -1,0 +1,539 @@
1 + //! The five public embeds, described.
2 + //!
3 + //! `54d7f8cf`. These were the last hand-written `<style>` blocks in the tree and
4 + //! the last five documents assembled by Askama out of `format!`-shaped markup.
5 + //! Each is now a [`Screen`] drawn by quasi-webview, and what is left of the
6 + //! hand-written CSS is [`DOCUMENT_CSS`] — the rules that are about *this
7 + //! document being an iframe on somebody else's page*, which is the one thing the
8 + //! design system has no opinion about.
9 + //!
10 + //! # Why an embed's shell is not [`Viewer::shell`](super::Viewer::shell)
11 + //!
12 + //! Three differences, all of them the reason this module exists:
13 + //!
14 + //! - **No stylesheet links.** An embed renders inside an iframe on a third
15 + //! party's page and cannot link a sheet, so the whole design system arrives
16 + //! in the document through [`Shell::with_head_first`]. Colour comes from
17 + //! makeover at render time, spacing and typography from `build.rs`, and
18 + //! composition from the generated `static/layout.css`.
19 + //! - **No htmx, no scripts.** An embed calls no route: every control on one
20 + //! is a link to `makenot.work`, which is [`Destination::External`] and is
21 + //! an `<a target="_blank" rel="noopener noreferrer">` in every renderer.
22 + //! `Shell::without_htmx` is the member `54d7f8cf` asked for and the rest go
23 + //! with it. The player is the one exception and it carries its own script
24 + //! inside its bespoke region.
25 + //! - **No chrome and no session.** `Chrome::new()` is the shell's default and
26 + //! produces the pre-chrome document byte for byte. There is no signed-in
27 + //! reader here and nothing to CSRF-protect, so none of `Viewer` applies:
28 + //! these handlers stay ordinary axum handlers and call [`document`]
29 + //! directly rather than going through the router and its per-request state.
30 + //!
31 + //! # `--font-display` is deliberately undefined, still
32 + //!
33 + //! The reasoning survives the conversion and is unchanged: the display tier is
34 + //! per product, an embed carries no brand face, and `EMBED_TYPOGRAPHY_CSS` is
35 + //! the house-only sheet rather than the site's, so `var(--font-display, ...)`
36 + //! falls through to its fallback. See `crate::templates::embed`'s note on the
37 + //! two typography sheets, which is where the two files are written.
38 + //!
39 + //! # What the description could not say, recorded rather than worked around
40 + //!
41 + //! An embed is **one region**, and [`layout::Arrangement`] has two members,
42 + //! both of which are about two: a list beside a detail, or a sidebar beside
43 + //! content. There is no arrangement for "one region, the whole document", so
44 + //! these screens name `list_detail` and [`DOCUMENT_CSS`] undoes the two-column
45 + //! grid. That is a small vocabulary gap and it is filed rather than left as a
46 + //! surprise for the next reader.
47 +
48 + use makeover_layout as layout;
49 + use quasi_axum::Serves as _;
50 + use quasi_router::screen::{Act, Picture, Row};
51 + use quasi_router::{Action, Chrome, Node, RegionKind, Screen, Slot};
52 + use quasi_webview::{Shell, Webview};
53 +
54 + use crate::templates::{EMBED_GEOMETRY_CSS, EMBED_TYPOGRAPHY_CSS, embed_theme_css};
55 +
56 + /// The composition layer, written by `build.rs` from makeover-webview.
57 + ///
58 + /// The sheet every other page links from `/static/layout.css`. An embed inlines
59 + /// it for [`DOCUMENT_CSS`]'s reason, which costs 15 KB in a response cached for
60 + /// five minutes and is what buys the row, the list and the control looking like
61 + /// the product rather than like five hand-written approximations of it.
62 + const LAYOUT_CSS: &str = include_str!("../../static/layout.css");
63 +
64 + /// The region every embed's content sits in.
65 + const REGION: &str = "embed";
66 +
67 + /// The rules that are about the document rather than about the design system.
68 + ///
69 + /// Everything here is either a browser default being undone or a fact about
70 + /// being an iframe: the frame is the size the host page gave it, so the body
71 + /// fills it, and one region fills the body. No colour, no font stack, no
72 + /// spacing step — those are tokens, and a literal here would drift from the
73 + /// theme with nothing looking. The five `<style>` blocks this replaces are what
74 + /// that looks like when it goes wrong: `#5a4bd6` sat in all five as a hover
75 + /// violet that matched no token in the tree.
76 + ///
77 + /// The two compact embeds size their picture here, and they are the only rule
78 + /// in this document that overrides the design system rather than sitting beside
79 + /// it. makeover gives a picture `width: 100%` and leaves the box to whoever
80 + /// placed it, which is right for a card — the cover spans it — and wrong for a
81 + /// button, where the cover is a 40-pixel thumbnail in a row. Stated rather than
82 + /// hidden: this document is unlayered and therefore beats `@layer makeover`,
83 + /// which is exactly what `check_css_overlap` names when it happens in a file.
84 + const DOCUMENT_CSS: &str = "\
85 + * { margin: 0; padding: 0; box-sizing: border-box; }
86 + body {
87 + font-family: var(--font-sans);
88 + background: var(--surface-page);
89 + color: var(--content);
90 + }
91 + main.list-detail {
92 + display: block;
93 + min-height: 100vh;
94 + padding: var(--step-base) var(--gap-section);
95 + }
96 + body.embed-button .picture-img { flex: none; width: 40px; height: 40px; }
97 + body.embed-tip .picture-img { flex: none; width: 32px; height: 32px; border-radius: 50%; }
98 + ";
99 +
100 + /// A whole embed document: the design system, then the described screen.
101 + ///
102 + /// `body_class` is what the two card layouts and the player differ by. It is
103 + /// [`Shell::body_class`] rather than a second description, because what changes
104 + /// between a horizontal and a vertical card is how one row is laid out in one
105 + /// document, which is this host's question about its own furniture.
106 + #[must_use]
107 + pub fn document(screen: &Screen, body_class: Option<&str>) -> String {
108 + let mut shell = Shell::default()
109 + // An embed calls no route, so it takes no transport and no scripts.
110 + // A document that does ask fails visibly on the first control pressed,
111 + // and nothing here asks: every control is an external link.
112 + .without_htmx()
113 + .without_hyperscript()
114 + .without_clock()
115 + .with_chrome(Chrome::new())
116 + .with_head_first(head_first());
117 + shell.body_class = body_class.map(str::to_owned);
118 + Webview::new().with_shell(shell).screen(screen)
119 + }
120 +
121 + /// Every layer of the design system, inlined, in cascade order.
122 + ///
123 + /// Colour first because the rest reads tokens off it. Composition last because
124 + /// it is the layer the described markup is styled by, and `@layer makeover` puts
125 + /// it under anything the document adds after.
126 + fn head_first() -> String {
127 + format!(
128 + "<style>{}{}{}{}{}</style>",
129 + embed_theme_css(),
130 + EMBED_GEOMETRY_CSS,
131 + EMBED_TYPOGRAPHY_CSS,
132 + LAYOUT_CSS,
133 + DOCUMENT_CSS,
134 + )
135 + }
136 +
137 + /// A screen with one region, holding one node.
138 + fn one(title: &str, node: Node) -> Screen {
139 + Screen::list_detail(title, false).with(Slot::new(REGION, RegionKind::Pane).with(node))
140 + }
141 +
142 + /// A cover picture, cropped to its box.
143 + ///
144 + /// `Fit::Cover` because a cover is a fixed square here and the art it holds is
145 + /// any shape: the alternative is letterboxing inside a 40-pixel box, which is
146 + /// the art unreadable and the box the wrong colour.
147 + fn cover(url: &str) -> Node {
148 + let mut picture = Picture::new(url, "");
149 + picture.fit = layout::Fit::Cover;
150 + Node::Image(picture)
151 + }
152 +
153 + /// What an item embed is drawn from.
154 + ///
155 + /// A view rather than the database row, so a screen can be built in a test
156 + /// without a connection. The same split every described screen here makes.
157 + pub struct ItemView {
158 + /// The item's title.
159 + pub title: String,
160 + /// The price as the canonical formatter writes it.
161 + pub price: String,
162 + /// What the buy control says: "Buy" or "Get".
163 + pub button_text: String,
164 + /// Where the buy control goes, on makenot.work.
165 + pub purchase_url: String,
166 + /// The cover art, when the item has any.
167 + pub cover_image_url: Option<String>,
168 + /// Who made it.
169 + pub creator_display_name: String,
170 + /// Their page, on makenot.work.
171 + pub profile_url: String,
172 + /// The first 150 characters of the description.
173 + pub description_excerpt: String,
174 + }
175 +
176 + /// The buy control: a link out to makenot.work.
177 + ///
178 + /// [`Destination::External`](quasi_router::Destination::External), which is what
179 + /// makes it an anchor with `rel="noopener noreferrer"` in the webview rather
180 + /// than a button that asks a route. An embed's every control is one of these.
181 + fn buy(view: &ItemView) -> Act {
182 + Act::new(&view.button_text, Action::external(&view.purchase_url))
183 + }
184 +
185 + /// The buy button: cover, title, price, and the control, on one line.
186 + ///
187 + /// The one embed that is genuinely a [`Row`]: a compact strip where the cover
188 + /// is a thumbnail beside the title rather than the card's own picture. Drawn
189 + /// with the `embed-button` body class, which is what sizes that thumbnail.
190 + #[must_use]
191 + pub fn item_button(view: &ItemView) -> Screen {
192 + let mut row = Row::new("");
193 + if let Some(url) = &view.cover_image_url {
194 + row = row.part(layout::RowPart::Primary, cover(url));
195 + }
196 + let row = row
197 + .part(layout::RowPart::Primary, Node::text(&view.title))
198 + .meta(&view.price)
199 + .part(layout::RowPart::Actions, Node::Act(buy(view)));
200 + one(&view.title, Node::list([row]))
201 + }
202 +
203 + /// The product card: the cover, what it is, who made it, and the control.
204 + ///
205 + /// Blocks rather than one row, which is the difference between a card and a
206 + /// button. A [`Row`] is an inline run and its parts share a line by role, so a
207 + /// card said as a row would read "coverTitle" with the excerpt and the price
208 + /// crushed in beside it. What a card actually is — a picture, a heading, a line
209 + /// about who made it, a paragraph, a price and a control, each on its own line —
210 + /// is a region holding six nodes, every one of which the vocabulary already
211 + /// names.
212 + #[must_use]
213 + pub fn item_card(view: &ItemView) -> Screen {
214 + let mut slot = Slot::new(REGION, RegionKind::Pane);
215 + if let Some(url) = &view.cover_image_url {
216 + slot = slot.with(cover(url));
217 + }
218 + slot = slot.with(Node::section(&view.title)).with(Node::Link {
219 + text: format!("by {}", view.creator_display_name),
220 + action: Action::external(&view.profile_url),
221 + });
222 + if !view.description_excerpt.is_empty() {
223 + slot = slot.with(Node::text(&view.description_excerpt));
224 + }
225 + let slot = slot
226 + .with(Node::text(&view.price))
227 + .with(Node::Act(buy(view)));
228 + Screen::list_detail(&view.title, false).with(slot)
229 + }
230 +
231 + /// The audio player: the card, with the transport in a bespoke region.
232 + ///
233 + /// `d86122cf`, ruled by Max 2026-08-18: **bespoke for now, widgets eventually.**
234 + /// A play button, a scrub bar and an elapsed readout are a media transport, and
235 + /// the vocabulary names none of the three on purpose — describing playback would
236 + /// put scrub, rate and chapters into a core two of the three renderers could only
237 + /// degrade. So this screen describes the chrome around the player and leaves the
238 + /// player alone, which is exactly what a [`RegionKind::Bespoke`] is for.
239 + ///
240 + /// The markup and the script that fills it are [`player_markup`], unchanged from
241 + /// the template this replaces.
242 + #[must_use]
243 + pub fn item_player(view: &ItemView) -> Screen {
244 + let mut slot = Slot::new(REGION, RegionKind::Pane);
245 + if let Some(url) = &view.cover_image_url {
246 + slot = slot.with(cover(url));
247 + }
248 + let slot = slot
249 + .with(Node::section(&view.title))
250 + .with(Node::text(format!("by {}", view.creator_display_name)))
251 + .with(Node::text(&view.price))
252 + .with(Node::Act(buy(view)));
253 +
254 + Screen::list_detail(&view.title, false)
255 + .with(slot)
256 + .with(Slot::new(
257 + PLAYER_REGION,
258 + RegionKind::Bespoke {
259 + name: "media-transport".into(),
260 + },
261 + ))
262 + }
263 +
264 + /// The bespoke region the transport is mounted in.
265 + pub const PLAYER_REGION: &str = "transport";
266 +
267 + /// The player document: the described chrome, with the transport mounted.
268 + ///
269 + /// Its own function rather than [`document`] with an argument, because the
270 + /// player is the one embed whose renderer carries a fill and whose head carries
271 + /// a second sheet. Both are about the same one thing — the island this screen
272 + /// deliberately does not describe — so they are named together.
273 + #[must_use]
274 + pub fn player_document(view: &ItemView, preview_url: &str) -> String {
275 + let mut shell = Shell::default()
276 + .without_htmx()
277 + .without_hyperscript()
278 + .without_clock()
279 + .with_chrome(Chrome::new())
280 + .with_head_first(format!("{}<style>{PLAYER_CSS}</style>", head_first()));
281 + shell.body_class = Some("embed-player".to_owned());
282 + Webview::new()
283 + .with_shell(shell)
284 + .with_fill(PLAYER_REGION, player_markup(preview_url))
285 + .screen(&item_player(view))
286 + }
287 +
288 + /// The player island, and the script that drives it.
289 + ///
290 + /// Verbatim from `templates/embed/item_player.html`, which is the whole point of
291 + /// a bespoke region: the behaviour is already implemented once and tested, and
292 + /// converting the page around it must not rewrite it. The classes are this
293 + /// host's own and are styled by [`PLAYER_CSS`].
294 + ///
295 + /// `preview_url` is the one value from outside, and it is escaped here: a
296 + /// bespoke fill is markup and nothing downstream escapes it.
297 + #[must_use]
298 + pub fn player_markup(preview_url: &str) -> String {
299 + format!(
300 + r#"<div class="transport" data-preview-url="{}">
301 + <button class="play-btn" id="play">&#9654;</button>
302 + <div class="progress-bar" id="progress-bar"><div class="progress-fill" id="progress"></div></div>
303 + <span class="time" id="time">0:00</span>
304 + </div>
305 + <span class="preview-label">Preview</span>
306 + <script src="/static/embed-item-player.js?v=0623" defer></script>"#,
307 + crate::helpers::escape_html(preview_url)
308 + )
309 + }
310 +
311 + /// The transport's own rules, which are about a control the design system does
312 + /// not name.
313 + ///
314 + /// Kept out of [`DOCUMENT_CSS`] because it applies to one embed, and kept in
315 + /// this crate because the markup it styles is this crate's. Colour is tokens
316 + /// throughout, the same rule the rest of the document keeps.
317 + pub const PLAYER_CSS: &str = "\
318 + .transport { display: flex; align-items: center; gap: var(--step-base); }
319 + .play-btn {
320 + width: 32px; height: 32px; border-radius: 50%;
321 + background: var(--action); color: var(--content-on-action); border: none;
322 + cursor: pointer; display: flex; align-items: center; justify-content: center;
323 + flex: none;
324 + }
325 + .play-btn:hover { background: var(--action-hover); }
326 + .progress-bar {
327 + flex: 1; height: 4px; background: var(--surface-sunken);
328 + border-radius: 2px; cursor: pointer; position: relative;
329 + }
330 + .progress-fill { height: 100%; background: var(--action); border-radius: 2px; width: 0%; }
331 + .time { font-family: var(--font-mono); color: var(--content-muted); white-space: nowrap; }
332 + .preview-label { color: var(--content-muted); }
333 + ";
334 +
335 + /// What a project embed is drawn from.
336 + pub struct ProjectView {
337 + /// The project's title.
338 + pub title: String,
339 + /// Who made it.
340 + pub creator_display_name: String,
341 + /// Their page, on makenot.work.
342 + pub profile_url: String,
343 + /// The project's page, on makenot.work.
344 + pub project_url: String,
345 + /// The cover art, when the project has any.
346 + pub cover_image_url: Option<String>,
347 + /// The first 150 characters of the description.
348 + pub description_excerpt: String,
349 + /// How many items it holds.
350 + pub item_count: usize,
351 + /// What kind of project it is.
352 + pub category_label: String,
353 + }
354 +
355 + /// The project card: [`item_card`]'s shape, about a project.
356 + #[must_use]
357 + pub fn project_card(view: &ProjectView) -> Screen {
358 + let mut slot = Slot::new(REGION, RegionKind::Pane);
359 + if let Some(url) = &view.cover_image_url {
360 + slot = slot.with(cover(url));
361 + }
362 + slot = slot.with(Node::section(&view.title)).with(Node::Link {
363 + text: format!("by {}", view.creator_display_name),
364 + action: Action::external(&view.profile_url),
365 + });
366 + if !view.description_excerpt.is_empty() {
367 + slot = slot.with(Node::text(&view.description_excerpt));
368 + }
369 + // The count and the kind read together and neither stands on its own, so
370 + // they are one line rather than two nodes.
371 + let slot = slot
372 + .with(Node::text(format!(
373 + "{} {} \u{b7} {}",
374 + view.item_count,
375 + if view.item_count == 1 {
376 + "item"
377 + } else {
378 + "items"
379 + },
380 + view.category_label
381 + )))
382 + .with(Node::Act(Act::new(
383 + "View project",
384 + Action::external(&view.project_url),
385 + )));
386 + Screen::list_detail(&view.title, false).with(slot)
387 + }
388 +
389 + /// What a tip embed is drawn from.
390 + pub struct TipView {
391 + /// The creator's display name, for the document title.
392 + pub display_name: String,
393 + /// Their handle, which is what the label reads.
394 + pub username: String,
395 + /// Where the support control goes, on makenot.work.
396 + pub tip_url: String,
397 + /// Their avatar, when they have one.
398 + pub avatar_url: Option<String>,
399 + }
400 +
401 + /// The tip button.
402 + #[must_use]
403 + pub fn tip_button(view: &TipView) -> Screen {
404 + let mut row = Row::new("");
405 + if let Some(url) = &view.avatar_url {
406 + row = row.part(layout::RowPart::Primary, cover(url));
407 + }
408 + let row = row
409 + .part(
410 + layout::RowPart::Primary,
411 + Node::text(format!("Support @{}", view.username)),
412 + )
413 + .part(
414 + layout::RowPart::Actions,
415 + Node::Act(Act::new("Support", Action::external(&view.tip_url))),
416 + );
417 + one(&format!("Support {}", view.display_name), Node::list([row]))
418 + }
419 +
420 + #[cfg(test)]
421 + mod tests {
422 + use super::*;
423 +
424 + fn item() -> ItemView {
425 + ItemView {
426 + title: "Item".into(),
427 + price: "$9".into(),
428 + button_text: "Buy".into(),
429 + purchase_url: "https://makenot.work/buy/one".into(),
430 + cover_image_url: Some("https://makenot.work/cover.png".into()),
431 + creator_display_name: "Creator".into(),
432 + profile_url: "https://makenot.work/u/creator".into(),
433 + description_excerpt: "About it.".into(),
434 + }
435 + }
436 +
437 + fn hex_literals(css: &str) -> Vec<String> {
438 + css.split('#')
439 + .skip(1)
440 + .map(|tail| {
441 + tail.chars()
442 + .take_while(char::is_ascii_hexdigit)
443 + .collect::<String>()
444 + })
445 + .filter(|run| run.len() == 3 || run.len() == 6)
446 + .map(|run| format!("#{run}"))
447 + .collect()
448 + }
449 +
450 + /// The regression guard the templates carried, kept: `#5a4bd6` sat in all
451 + /// five of them as a hover violet matching no token in the tree, and nothing
452 + /// was looking. What this host still writes by hand is two constants, so
453 + /// this is now a check on two strings rather than on five rendered pages.
454 + #[test]
455 + fn this_host_writes_no_colour_of_its_own() {
456 + for (name, css) in [("document", DOCUMENT_CSS), ("player", PLAYER_CSS)] {
457 + let found = hex_literals(css);
458 + assert!(
459 + found.is_empty(),
460 + "{name} writes its own colour: {found:?}. Use the token instead; \
461 + a literal here drifts from the theme and nothing will report it.",
462 + );
463 + }
464 + }
465 +
466 + /// An embed cannot link a sheet, so every layer has to arrive in the head.
467 + #[test]
468 + fn an_embed_document_carries_the_whole_design_system() {
469 + let html = document(&item_button(&item()), Some("embed-button"));
470 + assert!(html.contains("<style>"), "{html}");
471 + // Colour, spacing and typography come from the generated files, so the
472 + // check is that each block is present rather than what is in it.
473 + assert!(html.contains(":root"), "{html}");
474 + assert!(html.contains("--font-sans"), "{html}");
475 + assert!(html.contains(".row-primary"), "{html}");
476 + // And nothing is linked, because nothing can be.
477 + assert!(!html.contains("<link"), "{html}");
478 + }
479 +
480 + /// `54d7f8cf`'s one requirement of `Shell`: an embed asks no route, so it
481 + /// takes no transport.
482 + #[test]
483 + fn an_embed_document_carries_no_script() {
484 + let html = document(&item_button(&item()), Some("embed-button"));
485 + assert!(!html.contains("<script"), "{html}");
486 + assert!(!html.contains("htmx"), "{html}");
487 + }
488 +
489 + /// Every control on an embed leaves the site, which is what makes it an
490 + /// anchor rather than something that asks a route.
491 + #[test]
492 + fn every_control_is_a_link_out() {
493 + let html = document(&item_button(&item()), Some("embed-button"));
494 + assert!(
495 + html.contains(r#"href="https://makenot.work/buy/one""#),
496 + "{html}"
497 + );
498 + assert!(html.contains(r#"rel="noopener noreferrer""#), "{html}");
499 + assert!(!html.contains("hx-get"), "{html}");
500 + }
Lines truncated