Skip to main content

max / goingson

Take quasi 0.73 and makeover-webview 0.64.0 The menu script, which folds a run that ran out of room instead of wrapping it. quasi-menu.js joins the scripts written out at build time: the shell links every one of them, and a script the renderer ships and this file forgets is a 404 per document with nothing in the log.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 15:57 UTC
Signed with PGP, not checked
Commit: 22c38c6da55096bec3d8e0b931b486c68806e19b
Parent: 0eaa718
4 files changed, +191 insertions, -32 deletions
M Cargo.lock +23 -23
@@ -3444,9 +3444,9 @@
3444 3444
3445 3445 [[package]]
3446 3446 name = "makeover-build"
3447 - version = "0.56.0"
3447 + version = "0.57.0"
3448 3448 source = "registry+https://github.com/rust-lang/crates.io-index"
3449 - checksum = "56c8903ef617932723aea44f7ada31c7ce4f89d008e8d25ebaee956b2258bb91"
3449 + checksum = "0f8d7b3e487f34397f239cd34820a6f0a02fdd57bdd8c72af851c4e0dd8810ef"
3450 3450 dependencies = [
3451 3451 "makeover",
3452 3452 "makeover-geometry",
@@ -3499,9 +3499,9 @@
3499 3499
3500 3500 [[package]]
3501 3501 name = "makeover-webview"
3502 - version = "0.63.0"
3502 + version = "0.64.0"
3503 3503 source = "registry+https://github.com/rust-lang/crates.io-index"
3504 - checksum = "9149f06aa7b8f4660609a680169d76075d91def63c23127bd84af9b183be22fa"
3504 + checksum = "6b91db6e169bc8b7c7225e17b6bc83f9c6f1879eb3bc6f1554e96cb621c84e5c"
3505 3505 dependencies = [
3506 3506 "makeover-geometry",
3507 3507 "makeover-layout",
@@ -4717,7 +4717,7 @@
4717 4717
4718 4718 [[package]]
4719 4719 name = "quasi-http"
4720 - version = "0.72.0"
4720 + version = "0.73.0"
4721 4721 dependencies = [
4722 4722 "form_urlencoded",
4723 4723 "http",
@@ -4726,7 +4726,7 @@
4726 4726
4727 4727 [[package]]
4728 4728 name = "quasi-notifs"
4729 - version = "0.72.0"
4729 + version = "0.73.0"
4730 4730 dependencies = [
4731 4731 "quasi-router",
4732 4732 "synckit-config",
@@ -4734,14 +4734,14 @@
4734 4734
4735 4735 [[package]]
4736 4736 name = "quasi-router"
4737 - version = "0.72.0"
4737 + version = "0.73.0"
4738 4738 dependencies = [
4739 4739 "makeover-layout",
4740 4740 ]
4741 4741
4742 4742 [[package]]
4743 4743 name = "quasi-tauri"
4744 - version = "0.72.0"
4744 + version = "0.73.0"
4745 4745 dependencies = [
4746 4746 "http",
4747 4747 "quasi-http",
@@ -4767,7 +4767,7 @@
4767 4767
4768 4768 [[package]]
4769 4769 name = "quasi-webview"
4770 - version = "0.72.0"
4770 + version = "0.73.0"
4771 4771 dependencies = [
4772 4772 "docengine",
4773 4773 "makeover-layout",
@@ -8505,21 +8505,21 @@
8505 8505 ]
8506 8506
8507 8507 [[patch.unused]]
8508 - name = "quasi-axum"
8509 - version = "0.72.0"
8510 -
8511 - [[patch.unused]]
8512 - name = "quasi-basics"
8513 - version = "0.72.0"
8514 -
8515 - [[patch.unused]]
8516 - name = "quasi-immediate"
8517 - version = "0.72.0"
8518 -
8519 - [[patch.unused]]
8520 - name = "quasi-store"
8508 + name = "ops-status"
8521 8509 version = "0.1.0"
8522 8510
8523 8511 [[patch.unused]]
8524 - name = "ops-status"
8512 + name = "quasi-axum"
8513 + version = "0.73.0"
8514 +
8515 + [[patch.unused]]
8516 + name = "quasi-basics"
8517 + version = "0.73.0"
8518 +
8519 + [[patch.unused]]
8520 + name = "quasi-immediate"
8521 + version = "0.73.0"
8522 +
8523 + [[patch.unused]]
8524 + name = "quasi-store"
8525 8525 version = "0.1.0"
@@ -18,7 +18,7 @@
18 18 # Materialises all three generated files: themes/, geometry.css, layout.css,
19 19 # and holds both drift checks this build.rs used to carry itself: touch density
20 20 # as of 0.17, breakpoints as of 0.18.
21 - makeover-build = "0.56.0"
21 + makeover-build = "0.57.0"
22 22 # Cuts the two house faces into frontend/fonts/. A git dependency because
23 23 # quasi-type is `publish = false`, which is also why makeover-build cannot do
24 24 # this and only emits the CSS that fetches the result. The rev is the one
@@ -28,7 +28,7 @@
28 28 # The table CSS is generated here too: the columns are this app's, so the
29 29 # shared helper cannot know them, but the tracks and the narrowing rules come
30 30 # from the description rather than from hand-written nth-child cuts.
31 - makeover-webview = "0.63.0"
31 + makeover-webview = "0.64.0"
32 32 makeover-layout = "0.37.0"
33 33 # Width. Direct rather than through makeover-webview, because the narrow table
34 34 # pass keys off SizeClass::Compact and a boundary reached transitively is a
@@ -36,7 +36,7 @@
36 36 makeover-geometry = "0.7"
37 37 # Only to write its selection and clock scripts into the frontend. Those two are
38 38 # the whole of `frontend/js/` since the swap.
39 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
40 40
41 41 [dependencies]
42 42 goingson-core = { workspace = true }
@@ -54,10 +54,10 @@
54 54 # dependency's source even with the feature off -- so a public clone could not
55 55 # build at all against a URL it could not reach. quasi went public that day and
56 56 # the URL moved here with it.
57 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
58 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
59 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
60 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
61 61
62 62 # The notification declaration, and it is NOT behind the `quasi` feature: the
63 63 # watcher is desktop app behaviour rather than a described screen, and the
@@ -74,7 +74,7 @@
74 74 # before it has ported its screens cannot take it. `notifications::Notifier` is
75 75 # the eight lines meanwhile, and it goes when the flip makes quasi-router
76 76 # unconditional here.
77 - quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.72", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.73", features = ["synckit", "describe"] }
78 78
79 79 # Tauri
80 80 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -139,7 +139,7 @@
139 139 # The forms emitter and the description it renders. Runtime deps, not build:
140 140 # form markup depends on the value, the error and the options at the moment a
141 141 # modal opens, so it cannot be materialised the way the stylesheet is.
142 - makeover-webview = "0.63.0"
142 + makeover-webview = "0.64.0"
143 143 makeover-layout = "0.37.0"
144 144 # Browser opening
145 145 open = { workspace = true }
@@ -617,6 +617,7 @@
617 617 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
618 618 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
619 619 ("quasi-instant.js", quasi_webview::INSTANT_JS),
620 + ("quasi-menu.js", quasi_webview::MENU_JS),
620 621 ] {
621 622 fs::write(frontend.join("js").join(name), source)
622 623 .unwrap_or_else(|error| panic!("write js/{name}: {error}"));
@@ -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 + })();