oficina / fichas Public

The busy rule: submit buttons show a spinner for at least 650ms

Paul, 2026-09-29, for every app: a submit button's label is replaced by
a spinner, the button disables, the spinner shows for at least 650ms,
and Back resets it. This is rastrillo's busy.js (rastrillo a37bfd1),
carried by estilo's busy package (estilo 0994675): the shim without the
old busy rule (which would fight the new one), busy.js, and the
spinner-in-place CSS this app's tokens.css predates. A test checks the
page links them and that they are estilo's bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5465d245883fd6f30130b2d125ee32a6e6756b78 parent d0a082f
9 files changed, +367 −159
  • go.mod +1 −1
  • go.sum +4 −0
  • internal/fichas/static/busy.css +47 −0
  • internal/fichas/static/busy.js +247 −0
  • internal/fichas/static/rastrillo.js +9 −135
  • internal/fichas/templates/layout.html +2 −0
  • internal/fichastest/vendored_test.go +27 −2
  • scripts/writeestilo/main.go +30 −0
  • scripts/writetheme/main.go +0 −21

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.413s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	0.005s
ok  	amadan.net/oficina/fichas/internal/fichastest	0.346s
?   	amadan.net/oficina/fichas/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/go.mod b/go.mod
index e138632..886170a 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.26
toolchain go1.26.5
require (
- amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773
+ amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e
github.com/carlosframework/rastrillo v0.23.0
github.com/chromedp/chromedp v0.14.2
github.com/go-chi/chi/v5 v5.3.2
diff --git a/go.sum b/go.sum
index 3df52e0..c098712 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
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=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/internal/fichas/static/busy.css b/internal/fichas/static/busy.css
new file mode 100644
index 0000000..fc355e6
--- /dev/null
+++ b/internal/fichas/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/fichas/static/busy.js b/internal/fichas/static/busy.js
new file mode 100644
index 0000000..5d0a9f8
--- /dev/null
+++ b/internal/fichas/static/busy.js
@@ -0,0 +1,247 @@
+/* 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).
+ 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;
+ e.preventDefault();
+ held.add(e);
+ var btn = e.submitter || null;
+ 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/fichas/static/rastrillo.js b/internal/fichas/static/rastrillo.js
index dc4b344..2c9cd5a 100644
--- a/internal/fichas/static/rastrillo.js
+++ b/internal/fichas/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/fichas/templates/layout.html b/internal/fichas/templates/layout.html
index 72e22f4..2b86bfd 100644
--- a/internal/fichas/templates/layout.html
+++ b/internal/fichas/templates/layout.html
@@ -9,9 +9,11 @@
<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
<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"}}">
<link rel="stylesheet" href="{{asset "static/brand/brand.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/fichastest/vendored_test.go b/internal/fichastest/vendored_test.go
index ca709a3..5774872 100644
--- a/internal/fichastest/vendored_test.go
+++ b/internal/fichastest/vendored_test.go
@@ -1,6 +1,7 @@
package fichastest
import (
+ "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 Fichas' accent, from estilo (estilo AGENTS.md, decisions
// table, row 8). Pinned to estilo instead, by TestThemeIsEstilosForFichas.
"theme.css": true,
@@ -28,7 +32,7 @@ var vendoredIsMine = map[string]bool{
// TestThemeIsEstilosForFichas 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 TestThemeIsEstilosForFichas(t *testing.T) {
want, ok := theme.ForApp("fichas")
if !ok {
@@ -39,7 +43,7 @@ func TestThemeIsEstilosForFichas(t *testing.T) {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
- t.Error("static/theme.css differs from estilo's theme for fichas; run go run ./scripts/writetheme")
+ t.Error("static/theme.css differs from estilo's theme for fichas; run go run ./scripts/writeestilo")
}
}
@@ -72,3 +76,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": busy.ShimJS(),
+ "busy.js": busy.JS(),
+ "busy.css": busy.CSS(),
+ } {
+ got, err := os.ReadFile(filepath.Join("..", "fichas", "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/scripts/writeestilo/main.go b/scripts/writeestilo/main.go
new file mode 100644
index 0000000..0cd5d81
--- /dev/null
+++ b/scripts/writeestilo/main.go
@@ -0,0 +1,30 @@
+// Command writeestilo re-writes the files internal/fichas/static takes from
+// the estilo this module pins: theme.css (Calma, with Fichas'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/fichastest 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("fichas")
+ if !ok {
+ panic("estilo's theme ring has no fichas")
+ }
+ 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/fichas/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 80c7979..0000000
--- a/scripts/writetheme/main.go
+++ /dev/null
@@ -1,21 +0,0 @@
-// Command writetheme re-writes internal/fichas/static/theme.css from the
-// estilo this module pins: Calma, with Fichas' accent. Run it from the
-// repository root after upgrading estilo; TestThemeIsEstilosForFichas
-// fails until you do.
-package main
-
-import (
- "os"
-
- "amadan.net/oficina/estilo.git/theme"
-)
-
-func main() {
- css, ok := theme.ForApp("fichas")
- if !ok {
- panic("estilo's theme ring has no fichas")
- }
- if err := os.WriteFile("internal/fichas/static/theme.css", css, 0o644); err != nil {
- panic(err)
- }
-}