Skip to main content

max / makenotwork

The carousel moves to quasi-basics; only the Askama glue stays It was written to leave and it left: `Frame`, `frames()` and `carousel()` named no MNW type, so this is a move rather than a rewrite. The ten widget tests went with them, and what is left here is one test that the template's own frame type arrives whole on the way through, which is the only part MNW owns now. Not `makeover-basics`, which cannot exist. A widget is an assembly of `Node`s, and `Node`, `Slot` and `Picture` are quasi-router's by rule: makeover-layout defers every address, and `layout::Image` deliberately carries no source. A widget with a picture in it is unsayable below quasi-router, so the tier sits above the description suite.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 21:57 UTC
Signed with PGP, not checked
Commit: 2707c7a5b626353044e79397e3421ef7aeeca843
Parent: 6e76b75
4 files changed, +74 insertions, -270 deletions
M server/Cargo.lock +21 -12
@@ -5255,6 +5255,7 @@
5255 5255 "pom-contract",
5256 5256 "proptest",
5257 5257 "quasi-axum",
5258 + "quasi-basics",
5258 5259 "quasi-router",
5259 5260 "quasi-webview",
5260 5261 "rand 0.10.2",
@@ -6558,6 +6559,14 @@
6558 6559 "tokio",
6559 6560 ]
6560 6561
6562 + [[package]]
6563 + name = "quasi-basics"
6564 + version = "0.4.0"
6565 + dependencies = [
6566 + "makeover-layout",
6567 + "quasi-router",
6568 + ]
6569 +
6561 6570 [[package]]
6562 6571 name = "quasi-http"
6563 6572 version = "0.4.0"
@@ -10687,18 +10696,6 @@
10687 10696 "pkg-config",
10688 10697 ]
10689 10698
10690 - [[patch.unused]]
10691 - name = "kberg"
10692 - version = "0.1.0"
10693 -
10694 - [[patch.unused]]
10695 - name = "ops-status"
10696 - version = "0.1.0"
10697 -
10698 - [[patch.unused]]
10699 - name = "painhours"
10700 - version = "0.1.0"
10701 -
10702 10699 [[patch.unused]]
10703 10700 name = "quasi-immediate"
10704 10701 version = "0.4.0"
@@ -10718,3 +10715,15 @@
10718 10715 [[patch.unused]]
10719 10716 name = "synckit-config"
10720 10717 version = "0.2.0"
10718 +
10719 + [[patch.unused]]
10720 + name = "kberg"
10721 + version = "0.1.0"
10722 +
10723 + [[patch.unused]]
10724 + name = "ops-status"
10725 + version = "0.1.0"
10726 +
10727 + [[patch.unused]]
10728 + name = "painhours"
10729 + version = "0.1.0"
@@ -169,6 +169,11 @@
169 169 http = "1.3.1"
170 170 quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
171 171 quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
172 + # The first-party widget set. The carousel was proved here against three pages
173 + # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
174 + # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
175 + # sits above quasi-router rather than inside the description suite.
176 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
172 177
173 178 # Git source browser
174 179 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -1,152 +1,25 @@
1 - //! An ordered set of frames, said once.
1 + //! The Askama entry point for `quasi_basics::carousel`.
2 2 //!
3 - //! The widget tier's first consumer (`c0b63ea9`), and the gap that started
4 - //! look wave 2. `makeover_layout::Region::Widget` arrived at 0.20.0 to make
5 - //! this sayable and `makeover_layout::Image` at 0.21.0 because the first
6 - //! attempt found nothing named a picture.
3 + //! The widget left for `quasi-basics` once three real pages had proved it, and
4 + //! this is the part that stayed: glue between a template's own frame type and
5 + //! the widget's. Called from `partials/carousel.html`, so all three call sites
6 + //! keep the macro they already have.
7 7 //!
8 - //! # This is written to leave
9 - //!
10 - //! It lives here to be proved against three real pages before it becomes a
11 - //! published crate, and it is shaped so that becoming one is a move rather than
12 - //! a rewrite: [`frames`] and [`carousel`] name no MNW type and reach for
13 - //! nothing but `quasi_router` and `makeover_layout`. The only part that stays
14 - //! behind is [`html`], which is glue between an Askama template and a renderer.
15 - //!
16 - //! When `makeover-basics` exists, the two functions move and this module keeps
17 - //! [`html`] alone, calling them through the crate.
18 - //!
19 - //! # Why the body is the frames and nothing else
20 - //!
21 - //! A carousel is a set of frames, a position, prev/next and a strip of dots.
22 - //! Only the first of those is *content*; the rest is chrome, and chrome is what
23 - //! a renderer that recognises the name draws its own way. A webview draws
24 - //! buttons over the frame, a terminal draws a pager with a count, egui draws a
25 - //! selector, and none of them owes the others a carousel primitive.
26 - //!
27 - //! So the description says "an ordered set of pictures, called a carousel" and
28 - //! stops. That is the whole of what every host agrees on.
29 - //!
30 - //! # What a renderer that has never heard of a carousel does
31 - //!
32 - //! It walks the body and draws the pictures in order. Nothing is lost: every
33 - //! frame is content, in sequence, and the reader can see all of them.
34 - //!
35 - //! This is a **better** fallback than the one being replaced. The shipped
36 - //! partial shows the first frame and makes the other two unreachable without
37 - //! JS, because the controls that would reach them are the part that needs
38 - //! scripting. Here the unenhanced rendering is the whole gallery, and the
39 - //! script's job is to collapse it to one at a time rather than to unlock the
40 - //! rest. Progressive enhancement in the direction that degrades to *more*
41 - //! content instead of less.
8 + //! Why the island wraps the region rather than being it: a custom element is
9 + //! what the browser re-upgrades after an htmx swap, which is the whole reason
10 + //! islands are custom elements, and the description emits a plain `<div>`
11 + //! because it has no idea this host prefixes its tags with `mnw-`. The template
12 + //! puts one around the other and both halves stay honest.
42 13
43 - use makeover_layout::Fit;
44 - use quasi_router::{Node, Picture, Slot};
45 -
46 - /// What the recognising renderer keys on. Never interpreted by the description.
47 - pub const NAME: &str = "carousel";
48 -
49 - /// One frame: a picture and what it says.
50 - ///
51 - /// Deliberately not MNW's `CarouselFrame`. That type is an Askama concern and
52 - /// carries what the templates happened to need; this is what the widget needs,
53 - /// and keeping them apart is what lets this module leave without dragging the
54 - /// template layer with it.
55 - #[derive(Debug, Clone, PartialEq, Eq)]
56 - pub struct Frame {
57 - /// Where the picture is.
58 - pub src: String,
59 - /// What the picture says, for anything not showing it.
60 - pub alt: String,
61 - /// A visible line under it, where there is one.
62 - pub caption: Option<String>,
63 - /// The picture's own dimensions, where the caller knows them.
64 - ///
65 - /// What lets the renderer hold the frame's place from first paint. Without
66 - /// it the frame occupies nothing until the bytes land and then takes its
67 - /// full height at once, which measured as a 478px jump on the landing page.
68 - pub intrinsic: Option<(u32, u32)>,
69 - }
70 -
71 - impl Frame {
72 - /// A frame at a source.
73 - pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
74 - Self {
75 - src: src.into(),
76 - alt: alt.into(),
77 - caption: None,
78 - intrinsic: None,
79 - }
80 - }
81 -
82 - /// The picture's own dimensions.
83 - #[must_use]
84 - pub const fn intrinsic(mut self, width: u32, height: u32) -> Self {
85 - self.intrinsic = Some((width, height));
86 - self
87 - }
88 -
89 - /// A visible line under it.
90 - #[must_use]
91 - pub fn caption(mut self, caption: impl Into<String>) -> Self {
92 - self.caption = Some(caption.into());
93 - self
94 - }
95 - }
96 -
97 - /// The frames as description nodes.
98 - ///
99 - /// Split out from [`carousel`] because a set of pictures in order is worth
100 - /// having on its own: a gallery that does not page is this without the widget
101 - /// name around it, and that is the second consumer this module expects.
102 - pub fn frames(frames: impl IntoIterator<Item = Frame>) -> impl Iterator<Item = Node> {
103 - frames.into_iter().enumerate().map(|(i, frame)| {
104 - // Natural, and it is the whole reason `Fit` has three members rather
105 - // than the one MNW uses at 15 of its 17 other sites. A screenshot
106 - // cropped to fill its box is a screenshot with its edges cut off, and
107 - // the edges of a screenshot of an interface are where the interface is.
108 - let mut picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural);
109 - if let Some((w, h)) = frame.intrinsic {
110 - picture = picture.intrinsic(w, h);
111 - }
112 - // The first frame is the one on screen, and the rest are not. Eager for
113 - // the one, lazy for the others -- which is the case that proves loading
114 - // cannot be a single setting the renderer picks: both answers are
115 - // correct, in one widget, at one moment.
116 - //
117 - // Getting this backwards is what the old partial did by lazily loading
118 - // all three, including the one the visitor was already looking at. That
119 - // delays the only picture that matters and buys nothing, because the
120 - // other two are display:none and were never going to be fetched early
121 - // anyway.
122 - if i > 0 {
123 - picture = picture.lazy();
124 - }
125 - Node::Image(match frame.caption {
126 - Some(caption) => picture.caption(caption),
127 - None => picture,
128 - })
129 - })
130 - }
131 -
132 - /// A carousel under an address.
133 - ///
134 - /// The id is the region's, which is how a fragment finds its way back to the
135 - /// right place, so it has to be unique on the page the way every slot id does.
136 - pub fn carousel(id: &str, items: impl IntoIterator<Item = Frame>) -> Slot {
137 - Slot::widget(id, NAME).extend(frames(items))
138 - }
14 + use quasi_basics::Frame;
15 + use quasi_router::Node;
139 16
140 17 /// The markup, for an Askama template to drop in.
141 - ///
142 - /// The glue, and the one part of this module that stays behind when the rest
143 - /// moves to `makeover-basics`. Called from `partials/carousel.html` so all
144 - /// three call sites keep the macro they already have.
145 18 #[must_use]
146 19 pub fn html(id: &str, items: &[crate::templates::CarouselFrame]) -> String {
147 20 use quasi_axum::Serves as _;
148 21
149 - let node = Node::Region(carousel(
22 + let node = Node::Region(quasi_basics::carousel(
150 23 id,
151 24 items.iter().map(|frame| {
152 25 let mut built = Frame::new(&frame.image, &frame.alt);
@@ -167,122 +40,36 @@
167 40
168 41 #[cfg(test)]
169 42 mod tests {
170 - use super::*;
171 - use quasi_axum::Serves as _;
172 -
173 - fn render(id: &str, items: Vec<Frame>) -> String {
174 - quasi_webview::Webview::new().fragment(&Node::Region(carousel(id, items)))
175 - }
176 -
177 - fn three() -> Vec<Frame> {
178 - vec![
179 - Frame::new("/a.png", "The library, mid-import").caption("Library"),
180 - Frame::new("/b.png", "A project page with two items"),
181 - Frame::new("/c.png", "The payouts table"),
182 - ]
183 - }
43 + use crate::templates::CarouselFrame;
184 44
45 + /// What the widget guarantees is `quasi-basics`' to test, and it does.
46 + /// What is MNW's is that this conversion loses nothing on the way through,
47 + /// because it is the one place a template's frame becomes a widget's.
185 48 #[test]
186 - fn the_name_is_on_the_region_for_a_renderer_that_knows_it() {
187 - let html = render("landing-shots", three());
49 + fn a_template_frame_arrives_whole() {
50 + let html = super::html(
51 + "landing-shots",
52 + &[
53 + CarouselFrame {
54 + image: "/a.png".into(),
55 + alt: "The library, mid-import".into(),
56 + caption: Some("Library".into()),
57 + intrinsic: Some((5120, 3412)),
58 + },
59 + CarouselFrame {
60 + image: "/b.png".into(),
61 + alt: "A project page".into(),
62 + caption: None,
63 + intrinsic: None,
64 + },
65 + ],
66 + );
67 +
188 68 assert!(html.contains(r#"data-widget="carousel""#), "{html}");
189 69 assert!(html.contains(r#"id="landing-shots""#), "{html}");
190 - }
191 -
192 - #[test]
193 - fn every_frame_is_in_the_markup_and_not_only_the_first() {
194 - // The fallback this replaces showed frame one and made the rest
195 - // unreachable without JS. The whole gallery is here, in order.
196 - let html = render("g", three());
197 - for src in ["/a.png", "/b.png", "/c.png"] {
198 - assert!(html.contains(src), "{src} missing from {html}");
199 - }
200 - let first = html.find("/a.png").unwrap();
201 - let second = html.find("/b.png").unwrap();
202 - let third = html.find("/c.png").unwrap();
203 - assert!(first < second && second < third, "frames out of order");
204 - }
205 -
206 - #[test]
207 - fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() {
208 - let html = render("g", three());
209 70 assert!(html.contains(r#"alt="The library, mid-import""#), "{html}");
210 - assert!(html.contains(r#"alt="The payouts table""#), "{html}");
211 - }
212 -
213 - #[test]
214 - fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() {
215 - let html = render("g", three());
216 - // One caption across the three, so one figure element.
217 - assert_eq!(html.matches("<figure").count(), 1, "{html}");
218 71 assert!(html.contains("Library</figcaption>"), "{html}");
219 - }
220 -
221 - #[test]
222 - fn a_frame_that_knows_its_size_reserves_its_space() {
223 - // The 478px jump this exists to stop: without width/height the browser
224 - // gives the picture no room until the bytes land.
225 - let html = render(
226 - "g",
227 - vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)],
228 - );
229 72 assert!(html.contains(r#"width="5120" height="3412""#), "{html}");
230 - }
231 -
232 - #[test]
233 - fn a_frame_that_does_not_know_its_size_says_nothing() {
234 - // A creator upload. Reserving the wrong room is worse than none, so an
235 - // absent size must not become a guessed one.
236 - let html = render("g", vec![Frame::new("/a.png", "Alpha")]);
237 - assert!(!html.contains("width="), "{html}");
238 - assert!(!html.contains("height="), "{html}");
239 - }
240 -
241 - #[test]
242 - fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() {
243 - // Both answers in one widget at one moment, which is why loading is the
244 - // description's to say rather than a renderer-wide setting.
245 - let html = render("g", three());
246 - assert_eq!(
247 - html.matches("loading=\"lazy\"").count(),
248 - 2,
249 - "expected the two offscreen frames only: {html}"
250 - );
251 - // Everything before the second frame's source is the first frame, so
252 - // nothing in that span may defer: it is the picture already on screen.
253 - let upto_second = &html[..html.find("/b.png").unwrap()];
254 - assert!(
255 - !upto_second.contains("loading=\"lazy\""),
256 - "the visible frame must not be deferred: {html}"
257 - );
258 - }
259 -
260 - #[test]
261 - fn a_screenshot_keeps_its_own_shape() {
262 - // Natural is the default and emits no attribute, so the assertion is
263 - // that nothing asked for a crop. A cropped screenshot loses its edges,
264 - // which is where the interface is.
265 - let html = render("g", three());
266 - assert!(!html.contains("data-fit"), "{html}");
267 - }
268 -
269 - #[test]
270 - fn a_hostile_source_cannot_break_out_of_the_attribute() {
271 - let html = render(
272 - "g",
273 - vec![Frame::new(r#"x" onerror="alert(1)"#, "</title><script>")],
274 - );
275 - assert!(!html.contains("onerror=\"alert"), "{html}");
276 - assert!(!html.contains("<script>"), "{html}");
277 - }
278 -
279 - #[test]
280 - fn the_empty_case_is_a_region_with_nothing_in_it() {
281 - // Three pages call this and one of them has an empty gallery whenever
282 - // the creator uploaded nothing. It has to be a carousel with no frames
283 - // rather than a panic or a stray element.
284 - let html = render("g", Vec::new());
285 - assert!(html.contains(r#"data-widget="carousel""#), "{html}");
286 - assert!(!html.contains("<img"), "{html}");
73 + assert!(html.contains("/b.png"), "{html}");
287 74 }
288 75 }
@@ -8,15 +8,18 @@
8 8 //!
9 9 //! Full argument: wiki `widget-tier`.
10 10 //!
11 - //! # Why they are here and not in a crate yet
11 + //! # What is left here
12 12 //!
13 - //! `makeover-basics` is where these are going — MIT, ninth member of the suite.
14 - //! They are being proved against real pages first, because a widget that has
15 - //! never rendered anything is a guess about what the assembly needs, and the
16 - //! carousel already found one missing primitive on its way through
17 - //! (`makeover_layout::Image`, 0.21.0).
13 + //! Glue, and only glue. The widgets themselves live in `quasi-basics`, which is
14 + //! where the carousel went once it had been proved against three real pages.
15 + //! Each module here is an Askama entry point: it turns whatever the templates
16 + //! happened to need into the widget's own types, renders the fragment, and
17 + //! stops.
18 18 //!
19 - //! Each module here is written to leave: the description half names no MNW
20 - //! type, and only the Askama glue stays behind.
19 + //! A widget that has never rendered anything is a guess about what the assembly
20 + //! needs, so a new one is welcome to start here and leave later. The carousel
21 + //! found a missing primitive on its way through
22 + //! (`makeover_layout::Image`, 0.21.0), which is the argument for proving one in
23 + //! an app before it becomes shared.
21 24
22 25 pub mod carousel;