Skip to main content

max / quasi

Add quasi-basics, the first-party widget set, with the carousel in it The widget tier had a mechanism and no home. This is the home, and it is not where the note said it would be. `makeover-basics` cannot exist. A widget is an assembly of `Node`s, and `Node`, `Slot` and `Picture` are quasi-router's -- not by accident of typing but by rule. makeover-layout defers every address, and `layout::Image` carries a shape and its alt text and deliberately no source, so a widget with a picture in it is unsayable below quasi-router by construction. The tier sits above the description suite. Carried over from MNW/server/src/quasi/widgets/carousel.rs, which was written to leave: `Frame`, `frames()` and `carousel()` named no MNW type. A move, not a rewrite. The ten tests came with it, and they render through quasi-webview rather than walking the tree the assembly just built, because a widget's guarantees are claims about what a renderer draws.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 21:52 UTC
Signed with PGP, not checked
Commit: 4ce44e48d0ca00d52fd2752cb43e584b2871214b
Parent: 4e4c5f2
5 files changed, +335 insertions, -0 deletions
M Cargo.lock +10
@@ -3041,6 +3041,16 @@
3041 3041 "tower",
3042 3042 ]
3043 3043
3044 + [[package]]
3045 + name = "quasi-basics"
3046 + version = "0.4.0"
3047 + dependencies = [
3048 + "makeover-layout",
3049 + "quasi-http",
3050 + "quasi-router",
3051 + "quasi-webview",
3052 + ]
3053 +
3044 3054 [[package]]
3045 3055 name = "quasi-http"
3046 3056 version = "0.4.0"
M Cargo.toml +1
@@ -3,6 +3,7 @@
3 3 members = [
4 4 "crates/quasi",
5 5 "crates/quasi-axum",
6 + "crates/quasi-basics",
6 7 "crates/quasi-http",
7 8 "crates/quasi-router",
8 9 "crates/quasi-store",
@@ -1,0 +1,24 @@
1 + [package]
2 + name = "quasi-basics"
3 + version = "0.4.0"
4 + description = "The first-party widget set: named assemblies of primitives, shared across our apps"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + quasi-router = { path = "../quasi-router", version = "0.4.0" }
17 + makeover-layout = "0.22.0"
18 +
19 + [dev-dependencies]
20 + # A widget's guarantees are claims about what a renderer draws, so they are
21 + # tested against a real one rather than by walking the tree the assembly just
22 + # built. The webview is the renderer that recognises names today.
23 + quasi-webview = { path = "../quasi-webview", version = "0.4.0" }
24 + quasi-http = { path = "../quasi-http", version = "0.4.0" }
@@ -1,0 +1,253 @@
1 + //! An ordered set of frames, said once.
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.
7 + //!
8 + //! Proved against three real MNW pages before it moved here, which is the
9 + //! order Max asked for: a widget earns the shared crate by working somewhere
10 + //! first. What stayed behind in the app is the Askama glue, and nothing else.
11 + //!
12 + //! # Why the body is the frames and nothing else
13 + //!
14 + //! A carousel is a set of frames, a position, prev/next and a strip of dots.
15 + //! Only the first of those is *content*; the rest is chrome, and chrome is what
16 + //! a renderer that recognises the name draws its own way. A webview draws
17 + //! buttons over the frame, a terminal draws a pager with a count, egui draws a
18 + //! selector, and none of them owes the others a carousel primitive.
19 + //!
20 + //! So the description says "an ordered set of pictures, called a carousel" and
21 + //! stops. That is the whole of what every host agrees on.
22 + //!
23 + //! # What a renderer that has never heard of a carousel does
24 + //!
25 + //! It walks the body and draws the pictures in order. Nothing is lost: every
26 + //! frame is content, in sequence, and the reader can see all of them.
27 + //!
28 + //! This is a **better** fallback than the one it replaced. MNW's shipped
29 + //! partial showed the first frame and made the other two unreachable without
30 + //! JS, because the controls that would reach them are the part that needs
31 + //! scripting. Here the unenhanced rendering is the whole gallery, and the
32 + //! script's job is to collapse it to one at a time rather than to unlock the
33 + //! rest. Progressive enhancement in the direction that degrades to *more*
34 + //! content instead of less.
35 +
36 + use makeover_layout::Fit;
37 + use quasi_router::{Node, Picture, Slot};
38 +
39 + /// What the recognising renderer keys on. Never interpreted by the description.
40 + pub const NAME: &str = "carousel";
41 +
42 + /// One frame: a picture and what it says.
43 + ///
44 + /// Deliberately not an app's own frame type. Those carry what a template layer
45 + /// happened to need; this is what the widget needs, and keeping them apart is
46 + /// what let this leave MNW without dragging the template layer with it.
47 + #[derive(Debug, Clone, PartialEq, Eq)]
48 + pub struct Frame {
49 + /// Where the picture is.
50 + pub src: String,
51 + /// What the picture says, for anything not showing it.
52 + pub alt: String,
53 + /// A visible line under it, where there is one.
54 + pub caption: Option<String>,
55 + /// The picture's own dimensions, where the caller knows them.
56 + ///
57 + /// What lets the renderer hold the frame's place from first paint. Without
58 + /// it the frame occupies nothing until the bytes land and then takes its
59 + /// full height at once, which measured as a 478px jump on MNW's landing
60 + /// page.
61 + pub intrinsic: Option<(u32, u32)>,
62 + }
63 +
64 + impl Frame {
65 + /// A frame at a source.
66 + pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
67 + Self {
68 + src: src.into(),
69 + alt: alt.into(),
70 + caption: None,
71 + intrinsic: None,
72 + }
73 + }
74 +
75 + /// The picture's own dimensions.
76 + #[must_use]
77 + pub const fn intrinsic(mut self, width: u32, height: u32) -> Self {
78 + self.intrinsic = Some((width, height));
79 + self
80 + }
81 +
82 + /// A visible line under it.
83 + #[must_use]
84 + pub fn caption(mut self, caption: impl Into<String>) -> Self {
85 + self.caption = Some(caption.into());
86 + self
87 + }
88 + }
89 +
90 + /// The frames as description nodes.
91 + ///
92 + /// Split out from [`carousel`] because a set of pictures in order is worth
93 + /// having on its own: a gallery that does not page is this without the widget
94 + /// name around it, and that is the second consumer this module expects.
95 + pub fn frames(frames: impl IntoIterator<Item = Frame>) -> impl Iterator<Item = Node> {
96 + frames.into_iter().enumerate().map(|(i, frame)| {
97 + // Natural, and it is the whole reason `Fit` has three members rather
98 + // than the one MNW uses at 15 of its 17 other sites. A screenshot
99 + // cropped to fill its box is a screenshot with its edges cut off, and
100 + // the edges of a screenshot of an interface are where the interface is.
101 + let mut picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural);
102 + if let Some((w, h)) = frame.intrinsic {
103 + picture = picture.intrinsic(w, h);
104 + }
105 + // The first frame is the one on screen, and the rest are not. Eager for
106 + // the one, lazy for the others -- which is the case that proves loading
107 + // cannot be a single setting the renderer picks: both answers are
108 + // correct, in one widget, at one moment.
109 + //
110 + // Getting this backwards is what MNW's old partial did by lazily
111 + // loading all three, including the one the visitor was already looking
112 + // at. That delays the only picture that matters and buys nothing,
113 + // because the other two are display:none and were never going to be
114 + // fetched early anyway.
115 + if i > 0 {
116 + picture = picture.lazy();
117 + }
118 + Node::Image(match frame.caption {
119 + Some(caption) => picture.caption(caption),
120 + None => picture,
121 + })
122 + })
123 + }
124 +
125 + /// A carousel under an address.
126 + ///
127 + /// The id is the region's, which is how a fragment finds its way back to the
128 + /// right place, so it has to be unique on the page the way every slot id does.
129 + pub fn carousel(id: &str, items: impl IntoIterator<Item = Frame>) -> Slot {
130 + Slot::widget(id, NAME).extend(frames(items))
131 + }
132 +
133 + #[cfg(test)]
134 + mod tests {
135 + use super::*;
136 + use quasi_http::Serves as _;
137 +
138 + fn render(id: &str, items: Vec<Frame>) -> String {
139 + quasi_webview::Webview::new().fragment(&Node::Region(carousel(id, items)))
140 + }
141 +
142 + fn three() -> Vec<Frame> {
143 + vec![
144 + Frame::new("/a.png", "The library, mid-import").caption("Library"),
145 + Frame::new("/b.png", "A project page with two items"),
146 + Frame::new("/c.png", "The payouts table"),
147 + ]
148 + }
149 +
150 + #[test]
151 + fn the_name_is_on_the_region_for_a_renderer_that_knows_it() {
152 + let html = render("landing-shots", three());
153 + assert!(html.contains(r#"data-widget="carousel""#), "{html}");
154 + assert!(html.contains(r#"id="landing-shots""#), "{html}");
155 + }
156 +
157 + #[test]
158 + fn every_frame_is_in_the_markup_and_not_only_the_first() {
159 + // The fallback this replaces showed frame one and made the rest
160 + // unreachable without JS. The whole gallery is here, in order.
161 + let html = render("g", three());
162 + for src in ["/a.png", "/b.png", "/c.png"] {
163 + assert!(html.contains(src), "{src} missing from {html}");
164 + }
165 + let first = html.find("/a.png").unwrap();
166 + let second = html.find("/b.png").unwrap();
167 + let third = html.find("/c.png").unwrap();
168 + assert!(first < second && second < third, "frames out of order");
169 + }
170 +
171 + #[test]
172 + fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() {
173 + let html = render("g", three());
174 + assert!(html.contains(r#"alt="The library, mid-import""#), "{html}");
175 + assert!(html.contains(r#"alt="The payouts table""#), "{html}");
176 + }
177 +
178 + #[test]
179 + fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() {
180 + let html = render("g", three());
181 + // One caption across the three, so one figure element.
182 + assert_eq!(html.matches("<figure").count(), 1, "{html}");
183 + assert!(html.contains("Library</figcaption>"), "{html}");
184 + }
185 +
186 + #[test]
187 + fn a_frame_that_knows_its_size_reserves_its_space() {
188 + // The 478px jump this exists to stop: without width/height the browser
189 + // gives the picture no room until the bytes land.
190 + let html = render(
191 + "g",
192 + vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)],
193 + );
194 + assert!(html.contains(r#"width="5120" height="3412""#), "{html}");
195 + }
196 +
197 + #[test]
198 + fn a_frame_that_does_not_know_its_size_says_nothing() {
199 + // A creator upload. Reserving the wrong room is worse than none, so an
200 + // absent size must not become a guessed one.
201 + let html = render("g", vec![Frame::new("/a.png", "Alpha")]);
202 + assert!(!html.contains("width="), "{html}");
203 + assert!(!html.contains("height="), "{html}");
204 + }
205 +
206 + #[test]
207 + fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() {
208 + // Both answers in one widget at one moment, which is why loading is the
209 + // description's to say rather than a renderer-wide setting.
210 + let html = render("g", three());
211 + assert_eq!(
212 + html.matches("loading=\"lazy\"").count(),
213 + 2,
214 + "expected the two offscreen frames only: {html}"
215 + );
216 + // Everything before the second frame's source is the first frame, so
217 + // nothing in that span may defer: it is the picture already on screen.
218 + let upto_second = &html[..html.find("/b.png").unwrap()];
219 + assert!(
220 + !upto_second.contains("loading=\"lazy\""),
221 + "the visible frame must not be deferred: {html}"
222 + );
223 + }
224 +
225 + #[test]
226 + fn a_screenshot_keeps_its_own_shape() {
227 + // Natural is the default and emits no attribute, so the assertion is
228 + // that nothing asked for a crop. A cropped screenshot loses its edges,
229 + // which is where the interface is.
230 + let html = render("g", three());
231 + assert!(!html.contains("data-fit"), "{html}");
232 + }
233 +
234 + #[test]
235 + fn a_hostile_source_cannot_break_out_of_the_attribute() {
236 + let html = render(
237 + "g",
238 + vec![Frame::new(r#"x" onerror="alert(1)"#, "</title><script>")],
239 + );
240 + assert!(!html.contains("onerror=\"alert"), "{html}");
241 + assert!(!html.contains("<script>"), "{html}");
242 + }
243 +
244 + #[test]
245 + fn the_empty_case_is_a_region_with_nothing_in_it() {
246 + // Three MNW pages call this and one of them has an empty gallery
247 + // whenever the creator uploaded nothing. It has to be a carousel with
248 + // no frames rather than a panic or a stray element.
249 + let html = render("g", Vec::new());
250 + assert!(html.contains(r#"data-widget="carousel""#), "{html}");
251 + assert!(!html.contains("<img"), "{html}");
252 + }
253 + }
@@ -1,0 +1,47 @@
1 + //! The first-party widget set.
2 + //!
3 + //! A widget is an assembly of primitives under a name. `makeover-layout` grew
4 + //! [`Region::Widget`](makeover_layout::Region::Widget) at 0.20.0 to make one
5 + //! sayable; this crate is where ours live.
6 + //!
7 + //! # Why this is not a makeover crate
8 + //!
9 + //! It was going to be, as `makeover-basics`, and the carousel proved it could
10 + //! not be. A widget is an assembly of [`Node`](quasi_router::Node)s, and
11 + //! `Node`, [`Slot`](quasi_router::Slot) and [`Picture`](quasi_router::Picture)
12 + //! are `quasi-router`'s. That is not an accident of which crate somebody typed
13 + //! them into: `makeover-layout` defers every address by rule, and
14 + //! `layout::Image` carries a shape and its alt text and deliberately no source.
15 + //! So a widget with a picture in it is unsayable below `quasi-router` by
16 + //! construction, and the widget tier sits above the description suite rather
17 + //! than inside it.
18 + //!
19 + //! The consequence worth knowing is the good one: nothing here is a new
20 + //! drawing surface. Every widget is made of members the vocabulary already
21 + //! has, so a renderer that has never heard of one walks its body and draws
22 + //! primitives. Naming a widget costs no renderer release, and a widget is
23 + //! never how a primitive gets added by the back door.
24 + //!
25 + //! # What belongs here
26 + //!
27 + //! What more than one of our apps wants, and what one of them wants badly
28 + //! enough to be worth sharing the shape of. The first-party set stays
29 + //! deliberately thin: the suite ships the tier, and every widget shipped here
30 + //! is one the ecosystem does not get to name itself.
31 + //!
32 + //! A thing that needs a member the vocabulary does not have is still a finding
33 + //! about the vocabulary. A thing specific to one app is still
34 + //! [`Region::Bespoke`](makeover_layout::Region::Bespoke).
35 + //!
36 + //! # Adopting one in a webview app
37 + //!
38 + //! The description emits a plain `<div>` carrying `data-widget="<name>"`,
39 + //! because it has no idea the host prefixes its custom elements. An app that
40 + //! enhances a widget with script wraps the region in its own custom element
41 + //! rather than expecting to be one: a custom element is what the browser
42 + //! re-upgrades after an htmx swap, which is the whole reason islands are
43 + //! custom elements.
44 +
45 + pub mod carousel;
46 +
47 + pub use carousel::{Frame, carousel};