rastrillo / fiesta Public

Mark the scheme that was applied, even when storage refuses

gallery.js marks every copy of the scheme menu one frame after a choice, so the bar's menu and the display settings menu agree. On a page that remembers it read the mark from localStorage. Where storage refuses every read and write (site data blocked), choosing Dark applied Dark to the root, fiesta.js's write failed silently, and the scheduled mark read nothing stored and marked System in both copies, over fiesta.js's correct mark. The marks now come from the scheme on the root, System when it carries none: after a stored choice is restored at parse or on a return from the back/forward cache the root is the stored scheme, and after a choice it is the one chosen, stored or not.

TestASchemeChosenWithStorageRefusedIsTheOneMarked makes getItem, setItem and removeItem throw before any script runs, requires that localStorage really refuses, chooses Dark in the bar and reads both copies three animation frames later: Dark on the root and Dark marked in each. Red first at 8da33b8 by assertion, 'scheme menu 0 marks "system" current after Dark was chosen, want "dark"' and the same for menu 1; green with the fix, beside TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint, TestTheSchemeMenuRepaintsTheFrames and the history package's cache drives.

Gated with make ci in a detached /var/tmp worktree at 992b61e with the chooseScheme fix and these two files copied in. One role went red on TestTheSchemeFollowsAPageBackFromTheCache, Forward not restored from the cache because the example's lazy frames were still navigating when the drive left it; the same drive fails one run in four or five with the old gallery.js too, so it is not this change. The history role rerun and the two www roles after it are green.
Human

Prompt

Astra on Task 3: gallery.js:74: when localStorage reads or writes fail, choosing Dark applies Dark but the scheduled mark() marks System in both menus. Derive the menu marks from the root scheme actually applied, and add a browser test with storage throwing (choose Dark: Dark applied and Dark marked in both copies), red first by assertion.

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

gallery.js marks the scheme menus from data-theme on the root rather than from storage, so a choice made where storage refuses is the one marked; TestASchemeChosenWithStorageRefusedIsTheOneMarked holds it.
Agent notes
TestTheSchemeFollowsAPageBackFromTheCache (history/cache_browser_test.go) is flaky on this box: it leaves the example page as soon as readyState is complete, and when its lazy frames are still loading Chromium refuses the back/forward cache (notRestoredReasons: navigating on the child frames). Settling the frames before leaving would fix it; not done here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0e066782563d32c59ded139cd5dedd06ffc70eeb parent 8da33b8
2 files changed, +62 −4
  • internal/gallery/bar_browser_test.go +56 −0
  • internal/gallery/gallery.js +6 −4

