oficina / sheets 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 fcd2580861ba8f0aeb80bfa88ff122c391267e6e parent ee6c07aparent fb43fd9
8 files changed, +53 −349
  • go.mod +1 −1
  • go.sum +6 −0
  • internal/guardstest/jscap_test.go +0 −4
  • internal/sheets/render.go +12 −3
  • internal/sheets/static/drop.js +6 −4
  • internal/sheets/static/rastrillo.js +0 −337
  • internal/sheets/templates/layout.html +2 −0
  • internal/sheets/theme_assets_test.go +26 −0

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.077s
ok  	amadan.net/oficina/sheets/internal/sheets	10.764s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.010s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.004s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.054s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.004s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.005s
ok  	amadan.net/oficina/sheets/internal/sheets/store	2.875s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.299s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	49.201s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.061s
diff --git a/go.mod b/go.mod
index c42e6ac..096e607 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module amadan.net/oficina/sheets
go 1.25.0
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-20260923190447-01166fcfc0a9
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
diff --git a/go.sum b/go.sum
index 4ad3acd..cb4ad0f 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,12 @@ amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwL
amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
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-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
diff --git a/internal/guardstest/jscap_test.go b/internal/guardstest/jscap_test.go
index 5d0b1a8..7d3b924 100644
--- a/internal/guardstest/jscap_test.go
+++ b/internal/guardstest/jscap_test.go
@@ -32,10 +32,6 @@ var jsLineCapExempt = map[string]bool{
"internal/sheets/static/vendor/jspreadsheet.js": true,
// This one bites: 13,601 lines.
"internal/sheets/static/vendor/jsuites.js": true,
- // rastrillo's own progressive-enhancement shim, copied by the
- // framework's scaffold and verified by `rastrillo doctor`. Ours to
- // serve, not ours to write.
- "internal/sheets/static/rastrillo.js": true,
}
// skipJSDir is deliberately NOT repo_test.go's skipDir.
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index 5b51841..d5dae1a 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -12,6 +12,7 @@ import (
"testing/fstest"
"amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/busy"
"amadan.net/oficina/estilo.git/chrome"
estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/theme"
@@ -27,9 +28,10 @@ import (
var templatesFS embed.FS
// staticFS holds everything served under /static: rastrillo's vendored
-// tokens.css and rastrillo.js (copied by the framework's own
-// scaffold and verified by `rastrillo doctor`), our own CSS and JS, and
-// the vendored grid.
+// tokens.css (copied by the framework's own scaffold and verified by
+// `rastrillo doctor`), our own CSS and JS, and the vendored grid.
+// rastrillo.js is not on disk: it comes from estilo's busy package, with
+// busy.js and busy.css, below.
//
// all: is required, not decorative — the default embed pattern skips
// files whose names begin with _ or ., and a vendored library that
@@ -72,6 +74,13 @@ func sharedStaticFiles() fs.FS {
files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()}
files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()}
files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()}
+ // 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. estilo carries rastrillo's shim without the old busy
+ // rule, busy.js, and the spinner-in-place CSS; see estilo's busy.
+ files["static/rastrillo.js"] = &fstest.MapFile{Data: busy.ShimJS()}
+ files["static/estilo/busy.js"] = &fstest.MapFile{Data: busy.JS()}
+ files["static/estilo/busy.css"] = &fstest.MapFile{Data: busy.CSS()}
return files
}
diff --git a/internal/sheets/static/drop.js b/internal/sheets/static/drop.js
index 3a89703..6d84b5f 100644
--- a/internal/sheets/static/drop.js
+++ b/internal/sheets/static/drop.js
@@ -122,10 +122,12 @@
say('Use the button to choose that file.');
return;
}
- /* requestSubmit fires the submit event, which is what rastrillo's
- * shim listens for to show the button busy. form.submit() skips
- * it, so it is the fallback and not the first choice. */
- if (typeof form.requestSubmit === 'function') form.requestSubmit();
+ /* requestSubmit fires the submit event, which is what the busy
+ * rule listens for; passing the form's button is what makes that
+ * button show the spinner. form.submit() skips the event, so it is
+ * the fallback and not the first choice. */
+ var button = form.querySelector('button[type=submit]');
+ if (typeof form.requestSubmit === 'function') form.requestSubmit(button || undefined);
else form.submit();
}
diff --git a/internal/sheets/static/rastrillo.js b/internal/sheets/static/rastrillo.js
deleted file mode 100644
index dc4b344..0000000
--- a/internal/sheets/static/rastrillo.js
+++ /dev/null
@@ -1,337 +0,0 @@
-/* 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.
-
- 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.
-
- 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
- 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
- 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();
- }
-
- // ── 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
- // 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);
- }
-
- // 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 {
- scan();
- }
-})();
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
index e45d566..6140c74 100644
--- a/internal/sheets/templates/layout.html
+++ b/internal/sheets/templates/layout.html
@@ -33,6 +33,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css">
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/busy.css"}}">
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}">
@@ -44,6 +45,7 @@
them (static/modal.js). */}}
<script defer src="{{asset "static/modal.js"}}"></script>
<script defer src="{{asset "static/rastrillo.js"}}"></script>
+<script defer src="{{asset "static/estilo/busy.js"}}"></script>
<script defer src="{{asset "static/estilo/chrome.js"}}"></script>
{{iconAssets}}
{{block "head" .}}{{end}}
diff --git a/internal/sheets/theme_assets_test.go b/internal/sheets/theme_assets_test.go
index e017514..c2bcfd4 100644
--- a/internal/sheets/theme_assets_test.go
+++ b/internal/sheets/theme_assets_test.go
@@ -1,6 +1,9 @@
package sheets
import (
+ "path"
+
+ "amadan.net/oficina/estilo.git/busy"
"bytes"
"crypto/sha256"
"fmt"
@@ -70,3 +73,26 @@ func TestOnestIsLinkedAndServed(t *testing.T) {
}
}
}
+
+// The busy rule (estilo's busy): the page links busy.css after tokens.css
+// and busy.js after the shim, all three are served, and the shim served is
+// estilo's — rastrillo's without the old busy rule, which would fight the
+// new one.
+func TestBusyRuleIsLinkedAndServed(t *testing.T) {
+ app := New(Config{})
+ page := do(t, app, httptest.NewRequest(http.MethodGet, "/signin", nil)).Body.String()
+ for _, name := range []string{"static/estilo/busy.css", "static/rastrillo.js", "static/estilo/busy.js"} {
+ body := map[string][]byte{"static/estilo/busy.css": busy.CSS(), "static/rastrillo.js": busy.ShimJS(), "static/estilo/busy.js": busy.JS()}[name]
+ sum := sha256.Sum256(body)
+ hashed := "/" + strings.TrimSuffix(name, path.Ext(name)) + fmt.Sprintf(".%x", sum[:8]) + path.Ext(name)
+ if !strings.Contains(page, `"`+hashed+`"`) {
+ t.Errorf("sign-in page does not link %s", hashed)
+ }
+ if res := do(t, app, httptest.NewRequest(http.MethodGet, hashed, nil)); res.Code != http.StatusOK || !bytes.Equal(res.Body.Bytes(), body) {
+ t.Errorf("GET %s = %d or the wrong bytes", hashed, res.Code)
+ }
+ }
+ if i, j := strings.Index(page, "static/rastrillo."), strings.Index(page, "static/estilo/busy."); i < 0 || j < 0 {
+ t.Error("the shim or busy.js is missing")
+ }
+}