Skip to main content

max / makenotwork

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