Skip to main content

max / makenotwork

24.6 KB · 608 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 quasi_axum::Serves as _;
48 use quasi_declare::declare;
49 use quasi_router::{Chrome, Document, RegionKind, Screen};
50 use quasi_webview::{Shell, Webview};
51
52 use crate::templates::{EMBED_GEOMETRY_CSS, EMBED_TYPOGRAPHY_CSS, embed_theme_css};
53
54 /// The composition layer, written by `build.rs` from makeover-webview.
55 ///
56 /// The sheet every other page links from `/static/layout.css`. An embed inlines
57 /// it for [`DOCUMENT_CSS`]'s reason, which costs 15 KB in a response cached for
58 /// five minutes and is what buys the row, the list and the control looking like
59 /// the product rather than like five hand-written approximations of it.
60 const LAYOUT_CSS: &str = include_str!("../../static/layout.css");
61
62 /// The region every embed's content sits in.
63 const REGION: &str = "embed";
64
65 /// The rules that are about the document rather than about the design system.
66 ///
67 /// Everything here is either a browser default being undone or a fact about
68 /// being an iframe: the frame is the size the host page gave it, so the body
69 /// fills it, and one region fills the body. No colour, no font stack, no
70 /// spacing step — those are tokens, and a literal here would drift from the
71 /// theme with nothing looking. The five `<style>` blocks this replaces are what
72 /// that looks like when it goes wrong: `#5a4bd6` sat in all five as a hover
73 /// violet that matched no token in the tree.
74 ///
75 /// The two compact embeds size their picture here, and they are the only rule
76 /// in this document that overrides the design system rather than sitting beside
77 /// it. makeover gives a picture `width: 100%` and leaves the box to whoever
78 /// placed it, which is right for a card — the cover spans it — and wrong for a
79 /// button, where the cover is a 40-pixel thumbnail in a row. Stated rather than
80 /// hidden: this document is unlayered and therefore beats `@layer makeover`,
81 /// which is exactly what `check_css_overlap` names when it happens in a file.
82 const DOCUMENT_CSS: &str = "\
83 * { margin: 0; padding: 0; box-sizing: border-box; }
84 body {
85 font-family: var(--font-sans);
86 background: var(--surface-page);
87 color: var(--content);
88 }
89 main.single {
90 min-height: 100vh;
91 padding: var(--step-base) var(--gap-section);
92 }
93 body.embed-button .picture-img { flex: none; width: 40px; height: 40px; }
94 body.embed-tip .picture-img { flex: none; width: 32px; height: 32px; border-radius: 50%; }
95 ";
96
97 /// A whole embed document: the design system, then the described screen.
98 ///
99 /// What the two card layouts and the player differ by is a class on `<body>`,
100 /// and it rides on the screen ([`Screen::document`]) rather than on this
101 /// function's shell. It used to be a second parameter, because a shell was the
102 /// only thing that could carry a body class and this function builds one per
103 /// call -- which is why the embeds could do it and the adapter-served screens
104 /// could not. quasicoherent `ee1882e0` put the fact on the screen, so the
105 /// workaround came out with it.
106 ///
107 /// Still this host's question about its own furniture rather than a described
108 /// property: how one row is laid out in one frame is not something a terminal
109 /// would have an answer to.
110 #[must_use]
111 pub fn document(screen: &Screen) -> String {
112 let shell = Shell::default()
113 // An embed calls no route, so it takes no transport and no scripts.
114 // A document that does ask fails visibly on the first control pressed,
115 // and nothing here asks: every control is an external link.
116 //
117 // This was nine `without_` calls, growing by one every time quasi
118 // added a script: 0.54.0 the fill script, 0.59.0 and 0.60.0 reveal and
119 // repeat, 0.68.0 copy, 0.73.0 menu, 0.79.0 outline. Each is
120 // independent of htmx by design -- what they read is attributes on a
121 // control, a region or a fieldset -- so dropping the transport never
122 // dropped them and each had to be refused by name. quasi 0.92.0 says
123 // it in one, which is the list living where the scripts do rather than
124 // at every host that wanted none.
125 .without_scripts()
126 .with_chrome(Chrome::new())
127 .with_head_first(head_first());
128 Webview::new().with_shell(shell).screen(screen)
129 }
130
131 /// Every layer of the design system, inlined, in cascade order.
132 ///
133 /// Colour first because the rest reads tokens off it. Composition last because
134 /// it is the layer the described markup is styled by, and `@layer makeover` puts
135 /// it under anything the document adds after.
136 fn head_first() -> String {
137 format!(
138 "<style>{}{}{}{}{}</style>",
139 embed_theme_css(),
140 EMBED_GEOMETRY_CSS,
141 EMBED_TYPOGRAPHY_CSS,
142 LAYOUT_CSS,
143 DOCUMENT_CSS,
144 )
145 }
146
147 declare! {
148 /// A cover picture, cropped to its box.
149 ///
150 /// `Fit::Cover` because a cover is a fixed square here and the art it holds
151 /// is any shape: the alternative is letterboxing inside a 40-pixel box,
152 /// which is the art unreadable and the box the wrong colour.
153 ///
154 /// The alt text is empty on purpose. A cover repeats the title beside it, so
155 /// a reader who cannot see the bytes is told nothing by a second copy of the
156 /// name -- which is what [`Image::speaks`](quasi_router::Image::speaks) is
157 /// for.
158 shape cover(url: &str) -> Node;
159
160 picture url "" {
161 fit Cover;
162 }
163 }
164
165 /// What an item embed is drawn from.
166 ///
167 /// A view rather than the database row, so a screen can be built in a test
168 /// without a connection. The same split every described screen here makes.
169 pub struct ItemView {
170 /// The item's title.
171 pub title: String,
172 /// The price as the canonical formatter writes it.
173 pub price: String,
174 /// What the buy control says: "Buy" or "Get".
175 pub button_text: String,
176 /// Where the buy control goes, on makenot.work.
177 pub purchase_url: String,
178 /// The cover art, when the item has any.
179 pub cover_image_url: Option<String>,
180 /// Who made it.
181 pub creator_display_name: String,
182 /// Their page, on makenot.work.
183 pub profile_url: String,
184 /// The first 150 characters of the description.
185 pub description_excerpt: String,
186 }
187
188 impl ItemView {
189 /// Whether there is cover art to draw.
190 ///
191 /// A predicate and a reader rather than an `Option` the description reaches
192 /// into: a guard asks one question and the picture is built either way, so
193 /// the absent case hands [`cover`] an empty source and nothing places it.
194 fn has_cover(&self) -> bool {
195 self.cover_image_url.is_some()
196 }
197
198 /// The cover art's address, or nothing.
199 fn cover_url(&self) -> &str {
200 self.cover_image_url.as_deref().unwrap_or_default()
201 }
202 }
203
204 declare! {
205 /// The buy control: a link out to makenot.work.
206 ///
207 /// [`Destination::External`](quasi_router::Destination::External), which is
208 /// what makes it an anchor with `rel="noopener noreferrer"` in the webview
209 /// rather than a button that asks a route. An embed's every control is one
210 /// of these.
211 shape buy(view: &ItemView) -> Act;
212
213 act &view.button_text to external &view.purchase_url;
214 }
215
216 declare! {
217 /// The buy button: cover, title, price, and the control, on one line.
218 ///
219 /// The one embed that is genuinely a [`Row`](quasi_router::Row): a compact
220 /// strip where the cover is a thumbnail beside the title rather than the
221 /// card's own picture. Drawn with the `embed-button` body class, which is
222 /// what sizes that thumbnail.
223 ///
224 /// The price is a setting and the other three are parts, which is the
225 /// difference between a short trailing fact and something placed by role.
226 #[must_use]
227 pub shape item_button(view: &ItemView) -> Screen;
228
229 screen single &view.title {
230 region REGION as Pane {
231 list {
232 row "" {
233 beside Primary include cover(view.cover_url()) when view.has_cover();
234 beside Primary text &view.title;
235 meta &view.price;
236 beside Actions include buy(view);
237 }
238 }
239 }
240 }
241 }
242
243 declare! {
244 /// The product card: the cover, what it is, who made it, and the control.
245 ///
246 /// Blocks rather than one row, which is the difference between a card and a
247 /// button. A row is an inline run and its parts share a line by role, so a
248 /// card said as a row would read "coverTitle" with the excerpt and the price
249 /// crushed in beside it. What a card actually is -- a picture, a heading, a
250 /// line about who made it, a paragraph, a price and a control, each on its
251 /// own line -- is a region holding six nodes, every one of which the
252 /// vocabulary already names.
253 #[must_use]
254 pub shape item_card(view: &ItemView) -> Screen;
255
256 screen single &view.title {
257 region REGION as Pane {
258 include cover(view.cover_url()) when view.has_cover();
259 section &view.title;
260 link "by {view.creator_display_name}" to external &view.profile_url;
261 text &view.description_excerpt unless view.description_excerpt.is_empty();
262 text &view.price;
263 include buy(view);
264 }
265 }
266 }
267
268 declare! {
269 /// The audio player: the card, with the transport in a bespoke region.
270 ///
271 /// **A handover for now, a widget eventually.** A play button, a scrub bar
272 /// and an elapsed readout are a media transport, and the vocabulary names
273 /// none of the three on purpose -- describing playback would put scrub, rate
274 /// and chapters into a core two of the three renderers could only degrade.
275 /// So this screen describes the chrome around the player and leaves the
276 /// player alone, which is exactly what a handover region is for: the fill is
277 /// owed, and a renderer without one should say so rather than draw an empty
278 /// box where the transport goes.
279 ///
280 /// The markup and the script that fills it are [`player_markup`], unchanged
281 /// from the template this replaces.
282 ///
283 /// Who made it is text here and a link on the card, which the template had
284 /// too: the player's chrome is a caption over a control, not a place to send
285 /// somebody else.
286 #[must_use]
287 pub shape item_player(view: &ItemView) -> Screen;
288
289 screen single &view.title {
290 region REGION as Pane {
291 include cover(view.cover_url()) when view.has_cover();
292 section &view.title;
293 text "by {view.creator_display_name}";
294 text &view.price;
295 include buy(view);
296 }
297
298 region PLAYER_REGION as RegionKind::handover("media-transport") {}
299 }
300 }
301
302 /// The handover region the transport is mounted in.
303 pub const PLAYER_REGION: &str = "transport";
304
305 /// The player document: the described chrome, with the transport mounted.
306 ///
307 /// Its own function rather than [`document`] with an argument, because the
308 /// player is the one embed whose renderer carries a fill and whose head carries
309 /// a second sheet. Both are about the same one thing — the island this screen
310 /// deliberately does not describe — so they are named together.
311 #[must_use]
312 pub fn player_document(view: &ItemView, preview_url: &str) -> String {
313 let shell = Shell::default()
314 .without_htmx()
315 .without_hyperscript()
316 .without_clock()
317 .without_fill()
318 .without_reveal()
319 .without_repeat()
320 .without_copy()
321 .without_menu()
322 .without_outline()
323 .with_chrome(Chrome::new())
324 .with_head_first(format!("{}<style>{PLAYER_CSS}</style>", head_first()));
325 Webview::new()
326 .with_shell(shell)
327 .with_fill(PLAYER_REGION, player_markup(preview_url))
328 .screen(&item_player(view).documented(Document::default().classed("embed-player")))
329 }
330
331 /// The player island, and the script that drives it.
332 ///
333 /// Verbatim from `templates/embed/item_player.html`, which is the whole point of
334 /// a bespoke region: the behaviour is already implemented once and tested, and
335 /// converting the page around it must not rewrite it. The classes are this
336 /// host's own and are styled by [`PLAYER_CSS`].
337 ///
338 /// `preview_url` is the one value from outside, and it is escaped here: a
339 /// bespoke fill is markup and nothing downstream escapes it.
340 #[must_use]
341 pub fn player_markup(preview_url: &str) -> String {
342 format!(
343 r#"<div class="transport" data-preview-url="{}">
344 <button class="play-btn" id="play">&#9654;</button>
345 <div class="progress-bar" id="progress-bar"><div class="progress-fill" id="progress"></div></div>
346 <span class="time" id="time">0:00</span>
347 </div>
348 <span class="preview-label">Preview</span>
349 <script src="/static/embed-item-player.js?v=0623" defer></script>"#,
350 crate::helpers::escape_html(preview_url)
351 )
352 }
353
354 /// The transport's own rules, which are about a control the design system does
355 /// not name.
356 ///
357 /// Kept out of [`DOCUMENT_CSS`] because it applies to one embed, and kept in
358 /// this crate because the markup it styles is this crate's. Colour is tokens
359 /// throughout, the same rule the rest of the document keeps.
360 pub const PLAYER_CSS: &str = "\
361 .transport { display: flex; align-items: center; gap: var(--step-base); }
362 .play-btn {
363 width: 32px; height: 32px; border-radius: 50%;
364 background: var(--action); color: var(--content-on-action); border: none;
365 cursor: pointer; display: flex; align-items: center; justify-content: center;
366 flex: none;
367 }
368 .play-btn:hover { background: var(--action-hover); }
369 .progress-bar {
370 flex: 1; height: 4px; background: var(--surface-sunken);
371 border-radius: 2px; cursor: pointer; position: relative;
372 }
373 .progress-fill { height: 100%; background: var(--action); border-radius: 2px; width: 0%; }
374 .time { font-family: var(--font-mono); color: var(--content-muted); white-space: nowrap; }
375 .preview-label { color: var(--content-muted); }
376 ";
377
378 /// What a project embed is drawn from.
379 pub struct ProjectView {
380 /// The project's title.
381 pub title: String,
382 /// Who made it.
383 pub creator_display_name: String,
384 /// Their page, on makenot.work.
385 pub profile_url: String,
386 /// The project's page, on makenot.work.
387 pub project_url: String,
388 /// The cover art, when the project has any.
389 pub cover_image_url: Option<String>,
390 /// The first 150 characters of the description.
391 pub description_excerpt: String,
392 /// How many items it holds.
393 pub item_count: usize,
394 /// What kind of project it is.
395 pub category_label: String,
396 }
397
398 impl ProjectView {
399 /// Whether there is cover art to draw. See [`ItemView::has_cover`].
400 fn has_cover(&self) -> bool {
401 self.cover_image_url.is_some()
402 }
403
404 /// The cover art's address, or nothing.
405 fn cover_url(&self) -> &str {
406 self.cover_image_url.as_deref().unwrap_or_default()
407 }
408
409 /// "item" or "items", for the count line.
410 fn items_word(&self) -> &'static str {
411 if self.item_count == 1 {
412 "item"
413 } else {
414 "items"
415 }
416 }
417 }
418
419 declare! {
420 /// The project card: [`item_card`]'s shape, about a project.
421 ///
422 /// The count and the kind read together and neither stands on its own, so
423 /// they are one line rather than two nodes.
424 #[must_use]
425 pub shape project_card(view: &ProjectView) -> Screen;
426
427 screen single &view.title {
428 region REGION as Pane {
429 include cover(view.cover_url()) when view.has_cover();
430 section &view.title;
431 link "by {view.creator_display_name}" to external &view.profile_url;
432 text &view.description_excerpt unless view.description_excerpt.is_empty();
433 text "{view.item_count} {view.items_word()} \u{b7} {view.category_label}";
434 act "View project" to external &view.project_url;
435 }
436 }
437 }
438
439 /// What a tip embed is drawn from.
440 pub struct TipView {
441 /// The creator's display name, for the document title.
442 pub display_name: String,
443 /// Their handle, which is what the label reads.
444 pub username: String,
445 /// Where the support control goes, on makenot.work.
446 pub tip_url: String,
447 /// Their avatar, when they have one.
448 pub avatar_url: Option<String>,
449 }
450
451 impl TipView {
452 /// Whether there is an avatar to draw. See [`ItemView::has_cover`].
453 fn has_avatar(&self) -> bool {
454 self.avatar_url.is_some()
455 }
456
457 /// The avatar's address, or nothing.
458 fn avatar(&self) -> &str {
459 self.avatar_url.as_deref().unwrap_or_default()
460 }
461 }
462
463 declare! {
464 /// The tip button.
465 ///
466 /// [`item_button`]'s strip with nothing between the label and the control:
467 /// a tip has no price to trail.
468 #[must_use]
469 pub shape tip_button(view: &TipView) -> Screen;
470
471 screen single "Support {view.display_name}" {
472 region REGION as Pane {
473 list {
474 row "" {
475 beside Primary include cover(view.avatar()) when view.has_avatar();
476 beside Primary text "Support @{view.username}";
477 beside Actions act "Support" to external &view.tip_url;
478 }
479 }
480 }
481 }
482 }
483
484 #[cfg(test)]
485 mod tests {
486 use super::*;
487
488 fn item() -> ItemView {
489 ItemView {
490 title: "Item".into(),
491 price: "$9".into(),
492 button_text: "Buy".into(),
493 purchase_url: "https://makenot.work/buy/one".into(),
494 cover_image_url: Some("https://makenot.work/cover.png".into()),
495 creator_display_name: "Creator".into(),
496 profile_url: "https://makenot.work/u/creator".into(),
497 description_excerpt: "About it.".into(),
498 }
499 }
500
501 fn hex_literals(css: &str) -> Vec<String> {
502 css.split('#')
503 .skip(1)
504 .map(|tail| {
505 tail.chars()
506 .take_while(char::is_ascii_hexdigit)
507 .collect::<String>()
508 })
509 .filter(|run| run.len() == 3 || run.len() == 6)
510 .map(|run| format!("#{run}"))
511 .collect()
512 }
513
514 /// The regression guard the templates carried, kept: `#5a4bd6` sat in all
515 /// five of them as a hover violet matching no token in the tree, and nothing
516 /// was looking. What this host still writes by hand is two constants, so
517 /// this is now a check on two strings rather than on five rendered pages.
518 #[test]
519 fn this_host_writes_no_colour_of_its_own() {
520 for (name, css) in [("document", DOCUMENT_CSS), ("player", PLAYER_CSS)] {
521 let found = hex_literals(css);
522 assert!(
523 found.is_empty(),
524 "{name} writes its own colour: {found:?}. Use the token instead; \
525 a literal here drifts from the theme and nothing will report it.",
526 );
527 }
528 }
529
530 /// An embed cannot link a sheet, so every layer has to arrive in the head.
531 #[test]
532 fn an_embed_document_carries_the_whole_design_system() {
533 let html =
534 document(&item_button(&item()).documented(Document::default().classed("embed-button")));
535 assert!(html.contains("<style>"), "{html}");
536 // Colour, spacing and typography come from the generated files, so the
537 // check is that each block is present rather than what is in it.
538 assert!(html.contains(":root"), "{html}");
539 assert!(html.contains("--font-sans"), "{html}");
540 assert!(html.contains(".row-primary"), "{html}");
541 // And nothing is linked, because nothing can be.
542 assert!(!html.contains("<link"), "{html}");
543 }
544
545 /// `54d7f8cf`'s one requirement of `Shell`: an embed asks no route, so it
546 /// takes no transport.
547 #[test]
548 fn an_embed_document_carries_no_script() {
549 let html =
550 document(&item_button(&item()).documented(Document::default().classed("embed-button")));
551 assert!(!html.contains("<script"), "{html}");
552 assert!(!html.contains("htmx"), "{html}");
553 }
554
555 /// Every control on an embed leaves the site, which is what makes it an
556 /// anchor rather than something that asks a route.
557 #[test]
558 fn every_control_is_a_link_out() {
559 let html =
560 document(&item_button(&item()).documented(Document::default().classed("embed-button")));
561 assert!(
562 html.contains(r#"href="https://makenot.work/buy/one""#),
563 "{html}"
564 );
565 assert!(html.contains(r#"rel="noopener noreferrer""#), "{html}");
566 assert!(!html.contains("hx-get"), "{html}");
567 }
568
569 /// A reader's text reaches the document as text. The templates got this
570 /// from Askama's autoescaping; a described screen gets it from the renderer,
571 /// and the guarantee has to survive the change.
572 #[test]
573 fn a_title_cannot_open_a_tag() {
574 let mut view = item();
575 view.title = "<script>alert(1)</script>".into();
576 let html =
577 document(&item_button(&view).documented(Document::default().classed("embed-button")));
578 assert!(!html.contains("<script>alert"), "{html}");
579 assert!(html.contains("&lt;script&gt;"), "{html}");
580 }
581
582 /// The player describes the chrome and leaves the transport alone, which is
583 /// `d86122cf`'s ruling in one assertion.
584 #[test]
585 fn the_player_keeps_its_transport_in_a_bespoke_region() {
586 let html = player_document(&item(), "https://makenot.work/p.mp3");
587 assert!(html.contains(r#"id="play""#), "{html}");
588 assert!(html.contains("embed-item-player.js"), "{html}");
589 assert!(html.contains(r#"data-bespoke="media-transport""#), "{html}");
590 // The chrome around it is described rather than written: a heading, a
591 // line about who made it, a price and a control, none of them markup
592 // this crate spells.
593 assert!(html.contains(r#"<h2 class="heading">Item</h2>"#), "{html}");
594 assert!(html.contains(">Buy</a>"), "{html}");
595 // The player is the one embed that carries a script, and it carries
596 // exactly one: its own.
597 assert_eq!(html.matches("<script").count(), 1, "{html}");
598 }
599
600 /// A preview URL is the one value that reaches markup this crate writes, so
601 /// it is the one this crate escapes.
602 #[test]
603 fn a_preview_url_cannot_break_out_of_its_attribute() {
604 let markup = player_markup("\" onload=alert(1) x=\"");
605 assert!(!markup.contains("\" onload"), "{markup}");
606 }
607 }
608