Skip to main content

max / makenotwork

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