rastrillo / fiesta Public

fiesta.js: menus, switch drag, touch press and a scheme switch that remembers on a static site

Task 4 of the Fiesta part-1 plan, from rastrillo at SEED, e14b745.

js/fiesta.js is ui/rastrillo.js minus the polling section (poll, its
EventSource push path and the Rastrillo-Fragment and
Rastrillo-Location headers), localPath, scan, start and the cookie
write. Every name the brief told it to keep was where SEED put it:
MENUS, TAIL, menuAround, closeMenus, dismissMenus, the switch drag
(TRACK, settle, unsettle, drop and the five pointer listeners), the
touchstart listener, land() and osScheme. None had moved. The guard
at the top, window.fiestaShim, makes a second copy inert.

The scheme section is new. <html data-rst-scheme-store="local"> is
the ownership signal, read before anything else runs. With it, a
submit in a [rst-scheme] menu stores localStorage["fiesta-scheme"]
as "light" or "dark" and System removes the key; on load, a stored
value is applied to the root when it has no data-theme, so a copy in
<head> without defer restores before first paint, and the matching
button is marked aria-current once the DOM is ready. Without it,
storage is neither read nor written. Either way a submit applies
data-theme in place, lands transitions, moves aria-current, closes
the menu and dispatches CustomEvent("rst-scheme", {bubbles: true,
detail: {scheme}}) from the form. Every storage call is in a
try/catch. Anything but light or dark submits as system, as SEED's
handler treated it, so the key only ever holds a scheme.

fiesta.go lists fiesta.js first in scriptNames, and
TestEveryScriptIsEmbedded's want list follows it (an adapted
preservation assertion: the list is hard-coded there).

check/scripts_test.go, untagged, is TestShimContract and
TestTheLightDismissGateReadsTheMenuList with lightDismissReach from
ui/shim_test.go. The contract test keeps its menu, switch and touch
assertions, drops the polling strings, and replaces the cookie
strings with "rst-scheme", "fiesta-scheme" (both quoted, so the
event name and the key are what is checked), data-rst-scheme-store
and localStorage. The kept assertions passed on their first run
against the stub, SEED's file copied unchanged into js/fiesta.js;
the four replaced ones failed by name on it (F19). Eight new tests
read check/node/fiesta_node.mjs, which runs fiesta.js in a node:vm
context against a stub page (a root with attributes, a document
with listeners, three scheme forms, a counting or throwing storage)
and reports one JSON object. Seven failed by name on the stub:
  TestFiestaJSHasNoServerBehaviour   all five strings present
  TestFiestaJSIsSmall                19434 bytes
  TestFiestaJSIsIdempotent           24 listeners for 12
  TestAStandaloneSubmitStoresTheScheme   stored "", one cookie write
  TestSystemClearsTheStoredScheme    the key stays
  TestABrokenStorageStillSwitches    no rst-scheme event
  TestAServerOwnedSubmitStoresNothing    one cookie write
  TestTheStoredSchemeIsAppliedOnLoad data-theme "" after evaluating
TestAServerOwnedPageIgnoresTheStoredScheme cannot fail on a stub
that never reads storage; its control was a scratch mutation that
dropped the ownership check in stored(): "a server-owned page took
data-theme "dark" from storage" and "read storage 1 times on load",
and TestAServerOwnedSubmitStoresNothing with it. Reverted.

check/scheme_browser_test.go hand-writes the scheme-menu partial's
markup on three pages (schemePages: / standalone, /server without
the attribute, /early with fiesta.js undeferred in <head>), each
ending in an inline script that records data-theme as the body saw
it. driveSchemeRemember(t, rig) is the body of
TestAStandaloneSchemeSurvivesAReload, for Task 12 to drive with the
release's scripts. Against the stub: Dark wrote rastrillo_scheme=dark
and stored nothing; the early page's body saw ""; the standalone
page stored nothing for the server-owned page to ignore. Green
after fiesta.js.

TestTheFixturePageLoadsEveryAsset serves sizingDoc("smoke", "<p>x</p>")
and holds the eight linked assets to 200 with a body, window.fiestaShim
to true, and the harness to no problem. Run before js/fiesta.js
existed: "GET /fiesta.js answered 404" and the harness's 404 report.

The keep list in rig_browser_test.go lost sizingRig and settleUntil,
which the scheme drives call.

fiesta.js is 12,144 bytes, 144 under the cap: 6.4 KB of code and the
rest comment, trimmed from 13,954 by shortening the light-dismiss,
switch and landing paragraphs rather than cutting any of them.

make ci green: gofmt, no-rastrillo, root, staticcheck, govulncheck,
gitleaks, browser.internal.harness, browser.check.
Human

Prompt

