Skip to main content

max / makenotwork

Take quasi 0.80, and let the description carry /pricing's meta quasicoherent a0e16839: Screen::summarised reaches the plain <meta name="description"> now, not only the social pair, so the Shell::head append pricing.rs was making by hand goes. The summary carries the fee sentence it used to append, so the emitted tag is unchanged. quasicoherent 370c0849: a described path asked with a verb it does not hold answers 405 with Allow rather than 404, so the method-mismatch test names /pricing again beside /creators. Both are 405 and only one is this server's error page, which is the difference the test now states. quasi-focus.js joins the generated static list. The shell links every script the renderer ships unless the host says otherwise, and this host says otherwise about none of them.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 18:17 UTC
Signed with PGP, not checked
Commit: 43aa8363c5a506ed520dada9126cd50e4d44f96b
Parent: 790f607
7 files changed, +283 insertions, -50 deletions
M server/Cargo.lock +25 -25
@@ -2875,7 +2875,7 @@
2875 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2876 2876 dependencies = [
2877 2877 "libc",
2878 - "windows-sys 0.61.2",
2878 + "windows-sys 0.52.0",
2879 2879 ]
2880 2880
2881 2881 [[package]]
@@ -3660,7 +3660,7 @@
3660 3660 checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
3661 3661 dependencies = [
3662 3662 "bstr",
3663 - "hashbrown 0.17.1",
3663 + "hashbrown 0.15.5",
3664 3664 ]
3665 3665
3666 3666 [[package]]
@@ -4811,7 +4811,7 @@
4811 4811 source = "registry+https://github.com/rust-lang/crates.io-index"
4812 4812 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4813 4813 dependencies = [
4814 - "windows-sys 0.61.2",
4814 + "windows-sys 0.59.0",
4815 4815 ]
4816 4816
4817 4817 [[package]]
@@ -5722,7 +5722,7 @@
5722 5722 source = "registry+https://github.com/rust-lang/crates.io-index"
5723 5723 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5724 5724 dependencies = [
5725 - "windows-sys 0.61.2",
5725 + "windows-sys 0.59.0",
5726 5726 ]
5727 5727
5728 5728 [[package]]
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.79.0"
6639 + version = "0.80.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.79.0"
6650 + version = "0.80.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.79.0"
6658 + version = "0.80.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,14 +6664,14 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.79.0"
6667 + version = "0.80.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
6671 6671
6672 6672 [[package]]
6673 6673 name = "quasi-type"
6674 - version = "0.1.2"
6674 + version = "0.1.3"
6675 6675 dependencies = [
6676 6676 "brotli",
6677 6677 "font-types",
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.79.0"
6690 + version = "0.80.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -7339,7 +7339,7 @@
7339 7339 "errno",
7340 7340 "libc",
7341 7341 "linux-raw-sys 0.12.1",
7342 - "windows-sys 0.61.2",
7342 + "windows-sys 0.52.0",
7343 7343 ]
7344 7344
7345 7345 [[package]]
@@ -7419,7 +7419,7 @@
7419 7419 "security-framework 3.7.0",
7420 7420 "security-framework-sys",
7421 7421 "webpki-root-certs",
7422 - "windows-sys 0.61.2",
7422 + "windows-sys 0.52.0",
7423 7423 ]
7424 7424
7425 7425 [[package]]
@@ -8545,7 +8545,7 @@
8545 8545 "getrandom 0.4.3",
8546 8546 "once_cell",
8547 8547 "rustix 1.1.4",
8548 - "windows-sys 0.61.2",
8548 + "windows-sys 0.52.0",
8549 8549 ]
8550 8550
8551 8551 [[package]]
@@ -9971,7 +9971,7 @@
9971 9971 source = "registry+https://github.com/rust-lang/crates.io-index"
9972 9972 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
9973 9973 dependencies = [
9974 - "windows-sys 0.61.2",
9974 + "windows-sys 0.48.0",
9975 9975 ]
9976 9976
9977 9977 [[package]]
@@ -10759,13 +10759,21 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 + [[patch.unused]]
10763 + name = "synckit-client"
10764 + version = "0.10.0"
10765 +
10766 + [[patch.unused]]
10767 + name = "synckit-config"
10768 + version = "0.2.0"
10769 +
10762 10770 [[patch.unused]]
10763 10771 name = "quasi-immediate"
10764 - version = "0.79.0"
10772 + version = "0.80.0"
10765 10773
10766 10774 [[patch.unused]]
10767 10775 name = "quasi-notifs"
10768 - version = "0.79.0"
10776 + version = "0.80.0"
10769 10777
10770 10778 [[patch.unused]]
10771 10779 name = "quasi-store"
@@ -10773,15 +10781,7 @@
10773 10781
10774 10782 [[patch.unused]]
10775 10783 name = "quasi-tauri"
10776 - version = "0.79.0"
10777 -
10778 - [[patch.unused]]
10779 - name = "synckit-client"
10780 - version = "0.10.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "synckit-config"
10784 - version = "0.2.0"
10784 + version = "0.80.0"
10785 10785
10786 10786 [[patch.unused]]
10787 10787 name = "kberg"
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -179,13 +179,13 @@
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.80" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
M server/build.rs +2 -1
@@ -563,7 +563,7 @@
563 563 /// and unserved -- a 404 per document, with nothing in the log -- and
564 564 /// `quasi-awaiting.js` would have joined them the moment it shipped. Found
565 565 /// adding the third, which is the only reason the other two were.
566 - const GENERATED_STATIC_JS: [(&str, &str); 11] = [
566 + const GENERATED_STATIC_JS: [(&str, &str); 12] = [
567 567 ("quasi-selection.js", quasi_webview::SELECTION_JS),
568 568 ("quasi-clock.js", quasi_webview::CLOCK_JS),
569 569 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
@@ -573,6 +573,7 @@
573 573 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
574 574 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
575 575 ("quasi-instant.js", quasi_webview::INSTANT_JS),
576 + ("quasi-focus.js", quasi_webview::FOCUS_JS),
576 577 ("quasi-menu.js", quasi_webview::MENU_JS),
577 578 ("quasi-outline.js", quasi_webview::OUTLINE_JS),
578 579 ];
@@ -364,9 +364,14 @@
364 364 // What `pricing.html` said as `class="centered-page"`. The renderer
365 365 // turns it back into that class; see `renderer`.
366 366 .measured(MEASURE)
367 + // The whole of what `base.html` put in `<meta name="description">`,
368 + // including the fee sentence, because this one string is now all three
369 + // tags: the social pair and the plain one. It carried only the first
370 + // sentence while the plain tag was appended separately.
367 371 .summarised(
368 372 "Work out what you keep on every sale here, against whatever the \
369 - platform you sell on now deducts.",
373 + platform you sell on now deducts. 0% platform fee, only the \
374 + payment processor's ~3%.",
370 375 )
371 376 .with(
372 377 Slot::new(PAGE, RegionKind::Pane)
@@ -660,17 +665,13 @@
660 665 let mut shell = crate::shell::described()
661 666 .with_body_first(crate::shell::skip_link(PAGE))
662 667 .with_body_last(crate::shell::body_last());
663 - // Appended rather than set: `described` already put the favicon, the
664 - // history-cache extension and the core module in the head, and `with_head`
665 - // replaces. A description meta tag is the one thing `base.html` emitted for
666 - // this page that the description layer has no word for --
667 - // `Screen::summarised` reaches `og:description` and stops there.
668 - shell.head = Some(format!(
669 - "{}<meta name=\"description\" content=\"Work out what you keep on every \
670 - sale here, against whatever the platform you sell on now deducts. \
671 - 0% platform fee, only the payment processor's ~3%.\">",
672 - shell.head.take().unwrap_or_default()
673 - ));
668 + // The `Shell::head` append that stood here is gone as of quasi 0.80
669 + // (quasicoherent `a0e16839`). It wrote the plain `<meta name="description">`
670 + // by hand because `Screen::summarised` reached `og:description` and
671 + // `twitter:description` and stopped; it reaches all three now, off the one
672 + // string the screen already declares. An escape hatch spending itself on
673 + // something a described property carries is the escape hatch going unused.
674 + //
674 675 // What `pricing.html` wrote as `class="centered-page"`, read off the same
675 676 // measure the screen declares rather than off a route table.
676 677 shell.body_class = Some(crate::shell::measure(MEASURE).to_owned());
@@ -555,13 +555,8 @@
555 555 // The same holds for any other method mismatch.
556 556 let resp = h.client.post_form("/login", "").await;
557 557 assert_ne!(resp.status, 405, "/login accepts POST");
558 - // `/creators` rather than `/pricing`, which used to stand here. `/pricing`
559 - // is a described screen since `1e35bc8a` and the description layer routes
560 - // itself: a method it does not hold on a path it does answers 404 with no
561 - // page, not 405 with the error template. That is quasi's routing rather
562 - // than this server's, and it is filed as quasicoherent `370c0849`; what this test is
563 - // about is what axum's own method fallback renders, so it asks a route axum
564 - // still owns.
558 + // `/creators` is a route axum still owns, so this is what axum's own method
559 + // fallback renders and is what this test is about.
565 560 let resp = h.client.delete("/creators").await;
566 561 assert_eq!(resp.status, 405, "DELETE /creators should be 405");
567 562 assert!(
@@ -569,6 +564,23 @@
569 564 "405 should render the error template, got: {}",
570 565 resp.text
571 566 );
567 +
568 + // And `/pricing` again, which stood here until `1e35bc8a` made it a
569 + // described screen and the description layer answered 404: the path existed
570 + // and only the verb did not, which is what quasicoherent `370c0849` split
571 + // apart. It answers 405 with `Allow` since quasi 0.80.
572 + //
573 + // No error template here, and that is the difference worth keeping in one
574 + // test rather than two: the described screen refuses inside quasi and never
575 + // reaches axum's fallback, so what comes back is the description layer's
576 + // own notice. Both are 405; only one is this server's page.
577 + let resp = h.client.delete("/pricing").await;
578 + assert_eq!(resp.status, 405, "DELETE /pricing should be 405");
579 + assert_eq!(
580 + resp.headers.get("allow").and_then(|v| v.to_str().ok()),
581 + Some("GET"),
582 + "and it names the verb the address does take"
583 + );
572 584 }
573 585
574 586 /// A 401 page offers the actions that resolve it, not only "Go Home".
@@ -1,0 +1,131 @@
1 + // Keeping the reader's place in the tab order across a swap htmx does not
2 + // restore focus for.
3 + //
4 + // htmx saves and restores focus around the *main* swap target, and only that
5 + // one. A focused control inside an out-of-band region is destroyed outside that
6 + // window, so focus falls to `body`: the caret vanishes, and the next Tab starts
7 + // from the top of the document rather than from where the reader was. This
8 + // renderer emits out-of-band swaps -- `node::oob_html`, which is how a response
9 + // updates a second region it did not target -- so it owns the gap.
10 + //
11 + // The failure is silent to anyone not navigating by keyboard, which is the
12 + // reason to fix it rather than leave it: nobody will report it.
13 + //
14 + // # Why it triggers on focus actually being lost
15 + //
16 + // The obvious shape is "record what was focused inside a region about to be
17 + // replaced, put it back afterwards". This does that, with one narrowing: it
18 + // only restores when focus has genuinely fallen to `body` or out of the
19 + // document. htmx already restores focus for the main target, correctly and
20 + // with its own record of the caret, and a second party putting focus somewhere
21 + // after it would be two scripts fighting over the same frame. So the record is
22 + // taken from anything focused, and spent only where htmx left nothing.
23 + (() => {
24 + "use strict";
25 +
26 + // What was focused when the request went out, and where its caret was.
27 + // One slot: a document makes one focused element, and a second request
28 + // starting before the first settles is the reader having moved on.
29 + let held = null;
30 +
31 + /**
32 + * How to find this element again in markup that has replaced it.
33 + *
34 + * An `id` is the description's own address for the thing and survives a
35 + * re-render, so it is preferred. A `name` is the next best: a form control
36 + * that carries one is being identified by the description too, since that
37 + * is the key the route reads it under. Anything with neither is not
38 + * addressable and is left alone -- guessing at a neighbour by position is
39 + * how focus lands on the wrong control, which is worse than losing it.
40 + */
41 + const address = (element) => {
42 + if (element.id) return `#${CSS.escape(element.id)}`;
43 + const name = element.getAttribute?.("name");
44 + if (name) {
45 + const tag = element.tagName.toLowerCase();
46 + return `${tag}[name="${CSS.escape(name)}"]`;
47 + }
48 + return null;
49 + };
50 +
51 + /**
52 + * The caret, for a control that has one.
53 + *
54 + * `selectionStart` throws on input types that have no text selection
55 + * (`email`, `number`, and others depending on the browser), which is a
56 + * `DOMException` rather than a `null`, so this is guarded rather than
57 + * checked: the list of which types support it differs between engines and
58 + * hard-coding it here would be a second thing to keep in step.
59 + */
60 + const caret = (element) => {
61 + try {
62 + const { selectionStart, selectionEnd, selectionDirection } = element;
63 + if (selectionStart === null || selectionStart === undefined) return null;
64 + return { selectionStart, selectionEnd, selectionDirection };
65 + } catch {
66 + return null;
67 + }
68 + };
69 +
70 + /** Put a caret back, ignoring a control that has stopped accepting one. */
71 + const restoreCaret = (element, at) => {
72 + if (!at) return;
73 + try {
74 + element.setSelectionRange(
75 + at.selectionStart,
76 + at.selectionEnd,
77 + at.selectionDirection ?? "none",
78 + );
79 + } catch {
80 + // The replacement is a different kind of control than the one that
81 + // was there. Focus is still right; the caret is not applicable.
82 + }
83 + };
84 +
85 + // Recorded on the way out rather than on the swap: by the time a swap
86 + // fires, an out-of-band region may already have been replaced and the
87 + // element read from `document.activeElement` would be detached, with its
88 + // caret gone with it.
89 + //
90 + // `htmx:before:request` is htmx 4's name; 2.x spelled it
91 + // `htmx:beforeRequest`.
92 + const remember = () => {
93 + const active = document.activeElement;
94 + if (!active || active === document.body) {
95 + held = null;
96 + return;
97 + }
98 + const selector = address(active);
99 + held = selector ? { selector, at: caret(active) } : null;
100 + };
101 +
102 + // Spent after everything has settled, including out-of-band regions, and
103 + // only where focus was actually lost. `htmx:after:settle` is htmx 4's name;
104 + // 2.x spelled it `htmx:afterSettle`.
105 + const restore = () => {
106 + const record = held;
107 + held = null;
108 + if (!record) return;
109 +
110 + // htmx put focus somewhere itself, which is the main target's case and
111 + // is already right. Leave it alone.
112 + const active = document.activeElement;
113 + if (active && active !== document.body) return;
114 +
115 + const found = document.querySelector(record.selector);
116 + // No match means the control the reader was in is genuinely gone --
117 + // a row they deleted, a field a reveal took away. Focus stays where
118 + // htmx left it rather than being moved somewhere arbitrary.
119 + if (!found || typeof found.focus !== "function") return;
120 +
121 + // `preventScroll`, because the reader has not asked to go anywhere: the
122 + // point is that nothing visible changed for them. Restoring focus and
123 + // jumping the viewport to it would be a second surprise on top of the
124 + // one being fixed.
125 + found.focus({ preventScroll: true });
126 + restoreCaret(found, record.at);
127 + };
128 +
129 + document.addEventListener("htmx:before:request", remember);
130 + document.addEventListener("htmx:after:settle", restore);
131 + })();
@@ -1,0 +1,88 @@
1 + // Folding a branch of an outline, which is the reader tidying their own view.
2 + //
3 + // `Row::depth` and `Row::open` describe a hierarchy as a flat list of rows,
4 + // each saying how deep it sits. Which rows a shut branch hides falls out of
5 + // that reading, and the renderer already did it once: every row is in the
6 + // document and the ones under a shut branch arrived `hidden`. What is left is
7 + // the gesture, and the gesture is a browser's.
8 + //
9 + // Nothing here asks the app anything. Folding changes what the reader is
10 + // looking at and nothing anyone else can observe -- see `Row::open` for why
11 + // that is not a write -- so a round trip would be a request for rows the page
12 + // is already holding.
13 + //
14 + // It reads two hooks the renderer emits and nothing else: `[data-disclose]` on
15 + // a branch's chevron, and `data-depth` on the rows. A page that never runs this
16 + // keeps the outline in the shape the description gave it, with every row still
17 + // reachable -- the shut branches simply stay shut.
18 + (() => {
19 + "use strict";
20 +
21 + const DISCLOSE = "[data-disclose]";
22 + const ROW = "[data-row]";
23 +
24 + /** How deep a row sits. Absent means top level, which is what it means. */
25 + const depth = (row) => Number(row.getAttribute("data-depth") ?? 0);
26 +
27 + /** The row a chevron belongs to. */
28 + const rowOf = (el) => el.closest(ROW);
29 +
30 + /**
31 + * The rows under `row`: the ones after it that are deeper, up to the first
32 + * one no deeper than it.
33 + *
34 + * The same reading `quasi_router::folded` makes on the other side, and it
35 + * has to be: a reader who folds a branch here and reloads the page must see
36 + * the list the server would have folded.
37 + */
38 + const under = (row) => {
39 + const rows = [...row.parentElement.querySelectorAll(`:scope > ${ROW}`)];
40 + const start = rows.indexOf(row);
41 + const level = depth(row);
42 + const kin = [];
43 + for (const next of rows.slice(start + 1)) {
44 + if (depth(next) <= level) break;
45 + kin.push(next);
46 + }
47 + return kin;
48 + };
49 +
50 + /**
51 + * Draw the outline under `row` as its chevrons say it should be.
52 + *
53 + * Walked rather than toggled row by row: a branch inside a shut branch
54 + * keeps its own state and stays shut when the outer one opens, which is
55 + * what a reader who left it shut expects to come back to. `folded` is the
56 + * same one-variable walk the Rust side makes.
57 + */
58 + const settle = (row) => {
59 + let shut = null;
60 + for (const next of under(row)) {
61 + const level = depth(next);
62 + if (shut !== null && level <= shut) shut = null;
63 + const hidden = shut !== null;
64 + next.hidden = hidden;
65 + const chevron = next.querySelector(DISCLOSE);
66 + if (!hidden && chevron?.getAttribute("aria-expanded") === "false") {
67 + shut = level;
68 + }
69 + }
70 + };
71 +
72 + document.addEventListener("click", (event) => {
73 + const chevron = event.target.closest(DISCLOSE);
74 + if (!chevron) return;
75 + const row = rowOf(chevron);
76 + if (!row) return;
77 + // Never the row's own act. A chevron is a separate hit target from the
78 + // label precisely so that pressing it is not pressing the row, and a
79 + // row that activates on click would otherwise navigate underneath the
80 + // fold.
81 + event.preventDefault();
82 + event.stopPropagation();
83 + const open = chevron.getAttribute("aria-expanded") === "true";
84 + chevron.setAttribute("aria-expanded", String(!open));
85 + chevron.setAttribute("aria-label", open ? "Expand" : "Collapse");
86 + settle(row);
87 + });
88 + })();