Skip to main content

max / goingson

Ignore every generated quasi script, not one of eight src-tauri/build.rs writes all eight out of quasi-webview on every build, but .gitignore named only quasi-selection.js and the other seven were tracked. A tracked copy of somebody else's emitted source is the drift the rule in that block exists to prevent. The committed copies matched what the crate emits today, so nothing is lost by untracking them. Also corrects the block's comment: it said the scripts are written under the quasi feature only, and nothing gates the loop. The MNW server had the same split the other way round (five ignored, three tracked) and is fixed in the same pass.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-27 20:49 UTC
Signed with PGP, not checked
Commit: 1dafcc7c19e2443f377b0f42d1f85006772840fa
Parent: 5d146c5
8 files changed, +14 insertions, -909 deletions
M .gitignore +14 -4
@@ -86,11 +86,21 @@
86 86 /src-tauri/frontend/tables.columns.json
87 87 /src-tauri/frontend/js/tables.generated.js
88 88
89 - # Written by src-tauri/build.rs out of quasi-webview, under the `quasi` feature
90 - # only. Not committed for the reason none of the above are: it is a copy of
91 - # somebody else's source, and a copy that can go stale against the markup it
92 - # reads is the one failure mode it has.
89 + # Written by src-tauri/build.rs out of quasi-webview, every build: the loop in
90 + # main() names all eight and nothing gates it. Not committed for the reason
91 + # none of the above are: it is a copy of somebody else's source, and a copy
92 + # that can go stale against the markup it reads is the one failure mode it has.
93 + # Seven of the eight were tracked until 2026-08-27, which is that failure mode
94 + # sitting in the tree waiting to happen; the MNW server had the same split the
95 + # other way round and was fixed in the same pass.
93 96 /src-tauri/frontend/js/quasi-selection.js
97 + /src-tauri/frontend/js/quasi-clock.js
98 + /src-tauri/frontend/js/quasi-download.js
99 + /src-tauri/frontend/js/quasi-fill.js
100 + /src-tauri/frontend/js/quasi-reveal.js
101 + /src-tauri/frontend/js/quasi-copy.js
102 + /src-tauri/frontend/js/quasi-repeat.js
103 + /src-tauri/frontend/js/quasi-awaiting.js
94 104
95 105 # Secrets and credentials. The baseline every repo under ~/Code carries, kept
96 106 # identical so no repo is the one that forgot. Suffix-matched rather than
@@ -1,140 +1,0 @@
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,162 +1,0 @@
1 - // What a browser knows about the time and a description cannot.
2 - //
3 - // `f00244a6`: the renderer owns the clock. A description carries an instant and
4 - // which way a readout runs against now, and picking the words and the rate is
5 - // the renderer's half. In this renderer the markup is emitted once by a server
6 - // and then held by a browser for as long as the reader is on the page, so this
7 - // file is where every set of words after the first one is made.
8 - //
9 - // The first set is Rust's, in `clock.rs`, and the two formats have to agree or
10 - // the page changes what it says a second after it loads. Both sides carry a
11 - // note saying so and both are covered by tests.
12 - //
13 - // It also takes toasts away, which is the other thing on a page that happens
14 - // because time passed and for which the browser is the only one awake.
15 - // `4453bf82`: the description says a notice goes away on its own and declines
16 - // to say when, so the when is the renderer's, and in this renderer that means
17 - // here. It rides in this file rather than in a second script the host has to
18 - // serve, because it is the same fact -- what a browser knows about the time and
19 - // a description cannot -- and a page dropping the clock has no timer left to
20 - // take a toast away with either.
21 - //
22 - // Nothing here knows what a readout is of. It reads two attributes the renderer
23 - // emits, `data-clock` for which way the readout runs and `data-at` for the
24 - // instant in milliseconds, and it is the whole of the per-app elapsed-time
25 - // plumbing this stack exists to delete. The apps that hand-write it run to
26 - // hundreds of lines each.
27 - (() => {
28 - "use strict";
29 -
30 - // Two rates, because the cadence follows the granularity the words are
31 - // shown at. A stopwatch reads seconds and needs one; a stamp reads "3h ago"
32 - // and changes on the minute at its finest, so waking for it every second
33 - // would repaint the page 60 times to change nothing.
34 - //
35 - // Many readouts, one tick: every readout of a kind is written by that
36 - // kind's own interval, rather than each element carrying a timer. A page of
37 - // running timers costs one wake a second however many of them there are,
38 - // which is what the hand-written version arrives at after it has been
39 - // rewritten twice.
40 - const RATES = { since: 1000, until: 1000, age: 30000 };
41 -
42 - // How long a toast stays before it is taken off the page.
43 - //
44 - // `eea7ba88`. Read rather than written: `makeover-timing` names the
45 - // duration `Intent::Dismiss`, and `makeover-build` writes it into every
46 - // consumer's `timing.css` as `--timing-dismiss`. The terminal and egui
47 - // renderers take the same number out of the same crate, so a screen
48 - // described once and drawn three times keeps its messages for one length of
49 - // time and moving that length is moving one constant.
50 - //
51 - // The fallback is for a page that ships this script without that
52 - // stylesheet. It is the crate's own value and a Rust test pins it there, the
53 - // same arrangement the two format implementations above already run on: a
54 - // number in two languages is fine so long as something fails when they part.
55 - const LINGER_FALLBACK = 3000;
56 -
57 - // A CSS time as a number of milliseconds. `3000ms` and `3s` are both legal
58 - // spellings of the same duration and a stylesheet may carry either, so the
59 - // unit is read rather than assumed. An absent or unparseable value is a page
60 - // with no timing sheet, not a page asking for zero.
61 - const milliseconds = (token, fallback) => {
62 - const raw = getComputedStyle(document.documentElement)
63 - .getPropertyValue(token)
64 - .trim();
65 - const parsed = Number.parseFloat(raw);
66 - if (!Number.isFinite(parsed)) return fallback;
67 - return raw.endsWith("ms") ? parsed : parsed * 1000;
68 - };
69 -
70 - // Read once, on the first settle rather than at parse time: this script may
71 - // run before the stylesheet it is asking about has been applied, and a
72 - // custom property that is not there yet reads as the empty string.
73 - let lingerMs = null;
74 -
75 - /** A span in seconds as a stopwatch: `h:mm:ss`, hours unbounded. */
76 - const face = (seconds) => {
77 - const whole = Math.max(0, Math.floor(seconds));
78 - const mm = String(Math.floor((whole % 3600) / 60)).padStart(2, "0");
79 - const ss = String(whole % 60).padStart(2, "0");
80 - return `${Math.floor(whole / 3600)}:${mm}:${ss}`;
81 - };
82 -
83 - /** A span in seconds as a stamp: the largest unit that is not zero. */
84 - const ago = (seconds) => {
85 - const whole = Math.max(0, Math.floor(seconds));
86 - if (whole < 60) return "just now";
87 - if (whole < 3600) return `${Math.floor(whole / 60)}m ago`;
88 - if (whole < 86400) return `${Math.floor(whole / 3600)}h ago`;
89 - return `${Math.floor(whole / 86400)}d ago`;
90 - };
91 -
92 - /** What one readout says now. */
93 - const words = (kind, at, now) => {
94 - const seconds = (now - at) / 1000;
95 - if (kind === "until") return face(-seconds);
96 - if (kind === "age") return ago(seconds);
97 - return face(seconds);
98 - };
99 -
100 - /** Write every readout of one kind. */
101 - const sync = (kind) => {
102 - const now = Date.now();
103 - for (const readout of document.querySelectorAll(`[data-clock="${kind}"]`)) {
104 - const at = Number(readout.getAttribute("data-at"));
105 - if (Number.isNaN(at)) {
106 - continue;
107 - }
108 - readout.textContent = words(kind, at, now);
109 - }
110 - };
111 -
112 - // One interval per kind, started once and never cleared. A swap brings new
113 - // readouts and takes old ones away, and both are found by the next tick
114 - // because the walk is over the document rather than over a list captured
115 - // when the page loaded -- the same reason the selection script delegates
116 - // its events.
117 - for (const [kind, rate] of Object.entries(RATES)) {
118 - setInterval(() => sync(kind), rate);
119 - }
120 -
121 - // Start the clock on every toast that has not been given one.
122 - //
123 - // A timer per element, which is what the readouts above deliberately do not
124 - // do. The reasoning is the same reasoning and it comes out the other way: a
125 - // page carries many readouts and each one wants waking again and again, and
126 - // it carries one or two toasts whose timer runs once and is over. What a
127 - // shared interval would buy here is a message that leaves up to a second
128 - // late, which is the whole of what the reader would see.
129 - //
130 - // The mark is what keeps a settle from starting a second timer on a toast
131 - // that is already counting.
132 - const linger = () => {
133 - if (lingerMs === null) {
134 - lingerMs = milliseconds("--timing-dismiss", LINGER_FALLBACK);
135 - }
136 - const fresh = '[data-notice="toast"]:not([data-lingering])';
137 - for (const toast of document.querySelectorAll(fresh)) {
138 - toast.setAttribute("data-lingering", "");
139 - setTimeout(() => toast.remove(), lingerMs);
140 - }
141 - };
142 -
143 - // A readout that arrives mid-page should not wait out a whole period
144 - // showing what the server said a minute ago, and a toast that arrives in a
145 - // swap is exactly how a toast arrives. htmx's event and the initial parse
146 - // both land here.
147 - const settled = () => {
148 - for (const kind of Object.keys(RATES)) {
149 - sync(kind);
150 - }
151 - linger();
152 - };
153 -
154 - // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same
155 - // event `htmx:afterSettle`, and 4 renamed every event to phase:action.
156 - document.addEventListener("htmx:after:settle", settled);
157 - if (document.readyState === "loading") {
158 - document.addEventListener("DOMContentLoaded", settled);
159 - } else {
160 - settled();
161 - }
162 - })();
@@ -1,73 +1,0 @@
1 - // Putting a described value on the clipboard.
2 - //
3 - // `c3e145e0`. An `Act` can say that pressing it copies a value, and this is the
4 - // browser's half. It replaces seven `window.<name>` globals across 14 sites on
5 - // the MNW server, six of which scraped the text back off the DOM at press time
6 - // and one of which read an element by id.
7 - //
8 - // It reads one attribute the node emitter writes and nothing else:
9 - // `data-copies` carries the value. An ordinary escaped attribute, so nothing
10 - // here is a program built out of app text -- the same rule that makes this a
11 - // script rather than an emitted _hyperscript program. A licence key reading
12 - // `"; alert(1); "` is a value in an attribute here and could not be one in a
13 - // program.
14 - //
15 - // What it does NOT do is say "Copied!". Every shipped site relabelled its own
16 - // button and reverted, six at 1500ms and one at 2000ms, and that is a temporary
17 - // label rather than a copy: it belongs to `makeover-timing` and to mnw-server
18 - // `033c722f`. Splitting them is deliberate -- a host with no notion of a
19 - // reverting label still needs to be told the act copies something.
20 - (() => {
21 - "use strict";
22 -
23 - /** The value to put on the clipboard. */
24 - const VALUE = "data-copies";
25 -
26 - /**
27 - * Write `text` to the clipboard, preferring the async API.
28 - *
29 - * `navigator.clipboard` is unavailable on an insecure origin and can be
30 - * refused by permissions policy, and neither is an error worth surfacing to
31 - * a reader who pressed a copy button. The fallback is the old selection
32 - * dance, which works in both cases and is why it is still here.
33 - */
34 - const write = async (text) => {
35 - try {
36 - if (navigator.clipboard?.writeText) {
37 - await navigator.clipboard.writeText(text);
38 - return;
39 - }
40 - } catch {
41 - // Fall through: refused, or no permission. The fallback below does
42 - // not ask for one.
43 - }
44 -
45 - const carrier = document.createElement("textarea");
46 - carrier.value = text;
47 - // Off-screen rather than hidden: a `display: none` element cannot be
48 - // selected, and selecting is the whole mechanism here.
49 - carrier.setAttribute("readonly", "");
50 - carrier.style.position = "fixed";
51 - carrier.style.top = "-9999px";
52 - document.body.appendChild(carrier);
53 - carrier.select();
54 - try {
55 - document.execCommand("copy");
56 - } catch {
57 - // Nothing left to try. The reader sees no change, which is the
58 - // same outcome as a page that never described the copy at all.
59 - }
60 - carrier.remove();
61 - };
62 -
63 - // One delegated listener rather than one per control, for `FILL_JS`'
64 - // reason: these controls arrive in swaps, so binding per element would mean
65 - // rebinding on every swap.
66 - document.addEventListener("click", (event) => {
67 - const control = event.target?.closest?.(`[${VALUE}]`);
68 - if (!control) {
69 - return;
70 - }
71 - void write(control.getAttribute(VALUE) ?? "");
72 - });
73 - })();
@@ -1,108 +1,0 @@
1 - // What a browser needs before a route's answer can become a saved file.
2 - //
3 - // `67881a88`. A route answers `Outcome::File` and this host puts the bytes
4 - // where downloads go. The header alone is enough for a plain link -- the
5 - // browser navigates, sees `Content-Disposition: attachment`, and saves -- but a
6 - // control this renderer emits reaches its route through htmx, and htmx reads
7 - // the answer into an XHR. An XHR that arrives is not a navigation, so nothing
8 - // is saved and the file is swapped into the page as text.
9 - //
10 - // So this cancels that swap and does the saving itself: a Blob of the body, an
11 - // anchor clicked, the object URL revoked. It is the browser's own download
12 - // path, reached from script because the request was made from script.
13 - //
14 - // Nothing here knows what the file is. It reads one thing, the
15 - // `Content-Disposition` the http adapter wrote, which is the same header the
16 - // no-script path relies on -- so there is one fact on the wire rather than a
17 - // second one invented for this file.
18 - (() => {
19 - "use strict";
20 -
21 - // The header the http adapter writes. RFC 6266 says a lot more than this
22 - // reads; the two forms below are the two it writes.
23 - const DISPOSITION = "content-disposition";
24 -
25 - /**
26 - * The file name the header suggests, or null when it suggests none.
27 - *
28 - * `filename*` first, because it carries the real characters and the quoted
29 - * `filename` beside it is the ASCII fallback for agents that cannot read
30 - * one. Both are written for every download, so preferring the richer one is
31 - * always available and never a guess.
32 - */
33 - const named = (header) => {
34 - const extended = /filename\*=UTF-8''([^;]+)/i.exec(header);
35 - if (extended) {
36 - try {
37 - return decodeURIComponent(extended[1]);
38 - } catch {
39 - // A name we cannot decode is a name we do not use. Falling
40 - // through to the quoted form is better than saving a file
41 - // called `%E2%9C`.
42 - }
43 - }
44 - const quoted = /filename="([^"]*)"/i.exec(header);
45 - return quoted ? quoted[1] : null;
46 - };
47 -
48 - /** Hand the bytes to the browser under this name. */
49 - const save = (blob, name) => {
50 - const url = URL.createObjectURL(blob);
51 - const link = document.createElement("a");
52 - link.href = url;
53 - link.download = name;
54 - // Firefox will not follow a click on an element outside the document,
55 - // which is the one reason this touches the DOM at all.
56 - document.body.append(link);
57 - link.click();
58 - link.remove();
59 - // Not revoked synchronously: the click starts the download
60 - // asynchronously and revoking first cancels it in some browsers. A task
61 - // later is after the download has taken its reference.
62 - setTimeout(() => URL.revokeObjectURL(url), 0);
63 - };
64 -
65 - // `htmx:beforeSwap` is the last event that can still stop the body reaching
66 - // the page, and it carries the XHR, which is where both the header and the
67 - // bytes are. Anything later has already destroyed a region.
68 - document.addEventListener("htmx:beforeSwap", (event) => {
69 - const xhr = event.detail?.xhr;
70 - if (!xhr) {
71 - return;
72 - }
73 - const header = xhr.getResponseHeader(DISPOSITION);
74 - if (!header || !/^\s*attachment/i.test(header)) {
75 - return;
76 - }
77 -
78 - // Nothing goes into the document. The screen the user pressed the
79 - // control on is the screen they keep, which is what the router said by
80 - // answering a file rather than a fragment.
81 - event.detail.shouldSwap = false;
82 - event.detail.isError = false;
83 -
84 - const type = xhr.getResponseHeader("content-type") || "application/octet-stream";
85 - // THIS IS TEXT-ONLY, AND IT IS A REAL LIMIT RATHER THAN AN OVERSIGHT.
86 - //
87 - // htmx leaves `responseType` unset, so the browser has already decoded
88 - // the body as UTF-8 by the time this runs and `xhr.response` is a
89 - // string. That round-trips losslessly for the measured sites -- JSON,
90 - // CSV, ICS, every export in the tree -- and mangles any byte sequence
91 - // that is not valid UTF-8, because the replacement characters were
92 - // substituted before this file could see them.
93 - //
94 - // The fixes all cost more than the case is worth today. Setting
95 - // `responseType = "blob"` up front breaks every ordinary swap, since
96 - // htmx reads the same field to get its markup. `overrideMimeType` with
97 - // `x-user-defined` does the same damage to every UTF-8 page. Refetching
98 - // the URL as a blob is correct and runs the route a second time, which
99 - // is wrong for a POST. The route this serves is scoped to a task
100 - // database by its own ruling; a binary payload over htmx is filed
101 - // rather than guessed at.
102 - //
103 - // Nothing here is wrong for a plain link, which never reaches this file
104 - // at all: the browser navigates, reads the same header, and saves the
105 - // bytes as they arrived.
106 - save(new Blob([xhr.response], { type }), named(header) || "download");
107 - });
108 - })();
@@ -1,95 +1,0 @@
1 - // Putting a chosen value into the box the reader was already typing in.
2 - //
3 - // `f35aafee`. An `Act` can name a field on the same screen that receives its
4 - // value, and this is the browser's half of "the renderer decides where in it
5 - // the value lands". Here that is the selection: a reader who has put the caret
6 - // in the middle of a paragraph and opened a picker means to insert there, and
7 - // that is what every editor they have ever used does.
8 - //
9 - // It reads two attributes the node emitter writes and nothing else: `data-fills`
10 - // names the field, `data-fill` carries the value. Both are ordinary escaped
11 - // attributes, so nothing here is a program built out of app text -- which is why
12 - // the behaviour is a script rather than one of the emitted _hyperscript
13 - // programs, whose one rule is that no program is written from text a user typed.
14 - // A media file called `"; alert(1); "` is a value in an attribute here and could
15 - // not be one in a program.
16 - //
17 - // What it does NOT do is call anything. The act's own action is emitted beside
18 - // this and htmx performs it as it would any other; an act that only fills says
19 - // so with `Destination::Local`, which emits no transport at all.
20 - (() => {
21 - "use strict";
22 -
23 - /** The field the act writes into. */
24 - const FIELD = "data-fills";
25 - /** What lands there. */
26 - const VALUE = "data-fill";
27 -
28 - /**
29 - * The box on this document under that name.
30 - *
31 - * By `name` rather than by `id`, because `name` is what the description
32 - * carries: it is what a submit sends the value under and what `Field::changes`
33 - * names. An id is scoped per form instance by the emitter and is therefore
34 - * not the string the description wrote.
35 - *
36 - * Searched from the pressed control outward: the nearest enclosing form
37 - * first, then the document. A screen showing the same form twice -- an
38 - * edit modal over a list -- otherwise gets the first copy in the document
39 - * whichever one the reader is in.
40 - */
41 - const box = (control, name) => {
42 - const selector = `[name="${CSS.escape(name)}"]`;
43 - return control.closest("form")?.querySelector(selector)
44 - ?? document.querySelector(selector);
45 - };
46 -
47 - /**
48 - * Put `text` where the caret is, and leave the caret after it.
49 - *
50 - * `selectionStart` is null on an input whose type has no text selection --
51 - * a colour or a date -- and on anything that is not a text control at all.
52 - * Appending is the honest fallback there: the value still arrives, which is
53 - * what the description asked for, and the position was never described.
54 - */
55 - const insert = (target, text) => {
56 - const value = target.value ?? "";
57 - const at = typeof target.selectionStart === "number" ? target.selectionStart : value.length;
58 - const stop = typeof target.selectionEnd === "number" ? target.selectionEnd : at;
59 -
60 - target.value = value.slice(0, at) + text + value.slice(stop);
61 - const after = at + text.length;
62 - try {
63 - target.setSelectionRange(after, after);
64 - } catch {
65 - // Same class of control as the null above: it holds a value and has
66 - // no selection to set. The text is in it either way.
67 - }
68 - // What a browser sends when a control's value settles, so an autosave
69 - // debounce, a word count and a `Field::changes` route all see this the
70 - // way they see typing. Without it the three surfaces this was measured
71 - // on would show the reference and save a document without it.
72 - target.dispatchEvent(new Event("input", { bubbles: true }));
73 - };
74 -
75 - // One delegated listener rather than one per control. A picker draws a card
76 - // per media file and its markup arrives in a swap, so binding per element
77 - // would mean rebinding on every swap; the document has been listening the
78 - // whole time instead.
79 - document.addEventListener("click", (event) => {
80 - const control = event.target?.closest?.(`[${FIELD}]`);
81 - if (!control) {
82 - return;
83 - }
84 - const target = box(control, control.getAttribute(FIELD));
85 - if (!target) {
86 - // A description naming a field that is not on the screen. Nothing
87 - // is written and nothing is broken: the act's own action still
88 - // runs, which is the same shape as a renderer ignoring a key it
89 - // does not know.
90 - return;
91 - }
92 - target.focus();
93 - insert(target, control.getAttribute(VALUE) ?? "");
94 - });
95 - })();
@@ -1,204 +1,0 @@
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,123 +1,0 @@
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 - })();