CI failed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.125s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.021s
=== suite ===
--- shard 0 of 15 ---
==> shard 0 of 15: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	1.564s
ok  	amadan.net/rastrillo/fiesta/check	6.501s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	102.817s
ok  	amadan.net/rastrillo/fiesta/example	0.269s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.232s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.662s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.042s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	89.719s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.008s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.469s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.344s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.013s
ok  	amadan.net/rastrillo/fiesta/internal/www	85.871s
--- shard 1 of 15 ---
==> shard 1 of 15: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 15 ---
==> shard 2 of 15: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 15 ---
==> shard 3 of 15: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
2:47AM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
2:47AM INF 1 commits scanned.
2:47AM INF scanned ~6229752 bytes (6.23 MB) in 4.3s
2:47AM INF no leaks found
--- shard 4 of 15 ---
==> shard 4 of 15: 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	14.147s
--- shard 5 of 15 ---
==> shard 5 of 15: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	245.181s
--- shard 6 of 15 ---
==> shard 6 of 15: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	263.190s
--- shard 7 of 15 ---
==> shard 7 of 15: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	267.366s
--- shard 8 of 15 ---
==> shard 8 of 15: make browser.internal.gallery~1of4
./hack/gotest.sh browser 'internal.gallery~1of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 4 of ./internal/gallery/ runs 1 of 105 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	139.815s
--- shard 9 of 15 ---
==> shard 9 of 15: make browser.internal.gallery~2of4
./hack/gotest.sh browser 'internal.gallery~2of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 4 of ./internal/gallery/ runs 32 of 105 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	113.356s
--- shard 10 of 15 ---
==> shard 10 of 15: make browser.internal.gallery~3of4
./hack/gotest.sh browser 'internal.gallery~3of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 4 of ./internal/gallery/ runs 36 of 105 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	121.279s
--- shard 11 of 15 ---
==> shard 11 of 15: make browser.internal.gallery~4of4
./hack/gotest.sh browser 'internal.gallery~4of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 4 of 4 of ./internal/gallery/ runs 36 of 105 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	103.344s
--- shard 12 of 15 ---
==> shard 12 of 15: make browser.internal.gallery.history
./hack/gotest.sh browser 'internal.gallery.history'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
--- FAIL: TestTheSchemeFollowsAPageBackFromTheCache (15.00s)
    cache_browser_test.go:109: day/en at 390px, system: Forward was not a back/forward cache restore (persisted false, navigation 1793242073030 then 1793242073980; the engine says {"src":null,"id":null,"url":"http://localhost:34695/gallery/day/en/field.html","name":null,"reasons":[],"children":[{"src":"/gallery/day/en/frames/field/bare.html","id":null,"url":"","name":null,"reasons":[{"reason":"masked"}],"children":[]},{"src":"/gallery/day/en/frames/field/with-a-hint.html","id":null,"url":"","name":null,"reasons":[{"reason":"masked"}],"children":[]},{"src":"/gallery/day/en/frames/field/with-help-under-the-control.html","id":null,"url":"","name":null,"reasons":[{"reason":"masked"}],"children":[]},{"src":"/gallery/day/en/frames/field/required.html","id":null,"url":"http://localhost:34695/gallery/day/en/frames/field/required.html","name":null,"reasons":[{"reason":"navigating"}],"children":[]},{"src":"/gallery/day/en/frames/field/with-an-error.html","id":null,"url":"http://localhost:34695/gallery/day/en/frames/field/with-an-error.html","name":null,"reasons":[{"reason":"navigating"}],"children":[]},{"src":"/gallery/day/en/frames/field/short.html","id":null,"url":"","name":null,"reasons":[{"reason":"masked"}],"children":[]},{"src":"/gallery/day/en/frames/field/the-headline-field.html","id":null,"url":"","name":null,"reasons":[{"reason":"masked"}],"children":[]}]}). The tree handler must not send Cache-Control: no-store
2026/10/29 02:48:03 ERROR: received DOM.documentUpdated when there's no top-level frame
FAIL
FAIL	amadan.net/rastrillo/fiesta/internal/gallery/history	25.253s
FAIL
make[1]: *** [Makefile:190: browser.internal.gallery.history] Error 1
make: *** [Makefile:64: suite] Error 2

amadan: step suite shard 12 of 15 failed: exit status 2
--- shard 13 of 15 ---
==> shard 13 of 15: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 17 of 34 tests
--- FAIL: TestTheLandingReadsWithNoScript (12.25s)
    landing_browser_test.go:453: the submit asked the server for /favicon.ico
FAIL
FAIL	amadan.net/rastrillo/fiesta/internal/www	148.331s
FAIL
make[1]: *** [Makefile:190: browser.internal.www~1of2] Error 1
make: *** [Makefile:64: suite] Error 2

amadan: step suite shard 13 of 15 failed: exit status 2
--- shard 14 of 15 ---
==> shard 14 of 15: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 17 of 34 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	134.505s

