max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+14 insertions,
-909 deletions
| @@ -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 | - | })(); |