Skip to main content

max / makenotwork

23.1 KB · 558 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 //! # One region, said as one region
40 //!
41 //! An embed is **one region**, which [`layout::Arrangement::Single`] says as of
42 //! makeover-layout 0.41.0. These screens named `list_detail` before that and
43 //! [`DOCUMENT_CSS`] spent a rule undoing the two-column grid it produced, which
44 //! is a host contradicting the description rather than adding to it. The
45 //! description now says the shape and the host adds only its own furniture.
46
47 use makeover_layout as layout;
48 use quasi_axum::Serves as _;
49 use quasi_router::screen::{Act, Picture, Row};
50 use quasi_router::{Action, Chrome, Document, Node, RegionKind, Screen, Slot};
51 use quasi_webview::{Shell, Webview};
52
53 use crate::templates::{EMBED_GEOMETRY_CSS, EMBED_TYPOGRAPHY_CSS, embed_theme_css};
54
55 /// The composition layer, written by `build.rs` from makeover-webview.
56 ///
57 /// The sheet every other page links from `/static/layout.css`. An embed inlines
58 /// it for [`DOCUMENT_CSS`]'s reason, which costs 15 KB in a response cached for
59 /// five minutes and is what buys the row, the list and the control looking like
60 /// the product rather than like five hand-written approximations of it.
61 const LAYOUT_CSS: &str = include_str!("../../static/layout.css");
62
63 /// The region every embed's content sits in.
64 const REGION: &str = "embed";
65
66 /// The rules that are about the document rather than about the design system.
67 ///
68 /// Everything here is either a browser default being undone or a fact about
69 /// being an iframe: the frame is the size the host page gave it, so the body
70 /// fills it, and one region fills the body. No colour, no font stack, no
71 /// spacing step — those are tokens, and a literal here would drift from the
72 /// theme with nothing looking. The five `<style>` blocks this replaces are what
73 /// that looks like when it goes wrong: `#5a4bd6` sat in all five as a hover
74 /// violet that matched no token in the tree.
75 ///
76 /// The two compact embeds size their picture here, and they are the only rule
77 /// in this document that overrides the design system rather than sitting beside
78 /// it. makeover gives a picture `width: 100%` and leaves the box to whoever
79 /// placed it, which is right for a card — the cover spans it — and wrong for a
80 /// button, where the cover is a 40-pixel thumbnail in a row. Stated rather than
81 /// hidden: this document is unlayered and therefore beats `@layer makeover`,
82 /// which is exactly what `check_css_overlap` names when it happens in a file.
83 const DOCUMENT_CSS: &str = "\
84 * { margin: 0; padding: 0; box-sizing: border-box; }
85 body {
86 font-family: var(--font-sans);
87 background: var(--surface-page);
88 color: var(--content);
89 }
90 main.single {
91 min-height: 100vh;
92 padding: var(--step-base) var(--gap-section);
93 }
94 body.embed-button .picture-img { flex: none; width: 40px; height: 40px; }
95 body.embed-tip .picture-img { flex: none; width: 32px; height: 32px; border-radius: 50%; }
96 ";
97
98 /// A whole embed document: the design system, then the described screen.
99 ///
100 /// What the two card layouts and the player differ by is a class on `<body>`,
101 /// and it rides on the screen ([`Screen::document`]) rather than on this
102 /// function's shell. It used to be a second parameter, because a shell was the
103 /// only thing that could carry a body class and this function builds one per
104 /// call -- which is why the embeds could do it and the adapter-served screens
105 /// could not. quasicoherent `ee1882e0` put the fact on the screen, so the
106 /// workaround came out with it.
107 ///
108 /// Still this host's question about its own furniture rather than a described
109 /// property: how one row is laid out in one frame is not something a terminal
110 /// would have an answer to.
111 #[must_use]
112 pub fn document(screen: &Screen) -> String {
113 let shell = Shell::default()
114 // An embed calls no route, so it takes no transport and no scripts.
115 // A document that does ask fails visibly on the first control pressed,
116 // and nothing here asks: every control is an external link.
117 //
118 // This was nine `without_` calls, growing by one every time quasi
119 // added a script: 0.54.0 the fill script, 0.59.0 and 0.60.0 reveal and
120 // repeat, 0.68.0 copy, 0.73.0 menu, 0.79.0 outline. Each is
121 // independent of htmx by design -- what they read is attributes on a
122 // control, a region or a fieldset -- so dropping the transport never
123 // dropped them and each had to be refused by name. quasi 0.92.0 says
124 // it in one, which is the list living where the scripts do rather than
125 // at every host that wanted none.
126 .without_scripts()
127 .with_chrome(Chrome::new())
128 .with_head_first(head_first());
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::single(title).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::single(&view.title).with(slot)
240 }
241
242 /// The audio player: the card, with the transport in a bespoke region.
243 ///
244 /// **Bespoke for now, widgets eventually.** A play button, a scrub bar and an
245 /// elapsed readout are a media transport, and the vocabulary names none of the
246 /// 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::single(&view.title).with(slot).with(Slot::new(
266 PLAYER_REGION,
267 RegionKind::Bespoke {
268 name: "media-transport".into(),
269 },
270 ))
271 }
272
273 /// The bespoke region the transport is mounted in.
274 pub const PLAYER_REGION: &str = "transport";
275
276 /// The player document: the described chrome, with the transport mounted.
277 ///
278 /// Its own function rather than [`document`] with an argument, because the
279 /// player is the one embed whose renderer carries a fill and whose head carries
280 /// a second sheet. Both are about the same one thing — the island this screen
281 /// deliberately does not describe — so they are named together.
282 #[must_use]
283 pub fn player_document(view: &ItemView, preview_url: &str) -> String {
284 let shell = Shell::default()
285 .without_htmx()
286 .without_hyperscript()
287 .without_clock()
288 .without_fill()
289 .without_reveal()
290 .without_repeat()
291 .without_copy()
292 .without_menu()
293 .without_outline()
294 .with_chrome(Chrome::new())
295 .with_head_first(format!("{}<style>{PLAYER_CSS}</style>", head_first()));
296 Webview::new()
297 .with_shell(shell)
298 .with_fill(PLAYER_REGION, player_markup(preview_url))
299 .screen(&item_player(view).documented(Document::default().classed("embed-player")))
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::single(&view.title).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 =
484 document(&item_button(&item()).documented(Document::default().classed("embed-button")));
485 assert!(html.contains("<style>"), "{html}");
486 // Colour, spacing and typography come from the generated files, so the
487 // check is that each block is present rather than what is in it.
488 assert!(html.contains(":root"), "{html}");
489 assert!(html.contains("--font-sans"), "{html}");
490 assert!(html.contains(".row-primary"), "{html}");
491 // And nothing is linked, because nothing can be.
492 assert!(!html.contains("<link"), "{html}");
493 }
494
495 /// `54d7f8cf`'s one requirement of `Shell`: an embed asks no route, so it
496 /// takes no transport.
497 #[test]
498 fn an_embed_document_carries_no_script() {
499 let html =
500 document(&item_button(&item()).documented(Document::default().classed("embed-button")));
501 assert!(!html.contains("<script"), "{html}");
502 assert!(!html.contains("htmx"), "{html}");
503 }
504
505 /// Every control on an embed leaves the site, which is what makes it an
506 /// anchor rather than something that asks a route.
507 #[test]
508 fn every_control_is_a_link_out() {
509 let html =
510 document(&item_button(&item()).documented(Document::default().classed("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 =
527 document(&item_button(&view).documented(Document::default().classed("embed-button")));
528 assert!(!html.contains("<script>alert"), "{html}");
529 assert!(html.contains("&lt;script&gt;"), "{html}");
530 }
531
532 /// The player describes the chrome and leaves the transport alone, which is
533 /// `d86122cf`'s ruling in one assertion.
534 #[test]
535 fn the_player_keeps_its_transport_in_a_bespoke_region() {
536 let html = player_document(&item(), "https://makenot.work/p.mp3");
537 assert!(html.contains(r#"id="play""#), "{html}");
538 assert!(html.contains("embed-item-player.js"), "{html}");
539 assert!(html.contains(r#"data-bespoke="media-transport""#), "{html}");
540 // The chrome around it is described rather than written: a heading, a
541 // line about who made it, a price and a control, none of them markup
542 // this crate spells.
543 assert!(html.contains(r#"<h2 class="heading">Item</h2>"#), "{html}");
544 assert!(html.contains(">Buy</a>"), "{html}");
545 // The player is the one embed that carries a script, and it carries
546 // exactly one: its own.
547 assert_eq!(html.matches("<script").count(), 1, "{html}");
548 }
549
550 /// A preview URL is the one value that reaches markup this crate writes, so
551 /// it is the one this crate escapes.
552 #[test]
553 fn a_preview_url_cannot_break_out_of_its_attribute() {
554 let markup = player_markup("\" onload=alert(1) x=\"");
555 assert!(!markup.contains("\" onload"), "{markup}");
556 }
557 }
558