amadan: job failed at step suite shards 12, 13 of 15 — its output is above, not at the tail of this log
diff --git a/internal/gallery/bar_browser_test.go b/internal/gallery/bar_browser_test.go
index bf0fc8e..9316baf 100644
--- a/internal/gallery/bar_browser_test.go
+++ b/internal/gallery/bar_browser_test.go
@@ -701,3 +701,59 @@ func TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint(t *testing.T) {
}
}
}
+
+// refusedStorage makes every read and write of localStorage throw, as
+// a browser with site data blocked does.
+const refusedStorage = `(() => {
+ const refuse = () => { throw new DOMException("site data is blocked", "SecurityError"); };
+ for (const name of ["getItem", "setItem", "removeItem"]) Storage.prototype[name] = refuse;
+})();`
+
+// TestASchemeChosenWithStorageRefusedIsTheOneMarked: with localStorage
+// refusing every read and write, choosing Dark in the bar's menu still
+// applies Dark to the page, and both copies of the scheme menu, the
+// bar's and the display settings menu's, mark Dark current. Nothing
+// can be remembered, so the marks follow the scheme on the root, not
+// the stored one.
+func TestASchemeChosenWithStorageRefusedIsTheOneMarked(t *testing.T) {
+ where := "storage refused"
+ rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
+ defer cancel()
+ mustRun(t, ctx,
+ addInit(refusedStorage),
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+pageAt(fiesta.ThemeNames()[0], "en", "tokens.html")),
+ chromedp.WaitReady("body"),
+ )
+ present(t, ctx, where, ".ds-prefs [rst-scheme]")
+ until(t, ctx, where, `document.readyState === "complete"`)
+ var refused bool
+ mustRun(t, ctx, chromedp.Evaluate(`(() => { try { localStorage.getItem("fiesta-scheme"); return false; } catch (e) { return true; } })()`, &refused))
+ if !refused {
+ t.Fatalf("%s: localStorage still answers; the drive would be testing working storage", where)
+ }
+
+ chooseScheme(t, ctx, "dark")
+ // gallery.js marks both copies one frame after the choice; two
+ // frames later the marks are settled whichever way they went.
+ var theme string
+ var marked []string
+ mustRun(t, ctx,
+ chromedp.Evaluate(`new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))))`, nil,
+ func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),
+ chromedp.Evaluate(`document.documentElement.getAttribute("data-theme") || "system"`, &theme),
+ chromedp.Evaluate(markedJS, &marked),
+ )
+ if theme != "dark" {
+ t.Errorf("%s: after choosing Dark the page is on %q", where, theme)
+ }
+ if len(marked) != 2 {
+ t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", where, len(marked))
+ }
+ for i, m := range marked {
+ if m != "dark" {
+ t.Errorf("%s: scheme menu %d marks %q current after Dark was chosen, want %q", where, i, m, "dark")
+ }
+ }
+}
diff --git a/internal/gallery/gallery.js b/internal/gallery/gallery.js
index 272583d..3c1dc28 100644
--- a/internal/gallery/gallery.js
+++ b/internal/gallery/gallery.js
@@ -47,9 +47,11 @@
// the back/forward cache (where the stored choice may have changed
// since), marks every copy of the scheme menu on the page (the
// bar's and the display settings menu's) the way fiesta.js marks
- // one: aria-current on the button whose form's scheme is the one
- // stored, System when none is. view.js repaints the frames from the
- // root on pageshow.
+ // one: aria-current on the button whose form's scheme is the one on
+ // the root, System when the root carries none. The root, not
+ // storage: where storage refuses, a choice is applied but never
+ // stored, and the marks follow what the page shows. view.js repaints
+ // the frames from the root on pageshow.
var KEY = "fiesta-scheme";
var remembers = root.getAttribute("data-rst-scheme-store") === "local";
@@ -71,7 +73,7 @@
// Not toggleAttribute: aria-current="" reads as false.
function mark() {
- var s = remembers ? stored() || "system" : root.getAttribute("data-theme") || "system";
+ var s = root.getAttribute("data-theme") || "system";
var menus = document.querySelectorAll("[rst-scheme]");
for (var i = 0; i < menus.length; i++) {
var buttons = menus[i].querySelectorAll("button");