Skip to main content

max / goingson

Take the wait the design system now draws, and serve its script makeover-webview 0.60.0 emits rules for `[data-awaiting]`, and quasi-webview 0.64.0 adds `AWAITING_JS` -- the half neither the emitter nor the stylesheet could supply, since whether a wait is running is true only between two events. `frontend/css/layout.css` carries the rules now and `frontend/js/quasi-awaiting.js` is served beside the other six. The script list in build.rs is a loop for exactly this reason, stated in its own comment: a script the renderer starts shipping and this file forgets is a 404 per document with nothing in the log.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 20:07 UTC
Signed with PGP, not checked
Commit: 73eaa92d3d7c0f7d67e9cbbff1d28f52d8380576
Parent: c5bc550
4 files changed, +174 insertions, -33 deletions
M Cargo.lock +23 -23
@@ -3444,9 +3444,9 @@
3444 3444
3445 3445 [[package]]
3446 3446 name = "makeover-build"
3447 - version = "0.52.0"
3447 + version = "0.53.0"
3448 3448 source = "registry+https://github.com/rust-lang/crates.io-index"
3449 - checksum = "7b10f15fe8101177872c65b4bf2dc521a98975a42cee7b1ddfb2646fdcbfaaa9"
3449 + checksum = "82a170c530883774fc44d41e8e785260ee1b44cc26d9bfaa4d77154176af794c"
3450 3450 dependencies = [
3451 3451 "makeover",
3452 3452 "makeover-geometry",
@@ -3498,9 +3498,9 @@
3498 3498
3499 3499 [[package]]
3500 3500 name = "makeover-webview"
3501 - version = "0.59.1"
3501 + version = "0.60.0"
3502 3502 source = "registry+https://github.com/rust-lang/crates.io-index"
3503 - checksum = "59714ea075f5c266616199eb8345808f85092be04278471ffde546bdb0f43cb6"
3503 + checksum = "203b3a259e249ed32ffd9d48d75a6c12e4aa173095607cc2c852f2c1f3fe9c0e"
3504 3504 dependencies = [
3505 3505 "makeover-geometry",
3506 3506 "makeover-layout",
@@ -4716,7 +4716,7 @@
4716 4716
4717 4717 [[package]]
4718 4718 name = "quasi-http"
4719 - version = "0.63.0"
4719 + version = "0.64.0"
4720 4720 dependencies = [
4721 4721 "form_urlencoded",
4722 4722 "http",
@@ -4725,7 +4725,7 @@
4725 4725
4726 4726 [[package]]
4727 4727 name = "quasi-notifs"
4728 - version = "0.63.0"
4728 + version = "0.64.0"
4729 4729 dependencies = [
4730 4730 "quasi-router",
4731 4731 "synckit-config",
@@ -4733,14 +4733,14 @@
4733 4733
4734 4734 [[package]]
4735 4735 name = "quasi-router"
4736 - version = "0.63.0"
4736 + version = "0.64.0"
4737 4737 dependencies = [
4738 4738 "makeover-layout",
4739 4739 ]
4740 4740
4741 4741 [[package]]
4742 4742 name = "quasi-tauri"
4743 - version = "0.63.0"
4743 + version = "0.64.0"
4744 4744 dependencies = [
4745 4745 "http",
4746 4746 "quasi-http",
@@ -4766,7 +4766,7 @@
4766 4766
4767 4767 [[package]]
4768 4768 name = "quasi-webview"
4769 - version = "0.63.0"
4769 + version = "0.64.0"
4770 4770 dependencies = [
4771 4771 "docengine",
4772 4772 "makeover-layout",
@@ -8504,21 +8504,21 @@
8504 8504 ]
8505 8505
8506 8506 [[patch.unused]]
8507 - name = "quasi-axum"
8508 - version = "0.63.0"
8509 -
8510 - [[patch.unused]]
8511 - name = "quasi-basics"
8512 - version = "0.63.0"
8513 -
8514 - [[patch.unused]]
8515 - name = "quasi-immediate"
8516 - version = "0.63.0"
8517 -
8518 - [[patch.unused]]
8519 - name = "quasi-store"
8507 + name = "ops-status"
8520 8508 version = "0.1.0"
8521 8509
8522 8510 [[patch.unused]]
8523 - name = "ops-status"
8511 + name = "quasi-axum"
8512 + version = "0.64.0"
8513 +
8514 + [[patch.unused]]
8515 + name = "quasi-basics"
8516 + version = "0.64.0"
8517 +
8518 + [[patch.unused]]
8519 + name = "quasi-immediate"
8520 + version = "0.64.0"
8521 +
8522 + [[patch.unused]]
8523 + name = "quasi-store"
8524 8524 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.52.0"
21 + makeover-build = "0.53.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.59.1"
31 + makeover-webview = "0.60.0"
32 32 makeover-layout = "0.35.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.63" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
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.63" }
58 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" }
59 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" }
60 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
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.63", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.64", 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.59.1"
142 + makeover-webview = "0.60.0"
143 143 makeover-layout = "0.35.0"
144 144 # Browser opening
145 145 open = { workspace = true }
@@ -603,7 +603,7 @@
603 603 // silently stops counting, a band showing the time the screen was served,
604 604 // an export pasted into the page, or a picker that writes nothing.
605 605 //
606 - // A loop rather than four calls, because the shell links every one of these
606 + // A loop rather than a call each, because the shell links every one of these
607 607 // by default: a script the renderer starts shipping and this file forgets
608 608 // is a 404 per document with nothing in the log. `quasi-download.js` was
609 609 // exactly that until 2026-08-22.
@@ -614,6 +614,7 @@
614 614 ("quasi-fill.js", quasi_webview::FILL_JS),
615 615 ("quasi-reveal.js", quasi_webview::REVEAL_JS),
616 616 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
617 + ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
617 618 ] {
618 619 fs::write(frontend.join("js").join(name), source)
619 620 .unwrap_or_else(|error| panic!("write js/{name}: {error}"));
@@ -1,0 +1,140 @@
1 + // The half of a wait that only the browser can see.
2 + //
3 + // The node emitter has written `data-awaiting` since the vocabulary gained
4 + // `Awaiting`: `determinate` with a `data-awaiting-amount` beside it, or
5 + // `indeterminate` alone. `makeover-webview` 0.60.0 draws both. Neither of them
6 + // knows when a wait is actually running, and that is what this file supplies.
7 + //
8 + // Three things, and nothing else:
9 + //
10 + // 1. `aria-busy` on the element that made the request, for as long as the
11 + // request is in flight. That is what the design system keys the mark off,
12 + // and it is a standard attribute rather than a class of ours, so it is
13 + // announced as well as drawn.
14 + // 2. `--awaiting-share`, for a control whose wait has a measured size, from
15 + // bytes actually observed arriving. Never from elapsed time: see below.
16 + // 3. Nothing at all for a control that is not marked as awaiting, which is
17 + // nearly all of them.
18 + //
19 + // # Why the attribute alone was not enough
20 + //
21 + // `data-awaiting` is a fact about the control -- pressing this waits -- and it
22 + // is true when the page is painted and stays true. Styling it directly would
23 + // put a blinking mark on every awaiting control on the screen from the moment
24 + // the document loaded. The running-or-not half is true only between two events,
25 + // which makes it the binder's, which is this.
26 + //
27 + // # What the share may not be
28 + //
29 + // Rule 1 of wiki `loading-and-progress-standard`, and `Awaiting`'s own docs
30 + // before it: what is done over what there is, and never a remaining time, an
31 + // arrival time, or a rate extrapolated forward. So the share is set from
32 + // `event.loaded` -- bytes the browser says have landed -- and from nothing
33 + // else. A control with no readable progress keeps a share of zero and draws an
34 + // empty trough, which is the honest picture: the size is known and the delivery
35 + // is not.
36 + //
37 + // The one thing this file must never grow is a timer that advances the share.
38 + // A bar walking forward on a stalled transfer is the confidently-wrong drawing
39 + // the rule exists to forbid, and it is the easiest thing in the world to add.
40 + //
41 + // # Degrading
42 + //
43 + // A page that does not serve this file draws no mark and no bar, and every
44 + // control still locks itself through `hx-disable` exactly as before. That is
45 + // the direction the rest of this renderer degrades in: less decoration, never
46 + // less behaviour.
47 + (() => {
48 + "use strict";
49 +
50 + /** The fact about the control: this call waits. */
51 + const AWAITING = "data-awaiting";
52 + /** The measured payload size, when the description carried one. */
53 + const AMOUNT = "data-awaiting-amount";
54 + /** How much of it has landed, as a share from 0 to 1. */
55 + const SHARE = "--awaiting-share";
56 +
57 + /**
58 + * The element a request belongs to, or null when it is not our business.
59 + *
60 + * htmx names the element on the event detail. An event for something that
61 + * never described a wait is left alone rather than given one: the emitter
62 + * decides which calls are worth marking, and a renderer that marked every
63 + * request would be overruling it.
64 + */
65 + const marked = (event) => {
66 + const element = event.detail?.elt;
67 + if (!element || typeof element.hasAttribute !== "function") {
68 + return null;
69 + }
70 + return element.hasAttribute(AWAITING) ? element : null;
71 + };
72 +
73 + /** Say a wait has started, with nothing delivered yet. */
74 + const begin = (event) => {
75 + const element = marked(event);
76 + if (!element) {
77 + return;
78 + }
79 + element.setAttribute("aria-busy", "true");
80 + element.style.removeProperty(SHARE);
81 + };
82 +
83 + /**
84 + * Say the wait is over.
85 + *
86 + * The share is cleared with it. A control pressed twice would otherwise
87 + * start its second wait wearing the first one's bar, which is a number
88 + * about a payload that has already arrived.
89 + */
90 + const end = (event) => {
91 + const element = marked(event);
92 + if (!element) {
93 + return;
94 + }
95 + element.removeAttribute("aria-busy");
96 + element.style.removeProperty(SHARE);
97 + };
98 +
99 + /**
100 + * What has landed, over what the description said there was.
101 + *
102 + * `event.loaded` when the browser is counting, against the described
103 + * amount rather than against `event.total`. The two usually agree, and
104 + * where they do not the description is the one the bar was drawn for: a
105 + * server that gzips a response reports a total the screen never described.
106 + *
107 + * A response with no readable length leaves the share alone, so the trough
108 + * stays empty rather than jumping to something invented.
109 + */
110 + const progressed = (event) => {
111 + const element = marked(event);
112 + if (!element || element.getAttribute(AWAITING) !== "determinate") {
113 + return;
114 + }
115 + const amount = Number(element.getAttribute(AMOUNT));
116 + const loaded = Number(event.detail?.loaded);
117 + if (!Number.isFinite(amount) || amount <= 0 || !Number.isFinite(loaded)) {
118 + return;
119 + }
120 + element.style.setProperty(SHARE, String(Math.min(loaded / amount, 1)));
121 + };
122 +
123 + // htmx 4 spells every event `phase:action`; 2.x spelled these
124 + // `htmx:beforeRequest`, `htmx:afterRequest` and `htmx:xhr:progress`.
125 + //
126 + // Delegated on the document rather than bound per control, for the reason
127 + // the reveal and repeat scripts give: a swap brings new controls and takes
128 + // old ones away, and a listener on the document covers both without a
129 + // re-scan.
130 + document.addEventListener("htmx:before:request", begin);
131 + document.addEventListener("htmx:after:request", end);
132 + document.addEventListener("htmx:xhr:progress", progressed);
133 +
134 + // A request that is aborted or errors out never reaches `after:request` in
135 + // every htmx version, and a control left wearing `aria-busy` blinks for the
136 + // rest of the page's life. Cheap insurance, and the same handler.
137 + document.addEventListener("htmx:abort", end);
138 + document.addEventListener("htmx:response:error", end);
139 + document.addEventListener("htmx:send:error", end);
140 + })();