Task 4 of the Fiesta part-1 plan: fiesta.js, the plain-browser half of rastrillo.js, and a scheme that persists, per the task brief and the controller rulings F5, F19 and F21.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Adds js/fiesta.js (SEED rastrillo.js without its server half, plus a localStorage scheme memory behind <html data-rst-scheme-store="local"> and an rst-scheme event), puts it first in ScriptNames, ports TestShimContract and the light-dismiss gate to check/scripts_test.go with eight Node-driven tests through check/node/fiesta_node.mjs, adds three browser drives in check/scheme_browser_test.go with driveSchemeRemember and schemePages for Task 12, and the fixture smoke test TestTheFixturePageLoadsEveryAsset.
Agent notes
driveSchemeRemember(t, rig) expects the rig to serve schemePages(): / standalone, /server without the attribute, /early with fiesta.js undeferred in <head>. The ownership check is the attribute value "local" exactly, not mere presence. rst-scheme is dispatched from the submitted form and bubbles. The keep list in rig_browser_test.go lost sizingRig and settleUntil. fiesta.js sits 144 bytes under its 12 KiB cap.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3e1b1d84ae5b620fae15bb5bcf1bf10136d59261 parent 6777d08
7 files changed, +1002 −11
  • check/node/fiesta_node.mjs +158 −0
  • check/rig_browser_test.go +44 −5
  • check/scheme_browser_test.go +220 −0
  • check/scripts_test.go +318 −0
  • fiesta.go +7 −5
  • fiesta_test.go +1 −1
  • js/fiesta.js +254 −0

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.154s
=== suite ===
--- shard 0 of 6 ---
==> shard 0 of 6: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	0.175s
ok  	amadan.net/rastrillo/fiesta/check	0.425s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.020s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.003s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.301s
--- shard 1 of 6 ---
==> shard 1 of 6: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 6 ---
==> shard 2 of 6: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
No vulnerabilities found.
--- shard 3 of 6 ---
==> shard 3 of 6: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
9:10PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
9:10PM INF 1 commits scanned.
9:10PM INF scanned ~1780943 bytes (1.78 MB) in 2.11s
9:10PM INF no leaks found
--- shard 4 of 6 ---
==> shard 4 of 6: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/internal/harness	10.242s
--- shard 5 of 6 ---
==> shard 5 of 6: make browser.check
./hack/gotest.sh browser 'check'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/check	8.210s
diff --git a/check/node/fiesta_node.mjs b/check/node/fiesta_node.mjs
new file mode 100644
index 0000000..fa19598
--- /dev/null
+++ b/check/node/fiesta_node.mjs
@@ -0,0 +1,158 @@
+// fiesta_node runs fiesta.js against a stub page in plain Node, one
+// fresh context per scenario, and reports what the scheme switch did:
+// what a submit in the [rst-scheme] menu applied, stored and
+// dispatched, with and without <html data-rst-scheme-store="local">,
+// with a storage that throws, and what a stored choice does on load.
+// One scenario evaluates the script twice and counts listeners.
+//
+// check/scripts_test.go is the only caller. It reads back one JSON
+// object, a scenario per key.
+//
+// The page is the least DOM fiesta.js touches on the scheme path: a
+// root with attributes, a document with listeners, one scheme menu of
+// three forms, and a storage. Everything else the file reaches for on
+// load (getAnimations, matchMedia) is absent and guarded.
+import { readFileSync } from "node:fs";
+import vm from "node:vm";
+
+const src = readFileSync(new URL("../../js/fiesta.js", import.meta.url), "utf8");
+
+// An element with attributes and nothing else.
+function element() {
+ const attrs = new Map();
+ return {
+ getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null),
+ setAttribute: (k, v) => { attrs.set(k, String(v)); },
+ removeAttribute: (k) => { attrs.delete(k); },
+ hasAttribute: (k) => attrs.has(k),
+ };
+}
+
+// A storage over a Map that counts its calls, or one whose every
+// method throws, as a browser's does with storage switched off.
+function storage(broken) {
+ const m = new Map();
+ const n = { calls: 0 };
+ const call = (fn) => (...args) => {
+ n.calls++;
+ if (broken) throw new Error("storage is off");
+ return fn(...args);
+ };
+ return {
+ n, m,
+ getItem: call((k) => (m.has(k) ? m.get(k) : null)),
+ setItem: call((k, v) => { m.set(k, String(v)); }),
+ removeItem: call((k) => { m.delete(k); }),
+ };
+}
+
+// page builds the stub, evaluates fiesta.js in it, and hands back the
+// hooks a scenario drives. store sets the ownership attribute; stored
+// seeds the storage; broken makes it throw; twice evaluates the script
+// a second time in the same context.
+function page({ store = false, stored = null, broken = false, twice = false } = {}) {
+ const root = element();
+ if (store) root.setAttribute("data-rst-scheme-store", "local");
+ const local = storage(broken);
+ if (stored !== null) local.m.set("fiesta-scheme", stored);
+
+ const on = {};
+ let listeners = 0;
+ const events = [];
+ const dispatchEvent = (e) => { events.push(e); return true; };
+ const forms = ["light", "dark", "system"].map((v) => {
+ const form = { elements: { scheme: { value: v } }, dispatchEvent };
+ const button = Object.assign(element(), { form });
+ if (v === "system") button.setAttribute("aria-current", "true");
+ return { form, button };
+ });
+ const menu = {
+ open: true,
+ dispatchEvent,
+ querySelectorAll: (sel) => (sel === "button" ? forms.map((f) => f.button) : []),
+ };
+ for (const f of forms) f.form.closest = (sel) => (sel.includes("rst-scheme") ? menu : null);
+
+ let cookies = 0;
+ const document = {
+ readyState: "loading",
+ documentElement: root,
+ set cookie(v) { cookies++; },
+ get cookie() { return ""; },
+ querySelectorAll: (sel) => (sel.includes("rst-scheme") ? [menu] : []),
+ addEventListener: (type, fn) => { listeners++; (on[type] = on[type] || []).push(fn); },
+ dispatchEvent,
+ };
+ const window = { localStorage: local, addEventListener: () => { listeners++; } };
+ const context = {
+ window, document, localStorage: local, CustomEvent, setTimeout, clearTimeout,
+ location: { protocol: "http:" },
+ };
+ vm.runInNewContext(src, context);
+ const once = listeners;
+ if (twice) vm.runInNewContext(src, context);
+
+ return {
+ once, twice: listeners,
+ ready() { for (const fn of on.DOMContentLoaded || []) fn({}); },
+ submit(v) {
+ const f = forms.find((x) => x.form.elements.scheme.value === v).form;
+ const e = { type: "submit", target: f, prevented: false, preventDefault() { this.prevented = true; } };
+ let error = "";
+ try { for (const fn of on.submit || []) fn(e); } catch (err) { error = String(err); }
+ return { prevented: e.prevented, error };
+ },
+ state(extra = {}) {
+ const cur = forms.find((f) => f.button.getAttribute("aria-current") === "true");
+ const stored = local.m.get("fiesta-scheme");
+ return {
+ theme: root.getAttribute("data-theme") || "",
+ stored: stored === undefined ? "" : stored,
+ keys: local.m.size,
+ storageCalls: local.n.calls,
+ cookies,
+ events: events.filter((e) => e.type === "rst-scheme").map((e) => (e.detail && e.detail.scheme) || ""),
+ current: cur ? cur.form.elements.scheme.value : "",
+ open: menu.open,
+ prevented: false,
+ error: "",
+ ...extra,
+ };
+ },
+ };
+}
+
+// run opens a page, submits one scheme, and reports.
+function run(opts, scheme) {
+ const p = page(opts);
+ p.ready();
+ const { prevented, error } = p.submit(scheme);
+ return p.state({ prevented, error });
+}
+
+const out = {};
+
+{
+ const p = page({ twice: true });
+ out.idempotent = { once: p.once, twice: p.twice };
+}
+
+out.standalone = run({ store: true }, "dark");
+out.system = run({ store: true, stored: "dark" }, "system");
+out.broken = run({ store: true, broken: true }, "dark");
+out.server = run({ store: false }, "dark");
+
+{
+ const p = page({ store: true, stored: "dark" });
+ const before = p.state();
+ p.ready();
+ out.storedOnLoad = { ...p.state(), theme: before.theme };
+}
+
+{
+ const p = page({ store: false, stored: "dark" });
+ p.ready();
+ out.serverIgnoresStored = p.state();
+}
+
+process.stdout.write(JSON.stringify(out));
diff --git a/check/rig_browser_test.go b/check/rig_browser_test.go
index 3bcf3ed..428426a 100644
--- a/check/rig_browser_test.go
+++ b/check/rig_browser_test.go
@@ -583,9 +583,9 @@ var (
// the use staticcheck's U1000 asks for. Each entry goes when its first
// drive lands; the list goes with the last of them.
var _ = []any{
- sizingRig, requirePointer, tokensJS, typeReading{}, fontsJS, fontReading{},
+ requirePointer, tokensJS, typeReading{}, fontsJS, fontReading{},
still, forceState, computed, rect, open, pressFallbackRig,
- probe, readTargets, settleUntil, classSpelling,
+ probe, readTargets, classSpelling,
}
// ── The fixture server's own test ────────────────────────────────────
@@ -598,9 +598,9 @@ var scriptTags = regexp.MustCompile(`<script[^>]*></script>`)
// check, ahead of the drives that read it: the depth page, served with
// no script tags, loads /tokens.css and /theme.css with a 200 each, the
// theme's tokens resolve on the page, and the harness reports no
-// console error and no failed request. The scripts are left off because
-// fiesta.js does not exist until Task 4, and a page linking it would
-// report the 404; the smoke test that loads every script lands with it.
+// console error and no failed request. The scripts are left off so
+// this reads the stylesheets alone; TestTheFixturePageLoadsEveryAsset
+// below is the one that loads them.
func TestTheDepthFixtureServesItsStylesheets(t *testing.T) {
pages := depthDoc(t)
pages["/"] = scriptTags.ReplaceAllString(pages["/"], "")
@@ -634,3 +634,42 @@ func TestTheDepthFixtureServesItsStylesheets(t *testing.T) {
}
rig.Screen("body", "the depth fixture")
}
+
+// TestTheFixturePageLoadsEveryAsset is the smoke test the depth check
+// above defers to: sizingDoc's page, served as it is, and every
+// stylesheet and script it links answers 200 with a body, the harness
+// sees no console error and no failed request, and fiesta.js ran (its
+// guard is on the window). The count is pinned so a page that linked
+// nothing could not pass.
+func TestTheFixturePageLoadsEveryAsset(t *testing.T) {
+ rig := harness.New(t, func(string) http.Handler { return sizingMux(t, map[string]string{"/": sizingDoc("smoke", "<p>x</p>")}) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
+ defer cancel()
+
+ mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("p", chromedp.ByQuery))
+ var assets []string
+ at(t, ctx, `JSON.stringify([...document.querySelectorAll("link[rel=stylesheet], script[src]")].map(e => e.href || e.src))`, &assets)
+ if len(assets) != 8 {
+ t.Fatalf("the page links %d assets, want 8 (two stylesheets, six scripts): %q", len(assets), assets)
+ }
+ for _, url := range assets {
+ res, err := http.Get(url)
+ if err != nil {
+ t.Fatalf("GET %s: %v", url, err)
+ }
+ body, _ := io.ReadAll(res.Body)
+ res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("GET %s answered %d, want 200", url, res.StatusCode)
+ }
+ if len(body) == 0 {
+ t.Errorf("GET %s answered an empty body", url)
+ }
+ }
+ var ran bool
+ mustRun(t, ctx, chromedp.Evaluate(`window.fiestaShim === true`, &ran))
+ if !ran {
+ t.Error("fiesta.js did not run on the page: window.fiestaShim is not set")
+ }
+ rig.Screen("body", "the smoke page with every asset")
+}
diff --git a/check/scheme_browser_test.go b/check/scheme_browser_test.go
new file mode 100644
index 0000000..daefa89
--- /dev/null
+++ b/check/scheme_browser_test.go
@@ -0,0 +1,220 @@
+//go:build browser
+
+// The scheme switch in a browser: a choice that a standalone page
+// remembers across a reload, a restore that lands before the body's
+// own script runs, and a server-owned page that reads nothing a
+// standalone page on the same origin stored.
+package check
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta/internal/harness"
+)
+
+// schemeMenu is the scheme-menu partial's markup at SEED, by hand: a
+// dropdown of three one-field forms, System current, no script. Task 5
+// adds the example's standalone state, which carries the same shape.
+// The forms name no action, so a submit fiesta.js failed to take would
+// POST to the page itself and the fixture server would answer 405,
+// which the harness reports.
+const schemeMenu = `<details rst-dropdown rst-scheme name="rst-menus">
+ <summary>Colour scheme</summary>
+ <div rst-dropdown-menu role="group" aria-label="Colour scheme">
+ <form method="post"><input type="hidden" name="scheme" value="light"><button type="submit">Light</button></form>
+ <form method="post"><input type="hidden" name="scheme" value="dark"><button type="submit">Dark</button></form>
+ <form method="post"><input type="hidden" name="scheme" value="system"><button type="submit" aria-current="true">System</button></form>
+ </div>
+</details>`
+
+// schemeDoc is a page with the stylesheet and a theme, the menu, and
+// the scripts head asks for. store puts data-rst-scheme-store="local"
+// on <html>, where fiesta.js reads it before anything else runs.
+func schemeDoc(store bool, head string) string {
+ attr := ""
+ if store {
+ attr = ` data-rst-scheme-store="local"`
+ }
+ return `<!doctype html><html lang="en" dir="ltr"` + attr + `><head><meta charset="utf-8">` +
+ `<meta name="viewport" content="width=device-width"><title>scheme</title>` +
+ `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme.css">` + head +
+ `</head><body><div rst-page>` + schemeMenu + `</div>` +
+ `<script>window.__early = document.documentElement.getAttribute("data-theme") || "";</script></body></html>`
+}
+
+// schemePages is what driveSchemeRemember and the drives beside it
+// navigate: / is a standalone page, /server the same page without the
+// attribute, and /early a standalone page with fiesta.js in <head>
+// without defer. Every page records data-theme as the body's own
+// script sees it, in window.__early.
+func schemePages() map[string]string {
+ deferred := `<script defer src="/fiesta.js"></script>`
+ return map[string]string{
+ "/": schemeDoc(true, deferred),
+ "/server": schemeDoc(false, deferred),
+ "/early": schemeDoc(true, `<script src="/fiesta.js"></script>`),
+ }
+}
+
+// schemeState is what the page says about itself after each step.
+type schemeState struct {
+ Theme string // data-theme on <html>, "" for none
+ Stored string // localStorage["fiesta-scheme"], "" for none
+ Cookie string // document.cookie
+ Current string // the scheme whose button is aria-current
+ Open bool // the menu is still open
+ Mark bool // window.__mark survived: no navigation happened
+ Early string // data-theme as the body's inline script saw it
+}
+
+const schemeStateJS = `(() => {
+ const m = document.querySelector("[rst-scheme]");
+ const cur = m.querySelector("button[aria-current]");
+ let stored = "";
+ try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {}
+ return JSON.stringify({
+ Theme: document.documentElement.getAttribute("data-theme") || "",
+ Stored: stored,
+ Cookie: document.cookie,
+ Current: cur ? cur.form.elements.scheme.value : "",
+ Open: m.open,
+ Mark: window.__mark === 1,
+ Early: window.__early
+ });
+})()`
+
+// chooseScheme opens the menu and presses one scheme's button, then
+// waits for the menu to close, which the submit handler does in place.
+// The mark it leaves on the window says afterwards whether the page
+// navigated.
+func chooseScheme(t *testing.T, ctx context.Context, scheme string) {
+ t.Helper()
+ btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme)
+ mustRun(t, ctx,
+ chromedp.Evaluate(`window.__mark = 1; true`, nil),
+ chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery),
+ chromedp.WaitVisible(btn, chromedp.ByQuery),
+ chromedp.Click(btn, chromedp.ByQuery),
+ )
+ settleUntil(t, ctx, `!document.querySelector("[rst-scheme]").open`)
+}
+
+// driveSchemeRemember is the body of TestAStandaloneSchemeSurvivesAReload,
+// against any rig that serves schemePages (Task 12 drives it with the
+// release's scripts). Dark is chosen in place: the attribute changes,
+// the key is stored, no cookie is written, the menu closes and nothing
+// navigates. A reload paints Dark again and marks its button, which
+// can only have come from storage. System undoes all of it, reload
+// included.
+func driveSchemeRemember(t *testing.T, rig *harness.Rig) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
+ defer cancel()
+
+ var got schemeState
+ mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ at(t, ctx, schemeStateJS, &got)
+ if got.Theme != "" || got.Current != "system" || got.Stored != "" {
+ t.Fatalf("first visit: %+v", got)
+ }
+
+ chooseScheme(t, ctx, "dark")
+ at(t, ctx, schemeStateJS, &got)
+ if got.Theme != "dark" || got.Current != "dark" || got.Stored != "dark" || got.Cookie != "" || got.Open || !got.Mark {
+ t.Fatalf("after choosing Dark in place: %+v", got)
+ }
+
+ mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ at(t, ctx, schemeStateJS, &got)
+ if got.Mark {
+ t.Fatal("the reload did not happen: the mark survived")
+ }
+ if got.Theme != "dark" || got.Current != "dark" {
+ t.Fatalf("after a reload the stored scheme was not restored: %+v", got)
+ }
+
+ chooseScheme(t, ctx, "system")
+ at(t, ctx, schemeStateJS, &got)
+ if got.Theme != "" || got.Current != "system" || got.Stored != "" || !got.Mark {
+ t.Fatalf("after choosing System in place: %+v", got)
+ }
+ mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ at(t, ctx, schemeStateJS, &got)
+ if got.Theme != "" || got.Current != "system" || got.Stored != "" {
+ t.Fatalf("after a reload on System: %+v", got)
+ }
+ rig.Screen("body", "the scheme menu after Dark, System and two reloads")
+}
+
+func TestAStandaloneSchemeSurvivesAReload(t *testing.T) {
+ driveSchemeRemember(t, sizingRig(t, false, schemePages()))
+}
+
+// With fiesta.js in <head> without defer, the restore runs before the
+// body: the inline script at the end of the body already sees
+// data-theme. Before anything is stored the same page records
+// nothing, which is the control.
+func TestTheStoredSchemeIsSetBeforeTheBodyRuns(t *testing.T) {
+ rig := sizingRig(t, false, schemePages())
+ ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
+ defer cancel()
+
+ var got schemeState
+ mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/early"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ at(t, ctx, schemeStateJS, &got)
+ if got.Early != "" || got.Theme != "" {
+ t.Fatalf("with nothing stored: %+v", got)
+ }
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil),
+ chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ at(t, ctx, schemeStateJS, &got)
+ if got.Early != "dark" {
+ t.Errorf("the body's script saw data-theme %q; the restore did not run before the body", got.Early)
+ }
+ if got.Theme != "dark" || got.Current != "dark" {
+ t.Errorf("after the reload: %+v", got)
+ }
+ rig.Screen("body", "the early page with Dark stored")
+}
+
+// A standalone page and a server-owned page on one origin share a
+// localStorage. The server-owned page, which carries no attribute,
+// neither reads what the other stored (it loads without data-theme and
+// stays light) nor writes over it when its own switch is used.
+func TestAServerOwnedSchemeSurvivesAStandalonePage(t *testing.T) {
+ rig := sizingRig(t, false, schemePages())
+ ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
+ defer cancel()
+
+ var got schemeState
+ mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ chooseScheme(t, ctx, "dark")
+ at(t, ctx, schemeStateJS, &got)
+ if got.Stored != "dark" {
+ t.Fatalf("the standalone page did not store Dark: %+v", got)
+ }
+
+ mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/server"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
+ at(t, ctx, schemeStateJS, &got)
+ if got.Theme != "" || got.Early != "" || got.Current != "system" {
+ t.Fatalf("the server-owned page took the stored scheme: %+v", got)
+ }
+ if got.Stored != "dark" {
+ t.Fatalf("the server-owned page touched storage on load: %+v", got)
+ }
+
+ chooseScheme(t, ctx, "light")
+ at(t, ctx, schemeStateJS, &got)
+ if got.Theme != "light" || got.Current != "light" || !got.Mark {
+ t.Fatalf("after choosing Light on the server-owned page: %+v", got)
+ }
+ if got.Stored != "dark" {
+ t.Errorf("the server-owned page wrote storage: fiesta-scheme is %q, want the standalone page's dark", got.Stored)
+ }
+ rig.Screen("body", "the server-owned page after Light")
+}
diff --git a/check/scripts_test.go b/check/scripts_test.go
new file mode 100644
index 0000000..fc3c8cb
--- /dev/null
+++ b/check/scripts_test.go
@@ -0,0 +1,318 @@
+package check
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/nodetest"
+)
+
+// fiestaJS is js/fiesta.js as the tree ships it.
+func fiestaJS(t *testing.T) []byte {
+ t.Helper()
+ js, ok := fiesta.Script("fiesta.js")
+ if !ok {
+ t.Fatal("fiesta.js is not shipped")
+ }
+ return js
+}
+
+// The shim's behaviour is driven in a browser (scheme_browser_test.go)
+// and in Node (fiesta_node.mjs, below). What a Go test can hold honest
+// is the contract the docs promise: the vocabulary the file answers to,
+// its inert-by-default IIFE shape, and the absence of anything a CSP
+// would reject. Ported from rastrillo's ui/shim_test.go at SEED: the
+// polling strings are gone with the polling, and the cookie strings
+// are replaced by Fiesta's own contract, the event, the storage key,
+// the ownership attribute and the store.
+func TestShimContract(t *testing.T) {
+ js := string(fiestaJS(t))
+ for _, want := range []string{
+ // Light dismiss: the menu classes it answers to, the nested
+ // rst-menu-group among them, so a submenu is never left open
+ // behind its closing parent, the containment test that keeps the
+ // menu being used open, the Escape key, and the focus hand-back to
+ // the summary. Delegated on the document, so the count of
+ // addEventListener calls stays at two however many menus a page
+ // renders.
+ "rst-dropdown", "rst-menu-group", "rst-row-menu",
+ "closeMenus", "contains", "Escape", "summary.focus()",
+ // Both spellings. tokens.css pairs them for the window between
+ // upgrading the module and running `rastrillo markup`, and the
+ // shim has to pair them for the same window or an app's menus
+ // stop dismissing halfway through the upgrade we hand it.
+ `MENUS.replace(`,
+ // The topbar's and console's narrow Menu is a card over the page,
+ // so it is in the light-dismiss list, in both spellings written
+ // out (the rewrite above would give .rst-shell-menu, and the
+ // class is .rst-shell__menu), with its tail counted as inside it
+ // only while its summary is rendered. Scoped to a topbar or a
+ // console that names its view: an old console's Menu also
+ // reveals its rail, which must not close on a tap at a group
+ // label.
+ `[rst-shell-topbar] [rst-shell-menu][open],[rst-shell-console~=page] [rst-shell-menu][open],[rst-shell-console~=index] [rst-shell-menu][open]`,
+ `.rst-shell-topbar .rst-shell__menu[open],.rst-shell-console--page .rst-shell__menu[open],.rst-shell-console--index .rst-shell__menu[open]`,
+ `TAIL = "[rst-shell-tail],.rst-shell__tail"`, "menuAround", "getClientRects().length",
+ // The colour-scheme switch: the menu it answers to in both
+ // spellings, the attribute the themes read, the event a choice
+ // dispatches, the key it is remembered under, the attribute that
+ // says the page remembers, and the store it remembers in.
+ `"[rst-scheme],.rst-scheme"`, "data-theme",
+ `"rst-scheme"`, `"fiesta-scheme"`, "data-rst-scheme-store", "localStorage",
+ // The iOS touch listener (A3): :active lands on a tap only when
+ // a touchstart listener exists, and this file owns it so an app
+ // that deletes light.js keeps its pressed states.
+ `addEventListener("touchstart", function () {}, { passive: true })`,
+ } {
+ if !strings.Contains(js, want) {
+ t.Errorf("shim does not mention %q", want)
+ }
+ }
+ if !strings.HasPrefix(strings.TrimSpace(strings.SplitN(js, "\n(function", 2)[0]), "/*") {
+ t.Error("shim should open with its contract comment")
+ }
+ if !strings.Contains(js, "(function () {") || !strings.Contains(js, "})();") {
+ t.Error("shim should be a single IIFE")
+ }
+ if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") {
+ t.Error("shim must stay CSP-clean")
+ }
+ // Light dismiss is delegated, not bound per element: a menu that
+ // arrives after load has to be covered the moment it lands, and
+ // re-scanning must never double-bind. A querySelectorAll over the
+ // menus at load would be exactly that bug, so pin the listeners to
+ // the document.
+ if !strings.Contains(js, `document.addEventListener("click", dismissMenus, true)`) ||
+ !strings.Contains(js, `document.addEventListener("keydown", dismissMenus, true)`) {
+ t.Error("light dismiss is not two delegated document listeners")
+ }
+ // Three mentions and no more: the definition and the two listeners.
+ // A fourth would mean someone started binding it per element again.
+ if n := strings.Count(js, "dismissMenus"); n != 3 {
+ t.Errorf("dismissMenus appears %d times, want 3 (one definition, two document listeners)", n)
+ }
+ // The old sidebar drawer and the toggle-block stay out of it: neither
+ // is a menu, and dismissing them on an outside click would fight the
+ // user. (The topbar's Menu is in, above: it is a card that overlays
+ // the page now, which is what light dismiss is for.) The check reads
+ // the light-dismiss code, closeMenus through the two listeners: the
+ // switch's drag names the toggle block's head, which drags like a
+ // switch, and that is not light dismiss.
+ reach, err := lightDismissReach(js)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range reach {
+ t.Errorf("light dismiss reaches for %q; it covers menus only", bad)
+ }
+}
+
+// lightDismissReach returns which of the names light dismiss must never
+// touch appear in its code, which runs from the menu list, MENUS, with
+// its extensions, through menuAround and closeMenus to the two document
+// listeners; an error when that code is not where it expects.
+func lightDismissReach(js string) ([]string, error) {
+ from, to := strings.Index(js, "var MENUS"), strings.Index(js, `document.addEventListener("keydown", dismissMenus, true)`)
+ if from < 0 || to < from {
+ return nil, fmt.Errorf("the light-dismiss code is not where this check expects it")
+ }
+ var out []string
+ for _, bad := range []string{"rst-shell-chrome", "rst-tblock"} {
+ if strings.Contains(js[from:to], bad) {
+ out = append(out, bad)
+ }
+ }
+ return out, nil
+}
+
+// TestTheLightDismissGateReadsTheMenuList (switch review 5): the gate
+// has to read the menu list, not only the functions after it. A drawer
+// or a toggle block spliced into MENUS makes closeMenus(null) close it,
+// and the functions' own text would never say so. The shipped shim is
+// the control.
+func TestTheLightDismissGateReadsTheMenuList(t *testing.T) {
+ js := string(fiestaJS(t))
+ if reach, err := lightDismissReach(js); err != nil || len(reach) != 0 {
+ t.Fatalf("the shipped shim: reach %v, err %v; want none", reach, err)
+ }
+ for _, m := range []struct{ name, bad string }{{"the drawer spliced into MENUS", "rst-shell-chrome"}, {"the toggle block spliced into MENUS", "rst-tblock"}} {
+ mutant := strings.Replace(js, `var MENUS = "`, `var MENUS = "[`+m.bad+`][open],`, 1)
+ if mutant == js {
+ t.Fatal("MENUS is not where this test expects it")
+ }
+ reach, err := lightDismissReach(mutant)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(reach) != 1 || reach[0] != m.bad {
+ t.Errorf("%s: the gate reports %v; want %q, which closeMenus(null) would now close", m.name, reach, m.bad)
+ }
+ }
+}
+
+// fiesta.js is the plain-browser half of rastrillo.js: nothing in it
+// talks to a server. Polling, its push channel, the request headers
+// that marked a poll, the scheme cookie and any fetch stay in
+// rastrillo.
+func TestFiestaJSHasNoServerBehaviour(t *testing.T) {
+ js := string(fiestaJS(t))
+ for _, bad := range []string{"data-poll", "EventSource", "Rastrillo-", "document.cookie", "fetch("} {
+ if strings.Contains(js, bad) {
+ t.Errorf("fiesta.js mentions %q; the server half stays in rastrillo", bad)
+ }
+ }
+}
+
+// 12 KiB: what is left of rastrillo.js once polling, its header and
+// the cookie are gone, with the scheme memory added (measured 12,144
+// bytes: 6.4 KB of code, the rest comment). The point of the cap is the
+// same as rastrillo's: whoever links this file can read the whole of
+// it in one sitting. Past it, trim a comment before raising the cap.
+func TestFiestaJSIsSmall(t *testing.T) {
+ js := fiestaJS(t)
+ if n := len(js); n > 12*1024 {
+ t.Fatalf("fiesta.js is %d bytes; the point is that a reader can take it in one sitting, trim it", n)
+ }
+ if bytes.Contains(js, []byte("\t")) {
+ t.Error("fiesta.js uses two-space indentation, not tabs")
+ }
+}
+
+// schemeRun is what one scenario in fiesta_node.mjs reports after
+// fiesta.js ran against its stub page and, where the scenario submits,
+// after the submit.
+type schemeRun struct {
+ Theme string // data-theme on <html>, "" for none
+ Prevented bool // the submit's default was prevented
+ Stored string // localStorage["fiesta-scheme"], "" for none
+ Keys int // keys in localStorage
+ StorageCalls int // calls on localStorage's methods
+ Cookies int // writes to document.cookie
+ Events []string // detail.scheme of each rst-scheme event dispatched
+ Current string // the scheme whose button is aria-current
+ Open bool // the menu is still open
+ Error string // what the submit threw, "" for nothing
+}
+
+// fiestaReport is fiesta_node.mjs's whole report, a scenario per field.
+type fiestaReport struct {
+ Idempotent struct{ Once, Twice int }
+ Standalone, System, Broken, Server, StoredOnLoad, ServerIgnoresStored schemeRun
+}
+
+// fiestaNode runs fiesta_node.mjs and decodes its report.
+func fiestaNode(t *testing.T) fiestaReport {
+ t.Helper()
+ var out fiestaReport
+ if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/fiesta_node.mjs"}}), &out); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// A second copy of fiesta.js on a page does nothing: the guard at the
+// top returns before a listener is added. Two evaluations in one
+// context register as many listeners as one, and one registers some.
+func TestFiestaJSIsIdempotent(t *testing.T) {
+ got := fiestaNode(t).Idempotent
+ if got.Once == 0 {
+ t.Fatal("one evaluation registered no listeners")
+ }
+ if got.Twice != got.Once {
+ t.Errorf("two evaluations registered %d listeners, one registered %d; the second copy is not inert", got.Twice, got.Once)
+ }
+}
+
+// On a page that says it remembers, choosing Dark applies it, stores
+// it, writes no cookie, and tells the page once.
+func TestAStandaloneSubmitStoresTheScheme(t *testing.T) {
+ got := fiestaNode(t).Standalone
+ if got.Error != "" {
+ t.Fatalf("the submit threw: %s", got.Error)
+ }
+ if got.Theme != "dark" || !got.Prevented || got.Stored != "dark" || got.Cookies != 0 {
+ t.Errorf("after Dark: theme %q, prevented %v, stored %q, cookie writes %d; want dark, true, dark, 0", got.Theme, got.Prevented, got.Stored, got.Cookies)
+ }
+ if len(got.Events) != 1 || got.Events[0] != "dark" {
+ t.Errorf("rst-scheme events %q, want one with detail.scheme dark", got.Events)
+ }
+ if got.Current != "dark" || got.Open {
+ t.Errorf("after Dark: aria-current on %q and the menu open is %v; want dark and closed", got.Current, got.Open)
+ }
+}
+
+// System is no attribute and no key: a stored Dark is removed, not
+// overwritten with "system".
+func TestSystemClearsTheStoredScheme(t *testing.T) {
+ got := fiestaNode(t).System
+ if got.Error != "" {
+ t.Fatalf("the submit threw: %s", got.Error)
+ }
+ if got.Theme != "" || got.Keys != 0 || got.Stored != "" {
+ t.Errorf("after System: theme %q, %d keys in storage, stored %q; want none, 0, none", got.Theme, got.Keys, got.Stored)
+ }
+ if len(got.Events) != 1 || got.Events[0] != "system" {
+ t.Errorf("rst-scheme events %q, want one with detail.scheme system", got.Events)
+ }
+ if got.Current != "system" {
+ t.Errorf("aria-current is on %q, want system", got.Current)
+ }
+}
+
+// A browser with storage switched off throws on the first touch. The
+// switch still switches, and the page still hears about it.
+func TestABrokenStorageStillSwitches(t *testing.T) {
+ got := fiestaNode(t).Broken
+ if got.Error != "" {
+ t.Fatalf("a storage that throws escaped the submit: %s", got.Error)
+ }
+ if got.Theme != "dark" || !got.Prevented || len(got.Events) != 1 {
+ t.Errorf("after Dark on broken storage: theme %q, prevented %v, events %q; want dark, true, one", got.Theme, got.Prevented, got.Events)
+ }
+}
+
+// Without the attribute the page is the server's, or has no memory:
+// the same attribute and event as standalone, and storage untouched.
+func TestAServerOwnedSubmitStoresNothing(t *testing.T) {
+ got := fiestaNode(t).Server
+ if got.Error != "" {
+ t.Fatalf("the submit threw: %s", got.Error)
+ }
+ if got.Theme != "dark" || !got.Prevented || len(got.Events) != 1 || got.Events[0] != "dark" {
+ t.Errorf("after Dark: theme %q, prevented %v, events %q; want dark, true, one dark", got.Theme, got.Prevented, got.Events)
+ }
+ if got.StorageCalls != 0 || got.Keys != 0 || got.Cookies != 0 {
+ t.Errorf("a server-owned submit made %d storage calls, left %d keys and wrote %d cookies; want none", got.StorageCalls, got.Keys, got.Cookies)
+ }
+}
+
+// With the attribute, a stored Dark and no data-theme, evaluating the
+// script sets data-theme synchronously, so a copy in <head> without
+// defer restores before first paint; once the DOM is ready the Dark
+// button is current.
+func TestTheStoredSchemeIsAppliedOnLoad(t *testing.T) {
+ got := fiestaNode(t).StoredOnLoad
+ if got.Theme != "dark" {
+ t.Errorf("after evaluating the script, data-theme is %q, want dark", got.Theme)
+ }
+ if got.Current != "dark" {
+ t.Errorf("once the DOM is ready, aria-current is on %q, want dark", got.Current)
+ }
+}
+
+// Without the attribute, a stored Dark means nothing: the root stays
+// without data-theme, since a missing data-theme is the server's
+// System.
+func TestAServerOwnedPageIgnoresTheStoredScheme(t *testing.T) {
+ got := fiestaNode(t).ServerIgnoresStored
+ if got.Theme != "" {
+ t.Errorf("a server-owned page took data-theme %q from storage; want none", got.Theme)
+ }
+ if got.StorageCalls != 0 {
+ t.Errorf("a server-owned page read storage %d times on load; want never", got.StorageCalls)
+ }
+}
diff --git a/fiesta.go b/fiesta.go
index cc086aa..fa4ef22 100644
--- a/fiesta.go
+++ b/fiesta.go
@@ -74,11 +74,13 @@ func ThemeCSS(name string) ([]byte, bool) {
}
// scriptNames lists the optional scripts in the order a page that
-// wants them all would load them: busy and light first, since they
-// touch every button, the shells' navigation next, then the controls,
-// and calendar.js last because it is the grid datetime.js asks it for.
-// The slice matches the files in js/ exactly.
-var scriptNames = []string{"busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"}
+// wants them all would load them: fiesta.js first, since it restores a
+// remembered colour scheme and owns the menus every page has, busy and
+// light next, since they touch every button, the shells' navigation
+// after that, then the controls, and calendar.js last because it is
+// the grid datetime.js asks it for. The slice matches the files in js/
+// exactly.
+var scriptNames = []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"}
// ScriptNames returns the optional scripts' file names, in load order.
// The returned slice is a copy.
diff --git a/fiesta_test.go b/fiesta_test.go
index a1e4186..83ef683 100644
--- a/fiesta_test.go
+++ b/fiesta_test.go
@@ -103,7 +103,7 @@ func TestEveryThemeIsEmbedded(t *testing.T) {
}
func TestEveryScriptIsEmbedded(t *testing.T) {
- want := []string{"busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"}
+ want := []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"}
if got := ScriptNames(); !slices.Equal(got, want) {
t.Fatalf("ScriptNames() = %q, want %q", got, want)
}
diff --git a/js/fiesta.js b/js/fiesta.js
new file mode 100644
index 0000000..915f832
--- /dev/null
+++ b/js/fiesta.js
@@ -0,0 +1,254 @@
+/* fiesta.js: the browser half of Fiesta. First-party, dependency-free,
+ and on by default, since nothing in it has a per-instance decision
+ to make. Everything it enhances also works with scripts disabled.
+
+ Menus. An open <details rst-dropdown>, <details rst-row-menu>, a
+ nested <details rst-menu-group>, or the topbar's and console's narrow
+ <details rst-shell-menu> card (whose content is its next sibling, the
+ tail) closes on an outside click and on Escape, in either spelling.
+ Exclusivity stays native.
+
+ Switch. A switch's knob can be dragged along its track, and snaps to
+ the nearer end.
+
+ Touch. A touchstart listener, so iOS applies :active on a tap.
+
+ Scheme. The colour-scheme menu's forms ([rst-scheme]) apply in place:
+ data-theme on <html> is what the themes read. Who remembers the
+ choice is the page's to say, on <html>, before any script runs.
+
+ data-rst-scheme-store="local"
+ The page remembers: localStorage["fiesta-scheme"] holds "light"
+ or "dark", and System removes the key. On load a stored choice
+ is applied when the root has no data-theme; in <head> without
+ defer that is before first paint.
+ no attribute
+ The server remembers, or nobody does: storage is neither read
+ nor written, and no data-theme still means System.
+
+ Either way a submit sets or removes data-theme, lands running
+ transitions, moves aria-current, closes the menu and dispatches
+ rst-scheme from the form (bubbles; detail.scheme is "light", "dark"
+ or "system"), so a server's own script can write its cookie from
+ it. A storage that throws never stops the switch.
+
+ A second copy of this file does nothing. Running beside rastrillo's
+ rastrillo.js is not supported: both answer the same elements. */
+(function () {
+ "use strict";
+ if (window.fiestaShim) return;
+ window.fiestaShim = true;
+
+ var root = document.documentElement, KEY = "fiesta-scheme", SCHEME = "[rst-scheme],.rst-scheme";
+ var remembers = root.getAttribute("data-rst-scheme-store") === "local";
+
+ // Storage is touched only on a page that said so, and never trusted:
+ // with storage off a browser throws on the first touch.
+ function stored() {
+ if (!remembers) return null;
+ try { return localStorage.getItem(KEY); } catch (e) { return null; }
+ }
+ function remember(s) {
+ if (!remembers) return;
+ try { if (s === "system") localStorage.removeItem(KEY); else localStorage.setItem(KEY, s); } catch (e) {}
+ }
+ function apply(s) {
+ if (s === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", s);
+ }
+ // Not toggleAttribute: aria-current="" reads as false.
+ function mark(m, s) {
+ m.querySelectorAll("button").forEach(function (b) {
+ var f = b.form, v = f && f.elements.scheme && f.elements.scheme.value;
+ if (v === s) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current");
+ });
+ }
+ function ready(fn) {
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); else fn();
+ }
+
+ // The restore, before anything else. A root that already carries
+ // data-theme is the server's choice and is left alone.
+ var kept = root.hasAttribute("data-theme") ? null : stored();
+ if (kept === "light" || kept === "dark") {
+ apply(kept);
+ ready(function () { document.querySelectorAll(SCHEME).forEach(function (m) { mark(m, kept); }); });
+ }
+
+ // Light dismiss, the one thing the native disclosure cannot do. Two
+ // delegated listeners on the document, never one per element, so a
+ // menu that arrives later is covered; capture phase, so a handler
+ // that stops propagation cannot leave a menu stuck open. The nested
+ // rst-menu-group is in MENUS though it is outside its parent's
+ // <details name> group: dismissal is not exclusivity, and a submenu
+ // left open behind its closing parent is a state nobody can see. The
+ // sidebar drawer and the toggle block are absent: neither is a menu.
+ var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]";
+ MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1");
+ // Written out: the class spelling of rst-shell-menu is
+ // .rst-shell__menu, which the rewrite above would spell wrong. Only
+ // in a topbar or a console that names its view: an older console
+ // gates its rail on this [open], and dismissing it closed the rail.
+ MENUS += ",[rst-shell-topbar] [rst-shell-menu][open],[rst-shell-console~=page] [rst-shell-menu][open],[rst-shell-console~=index] [rst-shell-menu][open]";
+ MENUS += ",.rst-shell-topbar .rst-shell__menu[open],.rst-shell-console--page .rst-shell__menu[open],.rst-shell-console--index .rst-shell__menu[open]";
+ var TAIL = "[rst-shell-tail],.rst-shell__tail";
+
+ // menuAround is closest(MENUS) plus one logical parent: the card's
+ // content (the tail) is the shell menu's next SIBLING, so a click in
+ // it must not close the card. Only while the Menu summary is
+ // rendered: widened, a <details> keeps [open] under a hidden summary.
+ function menuAround(node) {
+ var d = node.closest(MENUS), t, s;
+ if (d) return d;
+ t = node.closest(TAIL);
+ d = t && t.previousElementSibling;
+ s = d && d.matches(MENUS) && d.querySelector("summary");
+ return s && s.getClientRects().length ? d : null;
+ }
+
+ // except is the clicked node: every menu around it stays open, so a
+ // click on a menu item, or on a summary being opened, closes nothing.
+ function closeMenus(except) {
+ var keep = [], m;
+ for (m = except && except.closest && menuAround(except); m; m = m.parentElement && menuAround(m.parentElement)) keep.push(m);
+ document.querySelectorAll(MENUS).forEach(function (d) {
+ if (keep.indexOf(d) < 0 && !(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 sit in a subtree no longer rendered, which
+ // strands a keyboard user. Hand it to the OUTERMOST open menu's
+ // summary: an inner one is inside the thing about to close.
+ var el = document.activeElement;
+ var host = el && el.closest ? menuAround(el) : null;
+ while (host && host.parentElement && menuAround(host.parentElement)) {
+ host = menuAround(host.parentElement);
+ }
+ closeMenus(null);
+ if (host) {
+ var summary = host.querySelector("summary");
+ if (summary) summary.focus();
+ }
+ }
+
+ document.addEventListener("click", dismissMenus, true);
+ document.addEventListener("keydown", dismissMenus, true);
+
+ // A switch slides. A press on its track that moves 4px drags the knob
+ // (the CSS reads its share of the travel, --rst-switch-p, and the on
+ // colour's share, --rst-switch-on, off the track); on release it goes
+ // to the nearer end with the firm release curve, keyed off
+ // data-rst-settle on the label for the 0.18s it takes, and if that
+ // end is the other state the checkbox is clicked, so input and change
+ // fire as for a tap. After a drag the label's own click is cancelled
+ // (never the checkbox's), so nothing toggles twice.
+ var TRACK = "[rst-switch-track],.rst-switch__track", drag = null, after = null, settling = null, settleT = 0, releasing = false;
+ function unsettle() {
+ if (settling) settling.removeAttribute("data-rst-settle");
+ settling = null;
+ clearTimeout(settleT);
+ }
+ function settle(l) {
+ unsettle();
+ settling = l;
+ l.setAttribute("data-rst-settle", "");
+ settleT = setTimeout(unsettle, 300);
+ }
+ // A toggle while a switch settles takes a toggle's own curve: the
+ // mark goes on change, bar the release's own click.
+ document.addEventListener("change", function (e) { if (settling && !releasing && settling.contains(e.target)) unsettle(); }, true);
+ // One drag at a time: a second finger waits for the first to lift,
+ // and a drag ends however its pointer goes, committed only on
+ // pointerup. A press by the same pointer means its release was missed.
+ document.addEventListener("pointerdown", function (e) {
+ if (drag && e.pointerId !== drag.id) return;
+ // A toggle block's head is a switch too; its own input is first.
+ var t = e.target.closest && e.target.closest(TRACK), l = t && t.closest("[rst-switch],.rst-switch,[rst-tblock-head],.rst-tblock__head"), box = l && l.querySelector("input");
+ if (drag) drop({ type: "lost", pointerId: drag.id });
+ after = null;
+ // :disabled, not .disabled: a disabled fieldset disables the box
+ // without setting its property, and a click on it does nothing.
+ if (!box || box.matches(":disabled") || e.button) return;
+ var r = t.getBoundingClientRect(), max = r.width - r.height;
+ drag = { l: l, t: t, box: box, id: e.pointerId, x: e.clientX, max: max, at: box.checked ? max : 0, pos: -1,
+ dir: getComputedStyle(t).direction === "rtl" ? -1 : 1 };
+ });
+ document.addEventListener("pointermove", function (e) {
+ var g = drag, d;
+ if (!g || e.pointerId !== g.id) return;
+ d = (e.clientX - g.x) * g.dir;
+ if (g.pos < 0) {
+ if (Math.abs(d) < 4) return;
+ // A drag on a settling switch takes over: no mark.
+ if (settling === g.l) unsettle();
+ g.l.setAttribute("data-rst-drag", "");
+ g.t.setPointerCapture(e.pointerId);
+ }
+ g.pos = Math.min(g.max, Math.max(0, g.at + d));
+ d = g.pos / g.max;
+ g.t.style.setProperty("--rst-switch-p", d);
+ g.t.style.setProperty("--rst-switch-on", d * 100 + "%");
+ });
+ function drop(e) {
+ var g = drag;
+ if (!g || e.pointerId !== g.id) return;
+ drag = null;
+ if (g.pos < 0) return;
+ g.l.removeAttribute("data-rst-drag");
+ settle(g.l);
+ g.t.style.removeProperty("--rst-switch-p");
+ g.t.style.removeProperty("--rst-switch-on");
+ after = g.l;
+ releasing = true;
+ if (e.type === "pointerup" && !g.box.matches(":disabled") && g.pos >= g.max / 2 !== g.box.checked) g.box.click();
+ releasing = false;
+ }
+ document.addEventListener("pointerup", drop);
+ document.addEventListener("pointercancel", drop);
+ document.addEventListener("lostpointercapture", drop);
+ document.addEventListener("click", function (e) {
+ if (after && after.contains(e.target) && e.target.tagName !== "INPUT") {
+ e.preventDefault();
+ after = null;
+ }
+ }, true);
+
+ // iOS applies :active on a tap only when a touch listener exists. This
+ // file owns the listener, not light.js, so an app that deletes
+ // light.js keeps its pressed states.
+ document.addEventListener("touchstart", function () {}, { passive: true });
+
+ // The scheme switch. Anything but light or dark is System, as on the
+ // server, so the key only ever holds a scheme a theme can read.
+ document.addEventListener("submit", function (e) {
+ var f = e.target, m = f.closest(SCHEME), s = m && f.elements.scheme && f.elements.scheme.value;
+ if (!s) return;
+ e.preventDefault();
+ if (s !== "light" && s !== "dark") s = "system";
+ apply(s);
+ land();
+ remember(s);
+ mark(m, s);
+ m.open = false;
+ f.dispatchEvent(new CustomEvent("rst-scheme", { bubbles: true, detail: { scheme: s } }));
+ }, true);
+
+ // A scheme change lands in the frame it happens in: an app's own
+ // colour transition, or a switch track mid-toggle, would otherwise
+ // be retargeted while the text changes at once. getAnimations()
+ // flushes style, which starts the transitions, and cancelling one
+ // lands it on its end value. Transitions only, told by
+ // transitionProperty: cancelling an animation would stop a spinner.
+ function land() {
+ if (!document.getAnimations) return;
+ document.getAnimations().forEach(function (a) { if (typeof a.transitionProperty === "string") a.cancel(); });
+ }
+
+ // The OS flipping under a page on System is the same change with no
+ // click; the event arrives a frame after the style update started
+ // the fade, so this lands it a frame late rather than 200ms late.
+ var osScheme = window.matchMedia && matchMedia("(prefers-color-scheme: dark)");
+ if (osScheme) osScheme.onchange = function () { if (!root.hasAttribute("data-theme")) land(); };
+})();