Skip to main content

max / makenotwork

Fold the described tab strips, and serve the script that does it The five described strips declare Fallback::Menu and wrapped, because makeover-webview drew a menu run as wrap and MNW's own core/tabs.ts looked for .tabs while the renderer writes .selector[data-selector=tab]. quasi 0.73.0's menu.js is the measuring the vocabulary always meant, and it reads the described markup rather than one app's. core/tabs.ts is already gone — deleted with the Askama strips it served — so nothing races it and there was no second implementation left to retire. The brief assumed four strips still depended on it; measured 2026-08-28, none do. quasi-menu.js joins GENERATED_STATIC_JS, and the embed shells refuse it by name like every other htmx-independent script: an embed names no run that folds. The dead-vocabulary ratchet goes 46 to 48, and this rise is permanent by construction rather than pending a conversion: the two classes are built by a script at the moment it measures a run as tight, so no markup anywhere can spell them. Takes makeover-webview 0.64.0 and makeover-build 0.57.0.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 15:55 UTC
Signed with PGP, not checked
Commit: a5a57bd9ee4884714839a1a17f9e28b371bb8804
Parent: 1c0b9f4
5 files changed, +201 insertions, -29 deletions
M server/Cargo.lock +12 -12
@@ -5390,9 +5390,9 @@
5390 5390
5391 5391 [[package]]
5392 5392 name = "makeover-build"
5393 - version = "0.56.0"
5393 + version = "0.57.0"
5394 5394 source = "registry+https://github.com/rust-lang/crates.io-index"
5395 - checksum = "56c8903ef617932723aea44f7ada31c7ce4f89d008e8d25ebaee956b2258bb91"
5395 + checksum = "0f8d7b3e487f34397f239cd34820a6f0a02fdd57bdd8c72af851c4e0dd8810ef"
5396 5396 dependencies = [
5397 5397 "makeover",
5398 5398 "makeover-geometry",
@@ -5433,9 +5433,9 @@
5433 5433
5434 5434 [[package]]
5435 5435 name = "makeover-webview"
5436 - version = "0.63.0"
5436 + version = "0.64.0"
5437 5437 source = "registry+https://github.com/rust-lang/crates.io-index"
5438 - checksum = "9149f06aa7b8f4660609a680169d76075d91def63c23127bd84af9b183be22fa"
5438 + checksum = "6b91db6e169bc8b7c7225e17b6bc83f9c6f1879eb3bc6f1554e96cb621c84e5c"
5439 5439 dependencies = [
5440 5440 "makeover-geometry",
5441 5441 "makeover-layout",
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.72.0"
6639 + version = "0.73.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.72.0"
6650 + version = "0.73.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.72.0"
6658 + version = "0.73.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.72.0"
6667 + version = "0.73.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.72.0"
6690 + version = "0.73.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -10781,11 +10781,11 @@
10781 10781
10782 10782 [[patch.unused]]
10783 10783 name = "quasi-immediate"
10784 - version = "0.72.0"
10784 + version = "0.73.0"
10785 10785
10786 10786 [[patch.unused]]
10787 10787 name = "quasi-notifs"
10788 - version = "0.72.0"
10788 + version = "0.73.0"
10789 10789
10790 10790 [[patch.unused]]
10791 10791 name = "quasi-store"
@@ -10793,4 +10793,4 @@
10793 10793
10794 10794 [[patch.unused]]
10795 10795 name = "quasi-tauri"
10796 - version = "0.72.0"
10796 + version = "0.73.0"
@@ -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.72" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
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
@@ -175,17 +175,17 @@
175 175 # through quasi-webview for makeover-layout's reason: two `form::` paths from
176 176 # two resolutions are two crates, and the `data-format="markdown"` rules in
177 177 # `static/layout.css` are generated from this one.
178 - makeover-webview = "0.63.0"
178 + makeover-webview = "0.64.0"
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.72" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
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.72" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -254,7 +254,7 @@
254 254 # Emits static/geometry.css (makeover-geometry) and static/layout.css
255 255 # (makeover-webview). The same generator GO and BB run; only the output paths
256 256 # differ, since the server serves its stylesheets rather than bundling them.
257 - makeover-build = "0.56.0"
257 + makeover-build = "0.57.0"
258 258 # Read directly for the embeds' own copy of the spacing layer, which they need
259 259 # because an iframe cannot link a stylesheet. makeover-build does not re-export
260 260 # it, so the pin lives here and has to track the one makeover-build resolves.
@@ -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.72" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
M server/build.rs +13 -2
@@ -510,12 +510,22 @@
510 510 /// field messages of its own. It stops being dead the moment a described
511 511 /// screen here carries a note.
512 512 ///
513 + /// 46 to 48, 2026-08-28: makeover-webview 0.64.0 added `.run-overflow` and
514 + /// `.run-overflow-items`, the control a menu run's shed members move into. They
515 + /// arrive dead here by construction and will stay dead permanently, which is
516 + /// new: the two classes above are written by a Rust dependency and these are
517 + /// written by a *script* one, `quasi-webview`'s `menu.js`, which builds the
518 + /// control at the moment it measures the run as tight. No markup anywhere can
519 + /// spell them, because which members no longer fit is not a fact any markup
520 + /// holds. See `makeover_webview::RUN_CLASSES`, which carries them for exactly
521 + /// this reason.
522 + ///
513 523 /// So a rising number here is not automatically a regression. Read it against
514 524 /// what moved: markup deleted in favour of a description will raise it, and a
515 525 /// class that genuinely lost its writer will too. The list the failure prints
516 526 /// is what tells them apart -- if a name on it is one this server still means
517 527 /// to render, that is the bug.
518 - const DEAD_VOCABULARY_HIGH_WATER: usize = 46;
528 + const DEAD_VOCABULARY_HIGH_WATER: usize = 48;
519 529
520 530 /// The scripts this build script generates into `static/`.
521 531 ///
@@ -547,7 +557,7 @@
547 557 /// and unserved -- a 404 per document, with nothing in the log -- and
548 558 /// `quasi-awaiting.js` would have joined them the moment it shipped. Found
549 559 /// adding the third, which is the only reason the other two were.
550 - const GENERATED_STATIC_JS: [(&str, &str); 9] = [
560 + const GENERATED_STATIC_JS: [(&str, &str); 10] = [
551 561 ("quasi-selection.js", quasi_webview::SELECTION_JS),
552 562 ("quasi-clock.js", quasi_webview::CLOCK_JS),
553 563 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
@@ -557,6 +567,7 @@
557 567 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
558 568 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
559 569 ("quasi-instant.js", quasi_webview::INSTANT_JS),
570 + ("quasi-menu.js", quasi_webview::MENU_JS),
560 571 ];
561 572
562 573 /// Every file that can carry a class name.
@@ -110,14 +110,15 @@
110 110 // A document that does ask fails visibly on the first control pressed,
111 111 // and nothing here asks: every control is an external link.
112 112 //
113 - // Seven `without_` calls now. quasi 0.54.0 added the fill script,
114 - // 0.59.0 and 0.60.0 the reveal and repeat scripts, and 0.68.0 the copy
115 - // script. All four are independent of htmx by design — what they read
116 - // is attributes on a control, a region or a fieldset — so dropping the
117 - // transport does not drop them, and each has to be refused by name. An
118 - // embed names no destination field, no conditional region, no repeating
119 - // question and no act that copies, so all four have nothing to read
120 - // here.
113 + // Eight `without_` calls now. quasi 0.54.0 added the fill script,
114 + // 0.59.0 and 0.60.0 the reveal and repeat scripts, 0.68.0 the copy
115 + // script and 0.73.0 the menu script. All five are independent of htmx by
116 + // design — what they read is attributes on a control, a region or a
117 + // fieldset, or the room a run has — so dropping the transport does not
118 + // drop them, and each has to be refused by name. An embed names no
119 + // destination field, no conditional region, no repeating question, no
120 + // act that copies and no run that folds, so all five have nothing to
121 + // read here.
121 122 .without_htmx()
122 123 .without_hyperscript()
123 124 .without_clock()
@@ -125,6 +126,7 @@
125 126 .without_reveal()
126 127 .without_repeat()
127 128 .without_copy()
129 + .without_menu()
128 130 .with_chrome(Chrome::new())
129 131 .with_head_first(head_first());
130 132 shell.body_class = body_class.map(str::to_owned);
@@ -293,6 +295,7 @@
293 295 .without_reveal()
294 296 .without_repeat()
295 297 .without_copy()
298 + .without_menu()
296 299 .with_chrome(Chrome::new())
297 300 .with_head_first(format!("{}<style>{PLAYER_CSS}</style>", head_first()));
298 301 shell.body_class = Some("embed-player".to_owned());
@@ -1,0 +1,158 @@
1 + // Folding a run's overflow into a menu, because only a browser can measure it.
2 + //
3 + // `makeover_layout::Fallback::Menu` says the members a `Shed` would drop move
4 + // into one overflow control instead. That needs to know the run is out of room,
5 + // and CSS cannot ask: `@container` compares against a `<length>`, and there is
6 + // no `@container (inline-size < min-content)`. Every honest CSS spelling needs
7 + // an authored breakpoint, which is the thing the room ruling exists to forbid.
8 + // So the measuring is here, where the browser already knows.
9 + //
10 + // It reads what makeover-webview emits and nothing else: `.run-menu` for a run
11 + // that asked, and `.cell-drops-first` / `.cell-drops-next` for what a member is
12 + // worth. The control it builds carries that crate's `.run-overflow` classes, so
13 + // the surface and the shadow are the design system's and only the measuring is
14 + // this file's.
15 + //
16 + // Degrading is the point of the mark. A page that never runs this keeps
17 + // `.run-menu`'s own `flex-wrap: wrap` and every member stays reachable, which is
18 + // rule 1; `data-menu` is what switches the run back to `nowrap`, and it is
19 + // written from here, so the fallback is the default rather than the failure.
20 + //
21 + // The MNW server carried a copy of this for its four Askama tab strips
22 + // (`core/tabs.ts`'s `tabOverflow`), which looked for `.tabs` and never saw the
23 + // described strips at all.
24 + (() => {
25 + "use strict";
26 +
27 + // What a member is worth when room runs out, lowest first. Read off the
28 + // classes rather than off an attribute of our own: these are what
29 + // `Ranked::priority` already reaches the markup as, and inventing a second
30 + // spelling is how two encodings of one fact stop agreeing.
31 + const TIERS = ["cell-drops-first", "cell-drops-next"];
32 +
33 + // A tab strip is one member of its run and its tabs are what fold. The five
34 + // described MNW strips are all this shape: `across(Fallback::Menu)` on a
35 + // tab group whose run holds no members of its own, so a reading that only
36 + // looked at the run's children would find one strip that always fits and
37 + // fold nothing.
38 + const STRIP = '[role="tablist"]';
39 +
40 + /**
41 + * The element whose children fold, and the children in shedding order.
42 + *
43 + * Order is by priority tier and then last-first within a tier. Last-first
44 + * is what "shed by priority, not by document order" leaves once priority is
45 + * spent: among members worth the same, the ones at the end are the ones a
46 + * reader reaches for least. For a strip, where no tab carries a priority at
47 + * all, that is the whole rule and it is the one the description means --
48 + * the last tabs fold away and the first stay.
49 + */
50 + const shedding = (run) => {
51 + const strip = run.querySelector(STRIP);
52 + const host = strip ?? run;
53 + const members = [...host.children].filter(
54 + (el) => !el.classList.contains("run-overflow"),
55 + );
56 + const order = [];
57 + for (const tier of TIERS) {
58 + order.push(...members.filter((el) => el.classList.contains(tier)).reverse());
59 + }
60 + order.push(
61 + ...members
62 + .filter((el) => !TIERS.some((tier) => el.classList.contains(tier)))
63 + .reverse(),
64 + );
65 + return { host, order };
66 + };
67 +
68 + /** The control the shed members move into, built once per host. */
69 + const control = (host) => {
70 + const existing = host.querySelector(":scope > .run-overflow");
71 + if (existing) return existing;
72 +
73 + const wrap = document.createElement("div");
74 + wrap.className = "run-overflow";
75 +
76 + const button = document.createElement("button");
77 + button.type = "button";
78 + button.className = "button";
79 + button.setAttribute("aria-expanded", "false");
80 + button.setAttribute("aria-haspopup", "true");
81 + // A word rather than a glyph. The strips this replaces said "More" and
82 + // a screen reader has something to read either way.
83 + button.textContent = "More";
84 +
85 + const items = document.createElement("div");
86 + items.className = "run-overflow-items";
87 + items.hidden = true;
88 +
89 + button.addEventListener("click", () => {
90 + const open = items.hidden;
91 + items.hidden = !open;
92 + button.setAttribute("aria-expanded", String(open));
93 + });
94 +
95 + wrap.append(button, items);
96 + host.append(wrap);
97 + return wrap;
98 + };
99 +
100 + /** Put everything back, so a measurement starts from the whole run. */
101 + const restore = (host, wrap) => {
102 + const items = wrap.querySelector(".run-overflow-items");
103 + while (items.firstChild) host.insertBefore(items.firstChild, wrap);
104 + wrap.hidden = true;
105 + items.hidden = true;
106 + wrap.querySelector("button")?.setAttribute("aria-expanded", "false");
107 + };
108 +
109 + // The measurement. `scrollWidth > clientWidth` is the browser answering
110 + // "does this overflow" directly, which is the question CSS could not be
111 + // asked. It is read after a restore, so what is compared is always the
112 + // whole run rather than a run already missing what a previous pass took.
113 + const tight = (host) => host.scrollWidth > host.clientWidth + 1;
114 +
115 + /** Fold one run until what is left fits. */
116 + const fold = (run) => {
117 + const { host, order } = shedding(run);
118 + const wrap = control(host);
119 + restore(host, wrap);
120 +
121 + // Marked before measuring: the mark is what turns wrapping off, and a
122 + // wrapped run always fits, so measuring first would always find room.
123 + run.setAttribute("data-menu", "");
124 +
125 + const items = wrap.querySelector(".run-overflow-items");
126 + let shed = 0;
127 + // Never the last one. A control that folded everything into itself
128 + // would leave a strip with nothing on it, which is worse than an
129 + // overflowing strip and is not what the description asks for.
130 + while (tight(host) && shed < order.length - 1) {
131 + items.prepend(order[shed]);
132 + shed += 1;
133 + }
134 + wrap.hidden = shed === 0;
135 + };
136 +
137 + const all = () => {
138 + for (const run of document.querySelectorAll(".run-menu")) fold(run);
139 + };
140 +
141 + // Three occasions, and each is a different way the answer changes: the room
142 + // changed, the members changed, or the page just arrived. The walk is over
143 + // the document every time rather than over a list captured at load, which is
144 + // the selection script's reason -- a swap brings new runs and takes old ones
145 + // away, and both are found by the next pass.
146 + if (typeof ResizeObserver === "function") {
147 + const observer = new ResizeObserver(() => all());
148 + observer.observe(document.documentElement);
149 + } else {
150 + window.addEventListener("resize", all);
151 + }
152 + document.addEventListener("htmx:after:settle", all);
153 + if (document.readyState === "loading") {
154 + document.addEventListener("DOMContentLoaded", all);
155 + } else {
156 + all();
157 + }
158 + })();