Skip to main content

max / makenotwork

22.9 KB · 554 lines History Blame Raw
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 //
113 // Six `without_` calls now. quasi 0.54.0 added the fill script, and
114 // 0.59.0 and 0.60.0 added the reveal and repeat scripts. All three are
115 // independent of htmx by design — what they read is attributes on a
116 // control, a region or a fieldset — so dropping the transport does not
117 // drop them, and each has to be refused by name. An embed names no
118 // destination field, no conditional region and no repeating question,
119 // so all three have nothing to read here.
120 .without_htmx()
121 .without_hyperscript()
122 .without_clock()
123 .without_fill()
124 .without_reveal()
125 .without_repeat()
126 .with_chrome(Chrome::new())
127 .with_head_first(head_first());
128 shell.body_class = body_class.map(str::to_owned);
129 Webview::new().with_shell(shell).screen(screen)
130 }
131
132 /// Every layer of the design system, inlined, in cascade order.
133 ///
134 /// Colour first because the rest reads tokens off it. Composition last because
135 /// it is the layer the described markup is styled by, and `@layer makeover` puts
136 /// it under anything the document adds after.
137 fn head_first() -> String {
138 format!(
139 "<style>{}{}{}{}{}</style>",
140 embed_theme_css(),
141 EMBED_GEOMETRY_CSS,
142 EMBED_TYPOGRAPHY_CSS,
143 LAYOUT_CSS,
144 DOCUMENT_CSS,
145 )
146 }
147
148 /// A screen with one region, holding one node.
149 fn one(title: &str, node: Node) -> Screen {
150 Screen::list_detail(title, false).with(Slot::new(REGION, RegionKind::Pane).with(node))
151 }
152
153 /// A cover picture, cropped to its box.
154 ///
155 /// `Fit::Cover` because a cover is a fixed square here and the art it holds is
156 /// any shape: the alternative is letterboxing inside a 40-pixel box, which is
157 /// the art unreadable and the box the wrong colour.
158 fn cover(url: &str) -> Node {
159 let mut picture = Picture::new(url, "");
160 picture.fit = layout::Fit::Cover;
161 Node::Image(picture)
162 }
163
164 /// What an item embed is drawn from.
165 ///
166 /// A view rather than the database row, so a screen can be built in a test
167 /// without a connection. The same split every described screen here makes.
168 pub struct ItemView {
169 /// The item's title.
170 pub title: String,
171 /// The price as the canonical formatter writes it.
172 pub price: String,
173 /// What the buy control says: "Buy" or "Get".
174 pub button_text: String,
175 /// Where the buy control goes, on makenot.work.
176 pub purchase_url: String,
177 /// The cover art, when the item has any.
178 pub cover_image_url: Option<String>,
179 /// Who made it.
180 pub creator_display_name: String,
181 /// Their page, on makenot.work.
182 pub profile_url: String,
183 /// The first 150 characters of the description.
184 pub description_excerpt: String,
185 }
186
187 /// The buy control: a link out to makenot.work.
188 ///
189 /// [`Destination::External`](quasi_router::Destination::External), which is what
190 /// makes it an anchor with `rel="noopener noreferrer"` in the webview rather
191 /// than a button that asks a route. An embed's every control is one of these.
192 fn buy(view: &ItemView) -> Act {
193 Act::new(&view.button_text, Action::external(&view.purchase_url))
194 }
195
196 /// The buy button: cover, title, price, and the control, on one line.
197 ///
198 /// The one embed that is genuinely a [`Row`]: a compact strip where the cover
199 /// is a thumbnail beside the title rather than the card's own picture. Drawn
200 /// with the `embed-button` body class, which is what sizes that thumbnail.
201 #[must_use]
202 pub fn item_button(view: &ItemView) -> Screen {
203 let mut row = Row::new("");
204 if let Some(url) = &view.cover_image_url {
205 row = row.part(layout::RowPart::Primary, cover(url));
206 }
207 let row = row
208 .part(layout::RowPart::Primary, Node::text(&view.title))
209 .meta(&view.price)
210 .part(layout::RowPart::Actions, Node::Act(buy(view)));
211 one(&view.title, Node::list([row]))
212 }
213
214 /// The product card: the cover, what it is, who made it, and the control.
215 ///
216 /// Blocks rather than one row, which is the difference between a card and a
217 /// button. A [`Row`] is an inline run and its parts share a line by role, so a
218 /// card said as a row would read "coverTitle" with the excerpt and the price
219 /// crushed in beside it. What a card actually is — a picture, a heading, a line
220 /// about who made it, a paragraph, a price and a control, each on its own line —
221 /// is a region holding six nodes, every one of which the vocabulary already
222 /// names.
223 #[must_use]
224 pub fn item_card(view: &ItemView) -> Screen {
225 let mut slot = Slot::new(REGION, RegionKind::Pane);
226 if let Some(url) = &view.cover_image_url {
227 slot = slot.with(cover(url));
228 }
229 slot = slot.with(Node::section(&view.title)).with(Node::Link {
230 text: format!("by {}", view.creator_display_name),
231 action: Action::external(&view.profile_url),
232 });
233 if !view.description_excerpt.is_empty() {
234 slot = slot.with(Node::text(&view.description_excerpt));
235 }
236 let slot = slot
237 .with(Node::text(&view.price))
238 .with(Node::Act(buy(view)));
239 Screen::list_detail(&view.title, false).with(slot)
240 }
241
242 /// The audio player: the card, with the transport in a bespoke region.
243 ///
244 /// `d86122cf`, ruled by Max 2026-08-18: **bespoke for now, widgets eventually.**
245 /// A play button, a scrub bar and an elapsed readout are a media transport, and
246 /// the vocabulary names none of the three on purpose — describing playback would
247 /// put scrub, rate and chapters into a core two of the three renderers could only
248 /// degrade. So this screen describes the chrome around the player and leaves the
249 /// player alone, which is exactly what a [`RegionKind::Bespoke`] is for.
250 ///
251 /// The markup and the script that fills it are [`player_markup`], unchanged from
252 /// the template this replaces.
253 #[must_use]
254 pub fn item_player(view: &ItemView) -> Screen {
255 let mut slot = Slot::new(REGION, RegionKind::Pane);
256 if let Some(url) = &view.cover_image_url {
257 slot = slot.with(cover(url));
258 }
259 let slot = slot
260 .with(Node::section(&view.title))
261 .with(Node::text(format!("by {}", view.creator_display_name)))
262 .with(Node::text(&view.price))
263 .with(Node::Act(buy(view)));
264
265 Screen::list_detail(&view.title, false)
266 .with(slot)
267 .with(Slot::new(
268 PLAYER_REGION,
269 RegionKind::Bespoke {
270 name: "media-transport".into(),
271 },
272 ))
273 }
274
275 /// The bespoke region the transport is mounted in.
276 pub const PLAYER_REGION: &str = "transport";
277
278 /// The player document: the described chrome, with the transport mounted.
279 ///
280 /// Its own function rather than [`document`] with an argument, because the
281 /// player is the one embed whose renderer carries a fill and whose head carries
282 /// a second sheet. Both are about the same one thing — the island this screen
283 /// deliberately does not describe — so they are named together.
284 #[must_use]
285 pub fn player_document(view: &ItemView, preview_url: &str) -> String {
286 let mut shell = Shell::default()
287 .without_htmx()
288 .without_hyperscript()
289 .without_clock()
290 .without_fill()
291 .without_reveal()
292 .without_repeat()
293 .with_chrome(Chrome::new())
294 .with_head_first(format!("{}<style>{PLAYER_CSS}</style>", head_first()));
295 shell.body_class = Some("embed-player".to_owned());
296 Webview::new()
297 .with_shell(shell)
298 .with_fill(PLAYER_REGION, player_markup(preview_url))
299 .screen(&item_player(view))
300 }
301
302 /// The player island, and the script that drives it.
303 ///
304 /// Verbatim from `templates/embed/item_player.html`, which is the whole point of
305 /// a bespoke region: the behaviour is already implemented once and tested, and
306 /// converting the page around it must not rewrite it. The classes are this
307 /// host's own and are styled by [`PLAYER_CSS`].
308 ///
309 /// `preview_url` is the one value from outside, and it is escaped here: a
310 /// bespoke fill is markup and nothing downstream escapes it.
311 #[must_use]
312 pub fn player_markup(preview_url: &str) -> String {
313 format!(
314 r#"<div class="transport" data-preview-url="{}">
315 <button class="play-btn" id="play">&#9654;</button>
316 <div class="progress-bar" id="progress-bar"><div class="progress-fill" id="progress"></div></div>
317 <span class="time" id="time">0:00</span>
318 </div>
319 <span class="preview-label">Preview</span>
320 <script src="/static/embed-item-player.js?v=0623" defer></script>"#,
321 crate::helpers::escape_html(preview_url)
322 )
323 }
324
325 /// The transport's own rules, which are about a control the design system does
326 /// not name.
327 ///
328 /// Kept out of [`DOCUMENT_CSS`] because it applies to one embed, and kept in
329 /// this crate because the markup it styles is this crate's. Colour is tokens
330 /// throughout, the same rule the rest of the document keeps.
331 pub const PLAYER_CSS: &str = "\
332 .transport { display: flex; align-items: center; gap: var(--step-base); }
333 .play-btn {
334 width: 32px; height: 32px; border-radius: 50%;
335 background: var(--action); color: var(--content-on-action); border: none;
336 cursor: pointer; display: flex; align-items: center; justify-content: center;
337 flex: none;
338 }
339 .play-btn:hover { background: var(--action-hover); }
340 .progress-bar {
341 flex: 1; height: 4px; background: var(--surface-sunken);
342 border-radius: 2px; cursor: pointer; position: relative;
343 }
344 .progress-fill { height: 100%; background: var(--action); border-radius: 2px; width: 0%; }
345 .time { font-family: var(--font-mono); color: var(--content-muted); white-space: nowrap; }
346 .preview-label { color: var(--content-muted); }
347 ";
348
349 /// What a project embed is drawn from.
350 pub struct ProjectView {
351 /// The project's title.
352 pub title: String,
353 /// Who made it.
354 pub creator_display_name: String,
355 /// Their page, on makenot.work.
356 pub profile_url: String,
357 /// The project's page, on makenot.work.
358 pub project_url: String,
359 /// The cover art, when the project has any.
360 pub cover_image_url: Option<String>,
361 /// The first 150 characters of the description.
362 pub description_excerpt: String,
363 /// How many items it holds.
364 pub item_count: usize,
365 /// What kind of project it is.
366 pub category_label: String,
367 }
368
369 /// The project card: [`item_card`]'s shape, about a project.
370 #[must_use]
371 pub fn project_card(view: &ProjectView) -> Screen {
372 let mut slot = Slot::new(REGION, RegionKind::Pane);
373 if let Some(url) = &view.cover_image_url {
374 slot = slot.with(cover(url));
375 }
376 slot = slot.with(Node::section(&view.title)).with(Node::Link {
377 text: format!("by {}", view.creator_display_name),
378 action: Action::external(&view.profile_url),
379 });
380 if !view.description_excerpt.is_empty() {
381 slot = slot.with(Node::text(&view.description_excerpt));
382 }
383 // The count and the kind read together and neither stands on its own, so
384 // they are one line rather than two nodes.
385 let slot = slot
386 .with(Node::text(format!(
387 "{} {} \u{b7} {}",
388 view.item_count,
389 if view.item_count == 1 {
390 "item"
391 } else {
392 "items"
393 },
394 view.category_label
395 )))
396 .with(Node::Act(Act::new(
397 "View project",
398 Action::external(&view.project_url),
399 )));
400 Screen::list_detail(&view.title, false).with(slot)
401 }
402
403 /// What a tip embed is drawn from.
404 pub struct TipView {
405 /// The creator's display name, for the document title.
406 pub display_name: String,
407 /// Their handle, which is what the label reads.
408 pub username: String,
409 /// Where the support control goes, on makenot.work.
410 pub tip_url: String,
411 /// Their avatar, when they have one.
412 pub avatar_url: Option<String>,
413 }
414
415 /// The tip button.
416 #[must_use]
417 pub fn tip_button(view: &TipView) -> Screen {
418 let mut row = Row::new("");
419 if let Some(url) = &view.avatar_url {
420 row = row.part(layout::RowPart::Primary, cover(url));
421 }
422 let row = row
423 .part(
424 layout::RowPart::Primary,
425 Node::text(format!("Support @{}", view.username)),
426 )
427 .part(
428 layout::RowPart::Actions,
429 Node::Act(Act::new("Support", Action::external(&view.tip_url))),
430 );
431 one(&format!("Support {}", view.display_name), Node::list([row]))
432 }
433
434 #[cfg(test)]
435 mod tests {
436 use super::*;
437
438 fn item() -> ItemView {
439 ItemView {
440 title: "Item".into(),
441 price: "$9".into(),
442 button_text: "Buy".into(),
443 purchase_url: "https://makenot.work/buy/one".into(),
444 cover_image_url: Some("https://makenot.work/cover.png".into()),
445 creator_display_name: "Creator".into(),
446 profile_url: "https://makenot.work/u/creator".into(),
447 description_excerpt: "About it.".into(),
448 }
449 }
450
451 fn hex_literals(css: &str) -> Vec<String> {
452 css.split('#')
453 .skip(1)
454 .map(|tail| {
455 tail.chars()
456 .take_while(char::is_ascii_hexdigit)
457 .collect::<String>()
458 })
459 .filter(|run| run.len() == 3 || run.len() == 6)
460 .map(|run| format!("#{run}"))
461 .collect()
462 }
463
464 /// The regression guard the templates carried, kept: `#5a4bd6` sat in all
465 /// five of them as a hover violet matching no token in the tree, and nothing
466 /// was looking. What this host still writes by hand is two constants, so
467 /// this is now a check on two strings rather than on five rendered pages.
468 #[test]
469 fn this_host_writes_no_colour_of_its_own() {
470 for (name, css) in [("document", DOCUMENT_CSS), ("player", PLAYER_CSS)] {
471 let found = hex_literals(css);
472 assert!(
473 found.is_empty(),
474 "{name} writes its own colour: {found:?}. Use the token instead; \
475 a literal here drifts from the theme and nothing will report it.",
476 );
477 }
478 }
479
480 /// An embed cannot link a sheet, so every layer has to arrive in the head.
481 #[test]
482 fn an_embed_document_carries_the_whole_design_system() {
483 let html = document(&item_button(&item()), Some("embed-button"));
484 assert!(html.contains("<style>"), "{html}");
485 // Colour, spacing and typography come from the generated files, so the
486 // check is that each block is present rather than what is in it.
487 assert!(html.contains(":root"), "{html}");
488 assert!(html.contains("--font-sans"), "{html}");
489 assert!(html.contains(".row-primary"), "{html}");
490 // And nothing is linked, because nothing can be.
491 assert!(!html.contains("<link"), "{html}");
492 }
493
494 /// `54d7f8cf`'s one requirement of `Shell`: an embed asks no route, so it
495 /// takes no transport.
496 #[test]
497 fn an_embed_document_carries_no_script() {
498 let html = document(&item_button(&item()), Some("embed-button"));
499 assert!(!html.contains("<script"), "{html}");
500 assert!(!html.contains("htmx"), "{html}");
501 }
502
503 /// Every control on an embed leaves the site, which is what makes it an
504 /// anchor rather than something that asks a route.
505 #[test]
506 fn every_control_is_a_link_out() {
507 let html = document(&item_button(&item()), Some("embed-button"));
508 assert!(
509 html.contains(r#"href="https://makenot.work/buy/one""#),
510 "{html}"
511 );
512 assert!(html.contains(r#"rel="noopener noreferrer""#), "{html}");
513 assert!(!html.contains("hx-get"), "{html}");
514 }
515
516 /// A reader's text reaches the document as text. The templates got this
517 /// from Askama's autoescaping; a described screen gets it from the renderer,
518 /// and the guarantee has to survive the change.
519 #[test]
520 fn a_title_cannot_open_a_tag() {
521 let mut view = item();
522 view.title = "<script>alert(1)</script>".into();
523 let html = document(&item_button(&view), Some("embed-button"));
524 assert!(!html.contains("<script>alert"), "{html}");
525 assert!(html.contains("&lt;script&gt;"), "{html}");
526 }
527
528 /// The player describes the chrome and leaves the transport alone, which is
529 /// `d86122cf`'s ruling in one assertion.
530 #[test]
531 fn the_player_keeps_its_transport_in_a_bespoke_region() {
532 let html = player_document(&item(), "https://makenot.work/p.mp3");
533 assert!(html.contains(r#"id="play""#), "{html}");
534 assert!(html.contains("embed-item-player.js"), "{html}");
535 assert!(html.contains(r#"data-bespoke="media-transport""#), "{html}");
536 // The chrome around it is described rather than written: a heading, a
537 // line about who made it, a price and a control, none of them markup
538 // this crate spells.
539 assert!(html.contains(r#"<h2 class="heading">Item</h2>"#), "{html}");
540 assert!(html.contains(">Buy</a>"), "{html}");
541 // The player is the one embed that carries a script, and it carries
542 // exactly one: its own.
543 assert_eq!(html.matches("<script").count(), 1, "{html}");
544 }
545
546 /// A preview URL is the one value that reaches markup this crate writes, so
547 /// it is the one this crate escapes.
548 #[test]
549 fn a_preview_url_cannot_break_out_of_its_attribute() {
550 let markup = player_markup("\" onload=alert(1) x=\"");
551 assert!(!markup.contains("\" onload"), "{markup}");
552 }
553 }
554