oficina / estilo Public

busy: carry rastrillo's busy rule (650ms spinner) to the apps

Paul, 2026-09-29: every submit button's label gives way to a spinner for
at least 650ms, the button disables, and Back resets it — across all
the apps. The rule is rastrillo's busy.js; the apps vendor older
rastrillo files, and a rastrillo upgrade per app would carry far more
than this. So estilo carries three byte-exact copies, checked against
provenance.json: the shim without the busy rule (an app replaces its
static/rastrillo.js with it, or two rules fight), busy.js, and the
spinner-in-place block from tokens.css. Handler serves busy.js and
busy.css under a versioned prefix. The prefix lint exempts busy.css by
name: it is rastrillo's CSS, not estilo's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7892137d3abf530b6ca4a3a6fd209a8cf51d4df4 parent 4d41ba2
7 files changed, +695 −0
  • busy/busy.css +47 −0
  • busy/busy.go +80 −0
  • busy/busy.js +247 −0
  • busy/busy_test.go +76 −0
  • busy/provenance.json +22 −0
  • busy/rastrillo.js +211 −0
  • internal/lint/prefix_test.go +12 −0

CI passed — run details

CI log
=== modcache ===
all modules verified
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build -buildvcs=false ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/base	0.021s
ok  	amadan.net/oficina/estilo.git/brand	0.012s
ok  	amadan.net/oficina/estilo.git/busy	0.008s
ok  	amadan.net/oficina/estilo.git/callui	0.015s
ok  	amadan.net/oficina/estilo.git/chrome	0.757s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.039s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.007s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.036s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.020s
?   	amadan.net/oficina/estilo.git/internal/tokencheck	[no test files]
ok  	amadan.net/oficina/estilo.git/menu	0.056s
ok  	amadan.net/oficina/estilo.git/savestatus	0.346s
ok  	amadan.net/oficina/estilo.git/sky	0.086s
ok  	amadan.net/oficina/estilo.git/theme	0.028s
diff --git a/busy/busy.css b/busy/busy.css
new file mode 100644
index 0000000..fc355e6
--- /dev/null
+++ b/busy/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/busy/busy.go b/busy/busy.go
new file mode 100644
index 0000000..c1410f8
--- /dev/null
+++ b/busy/busy.go
@@ -0,0 +1,80 @@
+// Package busy carries rastrillo's busy rule to the Oficina apps (Paul,
+// 2026-09-29): a submit button's label gives way to a spinner for at
+// least 650ms, the button disables, its form refuses a second submit, and
+// Back hands the form back.
+//
+// The rule is rastrillo's (ui/busy.js). The apps vendor older rastrillo
+// files, and moving every app to a newer rastrillo would carry far more
+// than this, so estilo carries three byte-exact copies instead, each
+// recorded in provenance.json and checked against it:
+//
+// - ShimJS: rastrillo.js as it is now the busy rule has left it. An app
+// replaces its vendored static/rastrillo.js with these bytes — its old
+// copy still carries the old busy rule, and two busy rules on one
+// page fight over every submit.
+// - JS: busy.js, the rule itself.
+// - CSS: the spinner-in-place block from rastrillo's tokens.css, for a
+// tokens.css that predates it. Link it after tokens.css.
+//
+// When an app next re-vendors rastrillo for real, it takes all three from
+// there and drops this package.
+package busy
+
+import (
+ _ "embed"
+ "net/http"
+ "strings"
+)
+
+//go:embed rastrillo.js
+var shimJS []byte
+
+//go:embed busy.js
+var busyJS []byte
+
+//go:embed busy.css
+var busyCSS []byte
+
+// ShimJS is rastrillo.js without the busy rule, for an app's static/.
+func ShimJS() []byte { return append([]byte(nil), shimJS...) }
+
+// JS is busy.js.
+func JS() []byte { return append([]byte(nil), busyJS...) }
+
+// CSS is the spinner-in-place block.
+func CSS() []byte { return append([]byte(nil), busyCSS...) }
+
+// Prefix is the URL path Handler serves under. The version moves whenever
+// either file's bytes do: both are cached for a year.
+const Prefix = "/static/estilo/busy/v1/"
+
+// Handler serves GET for Prefix + "busy.js" and Prefix + "busy.css", and
+// nothing else. Public and sessionless: the sign-in form needs it too.
+func Handler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body []byte
+ var kind string
+ switch strings.TrimPrefix(r.URL.Path, Prefix) {
+ case "busy.js":
+ body, kind = busyJS, "text/javascript; charset=utf-8"
+ case "busy.css":
+ body, kind = busyCSS, "text/css; charset=utf-8"
+ }
+ if body == nil || !strings.HasPrefix(r.URL.Path, Prefix) {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", kind)
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.Method == http.MethodHead {
+ return
+ }
+ _, _ = w.Write(body)
+ })
+}
diff --git a/busy/busy.js b/busy/busy.js
new file mode 100644
index 0000000..5d0a9f8
--- /dev/null
+++ b/busy/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/busy/busy_test.go b/busy/busy_test.go
new file mode 100644
index 0000000..4e750ca
--- /dev/null
+++ b/busy/busy_test.go
@@ -0,0 +1,76 @@
+package busy
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+)
+
+// The three files are copies, and a copy that drifts is a fork nobody
+// chose: each must be the bytes provenance.json records.
+func TestFilesAreTheRecordedCopies(t *testing.T) {
+ raw, err := os.ReadFile("provenance.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var record struct {
+ Files map[string]struct {
+ SHA256 string `json:"sha256"`
+ Bytes int `json:"bytes"`
+ } `json:"files"`
+ }
+ if err := json.Unmarshal(raw, &record); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string][]byte{"rastrillo.js": ShimJS(), "busy.js": JS(), "busy.css": CSS()} {
+ want, ok := record.Files[name]
+ sum := sha256.Sum256(body)
+ if !ok || want.SHA256 != hex.EncodeToString(sum[:]) || want.Bytes != len(body) {
+ t.Errorf("%s is not the copy provenance.json records", name)
+ }
+ }
+}
+
+// The point of the package: the shim it hands out has no busy rule left in
+// it (or it would fight busy.js), and busy.js has the minimum.
+func TestTheShimLeftTheRuleToBusyJS(t *testing.T) {
+ if strings.Contains(string(ShimJS()), "busySubmit") || strings.Contains(string(ShimJS()), `"submit"`) {
+ t.Error("the shim still carries a busy rule; two on one page fight over every submit")
+ }
+ for _, want := range []string{"HOLD_MS = 650", "busySubmit", "pageshow", "pagehide"} {
+ if !strings.Contains(string(JS()), want) {
+ t.Errorf("busy.js lacks %q", want)
+ }
+ }
+ if !strings.Contains(string(CSS()), `:not([data-idle-label]):has(> [rst-spin])`) {
+ t.Error("busy.css lacks the spinner-in-place rule")
+ }
+}
+
+func TestHandlerServesOnlyItsTwoFiles(t *testing.T) {
+ h := Handler()
+ for path, want := range map[string]int{
+ Prefix + "busy.js": 200,
+ Prefix + "busy.css": 200,
+ Prefix + "rastrillo.js": 404,
+ Prefix + "../busy.go": 404,
+ Prefix: 404,
+ "/static/busy.js": 404,
+ } {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ if rec.Code != want {
+ t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
+ }
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, Prefix+"busy.js", nil))
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Errorf("POST = %d", rec.Code)
+ }
+}
diff --git a/busy/provenance.json b/busy/provenance.json
new file mode 100644
index 0000000..dde9dde
--- /dev/null
+++ b/busy/provenance.json
@@ -0,0 +1,22 @@
+{
+ "repository": "https://amadan.net/rastrillo/rastrillo",
+ "commit": "a6dd93e1d1a54adfdc94d94f91c474f1d6741850",
+ "note": "branch busy-min-duration; update to the merge commit when it lands",
+ "files": {
+ "rastrillo.js": {
+ "from": "ui/rastrillo.js",
+ "sha256": "b1eac44b25a98ca8144c59dc792ac6c5f66570b40b14f5b428c6838f88e137a3",
+ "bytes": 9784
+ },
+ "busy.js": {
+ "from": "ui/busy.js",
+ "sha256": "5894d72db12e143ab569150794fdf7beacec8dda523623e8caeebd949f5c9c06",
+ "bytes": 12130
+ },
+ "busy.css": {
+ "from": "ui/tokens.css (the block beginning \"With no busy label there are no words to show\", lines 333-374, after a four-line header)",
+ "sha256": "b127fb169a6b03b67688ed2b29187a4dda7a5c3f74cdb4c888cc6d3b28b7b654",
+ "bytes": 2695
+ }
+ }
+}
diff --git a/busy/rastrillo.js b/busy/rastrillo.js
new file mode 100644
index 0000000..2c9cd5a
--- /dev/null
+++ b/busy/rastrillo.js
@@ -0,0 +1,211 @@
+/* rastrillo.js — the fragment shim. First-party, dependency-free, and
+ almost entirely inert: the polling section answers only to an opt-in
+ data attribute, and everything it enhances also works with scripts
+ disabled (a status page's <noscript> meta refresh). This file is
+ app-owned from the moment it is scaffolded — edit it like any other
+ static file.
+
+ 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
+ element with it, and keep going only if the
+ replacement element itself carries data-poll
+ data-poll-every="2" seconds between polls (default 2)
+ data-poll-push="URL" optional, beside data-poll: open an
+ EventSource to URL and re-fetch the fragment
+ when the server says so, instead of on a
+ timer. Any EventSource error downgrades this
+ element to timer polling for good (once,
+ no flapping); a browser without EventSource
+ never leaves the timer path
+
+ select.js is a sibling file following exactly these rules, kept
+ separate so this one stays small enough to read in one sitting. It
+ answers data-rst-select on a <select>, which field-select emits past
+ ten options.
+
+ Every poll carries the request header Rastrillo-Fragment: 1, which
+ marks the request as a shim poll so a handler can tell it apart from
+ direct navigation. Reserved: the framework's own handlers do not
+ read it today.
+
+ Menus: an open <details rst-dropdown>, <details rst-row-menu> or a
+ nested <details rst-menu-group> closes on an outside click and on
+ Escape, in EITHER spelling, so upgrading this file before running
+ `rastrillo markup` leaves no dead menus. Exclusivity stays native.
+
+ A polled response may answer 204 with a Rastrillo-Location header
+ instead of a fragment; the shim navigates there, but only to a local
+ path. 403 and 404 end the poll for good — the job was swept, or was
+ never yours. Any other fetch error backs off (doubling to a 30s cap)
+ and keeps trying — a network blip must not strand a status page. */
+(function () {
+ "use strict";
+
+ // The same rule sessions.SafeReturn enforces server-side: a same-site
+ // absolute path — starts with exactly one "/", no scheme, no
+ // backslash, no control characters (browsers strip tab/CR/LF before
+ // parsing, so "/\t/evil.example" would resolve scheme-relative).
+ // Anything laxer is an open redirect, here driven by a response
+ // header instead of a form field.
+ function localPath(to) {
+ return to.charAt(0) === "/" && to.charAt(1) !== "/" &&
+ to.indexOf("\\") === -1 && !/[\u0000-\u001f\u007f]/.test(to);
+ }
+
+ function poll(el) {
+ var base = (parseFloat(el.getAttribute("data-poll-every")) || 2) * 1000;
+ var wait = base;
+ var src = null; // open EventSource while pushing; null on the timer path
+ var busy = false; // a fetch is in flight
+ var queued = false; // an update landed mid-fetch; tick again after it
+ function stop() {
+ if (src) { src.close(); src = null; }
+ }
+ // One downgrade, never back: push hands this element to the timer.
+ function fallback() {
+ stop();
+ schedule();
+ }
+ function tick() {
+ if (busy) { queued = true; return; }
+ busy = true;
+ fetch(el.getAttribute("data-poll"), { headers: { "Rastrillo-Fragment": "1" } })
+ .then(function (res) {
+ // Terminal, not an error to retry: retrying a 404 forever is
+ // noise, and following a signed-out redirect would swap a
+ // sign-in page into the fragment's slot.
+ if (res.status === 403 || res.status === 404) return null;
+ if (!res.ok && res.status !== 204) throw new Error("status " + res.status);
+ var to = res.headers.get("Rastrillo-Location");
+ if (to) {
+ if (localPath(to)) window.location.assign(to);
+ return null; // navigating, or refusing to — either way, done
+ }
+ return res.text();
+ })
+ .then(function (html) {
+ busy = false;
+ if (html === null) { stop(); return; } // stopped
+ wait = base; // a healthy response resets the backoff
+ var tpl = document.createElement("template");
+ tpl.innerHTML = html;
+ var next = tpl.content.firstElementChild;
+ if (!next) { stop(); return; } // fragment with no element: stop politely
+ el.replaceWith(next);
+ el = next;
+ if (!el.hasAttribute("data-poll")) { stop(); return; }
+ if (!src) { schedule(); return; }
+ // Pushing: a fragment that dropped data-poll-push falls back
+ // to the timer; otherwise catch up if an update was queued.
+ if (!el.hasAttribute("data-poll-push")) { fallback(); return; }
+ if (queued) { queued = false; tick(); }
+ })
+ .catch(function () {
+ busy = false;
+ wait = Math.min(wait * 2, 30000);
+ if (!src) schedule(); // pushing: the next event retries instead
+ });
+ }
+ function schedule() { setTimeout(tick, wait); }
+ var push = el.getAttribute("data-poll-push");
+ if (push && window.EventSource) {
+ src = new EventSource(push);
+ src.addEventListener("update", function () { tick(); });
+ src.addEventListener("done", function () { stop(); tick(); });
+ src.addEventListener("gone", stop);
+ src.onerror = fallback;
+ return;
+ }
+ schedule();
+ }
+
+ // 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
+ // else: not on a click elsewhere on the page, not on Escape. Both are
+ // what every menu anywhere else does, and neither is expressible in
+ // HTML or CSS.
+ //
+ // Two delegated listeners on the document, never one per element, so a
+ // dropdown that arrives inside a polled fragment is covered the moment
+ // it lands and re-scanning can never double-bind. Capture phase, so a
+ // page whose own handler stops propagation cannot leave a menu stuck
+ // open.
+ //
+ // The scriptless baseline is untouched: with this file removed, the
+ // menus still toggle and the native <details name> group still keeps
+ // one open at a time.
+ //
+ // The nested rst-menu-group is in MENUS even though it is never in the
+ // <details name> group with its parent — the two mechanisms answer
+ // different questions. The name group decides which menus may be open
+ // at once, and a submenu must be exempt from it or opening one would
+ // close the menu around it. Dismissal is not exclusivity: a submenu
+ // left open behind its closing parent is still open the next time the
+ // parent opens, which is a menu remembering a state the user has no
+ // way to see. Listing it here also gives the natural behaviour of
+ // clicking elsewhere INSIDE the parent closing the submenu — the
+ // contains(except) test below already draws that line for free.
+ //
+ // Shell chrome and the toggle-block are deliberately absent from
+ // MENUS. A sidebar's disclosure strip and a settings switch are not
+ // menus; closing them because a click landed elsewhere would fight the
+ // user rather than help.
+ var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]";
+ MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1");
+
+ // except is the clicked node: the menu containing it stays open, which
+ // is what keeps a click on a menu item — or on the summary of a menu
+ // being opened right now — from closing the thing being used.
+ function closeMenus(except) {
+ document.querySelectorAll(MENUS).forEach(function (d) {
+ if (!except || !d.contains(except)) d.open = false;
+ });
+ }
+
+ function dismissMenus(e) {
+ if (e.type === "click") { closeMenus(e.target); return; }
+ if (e.key !== "Escape") return;
+ // Focus is about to be inside a subtree that is no longer rendered,
+ // which strands a keyboard user at the top of the document. Hand it
+ // back to the summary that opened the menu.
+ var el = document.activeElement;
+ var host = el && el.closest ? el.closest(MENUS) : null;
+ // Climb to the OUTERMOST open menu around the focus. closest() finds
+ // the innermost, which for focus inside a submenu is the submenu —
+ // and its summary is inside the parent that is about to close too, so
+ // focusing it would hand focus to something no longer rendered, which
+ // is no hand-back at all.
+ while (host && host.parentElement && host.parentElement.closest(MENUS)) {
+ host = host.parentElement.closest(MENUS);
+ }
+ closeMenus(null);
+ if (host) {
+ var summary = host.querySelector("summary");
+ if (summary) summary.focus();
+ }
+ }
+
+ document.addEventListener("click", dismissMenus, true);
+ document.addEventListener("keydown", dismissMenus, true);
+
+ function scan() {
+ document.querySelectorAll("[data-poll]").forEach(poll);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", scan);
+ } else {
+ scan();
+ }
+})();
diff --git a/internal/lint/prefix_test.go b/internal/lint/prefix_test.go
index f5c4484..e19aca0 100644
--- a/internal/lint/prefix_test.go
+++ b/internal/lint/prefix_test.go
@@ -45,6 +45,15 @@ var rstSelector = regexp.MustCompile(`\.rst-[A-Za-z0-9_-]+|\[\s*rst-[A-Za-z0-9_-
// header included -- .css files only), or a component that avoids
// both prefixes and picks a third one. It is a tripwire on estilo's
// own CSS, not a full grammar checker.
+// notEstilosCSS names the CSS in this repository that is not estilo's own
+// but rastrillo's, carried byte for byte: its rst- selectors are rastrillo
+// styling rastrillo's markup, which is exactly what the prefix rule
+// permits. Each is pinned to its source by a provenance test, so it can't
+// quietly become estilo's own CSS with rastrillo's names.
+var notEstilosCSS = map[string]bool{
+ "busy/busy.css": true, // rastrillo ui/tokens.css; busy/provenance.json
+}
+
func TestNoRstPrefixInEstiloCSS(t *testing.T) {
root := repoRoot(t)
@@ -75,6 +84,9 @@ func TestNoRstPrefixInEstiloCSS(t *testing.T) {
if err != nil {
rel = path
}
+ if notEstilosCSS[filepath.ToSlash(rel)] {
+ return nil
+ }
for _, m := range rstSelector.FindAll(data, -1) {
hits = append(hits, hit{path: rel, match: string(m)})
}