Merge branch 'busy-rule' into main
The busy rule: submit buttons show a spinner for at least 650ms rastrillo's busy.js via estilo's busy package (estilo 846b397): the label gives way to a spinner for at least 650ms, the button disables, the form refuses a second submit, and Back resets it. Reviewed by Claude Fable 5.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+381
−165
AGENTS.md+1 −1go.mod+1 −1go.sum+6 −0internal/calendar/static/busy.css+47 −0internal/calendar/static/busy.js+252 −0internal/calendar/static/quickadd.js+4 −3internal/calendar/static/rastrillo.js+9 −135internal/calendar/templates/layout.html+2 −0internal/calendartest/vendored_test.go+27 −2internal/guardstest/guards_test.go+2 −2scripts/writeestilo/main.go+30 −0scripts/writetheme/main.go+0 −21
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 4.468s ? amadan.net/oficina/calendar/internal/calendar/icons [no test files] ? amadan.net/oficina/calendar/internal/calendar/seal [no test files] ok amadan.net/oficina/calendar/internal/calendartest 2.056s ok amadan.net/oficina/calendar/internal/guardstest 0.039s ? amadan.net/oficina/calendar/scripts/writeestilo [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/AGENTS.md b/AGENTS.md| index 565ddc5..491bb16 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -55,7 +55,7 @@ pass by reading a file the data has not reached. |
| | # | Ruling | Rejected | Who, when | |
| |---|---|---|---| |
| | 1 | **Store what the person said**: wall-clock time plus IANA zone is the record; the UTC columns are a derived index | Storing the instant as truth — a tzdata update then moves a meeting and nobody can say what was meant | This session, 2026-09-02 | |
| -| 2 | *(Superseded 2026-09-29: the suite's theme is estilo's Calma — estilo AGENTS.md row 8 — so Calendar now serves `theme.ForApp("calendar")`, written to static/theme.css by scripts/writetheme and pinned by TestThemeIsEstilosForCalendar.)* Pin rastrillo v0.23.0 and take theme `day` directly from `ui.ThemeCSS` | Consuming `estilo`, which is `day` byte-for-byte today and pins v0.22.0 — adopting it buys nothing and drags us back a release ("the two-pins trap", estilo's own AGENTS.md) | This session, 2026-09-02 | |
| +| 2 | *(Superseded 2026-09-29: the suite's theme is estilo's Calma — estilo AGENTS.md row 8 — so Calendar now serves `theme.ForApp("calendar")`, written to static/theme.css by scripts/writeestilo and pinned by TestThemeIsEstilosForCalendar.)* Pin rastrillo v0.23.0 and take theme `day` directly from `ui.ThemeCSS` | Consuming `estilo`, which is `day` byte-for-byte today and pins v0.22.0 — adopting it buys nothing and drags us back a release ("the two-pins trap", estilo's own AGENTS.md) | This session, 2026-09-02 | |
| | 3 | This app's own components take `cal-` | `rst-` is the framework's and a future release may claim a name; `estilo-` is the suite's, and a month grid is not a suite component until a second app copies one | This session, 2026-09-02 | |
| | 4 | The recurrence subset is the six a picker offers; anything else is stored verbatim, flagged, and not expanded | Refusing a rule we cannot expand — an importer that eats a rule it does not understand eats data | This session, 2026-09-02 | |
| | 5 | Reminders are per PERSON, not per event | Per-event reminders — two people on one meeting want different warnings, and the fix needs a person column anyway | This session, 2026-09-02 | |
diff --git a/go.mod b/go.mod| index 9f0b5ad..3708041 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -14,7 +14,7 @@ require ( |
| gorm.io/gorm v1.31.2 |
| ) |
| -require amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 |
| +require amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c |
| require ( |
| github.com/BurntSushi/toml v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index 93bf111..3d04629 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -2,6 +2,12 @@ amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d h1:3HsB96tGs+P0 |
| amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0= |
| amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZClOLqvppw+cCTXsn3b9NmRQ8CV5ZlXY= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjPb6m94Ynf7moyFYeBXr08rdOr3nmNqvg= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
diff --git a/internal/calendar/static/busy.css b/internal/calendar/static/busy.css| new file mode 100644 |
| index 0000000..fc355e6 |
| --- /dev/null |
| +++ b/internal/calendar/static/busy.css |
| @@ -0,0 +1,47 @@ |
| +/* busy.css — the one part of rastrillo's busy rule that lives in tokens.css: |
| + the spinner takes the label's place. Copied byte for byte from rastrillo's |
| + ui/tokens.css (estilo busy/provenance.json), for apps whose vendored |
| + tokens.css predates it. Link it after tokens.css. */ |
| + |
| +/* With no busy label there are no words to show while it works, so the |
| + spinner takes the label's place instead of joining it: joining widens |
| + the button mid-click and shoves whatever sits beside it. The shim |
| + marks a button that HAS a busy label with data-idle-label, which is |
| + what the :not() keys on — those words were asked for, so they stay. |
| + |
| + :has(> spinner) because aria-busy is ordinary ARIA an app may set on |
| + its own fetch button with no shim and no spinner; keyed on aria-busy |
| + alone, that button would lose its label and show nothing at all. |
| + |
| + The label is unpainted, never removed: aria-busy plus the untouched |
| + text is what a screen reader announces. -webkit-text-fill-color |
| + rather than color, because the ring is drawn in currentColor — a |
| + transparent color would take the spinner with it. Element children |
| + (an icon) are not reached by the text fill and take opacity 0, which |
| + unlike visibility keeps them in the accessibility tree. |
| + |
| + The media query stands the swap down for two readers a lone ring |
| + fails. Under reduced motion the ring is still and dimmed, and on its |
| + own reads as a stray "C". Under forced colours the engine repaints |
| + the text fill, so the label paints anyway and a centred ring would |
| + land on top of it. Both keep the older spinner-beside-label look. */ |
| +@media (prefers-reduced-motion: no-preference) and (forced-colors: none) { |
| + .rst-btn[aria-busy="true"]:not([data-idle-label]):has(> .rst-spin), [rst-btn][aria-busy="true"]:not([data-idle-label]):has(> [rst-spin]) { |
| + -webkit-text-fill-color: transparent; |
| + } |
| + .rst-btn[aria-busy="true"]:not([data-idle-label]):has(> .rst-spin) > :not(.rst-spin), [rst-btn][aria-busy="true"]:not([data-idle-label]):has(> [rst-spin]) > :not([rst-spin]) { |
| + opacity: 0; |
| + } |
| + .rst-btn[aria-busy="true"]:not([data-idle-label]) > .rst-spin, [rst-btn][aria-busy="true"]:not([data-idle-label]) > [rst-spin] { |
| + inset: 0; |
| + margin: auto; |
| + position: absolute; |
| + } |
| + /* The spinner's containing block. Zero specificity, so an app's own |
| + position: fixed or sticky on a floating Save still wins — either |
| + of those contains the spinner just as well, and losing it would |
| + drop the button back into the page flow mid-click. */ |
| + :where(.rst-btn[aria-busy="true"]:not([data-idle-label])), :where([rst-btn][aria-busy="true"]:not([data-idle-label])) { |
| + position: relative; |
| + } |
| +} |
diff --git a/internal/calendar/static/busy.js b/internal/calendar/static/busy.js| new file mode 100644 |
| index 0000000..550a843 |
| --- /dev/null |
| +++ b/internal/calendar/static/busy.js |
| @@ -0,0 +1,252 @@ |
| +/* busy.js — the busy rule. First-party, dependency-free, and on by |
| + default: link it and every submit button in every form that goes |
| + somewhere is covered, with no attribute to remember, because a button |
| + that changes something should say so while it works, and a form that |
| + double-submits on a second click is a bug on every screen. It is |
| + app-owned from the moment it is vendored; leave it unlinked to opt the |
| + whole app out. |
| + |
| + What a submitted button does: its label gives way to a spinner (the |
| + label stays, beside the spinner, under reduced motion or forced |
| + colours — tokens.css), it goes aria-busy and then disabled, and its |
| + form refuses a second submit. The spinner shows for at least |
| + HOLD_MS: a response faster than that is held back until then, so a |
| + quick save reads as a deliberate beat rather than a flicker. Going |
| + Back to the page hands every busy form back, and a submit still being |
| + held when the page is left is dropped rather than sent later. |
| + |
| + Vocabulary: |
| + data-busy="false" on a <form> or on one submit button: opt OUT |
| + of the busy rule. The rule is the default, so |
| + any other value — data-busy on its own |
| + included — changes nothing |
| + data-busy-label="…" on the form or on the button: replacement |
| + button text while it works |
| + |
| + A script that handles a submit itself (a fetch, say) cancels it in a |
| + listener on the form or the document, as it would anyway: those run |
| + before this file's window listener, which then sees the cancelled |
| + submit and leaves it — and its feedback — to that script. Two edges |
| + of the hold, on purpose: a handler that stops the submit event's |
| + propagation keeps it from the window, so that submit goes at once, |
| + unheld; and a form removed from the page during the hold (a polled |
| + fragment replacing it) takes its held submit with it. */ |
| +(function () { |
| + "use strict"; |
| + |
| + // How long the spinner shows, at least. Long enough to read as an |
| + // answer to the click, short enough never to feel like waiting. |
| + var HOLD_MS = 650; |
| + |
| + // ── The busy rule ──────────────────────────────────────────────── |
| + // |
| + // A button that CHANGES something says so while it works; a button |
| + // that only reveals something — a disclosure, a dropdown, a tab — |
| + // does not. So every submit button in every form is covered by |
| + // default, with no attribute to remember: the submitted button gets |
| + // aria-busy, a spinner and — once the submission is under way — |
| + // disabled, and its form gets aria-busy and a guard against a second |
| + // submit. The data-busy vocabulary above opts out of either half. |
| + // |
| + // The guard is the substance, the spinner is the manners, and neither |
| + // is a promise: with scripts off the form submits exactly as it |
| + // always did, twice if the visitor clicks twice. Idempotency stays |
| + // the server's job. One delegated capture-phase listener, for the |
| + // reasons light dismiss gives below: fragments, and never double-bind. |
| + // |
| + // Two traps this shape exists to avoid: |
| + // |
| + // - A form the browser REFUSED must not sit there looking busy. |
| + // Constraint validation fails before the submit event is fired at |
| + // all, so an invalid form never reaches this code; a cancelled |
| + // one is handed back in the tick below. (formnovalidate skips |
| + // validation and really does submit, which should look like it.) |
| + // - Nothing that could change the payload happens while the payload |
| + // is being read. The entry list is built before submit fires, but |
| + // engines have differed, so aria-busy, the spinner and a |
| + // <button>'s TEXT (never submitted — a button submits its value |
| + // attribute) go on synchronously, while disabled and an |
| + // <input type="submit">'s value (which IS what it submits) wait. |
| + // |
| + // Every busy element in the DOCUMENT, then the ownership test: a |
| + // form="id" submit button is owned by a form it does not live in. |
| + function busyOff(form) { |
| + form.removeAttribute("aria-busy"); |
| + document.querySelectorAll('[aria-busy="true"]').forEach(function (b) { |
| + if (b.form !== form && !form.contains(b)) return; |
| + b.disabled = false; |
| + b.removeAttribute("aria-busy"); |
| + var spin = b.querySelector("[rst-spin]"); |
| + if (spin) spin.remove(); |
| + var idle = b.getAttribute("data-idle-label"); |
| + if (idle === null) return; |
| + if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; } |
| + b.removeAttribute("data-idle-label"); |
| + }); |
| + } |
| + |
| + function busySubmit(e) { |
| + var form = e.target; |
| + if (!form || form.tagName !== "FORM") return; |
| + if (form.getAttribute("data-busy") === "false") return; |
| + // The button the browser submitted with: the one clicked, or — for |
| + // Enter in a field — the default one it implicitly clicked. Only |
| + // that one goes busy; every other submit button in the form keeps |
| + // its name and its value. An engine with no SubmitEvent.submitter |
| + // leaves the buttons alone and keeps the guard, which is the half |
| + // that matters. |
| + var btn = e.submitter; |
| + // A form that opens its result elsewhere, or a submit that closes a |
| + // dialog and stays put, has nothing to be busy about and nothing to |
| + // clear. The submitter's formmethod beats the form's. |
| + // |
| + // getAttribute, never the IDL properties. A form is |
| + // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a |
| + // target date, a method column — ordinary field names) shadows the IDL |
| + // attribute with the input itself, so the property form quietly |
| + // switches the rule off and hands back the double submit. The busy flag |
| + // below is the form's own aria-busy for the same reason: an expando is |
| + // shadowable, and under strict mode assigning to a shadowed one throws. |
| + var to = (btn && btn.getAttribute("formtarget")) || form.getAttribute("target"); |
| + if (to && to !== "_self") return; |
| + if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") || |
| + form.getAttribute("method"))) return; |
| + if (form === releasing) return; // the hold sending the submit it held |
| + if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; } |
| + form.setAttribute("aria-busy", "true"); |
| + if (!btn || btn.getAttribute("data-busy") === "false") btn = null; |
| + var label = btn && (btn.getAttribute("data-busy-label") || |
| + form.getAttribute("data-busy-label")); |
| + if (btn) { |
| + btn.setAttribute("aria-busy", "true"); |
| + if (btn.tagName === "BUTTON") { |
| + if (label) { |
| + btn.setAttribute("data-idle-label", btn.textContent); |
| + btn.textContent = label; |
| + } |
| + // A child element, not a pseudo-element: the shim has to be |
| + // able to take it away again. An <input type="submit"> has no |
| + // children, which is the one shape this cannot draw a spinner |
| + // on. tokens.css stops it rotating under reduced motion. |
| + var spin = document.createElement("span"); |
| + spin.setAttribute("rst-spin", ""); |
| + spin.setAttribute("aria-hidden", "true"); |
| + btn.insertBefore(spin, btn.firstChild); |
| + } |
| + } |
| + setTimeout(function () { |
| + // Someone downstream cancelled the submit — an app handler doing |
| + // the work itself, most likely. Nothing is on its way anywhere, |
| + // so hand the form back, guard included, and let whoever took the |
| + // job own the feedback too. |
| + if (e.defaultPrevented && !held.has(e)) { busyOff(form); return; } |
| + if (!btn) return; |
| + if (label && btn.tagName === "INPUT") { |
| + btn.setAttribute("data-idle-label", btn.value); |
| + btn.value = label; |
| + } |
| + btn.disabled = true; |
| + }, 0); |
| + } |
| + |
| + document.addEventListener("submit", busySubmit, true); |
| + |
| + // ── The hold ───────────────────────────────────────────────────── |
| + // |
| + // A spinner can only be promised a minimum time by holding the |
| + // submit itself: once a fast response arrives the browser navigates |
| + // and the spinner is gone. So the submit the busy rule just armed is |
| + // cancelled here and sent again, by requestSubmit, once HOLD_MS have |
| + // passed since the click. |
| + // |
| + // This listener is on the window, in the bubble phase — the last place |
| + // a submit event reaches — so every handler on the form and the |
| + // document has had its say first. One that cancelled the submit owns |
| + // it (e.defaultPrevented, and the busy tick above hands the form |
| + // back); so does the guard's own refusal of a second submit. Only a |
| + // submit nobody else stopped is held, and only if the busy rule armed |
| + // it (aria-busy on the form: not opted out, not a dialog, not a new |
| + // window) and a button is showing the spinner. |
| + var held = new WeakSet(); // submit events this listener cancelled |
| + var pending = new Map(); // form -> timer, for a hold still running |
| + var releasing = null; // the form whose held submit is going now |
| + window.addEventListener("submit", function (e) { |
| + var form = e.target; |
| + if (!form || form.tagName !== "FORM") return; |
| + if (form === releasing) { releasing = null; return; } |
| + if (e.defaultPrevented || form.getAttribute("aria-busy") !== "true") return; |
| + // Only a submit with a spinner to show is held: the hold is for the |
| + // spinner. A script's requestSubmit() with no submitter, or a button |
| + // that opted out, has nothing on screen to keep up, and holding it |
| + // would only be a silent 650ms stall. The form is still guarded. |
| + var btn = e.submitter || null; |
| + if (!btn || btn.getAttribute("aria-busy") !== "true") return; |
| + e.preventDefault(); |
| + held.add(e); |
| + pending.set(form, setTimeout(function () { release(form, btn); }, HOLD_MS)); |
| + }); |
| + |
| + // Send the held submit. The submitter goes with it, so its name and |
| + // value are in the payload exactly as a click would have sent them — |
| + // which means, for the moment the entry list is built, it has to be |
| + // enabled and an <input type="submit"> has to carry its own value |
| + // again, not the busy label. The form is re-checked first: fields |
| + // stay editable during the hold, and a form that is no longer valid |
| + // must be handed back with its errors showing, not left spinning. |
| + function release(form, btn) { |
| + pending.delete(form); |
| + if (!form.isConnected) return; |
| + var skip = form.hasAttribute("novalidate") || |
| + (btn && btn.hasAttribute("formnovalidate")); |
| + if (!skip && !form.checkValidity()) { |
| + busyOff(form); |
| + form.reportValidity(); |
| + return; |
| + } |
| + // The submitter may have left the form during the hold (moved, |
| + // removed, its form= changed): requestSubmit would throw and leave |
| + // the form guarded for good. Hand it back instead. |
| + if (btn && (!btn.isConnected || btn.form !== form)) { |
| + busyOff(form); |
| + return; |
| + } |
| + var busyValue = null; |
| + // Restored, not assumed: a button that opted out of the busy state |
| + // (data-busy="false") was never disabled, and must not end up so. |
| + var wasDisabled = btn ? btn.disabled : false; |
| + if (btn) { |
| + btn.disabled = false; |
| + var idle = btn.getAttribute("data-idle-label"); |
| + if (btn.tagName === "INPUT" && idle !== null) { |
| + busyValue = btn.value; |
| + btn.value = idle; |
| + } |
| + } |
| + releasing = form; |
| + try { |
| + form.requestSubmit(btn || undefined); |
| + } finally { |
| + releasing = null; |
| + if (btn) { |
| + btn.disabled = wasDisabled; |
| + if (busyValue !== null) btn.value = busyValue; |
| + } |
| + } |
| + } |
| + |
| + // Leaving the page — a link, Back — while a submit is held drops it: |
| + // the visitor has gone somewhere else, and a timer surviving into the |
| + // back/forward cache would otherwise send it on their return. |
| + window.addEventListener("pagehide", function () { |
| + pending.forEach(function (timer) { clearTimeout(timer); }); |
| + pending.clear(); |
| + }); |
| + |
| + // The back/forward cache restores a page's DOM exactly as it was left |
| + // — busy buttons still disabled, still wearing the busy label and the |
| + // spinner — so a visitor who navigates back finds a dead form. Hand |
| + // every busy form back. |
| + window.addEventListener("pageshow", function (e) { |
| + if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff); |
| + }); |
| +})(); |
diff --git a/internal/calendar/static/quickadd.js b/internal/calendar/static/quickadd.js| index 2d38281..099ba7e 100644 |
| --- a/internal/calendar/static/quickadd.js |
| +++ b/internal/calendar/static/quickadd.js |
| @@ -114,12 +114,13 @@ |
| var form = root.querySelector("[cal-quick-form]"); |
| if (!form) return; |
| // requestSubmit fires submit handlers and honours validation the |
| - // way a real button press does; form.submit() skips both. |
| + // way a real button press does; form.submit() skips both. The |
| + // button goes with it, so the busy rule shows its spinner on Add. |
| + var button = form.querySelector("button[type=submit]"); |
| if (typeof form.requestSubmit === "function") { |
| - form.requestSubmit(); |
| + form.requestSubmit(button || undefined); |
| return; |
| } |
| - var button = form.querySelector("button[type=submit]"); |
| if (button) button.click(); |
| } |
diff --git a/internal/calendar/static/rastrillo.js b/internal/calendar/static/rastrillo.js| index dc4b344..2c9cd5a 100644 |
| --- a/internal/calendar/static/rastrillo.js |
| +++ b/internal/calendar/static/rastrillo.js |
| @@ -5,15 +5,15 @@ |
| app-owned from the moment it is scaffolded — edit it like any other |
| static file. |
| - Two sections are on by default instead, because neither has a |
| - per-instance decision to make. Light dismiss for the menu idioms keys |
| - off the rst-dropdown / rst-row-menu attributes: a menu that closes on |
| - an outside click on one screen and not the next is worse than either |
| - rule applied everywhere. The busy rule keys off nothing at all — every |
| - submit button in every form that goes somewhere is covered, because |
| - a button that changes something should say so while it works, and a |
| - form that double-submits on a second click is a bug on every screen. |
| - Delete either section to opt the whole app out of it. |
| + One section is on by default instead, because it has no per-instance |
| + decision to make: light dismiss for the menu idioms, keyed off the |
| + rst-dropdown / rst-row-menu attributes — a menu that closes on an |
| + outside click on one screen and not the next is worse than either |
| + rule applied everywhere. Delete it to opt the whole app out. |
| + |
| + The busy rule, which used to live here too, is busy.js: link it and |
| + every submit button in every form is covered; leave it out and none |
| + is. |
| Vocabulary: |
| data-poll="URL" fetch URL for an HTML fragment, replace this |
| @@ -27,12 +27,6 @@ |
| element to timer polling for good (once, |
| no flapping); a browser without EventSource |
| never leaves the timer path |
| - data-busy="false" on a <form> or on one submit button: opt OUT |
| - of the busy rule below. The rule is the |
| - default, so any other value — data-busy on |
| - its own included — changes nothing |
| - data-busy-label="…" on the form or on the button: replacement |
| - button text while it works |
| select.js is a sibling file following exactly these rules, kept |
| separate so this one stays small enough to read in one sitting. It |
| @@ -135,118 +129,6 @@ |
| schedule(); |
| } |
| - // ── The busy rule ──────────────────────────────────────────────── |
| - // |
| - // A button that CHANGES something says so while it works; a button |
| - // that only reveals something — a disclosure, a dropdown, a tab — |
| - // does not. So every submit button in every form is covered by |
| - // default, with no attribute to remember: the submitted button gets |
| - // aria-busy, a spinner and — once the submission is under way — |
| - // disabled, and its form gets aria-busy and a guard against a second |
| - // submit. The data-busy vocabulary above opts out of either half. |
| - // |
| - // The guard is the substance, the spinner is the manners, and neither |
| - // is a promise: with scripts off the form submits exactly as it |
| - // always did, twice if the visitor clicks twice. Idempotency stays |
| - // the server's job. One delegated capture-phase listener, for the |
| - // reasons light dismiss gives below: fragments, and never double-bind. |
| - // |
| - // Two traps this shape exists to avoid: |
| - // |
| - // - A form the browser REFUSED must not sit there looking busy. |
| - // Constraint validation fails before the submit event is fired at |
| - // all, so an invalid form never reaches this code; a cancelled |
| - // one is handed back in the tick below. (formnovalidate skips |
| - // validation and really does submit, which should look like it.) |
| - // - Nothing that could change the payload happens while the payload |
| - // is being read. The entry list is built before submit fires, but |
| - // engines have differed, so aria-busy, the spinner and a |
| - // <button>'s TEXT (never submitted — a button submits its value |
| - // attribute) go on synchronously, while disabled and an |
| - // <input type="submit">'s value (which IS what it submits) wait. |
| - // |
| - // Every busy element in the DOCUMENT, then the ownership test: a |
| - // form="id" submit button is owned by a form it does not live in. |
| - function busyOff(form) { |
| - form.removeAttribute("aria-busy"); |
| - document.querySelectorAll('[aria-busy="true"]').forEach(function (b) { |
| - if (b.form !== form && !form.contains(b)) return; |
| - b.disabled = false; |
| - b.removeAttribute("aria-busy"); |
| - var spin = b.querySelector("[rst-spin]"); |
| - if (spin) spin.remove(); |
| - var idle = b.getAttribute("data-idle-label"); |
| - if (idle === null) return; |
| - if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; } |
| - b.removeAttribute("data-idle-label"); |
| - }); |
| - } |
| - |
| - function busySubmit(e) { |
| - var form = e.target; |
| - if (!form || form.tagName !== "FORM") return; |
| - if (form.getAttribute("data-busy") === "false") return; |
| - // The button the browser submitted with: the one clicked, or — for |
| - // Enter in a field — the default one it implicitly clicked. Only |
| - // that one goes busy; every other submit button in the form keeps |
| - // its name and its value. An engine with no SubmitEvent.submitter |
| - // leaves the buttons alone and keeps the guard, which is the half |
| - // that matters. |
| - var btn = e.submitter; |
| - // A form that opens its result elsewhere, or a submit that closes a |
| - // dialog and stays put, has nothing to be busy about and nothing to |
| - // clear. The submitter's formmethod beats the form's. |
| - // |
| - // getAttribute, never the IDL properties. A form is |
| - // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a |
| - // target date, a method column — ordinary field names) shadows the IDL |
| - // attribute with the input itself, so the property form quietly |
| - // switches the rule off and hands back the double submit. The busy flag |
| - // below is the form's own aria-busy for the same reason: an expando is |
| - // shadowable, and under strict mode assigning to a shadowed one throws. |
| - var to = form.getAttribute("target"); |
| - if (to && to !== "_self") return; |
| - if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") || |
| - form.getAttribute("method"))) return; |
| - if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; } |
| - form.setAttribute("aria-busy", "true"); |
| - if (!btn || btn.getAttribute("data-busy") === "false") btn = null; |
| - var label = btn && (btn.getAttribute("data-busy-label") || |
| - form.getAttribute("data-busy-label")); |
| - if (btn) { |
| - btn.setAttribute("aria-busy", "true"); |
| - if (btn.tagName === "BUTTON") { |
| - if (label) { |
| - btn.setAttribute("data-idle-label", btn.textContent); |
| - btn.textContent = label; |
| - } |
| - // A child element, not a pseudo-element: the shim has to be |
| - // able to take it away again. An <input type="submit"> has no |
| - // children, which is the one shape this cannot draw a spinner |
| - // on. tokens.css stops it rotating under reduced motion. |
| - var spin = document.createElement("span"); |
| - spin.setAttribute("rst-spin", ""); |
| - spin.setAttribute("aria-hidden", "true"); |
| - btn.insertBefore(spin, btn.firstChild); |
| - } |
| - } |
| - setTimeout(function () { |
| - // Someone downstream cancelled the submit — an app handler doing |
| - // the work itself, most likely. Nothing is on its way anywhere, |
| - // so hand the form back, guard included, and let whoever took the |
| - // job own the feedback too. |
| - if (e.defaultPrevented) { busyOff(form); return; } |
| - if (!btn) return; |
| - if (label && btn.tagName === "INPUT") { |
| - btn.setAttribute("data-idle-label", btn.value); |
| - btn.value = label; |
| - } |
| - btn.disabled = true; |
| - }, 0); |
| - } |
| - |
| - document.addEventListener("submit", busySubmit, true); |
| - |
| // Light dismiss — the one behaviour the native disclosure genuinely |
| // cannot do, which is the shim's whole admission rule. A <details> |
| // menu closes on a second click of its own summary and on nothing |
| @@ -321,14 +203,6 @@ |
| document.querySelectorAll("[data-poll]").forEach(poll); |
| } |
| - // The back/forward cache restores a page's DOM exactly as it was left |
| - // — busy buttons still disabled, still wearing the busy label and the |
| - // spinner — so a visitor who navigates back finds a dead form. Hand |
| - // every busy form back. |
| - window.addEventListener("pageshow", function (e) { |
| - if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff); |
| - }); |
| - |
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", scan); |
| } else { |
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html| index 78b7312..204ccda 100644 |
| --- a/internal/calendar/templates/layout.html |
| +++ b/internal/calendar/templates/layout.html |
| @@ -16,8 +16,10 @@ |
| <title>{{block "title" .}}Hello{{end}}</title> |
| <link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css"> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/busy.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| +<script defer src="{{asset "static/busy.js"}}"></script> |
| {{iconAssets}} |
| <script defer src="{{asset "static/select.js"}}"></script> |
| <script defer src="{{asset "static/datetime.js"}}"></script> |
diff --git a/internal/calendartest/vendored_test.go b/internal/calendartest/vendored_test.go| index 2212738..100f17f 100644 |
| --- a/internal/calendartest/vendored_test.go |
| +++ b/internal/calendartest/vendored_test.go |
| @@ -1,6 +1,7 @@ |
| package calendartest |
| import ( |
| + estilobusy "amadan.net/oficina/estilo.git/busy" |
| "amadan.net/oficina/estilo.git/theme" |
| "bytes" |
| "os" |
| @@ -21,6 +22,9 @@ const vendoredTheme = "day" |
| // record that the edit was on purpose, so the next reader (and the next |
| // upgrade) can tell it from drift. |
| var vendoredIsMine = map[string]bool{ |
| + // rastrillo's shim as estilo carries it: without the old busy rule, |
| + // which would fight busy.js. Pinned to estilo by TestBusyFilesAreEstilos. |
| + "rastrillo.js": true, |
| // Calma with Calendar's accent, from estilo (estilo AGENTS.md, |
| // decisions table, row 8; this repo's ruling 2 is superseded). Pinned |
| // to estilo instead, by TestThemeIsEstilosForCalendar below. |
| @@ -29,7 +33,7 @@ var vendoredIsMine = map[string]bool{ |
| // TestThemeIsEstilosForCalendar pins static/theme.css to the estilo this |
| // app is built against. After an estilo upgrade, re-write it with |
| -// `go run ./scripts/writetheme` — never by hand. |
| +// `go run ./scripts/writeestilo` — never by hand. |
| func TestThemeIsEstilosForCalendar(t *testing.T) { |
| want, ok := theme.ForApp("calendar") |
| if !ok { |
| @@ -40,7 +44,7 @@ func TestThemeIsEstilosForCalendar(t *testing.T) { |
| t.Fatal(err) |
| } |
| if !bytes.Equal(got, want) { |
| - t.Error("static/theme.css differs from estilo's theme for calendar; run go run ./scripts/writetheme") |
| + t.Error("static/theme.css differs from estilo's theme for calendar; run go run ./scripts/writeestilo") |
| } |
| } |
| @@ -73,3 +77,24 @@ func TestVendoredAssetsMatchTheLibrary(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// rastrillo's busy rule (Paul, 2026-09-29): a submit button's label gives |
| +// way to a spinner for at least 650ms, the button disables, and Back |
| +// resets it. static/ carries it as estilo does — the shim without the old |
| +// rule, busy.js and busy.css — pinned here to the estilo this app builds |
| +// against. After an estilo upgrade: go run ./scripts/writeestilo. |
| +func TestBusyFilesAreEstilos(t *testing.T) { |
| + for name, want := range map[string][]byte{ |
| + "rastrillo.js": estilobusy.ShimJS(), |
| + "busy.js": estilobusy.JS(), |
| + "busy.css": estilobusy.CSS(), |
| + } { |
| + got, err := os.ReadFile(filepath.Join("..", "calendar", "static", name)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(got, want) { |
| + t.Errorf("static/%s differs from estilo's; run go run ./scripts/writeestilo", name) |
| + } |
| + } |
| +} |
diff --git a/internal/guardstest/guards_test.go b/internal/guardstest/guards_test.go| index 271f089..8cf36df 100644 |
| --- a/internal/guardstest/guards_test.go |
| +++ b/internal/guardstest/guards_test.go |
| @@ -72,10 +72,10 @@ func walk(t *testing.T, root string, exts []string, fn func(path string, body [] |
| // line cap would break that pin, which is the more valuable guard. |
| var lineCapExempt = map[string]string{ |
| "internal/calendar/static/datetime.js": "vendored from rastrillo; pinned byte-for-byte", |
| - "internal/calendar/static/rastrillo.js": "vendored from rastrillo; pinned byte-for-byte", |
| + "internal/calendar/static/rastrillo.js": "rastrillo's, written from estilo's busy by scripts/writeestilo; pinned byte-for-byte", |
| "internal/calendar/static/select.js": "vendored from rastrillo; pinned byte-for-byte", |
| "internal/calendar/static/tokens.css": "vendored from rastrillo; pinned byte-for-byte", |
| - "internal/calendar/static/theme.css": "written from estilo by scripts/writetheme; pinned byte-for-byte", |
| + "internal/calendar/static/theme.css": "written from estilo by scripts/writeestilo; pinned byte-for-byte", |
| } |
| func TestNoFileExceedsTheLineCap(t *testing.T) { |
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go| new file mode 100644 |
| index 0000000..930cc4a |
| --- /dev/null |
| +++ b/scripts/writeestilo/main.go |
| @@ -0,0 +1,30 @@ |
| +// Command writeestilo re-writes the files internal/calendar/static takes from |
| +// the estilo this module pins: theme.css (Calma, with Calendar's accent), |
| +// and rastrillo's busy rule as estilo carries it — rastrillo.js without |
| +// the old rule, busy.js and busy.css. Run it from the repository root |
| +// after upgrading estilo; the pin tests in internal/calendartest fail until you do. |
| +package main |
| + |
| +import ( |
| + "os" |
| + |
| + "amadan.net/oficina/estilo.git/busy" |
| + "amadan.net/oficina/estilo.git/theme" |
| +) |
| + |
| +func main() { |
| + css, ok := theme.ForApp("calendar") |
| + if !ok { |
| + panic("estilo's theme ring has no calendar") |
| + } |
| + for name, body := range map[string][]byte{ |
| + "theme.css": css, |
| + "rastrillo.js": busy.ShimJS(), |
| + "busy.js": busy.JS(), |
| + "busy.css": busy.CSS(), |
| + } { |
| + if err := os.WriteFile("internal/calendar/static/"+name, body, 0o644); err != nil { |
| + panic(err) |
| + } |
| + } |
| +} |
diff --git a/scripts/writetheme/main.go b/scripts/writetheme/main.go| deleted file mode 100644 |
| index 33591f9..0000000 |
| --- a/scripts/writetheme/main.go |
| +++ /dev/null |
| @@ -1,21 +0,0 @@ |
| -// Command writetheme re-writes internal/calendar/static/theme.css from the |
| -// estilo this module pins: Calma, with Calendar's accent. Run it from the |
| -// repository root after upgrading estilo; TestThemeIsEstilosForCalendar |
| -// fails until you do. |
| -package main |
| - |
| -import ( |
| - "os" |
| - |
| - "amadan.net/oficina/estilo.git/theme" |
| -) |
| - |
| -func main() { |
| - css, ok := theme.ForApp("calendar") |
| - if !ok { |
| - panic("estilo's theme ring has no calendar") |
| - } |
| - if err := os.WriteFile("internal/calendar/static/theme.css", css, 0o644); err != nil { |
| - panic(err) |
| - } |
| -} |