Merge branch 'busy-rule' into main
busy: carry rastrillo's busy rule (650ms spinner) to the apps Byte-exact copies of rastrillo a37bfd1's shim without the old busy rule, busy.js and the spinner-in-place CSS, checked against provenance.json; served under a digest-versioned prefix. Reviewed by Claude Fable 5.1 (discussion 14).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+704
−0
busy/busy.css+47 −0busy/busy.go+88 −0busy/busy.js+247 −0busy/busy_test.go+77 −0busy/provenance.json+22 −0busy/rastrillo.js+211 −0internal/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.004s ok amadan.net/oficina/estilo.git/brand 0.003s ok amadan.net/oficina/estilo.git/busy 0.003s ok amadan.net/oficina/estilo.git/callui 0.004s ok amadan.net/oficina/estilo.git/chrome 0.166s ? amadan.net/oficina/estilo.git/cmd/oficina-notices [no test files] ok amadan.net/oficina/estilo.git/credits 0.007s ok amadan.net/oficina/estilo.git/credits/catalog 0.007s ok amadan.net/oficina/estilo.git/homeconnect 0.007s ok amadan.net/oficina/estilo.git/internal/lint 0.006s ? amadan.net/oficina/estilo.git/internal/tokencheck [no test files] ok amadan.net/oficina/estilo.git/menu 0.009s ok amadan.net/oficina/estilo.git/savestatus 0.076s ok amadan.net/oficina/estilo.git/sky 0.023s ok amadan.net/oficina/estilo.git/theme 0.007s
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..0ec2fc4 |
| --- /dev/null |
| +++ b/busy/busy.go |
| @@ -0,0 +1,88 @@ |
| +// 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 ( |
| + "crypto/sha256" |
| + _ "embed" |
| + "encoding/hex" |
| + "net/http" |
| + "strconv" |
| + "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. Its version segment is the |
| +// two files' own digest, so new bytes are always a new URL: both are |
| +// cached for a year, immutably, and no one has to remember to bump it. |
| +var Prefix = func() string { |
| + sum := sha256.Sum256(append(append([]byte(nil), busyJS...), busyCSS...)) |
| + return "/static/estilo/busy/" + hex.EncodeToString(sum[:6]) + "/" |
| +}() |
| + |
| +// 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") |
| + w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| + 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..461afe2 |
| --- /dev/null |
| +++ b/busy/busy_test.go |
| @@ -0,0 +1,77 @@ |
| +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, |
| + "/static/estilo/busy/v1/busy.js": 404, // the version is the digest, not a counter |
| + } { |
| + 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..f438b33 |
| --- /dev/null |
| +++ b/busy/provenance.json |
| @@ -0,0 +1,22 @@ |
| +{ |
| + "repository": "https://amadan.net/rastrillo/rastrillo", |
| + "commit": "a37bfd179cdeadd731e37f75dd2297d9dabe387a", |
| + "note": "rastrillo main, the merge of busy-min-duration (discussion 50)", |
| + "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)}) |
| } |