max / makenotwork
7 files changed,
+504 insertions,
-21 deletions
| @@ -5390,9 +5390,9 @@ | |||
| 5390 | 5390 | ||
| 5391 | 5391 | [[package]] | |
| 5392 | 5392 | name = "makeover-build" | |
| 5393 | - | version = "0.52.0" | |
| 5393 | + | version = "0.53.0" | |
| 5394 | 5394 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5395 | - | checksum = "7b10f15fe8101177872c65b4bf2dc521a98975a42cee7b1ddfb2646fdcbfaaa9" | |
| 5395 | + | checksum = "82a170c530883774fc44d41e8e785260ee1b44cc26d9bfaa4d77154176af794c" | |
| 5396 | 5396 | dependencies = [ | |
| 5397 | 5397 | "makeover", | |
| 5398 | 5398 | "makeover-geometry", | |
| @@ -5433,9 +5433,9 @@ | |||
| 5433 | 5433 | ||
| 5434 | 5434 | [[package]] | |
| 5435 | 5435 | name = "makeover-webview" | |
| 5436 | - | version = "0.59.1" | |
| 5436 | + | version = "0.60.0" | |
| 5437 | 5437 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5438 | - | checksum = "59714ea075f5c266616199eb8345808f85092be04278471ffde546bdb0f43cb6" | |
| 5438 | + | checksum = "203b3a259e249ed32ffd9d48d75a6c12e4aa173095607cc2c852f2c1f3fe9c0e" | |
| 5439 | 5439 | dependencies = [ | |
| 5440 | 5440 | "makeover-geometry", | |
| 5441 | 5441 | "makeover-layout", | |
| @@ -6636,7 +6636,7 @@ | |||
| 6636 | 6636 | ||
| 6637 | 6637 | [[package]] | |
| 6638 | 6638 | name = "quasi-axum" | |
| 6639 | - | version = "0.63.0" | |
| 6639 | + | version = "0.64.0" | |
| 6640 | 6640 | dependencies = [ | |
| 6641 | 6641 | "axum", | |
| 6642 | 6642 | "http 1.4.2", | |
| @@ -6647,7 +6647,7 @@ | |||
| 6647 | 6647 | ||
| 6648 | 6648 | [[package]] | |
| 6649 | 6649 | name = "quasi-basics" | |
| 6650 | - | version = "0.63.0" | |
| 6650 | + | version = "0.64.0" | |
| 6651 | 6651 | dependencies = [ | |
| 6652 | 6652 | "makeover-layout", | |
| 6653 | 6653 | "quasi-router", | |
| @@ -6655,7 +6655,7 @@ | |||
| 6655 | 6655 | ||
| 6656 | 6656 | [[package]] | |
| 6657 | 6657 | name = "quasi-http" | |
| 6658 | - | version = "0.63.0" | |
| 6658 | + | version = "0.64.0" | |
| 6659 | 6659 | dependencies = [ | |
| 6660 | 6660 | "form_urlencoded", | |
| 6661 | 6661 | "http 1.4.2", | |
| @@ -6664,7 +6664,7 @@ | |||
| 6664 | 6664 | ||
| 6665 | 6665 | [[package]] | |
| 6666 | 6666 | name = "quasi-router" | |
| 6667 | - | version = "0.63.0" | |
| 6667 | + | version = "0.64.0" | |
| 6668 | 6668 | dependencies = [ | |
| 6669 | 6669 | "makeover-layout", | |
| 6670 | 6670 | ] | |
| @@ -6687,7 +6687,7 @@ | |||
| 6687 | 6687 | ||
| 6688 | 6688 | [[package]] | |
| 6689 | 6689 | name = "quasi-webview" | |
| 6690 | - | version = "0.63.0" | |
| 6690 | + | version = "0.64.0" | |
| 6691 | 6691 | dependencies = [ | |
| 6692 | 6692 | "docengine", | |
| 6693 | 6693 | "makeover-layout", | |
| @@ -10781,11 +10781,11 @@ | |||
| 10781 | 10781 | ||
| 10782 | 10782 | [[patch.unused]] | |
| 10783 | 10783 | name = "quasi-immediate" | |
| 10784 | - | version = "0.63.0" | |
| 10784 | + | version = "0.64.0" | |
| 10785 | 10785 | ||
| 10786 | 10786 | [[patch.unused]] | |
| 10787 | 10787 | name = "quasi-notifs" | |
| 10788 | - | version = "0.63.0" | |
| 10788 | + | version = "0.64.0" | |
| 10789 | 10789 | ||
| 10790 | 10790 | [[patch.unused]] | |
| 10791 | 10791 | name = "quasi-store" | |
| @@ -10793,4 +10793,4 @@ | |||
| 10793 | 10793 | ||
| 10794 | 10794 | [[patch.unused]] | |
| 10795 | 10795 | name = "quasi-tauri" | |
| 10796 | - | version = "0.63.0" | |
| 10796 | + | version = "0.64.0" |
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 164 | 164 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 165 | 165 | # See wiki look-wave-2, tier G. | |
| 166 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" } | |
| 166 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" } | |
| 167 | 167 | # The description vocabulary quasi's screen types are built from. Pinned here | |
| 168 | 168 | # rather than reached through quasi-router's re-export because a described | |
| 169 | 169 | # screen names FieldKind and Tone directly; it has to track what quasi-router | |
| @@ -175,17 +175,17 @@ | |||
| 175 | 175 | # through quasi-webview for makeover-layout's reason: two `form::` paths from | |
| 176 | 176 | # two resolutions are two crates, and the `data-format="markdown"` rules in | |
| 177 | 177 | # `static/layout.css` are generated from this one. | |
| 178 | - | makeover-webview = "0.59.1" | |
| 178 | + | makeover-webview = "0.60.0" | |
| 179 | 179 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 180 | 180 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 181 | 181 | http = "1.3.1" | |
| 182 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" } | |
| 183 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" } | |
| 182 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" } | |
| 183 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" } | |
| 184 | 184 | # The first-party widget set. The carousel was proved here against three pages | |
| 185 | 185 | # and then moved there; what is left in `quasi/widgets/carousel.rs` is the | |
| 186 | 186 | # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it | |
| 187 | 187 | # sits above quasi-router rather than inside the description suite. | |
| 188 | - | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" } | |
| 188 | + | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" } | |
| 189 | 189 | ||
| 190 | 190 | # Git source browser | |
| 191 | 191 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -254,7 +254,7 @@ | |||
| 254 | 254 | # Emits static/geometry.css (makeover-geometry) and static/layout.css | |
| 255 | 255 | # (makeover-webview). The same generator GO and BB run; only the output paths | |
| 256 | 256 | # differ, since the server serves its stylesheets rather than bundling them. | |
| 257 | - | makeover-build = "0.52.0" | |
| 257 | + | makeover-build = "0.53.0" | |
| 258 | 258 | # Read directly for the embeds' own copy of the spacing layer, which they need | |
| 259 | 259 | # because an iframe cannot link a stylesheet. makeover-build does not re-export | |
| 260 | 260 | # it, so the pin lives here and has to track the one makeover-build resolves. | |
| @@ -277,7 +277,7 @@ | |||
| 277 | 277 | # Written from the crate rather than copied into the repo for the reason the | |
| 278 | 278 | # constants exist: a stale copy in a static directory is a silently dead bulk | |
| 279 | 279 | # bar, and the version has to be the one the emitter agrees with. | |
| 280 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" } | |
| 280 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.64" } | |
| 281 | 281 | ||
| 282 | 282 | [dev-dependencies] | |
| 283 | 283 | tower = { version = "0.5.3", features = ["util"] } |
| @@ -532,11 +532,22 @@ | |||
| 532 | 532 | /// an instance. This list plus the assertion in `markup_files` closes the door. | |
| 533 | 533 | /// | |
| 534 | 534 | /// **Adding a generated file under `static/`? Add it here too.** | |
| 535 | - | const GENERATED_STATIC_JS: [(&str, &str); 4] = [ | |
| 535 | + | /// | |
| 536 | + | /// **Three were missing until 2026-08-26**, and the failure was exactly the one | |
| 537 | + | /// goingson's copy of this list warns about: the shell links every script the | |
| 538 | + | /// renderer ships unless the host says otherwise, and this host says otherwise | |
| 539 | + | /// about none of them. `quasi-reveal.js` and `quasi-repeat.js` had been linked | |
| 540 | + | /// and unserved -- a 404 per document, with nothing in the log -- and | |
| 541 | + | /// `quasi-awaiting.js` would have joined them the moment it shipped. Found | |
| 542 | + | /// adding the third, which is the only reason the other two were. | |
| 543 | + | const GENERATED_STATIC_JS: [(&str, &str); 7] = [ | |
| 536 | 544 | ("quasi-selection.js", quasi_webview::SELECTION_JS), | |
| 537 | 545 | ("quasi-clock.js", quasi_webview::CLOCK_JS), | |
| 538 | 546 | ("quasi-download.js", quasi_webview::DOWNLOAD_JS), | |
| 539 | 547 | ("quasi-fill.js", quasi_webview::FILL_JS), | |
| 548 | + | ("quasi-reveal.js", quasi_webview::REVEAL_JS), | |
| 549 | + | ("quasi-repeat.js", quasi_webview::REPEAT_JS), | |
| 550 | + | ("quasi-awaiting.js", quasi_webview::AWAITING_JS), | |
| 540 | 551 | ]; | |
| 541 | 552 | ||
| 542 | 553 | /// Every file that can carry a class name. |
| @@ -16,7 +16,12 @@ | |||
| 16 | 16 | # standard, not the code. They are still READ, because they are where the | |
| 17 | 17 | # tokens the site's sheets spend are defined. | |
| 18 | 18 | SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css" | |
| 19 | - | GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css" | |
| 19 | + | # `timing.css` joined the list 2026-08-26. It was generated, linked from the | |
| 20 | + | # shell, and invisible here, so every token it defines read as undefined to | |
| 21 | + | # rule 2 -- which is the same silence rule 2 exists to break, one level up. | |
| 22 | + | # Found when makeover-webview 0.60.0 started using `--cadence-activity`, which | |
| 23 | + | # timing.css has defined since the axis existed. | |
| 24 | + | GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css $STATIC/timing.css" | |
| 20 | 25 | ||
| 21 | 26 | violations=0 | |
| 22 | 27 |
| @@ -1,0 +1,140 @@ | |||
| 1 | + | // The half of a wait that only the browser can see. | |
| 2 | + | // | |
| 3 | + | // The node emitter has written `data-awaiting` since the vocabulary gained | |
| 4 | + | // `Awaiting`: `determinate` with a `data-awaiting-amount` beside it, or | |
| 5 | + | // `indeterminate` alone. `makeover-webview` 0.60.0 draws both. Neither of them | |
| 6 | + | // knows when a wait is actually running, and that is what this file supplies. | |
| 7 | + | // | |
| 8 | + | // Three things, and nothing else: | |
| 9 | + | // | |
| 10 | + | // 1. `aria-busy` on the element that made the request, for as long as the | |
| 11 | + | // request is in flight. That is what the design system keys the mark off, | |
| 12 | + | // and it is a standard attribute rather than a class of ours, so it is | |
| 13 | + | // announced as well as drawn. | |
| 14 | + | // 2. `--awaiting-share`, for a control whose wait has a measured size, from | |
| 15 | + | // bytes actually observed arriving. Never from elapsed time: see below. | |
| 16 | + | // 3. Nothing at all for a control that is not marked as awaiting, which is | |
| 17 | + | // nearly all of them. | |
| 18 | + | // | |
| 19 | + | // # Why the attribute alone was not enough | |
| 20 | + | // | |
| 21 | + | // `data-awaiting` is a fact about the control -- pressing this waits -- and it | |
| 22 | + | // is true when the page is painted and stays true. Styling it directly would | |
| 23 | + | // put a blinking mark on every awaiting control on the screen from the moment | |
| 24 | + | // the document loaded. The running-or-not half is true only between two events, | |
| 25 | + | // which makes it the binder's, which is this. | |
| 26 | + | // | |
| 27 | + | // # What the share may not be | |
| 28 | + | // | |
| 29 | + | // Rule 1 of wiki `loading-and-progress-standard`, and `Awaiting`'s own docs | |
| 30 | + | // before it: what is done over what there is, and never a remaining time, an | |
| 31 | + | // arrival time, or a rate extrapolated forward. So the share is set from | |
| 32 | + | // `event.loaded` -- bytes the browser says have landed -- and from nothing | |
| 33 | + | // else. A control with no readable progress keeps a share of zero and draws an | |
| 34 | + | // empty trough, which is the honest picture: the size is known and the delivery | |
| 35 | + | // is not. | |
| 36 | + | // | |
| 37 | + | // The one thing this file must never grow is a timer that advances the share. | |
| 38 | + | // A bar walking forward on a stalled transfer is the confidently-wrong drawing | |
| 39 | + | // the rule exists to forbid, and it is the easiest thing in the world to add. | |
| 40 | + | // | |
| 41 | + | // # Degrading | |
| 42 | + | // | |
| 43 | + | // A page that does not serve this file draws no mark and no bar, and every | |
| 44 | + | // control still locks itself through `hx-disable` exactly as before. That is | |
| 45 | + | // the direction the rest of this renderer degrades in: less decoration, never | |
| 46 | + | // less behaviour. | |
| 47 | + | (() => { | |
| 48 | + | "use strict"; | |
| 49 | + | ||
| 50 | + | /** The fact about the control: this call waits. */ | |
| 51 | + | const AWAITING = "data-awaiting"; | |
| 52 | + | /** The measured payload size, when the description carried one. */ | |
| 53 | + | const AMOUNT = "data-awaiting-amount"; | |
| 54 | + | /** How much of it has landed, as a share from 0 to 1. */ | |
| 55 | + | const SHARE = "--awaiting-share"; | |
| 56 | + | ||
| 57 | + | /** | |
| 58 | + | * The element a request belongs to, or null when it is not our business. | |
| 59 | + | * | |
| 60 | + | * htmx names the element on the event detail. An event for something that | |
| 61 | + | * never described a wait is left alone rather than given one: the emitter | |
| 62 | + | * decides which calls are worth marking, and a renderer that marked every | |
| 63 | + | * request would be overruling it. | |
| 64 | + | */ | |
| 65 | + | const marked = (event) => { | |
| 66 | + | const element = event.detail?.elt; | |
| 67 | + | if (!element || typeof element.hasAttribute !== "function") { | |
| 68 | + | return null; | |
| 69 | + | } | |
| 70 | + | return element.hasAttribute(AWAITING) ? element : null; | |
| 71 | + | }; | |
| 72 | + | ||
| 73 | + | /** Say a wait has started, with nothing delivered yet. */ | |
| 74 | + | const begin = (event) => { | |
| 75 | + | const element = marked(event); | |
| 76 | + | if (!element) { | |
| 77 | + | return; | |
| 78 | + | } | |
| 79 | + | element.setAttribute("aria-busy", "true"); | |
| 80 | + | element.style.removeProperty(SHARE); | |
| 81 | + | }; | |
| 82 | + | ||
| 83 | + | /** | |
| 84 | + | * Say the wait is over. | |
| 85 | + | * | |
| 86 | + | * The share is cleared with it. A control pressed twice would otherwise | |
| 87 | + | * start its second wait wearing the first one's bar, which is a number | |
| 88 | + | * about a payload that has already arrived. | |
| 89 | + | */ | |
| 90 | + | const end = (event) => { | |
| 91 | + | const element = marked(event); | |
| 92 | + | if (!element) { | |
| 93 | + | return; | |
| 94 | + | } | |
| 95 | + | element.removeAttribute("aria-busy"); | |
| 96 | + | element.style.removeProperty(SHARE); | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | /** | |
| 100 | + | * What has landed, over what the description said there was. | |
| 101 | + | * | |
| 102 | + | * `event.loaded` when the browser is counting, against the described | |
| 103 | + | * amount rather than against `event.total`. The two usually agree, and | |
| 104 | + | * where they do not the description is the one the bar was drawn for: a | |
| 105 | + | * server that gzips a response reports a total the screen never described. | |
| 106 | + | * | |
| 107 | + | * A response with no readable length leaves the share alone, so the trough | |
| 108 | + | * stays empty rather than jumping to something invented. | |
| 109 | + | */ | |
| 110 | + | const progressed = (event) => { | |
| 111 | + | const element = marked(event); | |
| 112 | + | if (!element || element.getAttribute(AWAITING) !== "determinate") { | |
| 113 | + | return; | |
| 114 | + | } | |
| 115 | + | const amount = Number(element.getAttribute(AMOUNT)); | |
| 116 | + | const loaded = Number(event.detail?.loaded); | |
| 117 | + | if (!Number.isFinite(amount) || amount <= 0 || !Number.isFinite(loaded)) { | |
| 118 | + | return; | |
| 119 | + | } | |
| 120 | + | element.style.setProperty(SHARE, String(Math.min(loaded / amount, 1))); | |
| 121 | + | }; | |
| 122 | + | ||
| 123 | + | // htmx 4 spells every event `phase:action`; 2.x spelled these | |
| 124 | + | // `htmx:beforeRequest`, `htmx:afterRequest` and `htmx:xhr:progress`. | |
| 125 | + | // | |
| 126 | + | // Delegated on the document rather than bound per control, for the reason | |
| 127 | + | // the reveal and repeat scripts give: a swap brings new controls and takes | |
| 128 | + | // old ones away, and a listener on the document covers both without a | |
| 129 | + | // re-scan. | |
| 130 | + | document.addEventListener("htmx:before:request", begin); | |
| 131 | + | document.addEventListener("htmx:after:request", end); | |
| 132 | + | document.addEventListener("htmx:xhr:progress", progressed); | |
| 133 | + | ||
| 134 | + | // A request that is aborted or errors out never reaches `after:request` in | |
| 135 | + | // every htmx version, and a control left wearing `aria-busy` blinks for the | |
| 136 | + | // rest of the page's life. Cheap insurance, and the same handler. | |
| 137 | + | document.addEventListener("htmx:abort", end); | |
| 138 | + | document.addEventListener("htmx:response:error", end); | |
| 139 | + | document.addEventListener("htmx:send:error", end); | |
| 140 | + | })(); |
| @@ -1,0 +1,204 @@ | |||
| 1 | + | // A question answered N times, with the reader adding and removing the slots. | |
| 2 | + | // | |
| 3 | + | // `60d1753c`, ruled 2026-08-25: a repeating group enters the vocabulary and | |
| 4 | + | // submits once. This is the browser's half of the third hard part -- the reader | |
| 5 | + | // creates and destroys slots without a round trip on every one. The bytes are | |
| 6 | + | // already in the document: the emitter writes a `template` holding one blank | |
| 7 | + | // slot, and adding is cloning it. | |
| 8 | + | // | |
| 9 | + | // A round trip instead would re-render a form the reader is midway through, | |
| 10 | + | // which is `a135f898`, and it would ask a route for an empty box. | |
| 11 | + | // | |
| 12 | + | // It reads four attributes the node emitter writes and nothing else: | |
| 13 | + | // `data-repeat` names the question, `data-repeat-label` is what its slots are | |
| 14 | + | // called, and `data-repeat-least` / `data-repeat-most` are the floor and the | |
| 15 | + | // ceiling. All four are ordinary escaped attributes, so nothing here is a | |
| 16 | + | // program built out of app text -- which is why this is a script rather than | |
| 17 | + | // one of the emitted _hyperscript programs, whose one rule is that no program | |
| 18 | + | // is written from text a user typed. | |
| 19 | + | // | |
| 20 | + | // A page that does not serve this file shows every slot the description | |
| 21 | + | // offered, fillable and submittable, with two controls that do nothing. | |
| 22 | + | (() => { | |
| 23 | + | "use strict"; | |
| 24 | + | ||
| 25 | + | /** The question a fieldset repeats. */ | |
| 26 | + | const NAME = "data-repeat"; | |
| 27 | + | /** What one slot of it is called. */ | |
| 28 | + | const LABEL = "data-repeat-label"; | |
| 29 | + | /** The fewest slots that may stand. */ | |
| 30 | + | const LEAST = "data-repeat-least"; | |
| 31 | + | /** The most that may stand. Absent means no ceiling. */ | |
| 32 | + | const MOST = "data-repeat-most"; | |
| 33 | + | /** The box the slots sit in. */ | |
| 34 | + | const SLOTS = "data-repeat-slots"; | |
| 35 | + | /** One slot, and where in the order it currently sits. */ | |
| 36 | + | const AT = "data-repeat-at"; | |
| 37 | + | /** The control that adds one. */ | |
| 38 | + | const ADD = "data-repeat-add"; | |
| 39 | + | /** The control that takes one away. */ | |
| 40 | + | const REMOVE = "data-repeat-remove"; | |
| 41 | + | ||
| 42 | + | /** The attributes a rename has to follow the index through. */ | |
| 43 | + | const NAMED = ["name", "id", "for", "aria-describedby", "aria-labelledby"]; | |
| 44 | + | ||
| 45 | + | /** The group a control inside one belongs to. */ | |
| 46 | + | const groupOf = (element) => element.closest(`[${NAME}]`); | |
| 47 | + | ||
| 48 | + | /** Where a group keeps its slots. */ | |
| 49 | + | const boxOf = (group) => group.querySelector(`[${SLOTS}]`); | |
| 50 | + | ||
| 51 | + | /** The slots standing in a group, in document order. */ | |
| 52 | + | const slotsOf = (group) => [ | |
| 53 | + | ...(boxOf(group)?.querySelectorAll(`:scope > [${AT}]`) ?? []), | |
| 54 | + | ]; | |
| 55 | + | ||
| 56 | + | /** A number written in an attribute, or a fallback when it is not there. */ | |
| 57 | + | const count = (group, attribute, fallback) => { | |
| 58 | + | const written = group.getAttribute(attribute); | |
| 59 | + | if (written === null) { | |
| 60 | + | return fallback; | |
| 61 | + | } | |
| 62 | + | const value = Number.parseInt(written, 10); | |
| 63 | + | return Number.isNaN(value) ? fallback : value; | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * Move one slot to a place in the order. | |
| 68 | + | * | |
| 69 | + | * The index is in the slot's own attribute, in every name it submits under | |
| 70 | + | * and in the ordinal a person reads. All three are rewritten together, or a | |
| 71 | + | * removed slot leaves the ones after it answering under names nobody asked | |
| 72 | + | * for. | |
| 73 | + | * | |
| 74 | + | * The rename is a replacement of `question[old]` wherever it appears, which | |
| 75 | + | * covers the derived ids beside the name itself -- `question[0]-hint`, | |
| 76 | + | * `question[0]-error` -- without this having to know what the field | |
| 77 | + | * emitter derives. | |
| 78 | + | */ | |
| 79 | + | const renumber = (slot, question, label, to) => { | |
| 80 | + | const from = slot.getAttribute(AT); | |
| 81 | + | slot.setAttribute(AT, String(to)); | |
| 82 | + | if (from === null) { | |
| 83 | + | return; | |
| 84 | + | } | |
| 85 | + | const was = `${question}[${from}]`; | |
| 86 | + | const now = `${question}[${to}]`; | |
| 87 | + | if (was !== now) { | |
| 88 | + | for (const element of [slot, ...slot.querySelectorAll("*")]) { | |
| 89 | + | for (const attribute of NAMED) { | |
| 90 | + | const value = element.getAttribute(attribute); | |
| 91 | + | if (value !== null && value.includes(was)) { | |
| 92 | + | element.setAttribute(attribute, value.split(was).join(now)); | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | // The visible ordinal, which `Repeat::ordinal` wrote as "<label> <n>" | |
| 98 | + | // counting from one. Rewritten from the group's own label rather than | |
| 99 | + | // by editing whatever text is in there, so a slot cannot end up named | |
| 100 | + | // after the one it replaced. | |
| 101 | + | if (label !== null) { | |
| 102 | + | const named = slot.querySelector("label[for]"); | |
| 103 | + | if (named !== null) { | |
| 104 | + | named.textContent = `${label} ${to + 1}`; | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | /** | |
| 110 | + | * Put a group's slots and its two controls back in agreement. | |
| 111 | + | * | |
| 112 | + | * Called after every change and on arrival, so a group the reader has not | |
| 113 | + | * touched is in the same state as one they have. | |
| 114 | + | */ | |
| 115 | + | const settle = (group) => { | |
| 116 | + | const question = group.getAttribute(NAME) ?? ""; | |
| 117 | + | const label = group.getAttribute(LABEL); | |
| 118 | + | const slots = slotsOf(group); | |
| 119 | + | const least = count(group, LEAST, 0); | |
| 120 | + | const most = count(group, MOST, Number.POSITIVE_INFINITY); | |
| 121 | + | slots.forEach((slot, at) => renumber(slot, question, label, at)); | |
| 122 | + | for (const control of group.querySelectorAll(`[${REMOVE}]`)) { | |
| 123 | + | control.disabled = slots.length <= least; | |
| 124 | + | } | |
| 125 | + | for (const control of group.querySelectorAll(`[${ADD}]`)) { | |
| 126 | + | control.disabled = slots.length >= most; | |
| 127 | + | } | |
| 128 | + | }; | |
| 129 | + | ||
| 130 | + | /** Every group in the document, settled. */ | |
| 131 | + | const settleAll = () => { | |
| 132 | + | for (const group of document.querySelectorAll(`[${NAME}]`)) { | |
| 133 | + | settle(group); | |
| 134 | + | } | |
| 135 | + | }; | |
| 136 | + | ||
| 137 | + | /** Clone the blank slot onto the end, if the ceiling allows another. */ | |
| 138 | + | const add = (group) => { | |
| 139 | + | const blank = group.querySelector("template"); | |
| 140 | + | const box = boxOf(group); | |
| 141 | + | if (blank === null || box === null) { | |
| 142 | + | return; | |
| 143 | + | } | |
| 144 | + | const most = count(group, MOST, Number.POSITIVE_INFINITY); | |
| 145 | + | if (slotsOf(group).length >= most) { | |
| 146 | + | return; | |
| 147 | + | } | |
| 148 | + | const made = blank.content.cloneNode(true); | |
| 149 | + | const slot = made.firstElementChild; | |
| 150 | + | box.append(made); | |
| 151 | + | settle(group); | |
| 152 | + | // The reader pressed add because they have something to type, so the | |
| 153 | + | // caret goes where they meant it to. Nothing else in this file moves | |
| 154 | + | // focus: a settle after a swap must not steal it. | |
| 155 | + | slot?.querySelector("input, select, textarea")?.focus(); | |
| 156 | + | }; | |
| 157 | + | ||
| 158 | + | /** Take one slot out, if the floor allows one fewer. */ | |
| 159 | + | const remove = (group, slot) => { | |
| 160 | + | const least = count(group, LEAST, 0); | |
| 161 | + | if (slotsOf(group).length <= least) { | |
| 162 | + | return; | |
| 163 | + | } | |
| 164 | + | slot.remove(); | |
| 165 | + | settle(group); | |
| 166 | + | }; | |
| 167 | + | ||
| 168 | + | // Delegated, so a group that arrives in a swap needs no wiring of its own. | |
| 169 | + | document.addEventListener("click", (event) => { | |
| 170 | + | const target = event.target; | |
| 171 | + | if (!(target instanceof Element)) { | |
| 172 | + | return; | |
| 173 | + | } | |
| 174 | + | const adder = target.closest(`[${ADD}]`); | |
| 175 | + | if (adder !== null) { | |
| 176 | + | const group = groupOf(adder); | |
| 177 | + | if (group !== null) { | |
| 178 | + | event.preventDefault(); | |
| 179 | + | add(group); | |
| 180 | + | } | |
| 181 | + | return; | |
| 182 | + | } | |
| 183 | + | const remover = target.closest(`[${REMOVE}]`); | |
| 184 | + | if (remover === null) { | |
| 185 | + | return; | |
| 186 | + | } | |
| 187 | + | const group = groupOf(remover); | |
| 188 | + | const slot = remover.closest(`[${AT}]`); | |
| 189 | + | if (group !== null && slot !== null) { | |
| 190 | + | event.preventDefault(); | |
| 191 | + | remove(group, slot); | |
| 192 | + | } | |
| 193 | + | }); | |
| 194 | + | ||
| 195 | + | // A group that arrives mid-page settles like one that was parsed with the | |
| 196 | + | // document. `htmx:after:settle` is htmx 4's name for it; 2.x spelled the | |
| 197 | + | // same event `htmx:afterSettle`. | |
| 198 | + | document.addEventListener("htmx:after:settle", settleAll); | |
| 199 | + | if (document.readyState === "loading") { | |
| 200 | + | document.addEventListener("DOMContentLoaded", settleAll); | |
| 201 | + | } else { | |
| 202 | + | settleAll(); | |
| 203 | + | } | |
| 204 | + | })(); |
| @@ -1,0 +1,123 @@ | |||
| 1 | + | // A region that is out only while a control holds a value. | |
| 2 | + | // | |
| 3 | + | // `079a011e`, ruled 2026-08-25: the condition lives on the region. A region | |
| 4 | + | // names the control it watches and the value that brings it out, and every | |
| 5 | + | // renderer answers it from what it already has. This is the browser's half, and | |
| 6 | + | // it makes no request: the bytes are in the document, and a round trip to | |
| 7 | + | // reveal a section of a form the reader is midway through would re-render boxes | |
| 8 | + | // holding values they have not committed yet. | |
| 9 | + | // | |
| 10 | + | // It reads three attributes the node emitter writes and nothing else: | |
| 11 | + | // `data-reveal` names the control, `data-reveal-when` says whether the region | |
| 12 | + | // wants anything, nothing or one of a set of values, and `data-reveal-values` | |
| 13 | + | // carries that set as JSON. All three are ordinary escaped attributes, so | |
| 14 | + | // nothing here is a program built out of app text -- which is why the behaviour | |
| 15 | + | // is a script rather than one of the emitted _hyperscript programs, whose one | |
| 16 | + | // rule is that no program is written from text a user typed. | |
| 17 | + | // | |
| 18 | + | // A page that does not serve this file shows every conditional region, which is | |
| 19 | + | // the direction everything else in this renderer degrades in: more content | |
| 20 | + | // rather than less. The six hand-written toggles this replaces fail the other | |
| 21 | + | // way, since their markup starts hidden. | |
| 22 | + | (() => { | |
| 23 | + | "use strict"; | |
| 24 | + | ||
| 25 | + | /** The control a region watches. */ | |
| 26 | + | const CONTROL = "data-reveal"; | |
| 27 | + | /** What that control has to hold: `any`, `none` or `value`. */ | |
| 28 | + | const WHEN = "data-reveal-when"; | |
| 29 | + | /** The values `value` accepts, as a JSON array. */ | |
| 30 | + | const VALUES = "data-reveal-values"; | |
| 31 | + | ||
| 32 | + | /** | |
| 33 | + | * What the named control is holding now, or null when it holds nothing. | |
| 34 | + | * | |
| 35 | + | * By `name` rather than by `id`, because `name` is what the description | |
| 36 | + | * carries: it is what a submit sends the value under and what a region | |
| 37 | + | * names. An id is scoped per form instance by the emitter and is therefore | |
| 38 | + | * not the string the description wrote. | |
| 39 | + | * | |
| 40 | + | * Searched from the region outward: the nearest enclosing form first, then | |
| 41 | + | * the document, which is `fill.js`'s rule and is here for the same reason. | |
| 42 | + | * A screen showing the same form twice -- an edit modal over a list -- | |
| 43 | + | * otherwise reads the first copy in the document whichever one the reader | |
| 44 | + | * is in. | |
| 45 | + | * | |
| 46 | + | * A tick is there by presence, the way a form submits one, so an unticked | |
| 47 | + | * box and an empty box are one answer. That is the convention the router | |
| 48 | + | * states and the other two renderers hold. | |
| 49 | + | */ | |
| 50 | + | const held = (region, name) => { | |
| 51 | + | const selector = `[name="${CSS.escape(name)}"]`; | |
| 52 | + | const scope = region.closest("form") ?? document; | |
| 53 | + | const controls = scope.querySelectorAll(selector); | |
| 54 | + | let value = null; | |
| 55 | + | for (const control of controls) { | |
| 56 | + | if (control.type === "checkbox" || control.type === "radio") { | |
| 57 | + | if (control.checked) { | |
| 58 | + | value = control.value === "" ? "on" : control.value; | |
| 59 | + | } | |
| 60 | + | continue; | |
| 61 | + | } | |
| 62 | + | value = control.value ?? null; | |
| 63 | + | } | |
| 64 | + | return value === "" ? null : value; | |
| 65 | + | }; | |
| 66 | + | ||
| 67 | + | /** Whether a region asking for this is satisfied by what is held. */ | |
| 68 | + | const satisfied = (region, value) => { | |
| 69 | + | const when = region.getAttribute(WHEN); | |
| 70 | + | if (when === "any") { | |
| 71 | + | return value !== null; | |
| 72 | + | } | |
| 73 | + | if (when === "none") { | |
| 74 | + | return value === null; | |
| 75 | + | } | |
| 76 | + | if (value === null) { | |
| 77 | + | return false; | |
| 78 | + | } | |
| 79 | + | let wanted; | |
| 80 | + | try { | |
| 81 | + | wanted = JSON.parse(region.getAttribute(VALUES) ?? "[]"); | |
| 82 | + | } catch { | |
| 83 | + | // An attribute this file did not write. Nothing is revealed on a | |
| 84 | + | // condition nobody can read, which is the same answer a region | |
| 85 | + | // watching a control that is not on the screen gets. | |
| 86 | + | return false; | |
| 87 | + | } | |
| 88 | + | return Array.isArray(wanted) && wanted.includes(value); | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | /** | |
| 92 | + | * Put every conditional region where its condition says it belongs. | |
| 93 | + | * | |
| 94 | + | * A walk over the document rather than over a list captured when the page | |
| 95 | + | * loaded, for the reason the clock gives: a swap brings new regions and | |
| 96 | + | * takes old ones away, and both are found by the next pass. | |
| 97 | + | */ | |
| 98 | + | const settle = () => { | |
| 99 | + | for (const region of document.querySelectorAll(`[${CONTROL}]`)) { | |
| 100 | + | const value = held(region, region.getAttribute(CONTROL) ?? ""); | |
| 101 | + | region.hidden = !satisfied(region, value); | |
| 102 | + | } | |
| 103 | + | }; | |
| 104 | + | ||
| 105 | + | // Delegated, and on both events a control settles under: `change` is what a | |
| 106 | + | // checkbox and a select fire, `input` is what a box being typed into fires, | |
| 107 | + | // and a region may watch either. | |
| 108 | + | document.addEventListener("change", settle); | |
| 109 | + | document.addEventListener("input", settle); | |
| 110 | + | ||
| 111 | + | // A region that arrives mid-page should not wait for the reader to touch | |
| 112 | + | // something before it decides whether it applies. htmx's event and the | |
| 113 | + | // initial parse both land here. | |
| 114 | + | // | |
| 115 | + | // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same event | |
| 116 | + | // `htmx:afterSettle`, and 4 renamed every event to phase:action. | |
| 117 | + | document.addEventListener("htmx:after:settle", settle); | |
| 118 | + | if (document.readyState === "loading") { | |
| 119 | + | document.addEventListener("DOMContentLoaded", settle); | |
| 120 | + | } else { | |
| 121 | + | settle(); | |
| 122 | + | } | |
| 123 | + | })(); |