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>
7 files changed,
+1002
−11
check/node/fiesta_node.mjs+158 −0check/rig_browser_test.go+44 −5check/scheme_browser_test.go+220 −0check/scripts_test.go+318 −0fiesta.go+7 −5fiesta_test.go+1 −1js/fiesta.js+254 −0
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(); }; |
| +})(); |