oficina / memoria Public

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>
claude agent, operated by paul@keymail.dev 7d3fa702288885904ac76584a572364c00beefe0 parent 6aba99dparent 807986c
10 files changed, +379 −165
  • go.mod +1 −1
  • go.sum +6 −0
  • internal/memoria/static/busy.css +47 −0
  • internal/memoria/static/busy.js +252 −0
  • internal/memoria/static/rastrillo.js +9 −135
  • internal/memoria/templates/layout.html +2 −0
  • internal/memoriatest/vendored_test.go +26 −5
  • scripts/writeestilo/main.go +30 −0
  • scripts/writetheme/main.go +0 −21
  • tools/vendorassets/main.go +6 −3

CI failed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
--- FAIL: TestWiringPrunesOldCounters (0.01s)
    testing.go:1464: TempDir RemoveAll cleanup: unlinkat /tmp/TestWiringPrunesOldCounters807912468/001: directory not empty
FAIL
FAIL	amadan.net/oficina/memoria/internal/memoria	3.245s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.593s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
FAIL
make: *** [Makefile:62: test] Error 1

amadan: step test failed: exit status 2

amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/go.mod b/go.mod
index 20c7af1..6998b6e 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-20260929165053-846b39772f3c
amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d
diff --git a/go.sum b/go.sum
index aedce39..df349a7 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/memoria/static/busy.css b/internal/memoria/static/busy.css
new file mode 100644
index 0000000..fc355e6
--- /dev/null
+++ b/internal/memoria/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/memoria/static/busy.js b/internal/memoria/static/busy.js
new file mode 100644
index 0000000..550a843
--- /dev/null
+++ b/internal/memoria/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/memoria/static/rastrillo.js b/internal/memoria/static/rastrillo.js
index dc4b344..2c9cd5a 100644
--- a/internal/memoria/static/rastrillo.js
+++ b/internal/memoria/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/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index e4c4726..159c0c1 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -10,6 +10,7 @@
<title>{{block "title" .}}memoria{{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"}}">
{{/* theme.css carries the palette. tokens.css only USES --rst-bg and
friends — it defines eleven tokens, none of them colours — so
without this link every colour resolves to initial and whole
@@ -19,6 +20,7 @@
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<link rel="stylesheet" href="{{asset "static/memoria.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
+<script defer src="{{asset "static/busy.js"}}"></script>
{{/* drop.js is inert on every page but the one carrying [rst-dropzone]:
it looks for the zone first and returns if there is none. It is
linked here rather than in index.html because the layout is where
diff --git a/internal/memoriatest/vendored_test.go b/internal/memoriatest/vendored_test.go
index 7bab3de..dc3c996 100644
--- a/internal/memoriatest/vendored_test.go
+++ b/internal/memoriatest/vendored_test.go
@@ -1,6 +1,7 @@
package memoriatest
import (
+ "amadan.net/oficina/estilo.git/busy"
"bytes"
"os"
"path/filepath"
@@ -18,9 +19,8 @@ import (
// DELIBERATELY, delete its line below — the file is yours.
func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
for name, lib := range map[string][]byte{
- "tokens.css": ui.TokensCSS(),
- "rastrillo.js": ui.ShimJS(),
- "select.js": ui.SelectJS(),
+ "tokens.css": ui.TokensCSS(),
+ "select.js": ui.SelectJS(),
} {
vendored, err := os.ReadFile(filepath.Join("..", "memoria", "static", name))
if err != nil {
@@ -36,7 +36,7 @@ func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
// static/theme.css is Calma with Memoria's accent, from estilo (estilo
// AGENTS.md, decisions table, row 8). This pins it 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 TestThemeIsEstilosForMemoria(t *testing.T) {
want, ok := theme.ForApp("memoria")
if !ok {
@@ -47,7 +47,7 @@ func TestThemeIsEstilosForMemoria(t *testing.T) {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
- t.Error("static/theme.css differs from estilo's theme for memoria; run go run ./scripts/writetheme")
+ t.Error("static/theme.css differs from estilo's theme for memoria; run go run ./scripts/writeestilo")
}
}
@@ -62,3 +62,24 @@ func TestFontRouteIsEstilosPrefix(t *testing.T) {
t.Errorf("app.go does not mount Onest at %s", theme.FontPrefix)
}
}
+
+// 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("..", "memoria", "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..0a5c347
--- /dev/null
+++ b/scripts/writeestilo/main.go
@@ -0,0 +1,30 @@
+// Command writeestilo re-writes the files internal/memoria/static takes from
+// the estilo this module pins: theme.css (Calma, with Memoria'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/memoriatest 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("memoria")
+ if !ok {
+ panic("estilo's theme ring has no memoria")
+ }
+ 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/memoria/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 1726bb4..0000000
--- a/scripts/writetheme/main.go
+++ /dev/null
@@ -1,21 +0,0 @@
-// Command writetheme re-writes internal/memoria/static/theme.css from the
-// estilo this module pins: Calma, with Memoria's accent. Run it from the
-// repository root after upgrading estilo; TestThemeIsEstilosForMemoria
-// fails until you do.
-package main
-
-import (
- "os"
-
- "amadan.net/oficina/estilo.git/theme"
-)
-
-func main() {
- css, ok := theme.ForApp("memoria")
- if !ok {
- panic("estilo's theme ring has no memoria")
- }
- if err := os.WriteFile("internal/memoria/static/theme.css", css, 0o644); err != nil {
- panic(err)
- }
-}
diff --git a/tools/vendorassets/main.go b/tools/vendorassets/main.go
index 053590e..ca17873 100644
--- a/tools/vendorassets/main.go
+++ b/tools/vendorassets/main.go
@@ -2,6 +2,10 @@
// This is what `rastrillo doctor --fix` does; the CLI installed on this
// machine predates v0.22.0 and has no doctor, so we do it from the
// linked library instead — which is the same source the pin test reads.
+//
+// Not rastrillo.js: that file comes from estilo's busy package, via
+// scripts/writeestilo, without the old busy rule. The one in this library
+// still has it, and the two rules together cancel every submit.
package main
import (
@@ -15,9 +19,8 @@ import (
func main() {
dir := filepath.Join("internal", "memoria", "static")
for name, body := range map[string][]byte{
- "tokens.css": ui.TokensCSS(),
- "rastrillo.js": ui.ShimJS(),
- "select.js": ui.SelectJS(),
+ "tokens.css": ui.TokensCSS(),
+ "select.js": ui.SelectJS(),
} {
if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil {
log.Fatalf("write %s: %v", name, err)