Skip to main content

max / makenotwork

Take the wait the design system now draws, and serve the scripts makeover-webview 0.60.0 emits rules for `[data-awaiting]`, which this server has been writing on every awaiting control for months with nothing styling it. quasi-webview 0.64.0 adds `AWAITING_JS`, the half neither could supply: `aria-busy` while a request is actually in flight, and `--awaiting-share` from bytes the browser reports delivered. Both arrive through the pin bumps; `static/layout.css` and `static/timing.css` carry the rules and the cadence now. Three scripts were missing from `GENERATED_STATIC_JS`, and that list's own comment says exactly why it matters: the shell links every script the renderer ships unless the host opts out, and this host opts out of none, so `quasi-reveal.js` and `quasi-repeat.js` were linked and unserved -- a 404 per document with nothing in the log. `quasi-awaiting.js` would have joined them. Found while adding the third, which is the only reason the other two were. `timing.css` joins the frontend lint's generated sheets in the same pass. It is generated by build.rs and linked from the shell and the lint had never heard of it, so every token it defines read as undefined -- which is the same silent failure rule 2 exists to break, one level up. The new rules use `--cadence-activity`, and that is how it surfaced.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 20:06 UTC
Signed with PGP, not checked
Commit: 0c7e23c4d2cf92fb9ab1dbaec6c89f70265e7468
Parent: bdc082e
7 files changed, +504 insertions, -21 deletions
M server/Cargo.lock +12 -12
@@ -5390,9 +5390,9 @@
5390 5390
5391 5391 [[package]]
5392 5392 name = "makeover-build"
5393 - version = "0.52.0"
5393 + version = "0.53.0"
5394 5394 source = "registry+https://github.com/rust-lang/crates.io-index"
5395 - checksum = "7b10f15fe8101177872c65b4bf2dc521a98975a42cee7b1ddfb2646fdcbfaaa9"
5395 + checksum = "82a170c530883774fc44d41e8e785260ee1b44cc26d9bfaa4d77154176af794c"
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.59.1"
5436 + version = "0.60.0"
5437 5437 source = "registry+https://github.com/rust-lang/crates.io-index"
5438 - checksum = "59714ea075f5c266616199eb8345808f85092be04278471ffde546bdb0f43cb6"
5438 + checksum = "203b3a259e249ed32ffd9d48d75a6c12e4aa173095607cc2c852f2c1f3fe9c0e"
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.63.0"
6639 + version = "0.64.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.63.0"
6650 + version = "0.64.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.63.0"
6658 + version = "0.64.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.63.0"
6667 + version = "0.64.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.63.0"
6690 + version = "0.64.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.63.0"
10784 + version = "0.64.0"
10785 10785
10786 10786 [[patch.unused]]
10787 10787 name = "quasi-notifs"
10788 - version = "0.63.0"
10788 + version = "0.64.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.63.0"
10796 + version = "0.64.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.63" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
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.59.1"
178 + makeover-webview = "0.60.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.63" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
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.63" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
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.52.0"
257 + makeover-build = "0.53.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.63" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
M server/build.rs +12 -1
@@ -532,11 +532,22 @@
532 532 /// an instance. This list plus the assertion in `markup_files` closes the door.
533 533 ///
534 534 /// **Adding a generated file under `static/`? Add it here too.**
535 - const GENERATED_STATIC_JS: [(&str, &str); 4] = [
535 + ///
536 + /// **Three were missing until 2026-08-26**, and the failure was exactly the one
537 + /// goingson's copy of this list warns about: the shell links every script the
538 + /// renderer ships unless the host says otherwise, and this host says otherwise
539 + /// about none of them. `quasi-reveal.js` and `quasi-repeat.js` had been linked
540 + /// and unserved -- a 404 per document, with nothing in the log -- and
541 + /// `quasi-awaiting.js` would have joined them the moment it shipped. Found
542 + /// adding the third, which is the only reason the other two were.
543 + const GENERATED_STATIC_JS: [(&str, &str); 7] = [
536 544 ("quasi-selection.js", quasi_webview::SELECTION_JS),
537 545 ("quasi-clock.js", quasi_webview::CLOCK_JS),
538 546 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
539 547 ("quasi-fill.js", quasi_webview::FILL_JS),
548 + ("quasi-reveal.js", quasi_webview::REVEAL_JS),
549 + ("quasi-repeat.js", quasi_webview::REPEAT_JS),
550 + ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
540 551 ];
541 552
542 553 /// Every file that can carry a class name.
@@ -16,7 +16,12 @@
16 16 # standard, not the code. They are still READ, because they are where the
17 17 # tokens the site's sheets spend are defined.
18 18 SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css"
19 - GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css"
19 + # `timing.css` joined the list 2026-08-26. It was generated, linked from the
20 + # shell, and invisible here, so every token it defines read as undefined to
21 + # rule 2 -- which is the same silence rule 2 exists to break, one level up.
22 + # Found when makeover-webview 0.60.0 started using `--cadence-activity`, which
23 + # timing.css has defined since the axis existed.
24 + GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css $STATIC/timing.css"
20 25
21 26 violations=0
22 27
@@ -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 + })();
@@ -1,0 +1,204 @@
1 + // A question answered N times, with the reader adding and removing the slots.
2 + //
3 + // `60d1753c`, ruled 2026-08-25: a repeating group enters the vocabulary and
4 + // submits once. This is the browser's half of the third hard part -- the reader
5 + // creates and destroys slots without a round trip on every one. The bytes are
6 + // already in the document: the emitter writes a `template` holding one blank
7 + // slot, and adding is cloning it.
8 + //
9 + // A round trip instead would re-render a form the reader is midway through,
10 + // which is `a135f898`, and it would ask a route for an empty box.
11 + //
12 + // It reads four attributes the node emitter writes and nothing else:
13 + // `data-repeat` names the question, `data-repeat-label` is what its slots are
14 + // called, and `data-repeat-least` / `data-repeat-most` are the floor and the
15 + // ceiling. All four are ordinary escaped attributes, so nothing here is a
16 + // program built out of app text -- which is why this is a script rather than
17 + // one of the emitted _hyperscript programs, whose one rule is that no program
18 + // is written from text a user typed.
19 + //
20 + // A page that does not serve this file shows every slot the description
21 + // offered, fillable and submittable, with two controls that do nothing.
22 + (() => {
23 + "use strict";
24 +
25 + /** The question a fieldset repeats. */
26 + const NAME = "data-repeat";
27 + /** What one slot of it is called. */
28 + const LABEL = "data-repeat-label";
29 + /** The fewest slots that may stand. */
30 + const LEAST = "data-repeat-least";
31 + /** The most that may stand. Absent means no ceiling. */
32 + const MOST = "data-repeat-most";
33 + /** The box the slots sit in. */
34 + const SLOTS = "data-repeat-slots";
35 + /** One slot, and where in the order it currently sits. */
36 + const AT = "data-repeat-at";
37 + /** The control that adds one. */
38 + const ADD = "data-repeat-add";
39 + /** The control that takes one away. */
40 + const REMOVE = "data-repeat-remove";
41 +
42 + /** The attributes a rename has to follow the index through. */
43 + const NAMED = ["name", "id", "for", "aria-describedby", "aria-labelledby"];
44 +
45 + /** The group a control inside one belongs to. */
46 + const groupOf = (element) => element.closest(`[${NAME}]`);
47 +
48 + /** Where a group keeps its slots. */
49 + const boxOf = (group) => group.querySelector(`[${SLOTS}]`);
50 +
51 + /** The slots standing in a group, in document order. */
52 + const slotsOf = (group) => [
53 + ...(boxOf(group)?.querySelectorAll(`:scope > [${AT}]`) ?? []),
54 + ];
55 +
56 + /** A number written in an attribute, or a fallback when it is not there. */
57 + const count = (group, attribute, fallback) => {
58 + const written = group.getAttribute(attribute);
59 + if (written === null) {
60 + return fallback;
61 + }
62 + const value = Number.parseInt(written, 10);
63 + return Number.isNaN(value) ? fallback : value;
64 + };
65 +
66 + /**
67 + * Move one slot to a place in the order.
68 + *
69 + * The index is in the slot's own attribute, in every name it submits under
70 + * and in the ordinal a person reads. All three are rewritten together, or a
71 + * removed slot leaves the ones after it answering under names nobody asked
72 + * for.
73 + *
74 + * The rename is a replacement of `question[old]` wherever it appears, which
75 + * covers the derived ids beside the name itself -- `question[0]-hint`,
76 + * `question[0]-error` -- without this having to know what the field
77 + * emitter derives.
78 + */
79 + const renumber = (slot, question, label, to) => {
80 + const from = slot.getAttribute(AT);
81 + slot.setAttribute(AT, String(to));
82 + if (from === null) {
83 + return;
84 + }
85 + const was = `${question}[${from}]`;
86 + const now = `${question}[${to}]`;
87 + if (was !== now) {
88 + for (const element of [slot, ...slot.querySelectorAll("*")]) {
89 + for (const attribute of NAMED) {
90 + const value = element.getAttribute(attribute);
91 + if (value !== null && value.includes(was)) {
92 + element.setAttribute(attribute, value.split(was).join(now));
93 + }
94 + }
95 + }
96 + }
97 + // The visible ordinal, which `Repeat::ordinal` wrote as "<label> <n>"
98 + // counting from one. Rewritten from the group's own label rather than
99 + // by editing whatever text is in there, so a slot cannot end up named
100 + // after the one it replaced.
101 + if (label !== null) {
102 + const named = slot.querySelector("label[for]");
103 + if (named !== null) {
104 + named.textContent = `${label} ${to + 1}`;
105 + }
106 + }
107 + };
108 +
109 + /**
110 + * Put a group's slots and its two controls back in agreement.
111 + *
112 + * Called after every change and on arrival, so a group the reader has not
113 + * touched is in the same state as one they have.
114 + */
115 + const settle = (group) => {
116 + const question = group.getAttribute(NAME) ?? "";
117 + const label = group.getAttribute(LABEL);
118 + const slots = slotsOf(group);
119 + const least = count(group, LEAST, 0);
120 + const most = count(group, MOST, Number.POSITIVE_INFINITY);
121 + slots.forEach((slot, at) => renumber(slot, question, label, at));
122 + for (const control of group.querySelectorAll(`[${REMOVE}]`)) {
123 + control.disabled = slots.length <= least;
124 + }
125 + for (const control of group.querySelectorAll(`[${ADD}]`)) {
126 + control.disabled = slots.length >= most;
127 + }
128 + };
129 +
130 + /** Every group in the document, settled. */
131 + const settleAll = () => {
132 + for (const group of document.querySelectorAll(`[${NAME}]`)) {
133 + settle(group);
134 + }
135 + };
136 +
137 + /** Clone the blank slot onto the end, if the ceiling allows another. */
138 + const add = (group) => {
139 + const blank = group.querySelector("template");
140 + const box = boxOf(group);
141 + if (blank === null || box === null) {
142 + return;
143 + }
144 + const most = count(group, MOST, Number.POSITIVE_INFINITY);
145 + if (slotsOf(group).length >= most) {
146 + return;
147 + }
148 + const made = blank.content.cloneNode(true);
149 + const slot = made.firstElementChild;
150 + box.append(made);
151 + settle(group);
152 + // The reader pressed add because they have something to type, so the
153 + // caret goes where they meant it to. Nothing else in this file moves
154 + // focus: a settle after a swap must not steal it.
155 + slot?.querySelector("input, select, textarea")?.focus();
156 + };
157 +
158 + /** Take one slot out, if the floor allows one fewer. */
159 + const remove = (group, slot) => {
160 + const least = count(group, LEAST, 0);
161 + if (slotsOf(group).length <= least) {
162 + return;
163 + }
164 + slot.remove();
165 + settle(group);
166 + };
167 +
168 + // Delegated, so a group that arrives in a swap needs no wiring of its own.
169 + document.addEventListener("click", (event) => {
170 + const target = event.target;
171 + if (!(target instanceof Element)) {
172 + return;
173 + }
174 + const adder = target.closest(`[${ADD}]`);
175 + if (adder !== null) {
176 + const group = groupOf(adder);
177 + if (group !== null) {
178 + event.preventDefault();
179 + add(group);
180 + }
181 + return;
182 + }
183 + const remover = target.closest(`[${REMOVE}]`);
184 + if (remover === null) {
185 + return;
186 + }
187 + const group = groupOf(remover);
188 + const slot = remover.closest(`[${AT}]`);
189 + if (group !== null && slot !== null) {
190 + event.preventDefault();
191 + remove(group, slot);
192 + }
193 + });
194 +
195 + // A group that arrives mid-page settles like one that was parsed with the
196 + // document. `htmx:after:settle` is htmx 4's name for it; 2.x spelled the
197 + // same event `htmx:afterSettle`.
198 + document.addEventListener("htmx:after:settle", settleAll);
199 + if (document.readyState === "loading") {
200 + document.addEventListener("DOMContentLoaded", settleAll);
201 + } else {
202 + settleAll();
203 + }
204 + })();
@@ -1,0 +1,123 @@
1 + // A region that is out only while a control holds a value.
2 + //
3 + // `079a011e`, ruled 2026-08-25: the condition lives on the region. A region
4 + // names the control it watches and the value that brings it out, and every
5 + // renderer answers it from what it already has. This is the browser's half, and
6 + // it makes no request: the bytes are in the document, and a round trip to
7 + // reveal a section of a form the reader is midway through would re-render boxes
8 + // holding values they have not committed yet.
9 + //
10 + // It reads three attributes the node emitter writes and nothing else:
11 + // `data-reveal` names the control, `data-reveal-when` says whether the region
12 + // wants anything, nothing or one of a set of values, and `data-reveal-values`
13 + // carries that set as JSON. All three are ordinary escaped attributes, so
14 + // nothing here is a program built out of app text -- which is why the behaviour
15 + // is a script rather than one of the emitted _hyperscript programs, whose one
16 + // rule is that no program is written from text a user typed.
17 + //
18 + // A page that does not serve this file shows every conditional region, which is
19 + // the direction everything else in this renderer degrades in: more content
20 + // rather than less. The six hand-written toggles this replaces fail the other
21 + // way, since their markup starts hidden.
22 + (() => {
23 + "use strict";
24 +
25 + /** The control a region watches. */
26 + const CONTROL = "data-reveal";
27 + /** What that control has to hold: `any`, `none` or `value`. */
28 + const WHEN = "data-reveal-when";
29 + /** The values `value` accepts, as a JSON array. */
30 + const VALUES = "data-reveal-values";
31 +
32 + /**
33 + * What the named control is holding now, or null when it holds nothing.
34 + *
35 + * By `name` rather than by `id`, because `name` is what the description
36 + * carries: it is what a submit sends the value under and what a region
37 + * names. An id is scoped per form instance by the emitter and is therefore
38 + * not the string the description wrote.
39 + *
40 + * Searched from the region outward: the nearest enclosing form first, then
41 + * the document, which is `fill.js`'s rule and is here for the same reason.
42 + * A screen showing the same form twice -- an edit modal over a list --
43 + * otherwise reads the first copy in the document whichever one the reader
44 + * is in.
45 + *
46 + * A tick is there by presence, the way a form submits one, so an unticked
47 + * box and an empty box are one answer. That is the convention the router
48 + * states and the other two renderers hold.
49 + */
50 + const held = (region, name) => {
51 + const selector = `[name="${CSS.escape(name)}"]`;
52 + const scope = region.closest("form") ?? document;
53 + const controls = scope.querySelectorAll(selector);
54 + let value = null;
55 + for (const control of controls) {
56 + if (control.type === "checkbox" || control.type === "radio") {
57 + if (control.checked) {
58 + value = control.value === "" ? "on" : control.value;
59 + }
60 + continue;
61 + }
62 + value = control.value ?? null;
63 + }
64 + return value === "" ? null : value;
65 + };
66 +
67 + /** Whether a region asking for this is satisfied by what is held. */
68 + const satisfied = (region, value) => {
69 + const when = region.getAttribute(WHEN);
70 + if (when === "any") {
71 + return value !== null;
72 + }
73 + if (when === "none") {
74 + return value === null;
75 + }
76 + if (value === null) {
77 + return false;
78 + }
79 + let wanted;
80 + try {
81 + wanted = JSON.parse(region.getAttribute(VALUES) ?? "[]");
82 + } catch {
83 + // An attribute this file did not write. Nothing is revealed on a
84 + // condition nobody can read, which is the same answer a region
85 + // watching a control that is not on the screen gets.
86 + return false;
87 + }
88 + return Array.isArray(wanted) && wanted.includes(value);
89 + };
90 +
91 + /**
92 + * Put every conditional region where its condition says it belongs.
93 + *
94 + * A walk over the document rather than over a list captured when the page
95 + * loaded, for the reason the clock gives: a swap brings new regions and
96 + * takes old ones away, and both are found by the next pass.
97 + */
98 + const settle = () => {
99 + for (const region of document.querySelectorAll(`[${CONTROL}]`)) {
100 + const value = held(region, region.getAttribute(CONTROL) ?? "");
101 + region.hidden = !satisfied(region, value);
102 + }
103 + };
104 +
105 + // Delegated, and on both events a control settles under: `change` is what a
106 + // checkbox and a select fire, `input` is what a box being typed into fires,
107 + // and a region may watch either.
108 + document.addEventListener("change", settle);
109 + document.addEventListener("input", settle);
110 +
111 + // A region that arrives mid-page should not wait for the reader to touch
112 + // something before it decides whether it applies. htmx's event and the
113 + // initial parse both land here.
114 + //
115 + // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same event
116 + // `htmx:afterSettle`, and 4 renamed every event to phase:action.
117 + document.addEventListener("htmx:after:settle", settle);
118 + if (document.readyState === "loading") {
119 + document.addEventListener("DOMContentLoaded", settle);
120 + } else {
121 + settle();
122 + }
123 + })();