Frames padded, kept out of search, the sign-in card on its stage, the shell demos live
Every frame document now carries what Rastrillo's preview documents carry and what a shell demo needs to work on its own. The head: a robots meta saying noindex after the viewport meta, since thousands of frames sit under the gallery's directories and a search engine listing them would send a reader to a lone field; and, as its last element, the one frame style: body padded 1rem, and 0 where the body is or holds a shell, a backdrop or the stage (body:is() for the layout-stage example, whose body is itself the stage; body:has() for the wrapped sign-in, whose stage is the body's child). The browser reads the computed padding: 16px on the button frame, 0 on the stage, the sidebar, the modal and a staged sign-in. The column shell has no wrapper for a rule to name and takes a component's padding, as Rastrillo's rule pads it too. The stage: a sign-in state whose body opens with [rst-signin] is framed as <div rst-stage><scene><div rst-page>card</div></div>, the scene lifted by regex from examples/layout-stage/default.html (stageScene), once per frame; ten of the twelve states, the two rst-box screens left bare as Rastrillo keeps them. The HTML tab keeps showing the bare card. examples/ is untouched; tokens.css already says [rst-stage] may be a plain wrapper. A layout's links (layoutLinks): the back strip's way up goes to the shell's index document at its first nav id (layout-sidebar-page's up link is the sidebar index with #nav-posts); the nav goes to the shell's page document where the manifest has one, else stays dead; the brand and every "Back to the design system" go to the Overview of the theme and locale; the language menu's twelve links go to the same layout document in each locale, lang and dir kept, the page's own marked aria-current and the seeded mark on English removed. Every rewritten link carries data-gallery-nav; the deadening pass now works tag by tag and leaves a marked tag alone, so TestSampleURLsStayInTheMarkup admits them and TestEveryGalleryURLResolves and internal/www's resolver reach them, fragment included. A language the gallery does not render is an error, not a dead link. A layout has one state, so the other locales' documents are the default state's: frame and layoutLinks do not know the state. <html> gains data-rst-scheme-store="local" when it lacks it (the scheme-menu example already says it), and frame.js, opened in a tab of its own with no data-theme on the root, reads localStorage["fiesta-scheme"] at parse time and sets data-theme for light or dark before first paint; on pageshow with persisted it reads again and sets or removes it; in both cases, once the body is parsed, every [rst-scheme] copy the document holds is marked (aria-current on the stored scheme's button, System when none), since fiesta.js skips its own restore and marking when the root already carries the attribute. Framed, the copy from the parent stays as it was. Tests red first, by name, by assertion, in a detached worktree at HEAD with only the test files copied in: TestEveryFrameDocumentCarriesNoindexAndItsPaddingRule (no robots meta, the fill rule 0 times), TestTheSigninScreensAreOnTheStage (no stage opens the body), TestAShellDemosLinksAreLive (the brand goes to "#"), TestEveryFrameCarriesTheSchemeStore (store ""), TestTheWayBackFromAShellDemoIsATarget (no shell demo showed its way back, 0 foot links measured), TestFramePaddingIsAsTheRuleSays (the button frame's body computes 0px, want 16px), TestADemoOpenedInItsOwnTabKeepsTheScheme (data-theme "" when <body> was inserted, color-scheme "light dark" once loaded). The scheme drive reads the root's attribute at the moment <body> is inserted, which only a script in the head can have set: at the first animation frame fiesta.js's own deferred restore has already run behind a slow stylesheet, so that reading alone cannot tell the two apart. Ported and already passing when written, each with its mutation run in the gate worktree and reverted: TestAShellDemoLaysOutAsAnAppWould (Fiesta's frames link no gallery stylesheet, so the drive also switches off the frame style on a document the fill rule names; mutation: "> [rst-shell-sidebar]" dropped from the :has() list, reported as every box of the sidebar demo moving); TestTheSocialSignInButtonsStackAndCarryTheirMarks (holds frozen markup and CSS; mutation: the Apple mark's fill written as #000 in the example, reported as a fill that does not follow the text colour under both schemes). Preservation, re-run unchanged: TestA11yWalksTheStageShell (its slotted stage document goes through frame; the body is itself the stage, so it is not staged twice), TestTheSchemeMenuRepaintsTheFrames, TestFramesKeepTheReadersPlace, TestAFrameSubmitsNothing, TestAnExampleDocumentFramesLikeASeededOne, TestSampleURLsStayInTheMarkup, TestEveryGalleryURLResolves. Back in the scheme drive is history.back() on a tick, polled for, and the closing screen is taken on a fresh navigation: chromedp's NavigateBack waits for a load this engine does not always report and its node waits never resolve after a history navigation (check/menus_browser_test.go). Committed with git commit --only on these five paths while the other streams' uncommitted files sat in the worktree; gated with make ci in a detached /var/tmp worktree at HEAD with only these files copied in.
Human
Prompt
Task 5 of the Fiesta part 4 gallery plan: the frame document gains noindex, the padding rule, the stage under the sign-in card, live links on the layout documents so a shell demo works in a tab of its own, the scheme store on <html> and frame.js's standalone scheme bootstrap; in parallel with Tasks 3, 4 and 6, editing only frame.go, frame.js and three new test files.
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
frame() adds a robots noindex meta after the viewport meta, the one frame style (1rem padding, 0 on a body that is or holds a shell, a backdrop or the stage) as the head's last element, and data-rst-scheme-store=local on <html>; stageScene and stage put a sign-in body that opens with [rst-signin] on the layout-stage example's stage; layoutLinks makes a layout document's back strip, nav, brand, language menu and foot link live, marked data-gallery-nav, with the deadening pass now tag-aware. frame.js, opened outside a frame, restores the stored scheme before first paint, again on pageshow from the cache, and marks every [rst-scheme] copy. Four untagged tests in frame_test.go and five drives in footlink_browser_test.go and stage_browser_test.go.
Agent notes
A layout has one state, so layoutLinks points the other locales at the default state's document: neither frame nor layoutLinks is told the state. stageScene reads the layout-stage document per staged frame rather than once per render, since site has no field for it and gallery.go is not this task's; it is a small regex over one file. After a history navigation chromedp's node waits hang: ask the page to go back on a tick and poll, and re-navigate before rig.Screen. The browser build in the shared worktree was broken by another stream's started_test.go (release redeclared) while this was written; the gate ran in a detached worktree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+1264
−27
internal/gallery/footlink_browser_test.go+190 −0internal/gallery/frame.go+229 −21internal/gallery/frame.js+57 −6internal/gallery/frame_test.go+427 −0internal/gallery/stage_browser_test.go+361 −0
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.120s
=== 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.024s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: 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.171s
ok amadan.net/rastrillo/fiesta/check 5.682s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 58.541s
ok amadan.net/rastrillo/fiesta/example 0.147s
ok amadan.net/rastrillo/fiesta/internal/axe 0.129s
ok amadan.net/rastrillo/fiesta/internal/build 1.189s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.011s
ok amadan.net/rastrillo/fiesta/internal/gallery 38.858s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.009s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.389s
ok amadan.net/rastrillo/fiesta/internal/site 3.172s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.011s
ok amadan.net/rastrillo/fiesta/internal/www 48.424s
--- shard 1 of 12 ---
==> shard 1 of 12: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 12 ---
==> shard 2 of 12: 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 12 ---
==> shard 3 of 12: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m1:22AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m1:22AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m1:22AM[0m [32mINF[0m [1mscanned ~6025797 bytes (6.03 MB) in 3.88s[0m
[90m1:22AM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 12 ---
==> shard 4 of 12: 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 12.923s
--- shard 5 of 12 ---
==> shard 5 of 12: 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 230.978s
--- shard 6 of 12 ---
==> shard 6 of 12: 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 247.738s
--- shard 7 of 12 ---
==> shard 7 of 12: 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 249.487s
--- shard 8 of 12 ---
==> shard 8 of 12: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 8 of 65 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 128.460s
--- shard 9 of 12 ---
==> shard 9 of 12: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 57 of 65 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 88.805s
--- shard 10 of 12 ---
==> shard 10 of 12: 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
ok amadan.net/rastrillo/fiesta/internal/www 97.444s
--- shard 11 of 12 ---
==> shard 11 of 12: 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 89.293s
Intent
diff --git a/internal/gallery/footlink_browser_test.go b/internal/gallery/footlink_browser_test.go| new file mode 100644 |
| index 0000000..b43d7b0 |
| --- /dev/null |
| +++ b/internal/gallery/footlink_browser_test.go |
| @@ -0,0 +1,190 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The shell demos are the layout examples' frame documents opened full |
| +// width, and they are the shell as an app gets it. Rastrillo's two |
| +// drives on its shell demos, ported onto the layout documents. |
| + |
| +// layoutDemos is every layout document's path in the tree, in day and |
| +// English: the shell demos. |
| +func layoutDemos(t *testing.T) []string { |
| + t.Helper() |
| + var out []string |
| + for _, e := range manifest(t).Examples { |
| + if e.Document { |
| + out = append(out, frameFile(fiesta.ThemeNames()[0], "en", e.Name, e.Default)) |
| + } |
| + } |
| + if len(out) == 0 { |
| + t.Fatal("the manifest has no layout example; the drives below would read nothing") |
| + } |
| + return out |
| +} |
| + |
| +// mouseRig is a browser with a mouse, serving the tree. |
| +func mouseRig(t *testing.T) *harness.Rig { |
| + t.Helper() |
| + return harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| +} |
| + |
| +// TestTheWayBackFromAShellDemoIsATarget: the way back from a shell demo |
| +// is a link in the shell's foot, and it is a target like any other: at |
| +// least --rst-target tall, 32px with a mouse and 44px on a phone. Read |
| +// on every shell demo that has a foot, with a mouse at 1280px and a |
| +// coarse pointer at 390px; a demo whose foot link is not on screen is |
| +// counted, so a selector that stopped matching cannot pass by measuring |
| +// nothing. The link is found by where it goes, the Overview, so a foot |
| +// link left dead at "#" measures nothing and fails. |
| +func TestTheWayBackFromAShellDemoIsATarget(t *testing.T) { |
| + const read = `(() => [...document.querySelectorAll("[rst-shell-foot] a, [rst-stage-foot] a")] |
| + .filter(a => a.getAttribute("href").endsWith("/index.html") && a.checkVisibility()) |
| + .map(a => ({H: a.getBoundingClientRect().height, |
| + Floor: parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize)})))()` |
| + for _, c := range []struct { |
| + name string |
| + rig func(*testing.T) *harness.Rig |
| + w, h int64 |
| + min float64 |
| + }{ |
| + {"a mouse at 1280px", mouseRig, 1280, 800, 32}, |
| + {"a coarse pointer at 390px", func(t *testing.T) *harness.Rig { return phoneRig(t) }, 390, 844, 44}, |
| + } { |
| + rig := c.rig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + measured := 0 |
| + for _, demo := range layoutDemos(t) { |
| + where := fmt.Sprintf("%s with %s", demo, c.name) |
| + var raw string |
| + if err := chromedp.Run(ctx, chromedp.EmulateViewport(c.w, c.h), |
| + chromedp.Navigate(rig.Origin+href(demo)), |
| + chromedp.WaitReady("body"), |
| + chromedp.Evaluate(`JSON.stringify(`+read+`)`, &raw)); err != nil { |
| + cancel() |
| + t.Fatalf("%s: %v", where, err) |
| + } |
| + var links []struct{ H, Floor float64 } |
| + if err := json.Unmarshal([]byte(raw), &links); err != nil { |
| + cancel() |
| + t.Fatalf("%s: decoding %s: %v", where, raw, err) |
| + } |
| + for _, l := range links { |
| + measured++ |
| + if l.Floor < c.min { |
| + t.Errorf("%s: --rst-target is %.0fpx, want at least %.0f", where, l.Floor, c.min) |
| + } |
| + if l.H < l.Floor-0.5 { |
| + t.Errorf("%s: the way back is %.1fpx tall, under the %.0fpx target", where, l.H, l.Floor) |
| + } |
| + } |
| + } |
| + cancel() |
| + if measured == 0 { |
| + t.Errorf("with %s no shell demo showed its way back, so this measured nothing", c.name) |
| + } |
| + t.Logf("%s: %d foot links measured", c.name, measured) |
| + } |
| +} |
| + |
| +// TestAShellDemoLaysOutAsAnAppWould: a shell demo is the shell as an |
| +// app gets it, so nothing of the gallery's own styling may move it. |
| +// Rastrillo's demos once linked gallery.css and its bar layout took |
| +// hold of the sidebar demo. Fiesta's frame documents link no gallery |
| +// stylesheet; what they do carry of the gallery's is the frame style |
| +// in their head, whose fill rule is meant to leave a shell exactly as |
| +// it is. Every element's box is read as served and again with every |
| +// gallery stylesheet switched off, and, on a document the fill rule |
| +// names (a body that is or holds a shell or the stage), the frame |
| +// style too; the two must agree. The column shell has no wrapper for |
| +// a rule to name and takes a component's padding, as Rastrillo's rule |
| +// pads it, so it is read with the frame style in place, against the |
| +// gallery stylesheets alone. At 1280 and 900 with a mouse, either side |
| +// of the gallery bar's two-row band, and at 390 with a coarse pointer. |
| +// |
| +// Already passing when written: the fill rule left every shell at |
| +// padding 0 from the first render. Its mutation, run and reverted: |
| +// dropping "> [rst-shell-sidebar]" from the fill rule's :has() list, |
| +// which gave the sidebar demo 1rem of padding and moved every box in |
| +// it once the style was switched off, reported here per element. |
| +func TestAShellDemoLaysOutAsAnAppWould(t *testing.T) { |
| + const read = `(() => { |
| + const boxes = () => [...document.querySelectorAll("body *")].map(e => { |
| + const b = e.getBoundingClientRect(); |
| + return (e.getAttribute("id") || e.tagName.toLowerCase()) + " " + [b.left, b.top, b.width, b.height].map(n => Math.round(n)).join(","); |
| + }); |
| + const shell = document.body.matches("[rst-stage], [rst-shell-topbar], [rst-shell-sidebar], [rst-shell-console]") |
| + || !!document.body.querySelector(":scope > [rst-shell-topbar], :scope > [rst-shell-sidebar], :scope > [rst-shell-console], :scope > [rst-backdrop], :scope > [rst-stage]"); |
| + const served = boxes(); |
| + const gallery = [...document.styleSheets].filter(s => (s.href && /gallery[^/]*\.css/.test(s.href)) || (shell && !s.href && s.ownerNode && s.ownerNode.tagName === "STYLE")); |
| + gallery.forEach(s => { s.disabled = true; }); |
| + const bare = boxes(); |
| + gallery.forEach(s => { s.disabled = false; }); |
| + const diff = []; |
| + served.forEach((s, i) => { if (s !== bare[i]) diff.push(s + " (without the gallery's styling: " + bare[i] + ")"); }); |
| + return JSON.stringify({Main: !!document.querySelector("main"), Shell: shell, Sheets: gallery.length, Diff: diff.slice(0, 5)}); |
| + })()` |
| + for _, c := range []struct { |
| + name string |
| + rig func(*testing.T) *harness.Rig |
| + w, h int64 |
| + }{ |
| + {"a mouse at 1280px", mouseRig, 1280, 800}, |
| + {"a mouse at 900px", mouseRig, 900, 800}, |
| + {"a coarse pointer at 390px", func(t *testing.T) *harness.Rig { return phoneRig(t) }, 390, 844}, |
| + } { |
| + rig := c.rig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + shells := 0 |
| + for _, demo := range layoutDemos(t) { |
| + where := fmt.Sprintf("%s with %s", demo, c.name) |
| + var raw string |
| + if err := chromedp.Run(ctx, chromedp.EmulateViewport(c.w, c.h), |
| + chromedp.Navigate(rig.Origin+href(demo)), |
| + chromedp.WaitReady("body"), |
| + chromedp.Evaluate(read, &raw)); err != nil { |
| + cancel() |
| + t.Fatalf("%s: %v", where, err) |
| + } |
| + var got struct { |
| + Main, Shell bool |
| + Sheets int |
| + Diff []string |
| + } |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + cancel() |
| + t.Fatalf("%s: decoding %s: %v", where, raw, err) |
| + } |
| + if !got.Main { |
| + t.Errorf("%s: no main element; the demo is not the page this test knows", where) |
| + } |
| + if got.Shell { |
| + shells++ |
| + if got.Sheets == 0 { |
| + t.Errorf("%s: no gallery styling was switched off, so the two readings are the same reading", where) |
| + } |
| + } |
| + for _, d := range got.Diff { |
| + t.Errorf("%s: the gallery's styling moves %s", where, d) |
| + } |
| + } |
| + cancel() |
| + if shells == 0 { |
| + t.Errorf("with %s no demo's body was a shell or the stage, so the frame style was never switched off", c.name) |
| + } |
| + t.Logf("%s: %d demos read, %d of them with the frame style switched off", c.name, len(layoutDemos(t)), shells) |
| + } |
| +} |
diff --git a/internal/gallery/frame.go b/internal/gallery/frame.go| index c496baf..e25ab99 100644 |
| --- a/internal/gallery/frame.go |
| +++ b/internal/gallery/frame.go |
| @@ -11,26 +11,63 @@ import ( |
| // The frame document: one example document made a page the gallery |
| // can frame, with the gallery's own assets, the page's theme and |
| -// locale, and no link that goes anywhere. |
| +// locale, kept out of search, padded unless it is a whole page, the |
| +// sign-in card on its stage, a layout's links live where the interface |
| +// says and dead everywhere else, and the scheme store on <html> so a |
| +// document opened in a tab of its own remembers the reader's choice. |
| var ( |
| - headTag = regexp.MustCompile(`<head\b[^>]*>`) |
| - firstLink = regexp.MustCompile(`<(link|script)\b`) |
| - bodyTag = regexp.MustCompile(`<body\b[^>]*>`) |
| - assetLink = regexp.MustCompile(`(href|src)="\.\./\.\./(css|js)/([^"]+)"`) |
| - picture = regexp.MustCompile(`src="\.\./assets/([^"]+)"`) |
| - sampleRef = regexp.MustCompile(`\b(href|action)="([^"]*)"`) |
| - ltrHTML = `<html lang="en" dir="ltr"` |
| + headTag = regexp.MustCompile(`<head\b[^>]*>`) |
| + firstLink = regexp.MustCompile(`<(link|script)\b`) |
| + bodyTag = regexp.MustCompile(`<body\b[^>]*>`) |
| + viewportMeta = regexp.MustCompile(`<meta name="viewport"[^>]*>`) |
| + assetLink = regexp.MustCompile(`(href|src)="\.\./\.\./(css|js)/([^"]+)"`) |
| + picture = regexp.MustCompile(`src="\.\./assets/([^"]+)"`) |
| + sampleRef = regexp.MustCompile(`\b(href|action)="([^"]*)"`) |
| + anyTag = regexp.MustCompile(`<[a-zA-Z][^>]*>`) |
| + anchorTag = regexp.MustCompile(`<a\b[^>]*>`) |
| + tagEnd = regexp.MustCompile(`\s*/?>$`) |
| + ltrHTML = `<html lang="en" dir="ltr"` |
| + schemeLocal = ` data-rst-scheme-store="local"` |
| + |
| + // The stage's scene, lifted from the layout-stage example as |
| + // schemeMenu lifts the header's menu from scheme-menu. |
| + stageSceneTag = regexp.MustCompile(`(?s)<div rst-stage-scene aria-hidden="true">.*?</div>`) |
| + // A body whose first element is the sign-in card. |
| + signinFirst = regexp.MustCompile(`^\s*<[a-zA-Z][^>]*\srst-signin[\s>=]`) |
| + |
| + // A layout's own regions, matched on the shipped shells' markup. |
| + shellBack = regexp.MustCompile(`(?s)<div rst-shell-back>\s*<a\b[^>]*>`) |
| + shellNav = regexp.MustCompile(`(?s)<nav rst-shell-nav>.*?</nav>`) |
| + shellBrand = regexp.MustCompile(`<a rst-shell-brand\b[^>]*>`) |
| + localeMenu = regexp.MustCompile(`(?s)<details rst-dropdown rst-locale\b.*?</details>`) |
| + firstNavID = regexp.MustCompile(`<nav rst-shell-nav>\s*<a\b[^>]*\sid="([^"]+)"`) |
| + langAttr = regexp.MustCompile(`\slang="([^"]+)"`) |
| + currentOn = regexp.MustCompile(`\saria-current="[^"]*"`) |
| + footLink = regexp.MustCompile(`<a\b[^>]*>Back to the design system</a>`) |
| ) |
| +// frameStyle is the one style every frame document ends its head with. |
| +// A component sample gets breathing room; a whole-page sample fills the |
| +// frame, because insetting a page inside a page is not what any of them |
| +// look like, and the shells' rail and the stage are 100dvh tall, so |
| +// padding under one is also a scrollbar that can never be got rid of. |
| +// The layout-stage example's body is itself the stage, so :is() names |
| +// the body; a wrapped sign-in's stage is the body's child, so :has() |
| +// names the children. |
| +const frameStyle = `<style>body { padding: 1rem; } |
| +body:is([rst-stage], [rst-shell-topbar], [rst-shell-sidebar], [rst-shell-console]), body:has(> [rst-shell-topbar], > [rst-shell-sidebar], > [rst-shell-console], > [rst-backdrop], > [rst-stage]) { padding: 0; }</style>` |
| + |
| // frame turns one example document into a frame document: the |
| // gallery's hashed assets in place of the relative links, the page's |
| -// theme in place of the example's, frame.js first in the head, every |
| -// href and action in the body pointed at "#", and the page's language |
| -// and direction on <html> when it is not English. The example's title |
| -// and everything else in it are kept. e is the example the document |
| -// belongs to; the padding, the stage and a layout's live links read it |
| -// in a later task. |
| +// theme in place of the example's, frame.js first in the head, a robots |
| +// meta after the viewport's, the frame style last in the head, the |
| +// scheme store on <html>, the page's language and direction on <html> |
| +// when it is not English, the sign-in card on its stage, a layout's |
| +// links live where the interface says, and every other href and action |
| +// in the body pointed at "#". The example's title and everything else |
| +// in it are kept. e is the example the document belongs to: whether it |
| +// is a layout decides whether its links come alive. |
| func (s *site) frame(doc, theme, locale string, e example.Entry) (string, error) { |
| head := headTag.FindStringIndex(doc) |
| body := bodyTag.FindStringIndex(doc) |
| @@ -60,15 +97,64 @@ func (s *site) frame(doc, theme, locale string, e example.Entry) (string, error) |
| } |
| top = strings.Replace(top, ltrHTML, `<html lang="`+locale+`" dir="`+Dir(locale)+`"`, 1) |
| } |
| - rest := s.pictures(doc[body[1]:]) |
| - rest = sampleRef.ReplaceAllStringFunc(rest, func(m string) string { |
| - sub := sampleRef.FindStringSubmatch(m) |
| - if strings.HasPrefix(sub[2], "#") { |
| - return m |
| + // A frame is a fragment, not a page: thousands of them sit under |
| + // the gallery's directories, and a search engine listing them would |
| + // send a reader to a lone field with nothing around it. |
| + vp := viewportMeta.FindStringIndex(top) |
| + if vp == nil { |
| + return "", errors.New("no viewport meta in the head to put the robots meta after") |
| + } |
| + top = top[:vp[1]] + "\n" + `<meta name="robots" content="noindex">` + top[vp[1]:] |
| + // The scheme store, so fiesta.js in a document opened in a tab of |
| + // its own remembers the choice where the gallery's pages do. The |
| + // scheme-menu example's documents already say so. |
| + tag := htmlTag.FindStringIndex(top) |
| + if tag == nil { |
| + return "", errors.New("no <html> to put the scheme store on") |
| + } |
| + if !schemeStore.MatchString(top[tag[0]:tag[1]]) { |
| + top = top[:tag[1]-1] + schemeLocal + top[tag[1]-1:] |
| + } |
| + shut := strings.LastIndex(top, "</head>") |
| + if shut < 0 { |
| + return "", errors.New("no </head> to end with the frame style") |
| + } |
| + top = top[:shut] + frameStyle + "\n" + top[shut:] |
| + |
| + rest := doc[body[1]:] |
| + end := strings.LastIndex(rest, "</body>") |
| + if end < 0 { |
| + return "", errors.New("no </body>") |
| + } |
| + inner, tail := rest[:end], rest[end:] |
| + if e.Document { |
| + var err error |
| + if inner, err = s.layoutLinks(inner, e, theme, locale); err != nil { |
| + return "", err |
| } |
| - return sub[1] + `="#"` |
| + } else if signinFirst.MatchString(inner) { |
| + scene, err := s.stageScene() |
| + if err != nil { |
| + return "", err |
| + } |
| + inner = "\n" + stage(scene, inner) + "\n" |
| + } |
| + inner = s.pictures(inner) |
| + // Every href and action that is not the gallery's own goes to "#": |
| + // a sample's link goes nowhere and a sample's form requests nothing. |
| + inner = anyTag.ReplaceAllStringFunc(inner, func(tag string) string { |
| + if strings.Contains(tag, " data-gallery-nav") { |
| + return tag |
| + } |
| + return sampleRef.ReplaceAllStringFunc(tag, func(m string) string { |
| + sub := sampleRef.FindStringSubmatch(m) |
| + if strings.HasPrefix(sub[2], "#") { |
| + return m |
| + } |
| + return sub[1] + `="#"` |
| + }) |
| }) |
| - return top + rest, nil |
| + return top + inner + tail, nil |
| } |
| // pictures points every sample picture at its hashed copy under |
| @@ -80,6 +166,128 @@ func (s *site) pictures(html string) string { |
| }) |
| } |
| +// stageScene is the stage's scene, the <div rst-stage-scene> with its |
| +// art, lifted from the layout-stage example's default state, so the |
| +// staged sign-in screens show the stage the layout documents. |
| +func (s *site) stageScene() (string, error) { |
| + const name = "layout-stage" |
| + state, ok := s.m.Default(name) |
| + if !ok { |
| + return "", fmt.Errorf("the manifest has no %s example", name) |
| + } |
| + doc, err := example.Document(s.src, name, state) |
| + if err != nil { |
| + return "", err |
| + } |
| + scene := stageSceneTag.FindString(doc) |
| + if scene == "" { |
| + return "", fmt.Errorf("%s/%s carries no scene to lift", name, state) |
| + } |
| + return scene, nil |
| +} |
| + |
| +// stage puts a body whose first element is the sign-in card on the |
| +// stage: the scene, then the card in the page column, as the stage |
| +// shell lays a screen out. Ten of the twelve sign-in states; the two |
| +// rst-box screens stay bare, as Rastrillo keeps them. The HTML tab |
| +// still shows the bare card, which is what a reader copies. |
| +func stage(scene, body string) string { |
| + return `<div rst-stage>` + scene + `<div rst-page>` + strings.TrimSpace(body) + `</div></div>` |
| +} |
| + |
| +// layoutLinks makes a layout document's own links live, so a shell demo |
| +// opened full width works as the app it stands for: the back strip's |
| +// way up goes to the shell's index document at its first nav id; the |
| +// nav goes to the shell's page document where the manifest has one; |
| +// the brand and the foot's "Back to the design system" go to the |
| +// Overview; and the language menu's links go to the same layout |
| +// document in each locale, the page's own marked current, lang and dir |
| +// kept. Every link rewritten carries data-gallery-nav, which is what |
| +// marks it the gallery's: the deadening pass leaves it alone and the |
| +// URL gates resolve it. The rest of the body's links are a sample's |
| +// and go to "#" afterwards. |
| +// |
| +// The shell of a layout is its name without layout- and -page: the |
| +// index is layout-<shell>, the page layout-<shell>-page. A layout has |
| +// one state, so the document in another locale is its default state's. |
| +func (s *site) layoutLinks(doc string, e example.Entry, theme, locale string) (string, error) { |
| + shell := strings.TrimSuffix(strings.TrimPrefix(e.Name, "layout-"), "-page") |
| + index := "layout-" + shell |
| + indexState, ok := s.m.Default(index) |
| + if !ok { |
| + return "", fmt.Errorf("%s: the manifest has no %s, the shell's index document", e.Name, index) |
| + } |
| + own := func(l string) string { return s.href(theme, l, "frames/"+e.Name+"/"+e.Default+".html") } |
| + overview := s.href(theme, locale, s.fileOf("overview")) |
| + var err error |
| + |
| + if shellBack.MatchString(doc) { |
| + indexDoc, derr := example.Document(s.src, index, indexState) |
| + if derr != nil { |
| + return "", derr |
| + } |
| + id := firstNavID.FindStringSubmatch(indexDoc) |
| + if id == nil { |
| + return "", fmt.Errorf("%s: %s has no nav id for the way up to land on", e.Name, index) |
| + } |
| + up := s.href(theme, locale, "frames/"+index+"/"+indexState+".html") + "#" + id[1] |
| + doc = shellBack.ReplaceAllStringFunc(doc, func(m string) string { |
| + return anchorTag.ReplaceAllStringFunc(m, func(a string) string { return liveLink(a, up) }) |
| + }) |
| + } |
| + if pageState, ok := s.m.Default(index + "-page"); ok { |
| + page := s.href(theme, locale, "frames/"+index+"-page/"+pageState+".html") |
| + doc = shellNav.ReplaceAllStringFunc(doc, func(m string) string { |
| + return anchorTag.ReplaceAllStringFunc(m, func(a string) string { return liveLink(a, page) }) |
| + }) |
| + } |
| + doc = shellBrand.ReplaceAllStringFunc(doc, func(a string) string { return liveLink(a, overview) }) |
| + doc = localeMenu.ReplaceAllStringFunc(doc, func(m string) string { |
| + return anchorTag.ReplaceAllStringFunc(m, func(a string) string { |
| + lang := langAttr.FindStringSubmatch(a) |
| + if lang == nil { |
| + err = fmt.Errorf("%s: a language link carries no lang", e.Name) |
| + return a |
| + } |
| + if !known(lang[1]) { |
| + err = fmt.Errorf("%s: the language menu names %s, which the gallery has no locale for", e.Name, lang[1]) |
| + return a |
| + } |
| + a = currentOn.ReplaceAllString(a, "") |
| + a = liveLink(a, own(lang[1])) |
| + if lang[1] == locale { |
| + a = tagEnd.ReplaceAllString(a, ` aria-current="true">`) |
| + } |
| + return a |
| + }) |
| + }) |
| + doc = footLink.ReplaceAllStringFunc(doc, func(m string) string { |
| + return anchorTag.ReplaceAllString(m, liveLink(anchorTag.FindString(m), overview)) |
| + }) |
| + return doc, err |
| +} |
| + |
| +// liveLink points one <a> tag at href and marks it the gallery's own. |
| +func liveLink(tag, href string) string { |
| + tag = sampleRef.ReplaceAllStringFunc(tag, func(m string) string { |
| + if sampleRef.FindStringSubmatch(m)[1] != "href" { |
| + return m |
| + } |
| + return `href="` + href + `"` |
| + }) |
| + return tagEnd.ReplaceAllString(tag, ` data-gallery-nav>`) |
| +} |
| + |
| +// known says whether the gallery renders the locale. |
| +func known(locale string) bool { |
| + for _, l := range Locales { |
| + if l == locale { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // exampleDocument builds a document shaped like a seeded example, with |
| // the relative asset links the seeds carry, so frame treats it like |
| // any example: a title, the two stylesheets, the scripts by file name, |
diff --git a/internal/gallery/frame.js b/internal/gallery/frame.js| index 1035972..a1f2d32 100644 |
| --- a/internal/gallery/frame.js |
| +++ b/internal/gallery/frame.js |
| @@ -1,14 +1,29 @@ |
| /* frame.js: the first script in every frame document the gallery |
| - renders, and no part of the design system. Two jobs, both done |
| + renders, and no part of the design system. Three jobs, all done |
| before the document's own scripts run. |
| The scheme. A frame does not inherit its parent's colour scheme, |
| and the gallery's reader may have chosen Light or Dark before this |
| frame loaded. The parent is same-origin, so its <html> is readable: |
| whatever data-theme it carries is copied here before the first |
| - paint, and gallery.js writes it again on every change after. Opened |
| - on its own, outside a frame, there is no parent and nothing is |
| - copied. |
| + paint, and gallery.js writes it again on every change after. |
| + |
| + Opened on its own, outside a frame, there is no parent: a shell |
| + demo or the demo application in a tab of its own, or a document a |
| + reader followed a link into. Every frame document says |
| + data-rst-scheme-store="local", so fiesta.js would restore the stored |
| + choice, but fiesta.js is deferred here and restores only when it is |
| + in the head without defer; after the first paint is too late. So the |
| + restore is this script's: localStorage["fiesta-scheme"] is read at |
| + parse time and data-theme set for light or dark, and on pageshow |
| + from the back/forward cache storage is read again and the attribute |
| + set or removed. fiesta.js skips its own restore, the marking of the |
| + scheme menu included, when the root already carries data-theme, so |
| + once the body is parsed, and again after a cache restore, every |
| + [rst-scheme] copy the document holds is marked here: aria-current on |
| + the stored scheme's button, on System when nothing is stored, the |
| + same marking fiesta.js does. Framed, the parent's scheme is the one |
| + truth and nothing is read from storage. |
| Submits. Every href in a frame's body is "#", so a click goes |
| nowhere; a form would still request its action, "#" included, which |
| @@ -33,16 +48,52 @@ |
| (function () { |
| "use strict"; |
| + var root = document.documentElement, KEY = "fiesta-scheme", SCHEME = "[rst-scheme],.rst-scheme"; |
| + |
| var host = null; |
| try { |
| host = window.frameElement; |
| } catch (e) { |
| /* framed by another origin, whose page is not the gallery's */ |
| } |
| + |
| + function stored() { |
| + var s = null; |
| + try { s = localStorage.getItem(KEY); } catch (e) { /* storage off: nothing is stored */ } |
| + return s === "light" || s === "dark" ? s : "system"; |
| + } |
| + 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(s) { |
| + var menus = document.querySelectorAll(SCHEME); |
| + for (var m = 0; m < menus.length; m++) { |
| + var buttons = menus[m].querySelectorAll("button"); |
| + for (var i = 0; i < buttons.length; i++) { |
| + var f = buttons[i].form, v = f && f.elements.scheme && f.elements.scheme.value; |
| + if (v === s) buttons[i].setAttribute("aria-current", "true"); else buttons[i].removeAttribute("aria-current"); |
| + } |
| + } |
| + } |
| + function ready(fn) { |
| + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); else fn(); |
| + } |
| + |
| if (host) { |
| var parent = host.ownerDocument.documentElement, s = parent.getAttribute("data-theme"); |
| - if (s === "light" || s === "dark") document.documentElement.setAttribute("data-theme", s); |
| - else document.documentElement.removeAttribute("data-theme"); |
| + if (s === "light" || s === "dark") root.setAttribute("data-theme", s); |
| + else root.removeAttribute("data-theme"); |
| + } else if (!root.hasAttribute("data-theme")) { |
| + var kept = stored(); |
| + if (kept !== "system") apply(kept); |
| + ready(function () { mark(kept); }); |
| + addEventListener("pageshow", function (event) { |
| + if (!event.persisted) return; |
| + var now = stored(); |
| + apply(now); |
| + mark(now); |
| + }); |
| } |
| addEventListener("submit", function (event) { |
diff --git a/internal/gallery/frame_test.go b/internal/gallery/frame_test.go| new file mode 100644 |
| index 0000000..0994bc5 |
| --- /dev/null |
| +++ b/internal/gallery/frame_test.go |
| @@ -0,0 +1,427 @@ |
| +package gallery |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + xhtml "golang.org/x/net/html" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The frame document: what every frame carries in its head, the stage |
| +// under the sign-in card, the links a shell demo needs to work as the |
| +// app it stands for, and the attribute that lets a document opened in |
| +// a tab of its own remember the reader's scheme. |
| + |
| +// The two rules every frame's style carries, as written: a component |
| +// sample gets breathing room, a whole-page sample fills the frame. |
| +const ( |
| + paddingRule = `body { padding: 1rem; }` |
| + fillRule = `body:is([rst-stage], [rst-shell-topbar], [rst-shell-sidebar], [rst-shell-console]), body:has(> [rst-shell-topbar], > [rst-shell-sidebar], > [rst-shell-console], > [rst-backdrop], > [rst-stage]) { padding: 0; }` |
| +) |
| + |
| +// frameDocs is every frame document of the tree, by path. |
| +func frameDocs(t *testing.T, files map[string][]byte) map[string][]byte { |
| + t.Helper() |
| + out := map[string][]byte{} |
| + for name, body := range files { |
| + if isFrame(name) { |
| + out[name] = body |
| + } |
| + } |
| + if len(out) == 0 { |
| + t.Fatal("the render wrote no frame document; the check that follows would read nothing") |
| + } |
| + return out |
| +} |
| + |
| +// elementChildren is the element children of n, in order. |
| +func elementChildren(n node) []node { |
| + var out []node |
| + for c := n.n.FirstChild; c != nil; c = c.NextSibling { |
| + if c.Type == xhtml.ElementNode { |
| + out = append(out, node{c}) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestEveryFrameDocumentCarriesNoindexAndItsPaddingRule: every frame |
| +// document has one robots meta saying noindex, right after the |
| +// viewport meta, and one style, the last element of its head, with the |
| +// padding rule and the fill rule each exactly once, both selectors |
| +// present as written. |
| +func TestEveryFrameDocumentCarriesNoindexAndItsPaddingRule(t *testing.T) { |
| + frames := frameDocs(t, render(t, "")) |
| + for name, body := range frames { |
| + doc := parse(t, body) |
| + head, ok := doc.first(func(n node) bool { return n.n.Data == "head" }) |
| + if !ok { |
| + t.Errorf("%s has no <head>", name) |
| + continue |
| + } |
| + kids := elementChildren(head) |
| + robots := -1 |
| + for i, k := range kids { |
| + if k.n.Data != "meta" { |
| + continue |
| + } |
| + if v, _ := k.attr("name"); v == "robots" { |
| + if robots >= 0 { |
| + t.Errorf("%s carries two robots metas", name) |
| + } |
| + robots = i |
| + if c, _ := k.attr("content"); c != "noindex" { |
| + t.Errorf("%s says robots %q, want noindex", name, c) |
| + } |
| + } |
| + } |
| + if robots < 0 { |
| + t.Errorf("%s carries no robots meta", name) |
| + } else if prev := kids[robots-1]; robots == 0 || prev.n.Data != "meta" || func() bool { v, _ := prev.attr("name"); return v != "viewport" }() { |
| + t.Errorf("%s: the robots meta does not follow the viewport meta", name) |
| + } |
| + if last := kids[len(kids)-1]; last.n.Data != "style" { |
| + t.Errorf("%s: the last element of the head is <%s>, want the frame's <style>", name, last.n.Data) |
| + } |
| + s := string(body) |
| + if n := strings.Count(s, paddingRule); n != 1 { |
| + t.Errorf("%s carries the padding rule %d times, want once", name, n) |
| + } |
| + if n := strings.Count(s, fillRule); n != 1 { |
| + t.Errorf("%s carries the fill rule %d times, want once", name, n) |
| + } |
| + } |
| + t.Logf("%d frame documents checked", len(frames)) |
| +} |
| + |
| +// sceneOf lifts the stage's scene from the layout-stage example's own |
| +// document, independently of the render, so the test and the code |
| +// cannot agree on a wrong scene. |
| +func sceneOf(t *testing.T) string { |
| + t.Helper() |
| + doc, err := example.Document(fiesta.FS(), "layout-stage", mustEntry(t, "layout-stage").Default) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + scene := regexp.MustCompile(`(?s)<div rst-stage-scene aria-hidden="true">.*?</div>`).FindString(doc) |
| + if scene == "" { |
| + t.Fatal("the layout-stage example carries no scene to lift") |
| + } |
| + return scene |
| +} |
| + |
| +// hasAttr says whether the element carries the attribute, whatever its |
| +// value. |
| +func hasAttr(n node, name string) bool { |
| + _, ok := n.attr(name) |
| + return ok |
| +} |
| + |
| +// TestTheSigninScreensAreOnTheStage: the ten sign-in states whose body |
| +// opens with the card are framed on the stage, the scene first and the |
| +// card inside the page column; the two bare rst-box screens are not; |
| +// every scene is the layout-stage example's own; and the signin page's |
| +// HTML tab shows the bare card, never the stage. Read in every theme |
| +// and locale, the structure parsed in day/en. |
| +func TestTheSigninScreensAreOnTheStage(t *testing.T) { |
| + files := render(t, "") |
| + e := mustEntry(t, "signin") |
| + scene := sceneOf(t) |
| + bare := map[string]bool{"email-and-password": true, "google-apple-and-the-rest": true} |
| + |
| + staged := 0 |
| + for _, dir := range dirs() { |
| + for _, st := range e.States { |
| + name := dir + "frames/signin/" + st.ID + ".html" |
| + body, ok := files[name] |
| + if !ok { |
| + t.Fatalf("no %s", name) |
| + } |
| + // The body alone: the frame style's selectors name the |
| + // stage in every frame's head. |
| + s := string(body) |
| + s = s[strings.Index(s, "<body"):] |
| + if bare[st.ID] { |
| + if strings.Contains(s, "rst-stage") { |
| + t.Errorf("%s is staged; the rst-box screens stay bare", name) |
| + } |
| + continue |
| + } |
| + if dir == "day/en/" { |
| + staged++ |
| + } |
| + if !strings.Contains(s, `<div rst-stage><div rst-stage-scene aria-hidden="true">`) { |
| + t.Errorf("%s does not open its stage with the scene", name) |
| + } |
| + if n := strings.Count(s, scene); n != 1 { |
| + t.Errorf("%s carries the layout-stage scene %d times, want once", name, n) |
| + } |
| + } |
| + } |
| + if staged != 10 { |
| + t.Errorf("%d signin states are staged in day/en, want 10", staged) |
| + } |
| + |
| + for _, st := range e.States { |
| + if bare[st.ID] { |
| + continue |
| + } |
| + name := "day/en/frames/signin/" + st.ID + ".html" |
| + doc := parse(t, files[name]) |
| + body, _ := doc.first(func(n node) bool { return n.n.Data == "body" }) |
| + kids := elementChildren(body) |
| + if len(kids) != 1 || kids[0].n.Data != "div" || !hasAttr(kids[0], "rst-stage") { |
| + t.Errorf("%s: the body's one child is not <div rst-stage>", name) |
| + continue |
| + } |
| + parts := elementChildren(kids[0]) |
| + if len(parts) != 2 || !hasAttr(parts[0], "rst-stage-scene") || !hasAttr(parts[1], "rst-page") { |
| + t.Errorf("%s: the stage holds %d parts, want the scene then the page column", name, len(parts)) |
| + continue |
| + } |
| + if v, _ := parts[0].attr("aria-hidden"); v != "true" { |
| + t.Errorf("%s: the scene is not aria-hidden", name) |
| + } |
| + card := elementChildren(parts[1]) |
| + if len(card) != 1 || !hasAttr(card[0], "rst-signin") { |
| + t.Errorf("%s: the page column holds %d elements, want the one [rst-signin] card", name, len(card)) |
| + } |
| + } |
| + |
| + page := files["day/en/signin.html"] |
| + for _, st := range e.States { |
| + if tab := htmlTab(t, page, "signin", st.ID); strings.Contains(tab, "rst-stage") { |
| + t.Errorf("signin/%s: the HTML tab shows the stage; it shows the card a reader copies", st.ID) |
| + } |
| + } |
| +} |
| + |
| +// bodyLinks is every <a> in the document's body, in order. |
| +func bodyLinks(t *testing.T, doc node) []node { |
| + t.Helper() |
| + body, ok := doc.first(func(n node) bool { return n.n.Data == "body" }) |
| + if !ok { |
| + t.Fatal("the document has no <body>") |
| + } |
| + return body.find(func(n node) bool { return n.n.Data == "a" }) |
| +} |
| + |
| +// within says whether n has an ancestor pred accepts. |
| +func within(n node, pred func(node) bool) bool { |
| + for p := n.n.Parent; p != nil; p = p.Parent { |
| + if p.Type == xhtml.ElementNode && pred(node{p}) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestAShellDemosLinksAreLive: in the sidebar shell's frame document |
| +// the back link, the nav links, the brand and the twelve language links |
| +// point where the interface says, each marked as the gallery's own and |
| +// the current language marked; in the sidebar's page document the way |
| +// up is the sidebar's index with its first nav id; in the stage shell |
| +// the foot link is the Overview; and every other href in a layout's |
| +// body is a fragment. Rendered at a mount, so a link written for the |
| +// root could not pass. |
| +func TestAShellDemosLinksAreLive(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + s := newTestSite(t, fiesta.FS(), mount) |
| + overview := s.href("day", "en", "index.html") |
| + frameOf := func(theme, locale, name string) string { |
| + return s.href(theme, locale, "frames/"+name+"/"+mustEntry(t, name).Default+".html") |
| + } |
| + nav := func(t *testing.T, n node, where string) { |
| + t.Helper() |
| + if !hasAttr(n, "data-gallery-nav") { |
| + t.Errorf("%s is not marked data-gallery-nav", where) |
| + } |
| + } |
| + attr := func(n node, name string) string { v, _ := n.attr(name); return v } |
| + hrefOf := func(n node) string { return attr(n, "href") } |
| + |
| + // The sidebar's index document, in English and in Arabic for the |
| + // language mark. |
| + for _, locale := range []string{"en", "ar"} { |
| + name := "day/" + locale + "/frames/layout-sidebar/default.html" |
| + doc := parse(t, files[name]) |
| + live := map[*xhtml.Node]bool{} |
| + links := bodyLinks(t, doc) |
| + mark := func(n node) { live[n.n] = true } |
| + |
| + back := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && attr(n, "rel") == "up" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-back") }) |
| + }) |
| + if len(back) != 1 { |
| + t.Fatalf("%s has %d back links, want 1", name, len(back)) |
| + } |
| + if want := frameOf("day", locale, "layout-sidebar") + "#nav-posts"; hrefOf(back[0]) != want { |
| + t.Errorf("%s: the back link goes to %q, want %q", name, hrefOf(back[0]), want) |
| + } |
| + nav(t, back[0], name+": the back link") |
| + mark(back[0]) |
| + |
| + navLinks := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-nav") }) |
| + }) |
| + if len(navLinks) != 3 { |
| + t.Fatalf("%s has %d nav links, want 3", name, len(navLinks)) |
| + } |
| + for _, a := range navLinks { |
| + if want := frameOf("day", locale, "layout-sidebar-page"); hrefOf(a) != want { |
| + t.Errorf("%s: nav link %q goes to %q, want %q", name, a.text(), hrefOf(a), want) |
| + } |
| + nav(t, a, name+": nav link "+a.text()) |
| + mark(a) |
| + } |
| + |
| + brands := doc.find(func(n node) bool { return n.n.Data == "a" && hasAttr(n, "rst-shell-brand") }) |
| + if len(brands) != 2 { |
| + t.Fatalf("%s has %d brand links, want 2", name, len(brands)) |
| + } |
| + for _, a := range brands { |
| + if want := s.href("day", locale, "index.html"); hrefOf(a) != want { |
| + t.Errorf("%s: the brand goes to %q, want the Overview %q", name, hrefOf(a), want) |
| + } |
| + nav(t, a, name+": the brand") |
| + mark(a) |
| + } |
| + |
| + langs := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-locale") }) |
| + }) |
| + if len(langs) != len(Locales) { |
| + t.Fatalf("%s has %d language links, want %d", name, len(langs), len(Locales)) |
| + } |
| + current := 0 |
| + seen := map[string]bool{} |
| + for _, a := range langs { |
| + l := attr(a, "lang") |
| + seen[l] = true |
| + if want := frameOf("day", l, "layout-sidebar"); hrefOf(a) != want { |
| + t.Errorf("%s: the %s link goes to %q, want %q", name, l, hrefOf(a), want) |
| + } |
| + if d := attr(a, "dir"); d != Dir(l) { |
| + t.Errorf("%s: the %s link says dir=%q, want %q", name, l, d, Dir(l)) |
| + } |
| + cur, _ := a.attr("aria-current") |
| + if cur == "true" { |
| + current++ |
| + if l != locale { |
| + t.Errorf("%s marks %s current, want %s", name, l, locale) |
| + } |
| + } |
| + nav(t, a, name+": the "+l+" link") |
| + mark(a) |
| + } |
| + for _, l := range Locales { |
| + if !seen[l] { |
| + t.Errorf("%s: the language menu has no %s", name, l) |
| + } |
| + } |
| + if current != 1 { |
| + t.Errorf("%s marks %d languages current, want 1", name, current) |
| + } |
| + |
| + backs := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && strings.TrimSpace(n.text()) == "Back to the design system" |
| + }) |
| + if len(backs) == 0 { |
| + t.Fatalf("%s has no link back to the design system", name) |
| + } |
| + for _, a := range backs { |
| + if want := s.href("day", locale, "index.html"); hrefOf(a) != want { |
| + t.Errorf("%s: the way back to the design system goes to %q, want %q", name, hrefOf(a), want) |
| + } |
| + nav(t, a, name+": the way back to the design system") |
| + mark(a) |
| + } |
| + |
| + rest := 0 |
| + for _, a := range links { |
| + if live[a.n] { |
| + continue |
| + } |
| + rest++ |
| + if h := hrefOf(a); !strings.HasPrefix(h, "#") { |
| + t.Errorf("%s: <a href=%q>%s</a> is neither the gallery's nor a fragment", name, h, a.text()) |
| + } |
| + if hasAttr(a, "data-gallery-nav") { |
| + t.Errorf("%s: <a href=%q> is marked the gallery's and points nowhere the interface says", name, hrefOf(a)) |
| + } |
| + } |
| + if rest == 0 { |
| + t.Errorf("%s: every body link was rewritten; the sample's own links should stay fragments", name) |
| + } |
| + } |
| + |
| + // The sidebar's page document: the way up is the index and its |
| + // first nav id. |
| + pageDoc := parse(t, files["day/en/frames/layout-sidebar-page/default.html"]) |
| + up := pageDoc.find(func(n node) bool { return n.n.Data == "a" && attr(n, "rel") == "up" }) |
| + if len(up) != 1 { |
| + t.Fatalf("the sidebar's page document has %d up links, want 1", len(up)) |
| + } |
| + if want := frameOf("day", "en", "layout-sidebar") + "#nav-posts"; hrefOf(up[0]) != want { |
| + t.Errorf("the sidebar's page document goes up to %q, want %q", hrefOf(up[0]), want) |
| + } |
| + nav(t, up[0], "the sidebar page's way up") |
| + |
| + // The stage: its foot link is the Overview. |
| + stageDoc := parse(t, files["day/en/frames/layout-stage/default.html"]) |
| + foot := stageDoc.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-stage-foot") }) |
| + }) |
| + if len(foot) != 1 { |
| + t.Fatalf("the stage shell has %d foot links, want 1", len(foot)) |
| + } |
| + if hrefOf(foot[0]) != overview { |
| + t.Errorf("the stage's foot link goes to %q, want %q", hrefOf(foot[0]), overview) |
| + } |
| + nav(t, foot[0], "the stage's foot link") |
| + |
| + // A shell with no page document: its nav links stay fragments. |
| + topbar := parse(t, files["day/en/frames/layout-topbar/default.html"]) |
| + for _, a := range topbar.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-nav") }) |
| + }) { |
| + if h := hrefOf(a); h != "#" { |
| + t.Errorf("the topbar, which has no page document, links its nav to %q, want #", h) |
| + } |
| + } |
| + |
| + // A component's frame has no gallery link at all: the rewrite is |
| + // the layouts' alone. |
| + for _, a := range bodyLinks(t, parse(t, files["day/en/frames/button/"+mustEntry(t, "button").Default+".html"])) { |
| + if hasAttr(a, "data-gallery-nav") { |
| + t.Errorf("the button frame carries a gallery link to %q", hrefOf(a)) |
| + } |
| + } |
| +} |
| + |
| +// TestEveryFrameCarriesTheSchemeStore: every frame document's <html> |
| +// says data-rst-scheme-store="local", so fiesta.js in a document |
| +// opened in a tab of its own remembers the reader's choice where the |
| +// gallery's pages do; the one example that already says so says it |
| +// once. |
| +func TestEveryFrameCarriesTheSchemeStore(t *testing.T) { |
| + frames := frameDocs(t, render(t, "")) |
| + for name, body := range frames { |
| + html, ok := parse(t, body).first(func(n node) bool { return n.n.Data == "html" }) |
| + if !ok { |
| + t.Errorf("%s has no <html>", name) |
| + continue |
| + } |
| + if v, _ := html.attr("data-rst-scheme-store"); v != "local" { |
| + t.Errorf("%s says data-rst-scheme-store=%q, want local", name, v) |
| + } |
| + if n := strings.Count(string(body), "data-rst-scheme-store="); n != 1 { |
| + t.Errorf("%s carries data-rst-scheme-store %d times, want once", name, n) |
| + } |
| + } |
| +} |
diff --git a/internal/gallery/stage_browser_test.go b/internal/gallery/stage_browser_test.go| new file mode 100644 |
| index 0000000..c09ae9a |
| --- /dev/null |
| +++ b/internal/gallery/stage_browser_test.go |
| @@ -0,0 +1,361 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The frame document as a browser lays it out: the padding the frame |
| +// style gives a component and withholds from a whole page, the social |
| +// sign-in card as an app would show it, and a demo opened in a tab of |
| +// its own in the reader's scheme before first paint and after Back. |
| + |
| +// TestTheSocialSignInButtonsStackAndCarryTheirMarks: the social sign-in |
| +// screen is markup an app copies, so what it looks like in the frame is |
| +// what it will look like in the app. Rastrillo's first cut shipped two |
| +// forms that touched and two text-only buttons under a blurb promising |
| +// the companies' marks. Neither shows up in the bytes: a gap is a |
| +// computed box, and an icon that is present but sized off the text, |
| +// pushed off-centre or painted in a fixed colour that vanishes on a |
| +// dark surface is still present. So this reads the rendered frame, in |
| +// both schemes, because the Apple mark following the button's text |
| +// colour is only proven by a scheme that changes it. Ported onto the |
| +// signin example's page. |
| +// |
| +// Already passing when written: it holds the shipped sign-in markup |
| +// and tokens.css, which this part does not change. Its mutation, run |
| +// in the gate worktree and reverted: the Apple mark's fill written as |
| +// a fixed colour in the example, reported as a fill that does not |
| +// follow the text colour under both schemes. |
| +func TestTheSocialSignInButtonsStackAndCarryTheirMarks(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + const frame = `#google-apple-and-the-rest .ds-view__frame` |
| + |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(1500, 1000), |
| + chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/signin.html")), |
| + chromedp.WaitVisible(frame, chromedp.ByQuery), |
| + chromedp.Evaluate(`(() => { |
| + const f = document.querySelector(`+"`"+frame+"`"+`); |
| + f.loading = "eager"; f.scrollIntoView(); |
| + return "ok"; |
| + })()`, new(string)), |
| + chromedp.Poll(`(() => { |
| + const d = document.querySelector(`+"`"+frame+"`"+`).contentDocument; |
| + return !!d && d.readyState === "complete" && !!d.querySelector("[rst-btn]"); |
| + })()`, nil, chromedp.WithPollingTimeout(30*time.Second)), |
| + ); err != nil { |
| + t.Fatalf("loading the social sign-in frame: %v", err) |
| + } |
| + |
| + type mark struct { |
| + Label string |
| + FirstIsSVG bool |
| + Hidden string |
| + Focusable string |
| + IconH, FontPx float64 |
| + CentreOff float64 // icon's vertical centre minus the button's |
| + LabelGap float64 // icon's end edge to the label's start |
| + BtnGap float64 // the button's own column gap |
| + Fill, Colour string // the last path's computed fill, the button's text colour |
| + } |
| + type reading struct { |
| + Buttons int |
| + Gap, Sp3 float64 |
| + Widths []float64 |
| + Container float64 |
| + Marks []mark |
| + } |
| + |
| + for _, scheme := range []string{"light", "dark"} { |
| + var raw string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { |
| + const d = document.querySelector(`+"`"+frame+"`"+`).contentDocument; |
| + d.documentElement.setAttribute("data-theme", `+"`"+scheme+"`"+`); |
| + const probe = d.createElement("div"); |
| + probe.style.blockSize = "var(--rst-sp-3)"; |
| + d.body.append(probe); |
| + const sp3 = probe.getBoundingClientRect().height; |
| + probe.remove(); |
| + const btns = [...d.querySelectorAll("button[rst-btn]")]; |
| + const r = btns.map(b => b.getBoundingClientRect()); |
| + const wrap = btns.length ? btns[0].closest("form").parentElement : null; |
| + const cs = wrap ? getComputedStyle(wrap) : null; |
| + const inner = wrap ? wrap.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) : 0; |
| + const marks = btns.map((b, i) => { |
| + const svg = b.firstElementChild; |
| + const isSVG = !!svg && svg.localName === "svg"; |
| + const text = [...b.childNodes].find(n => n.nodeType === 3 && n.textContent.trim()); |
| + const out = { Label: b.textContent.trim(), FirstIsSVG: isSVG, FontPx: parseFloat(getComputedStyle(b).fontSize), |
| + BtnGap: parseFloat(getComputedStyle(b).columnGap), Colour: getComputedStyle(b).color }; |
| + if (!isSVG) return out; |
| + const s = svg.getBoundingClientRect(); |
| + out.Hidden = svg.getAttribute("aria-hidden") || ""; |
| + out.Focusable = svg.getAttribute("focusable") || ""; |
| + out.IconH = s.height; |
| + out.CentreOff = (s.top + s.height / 2) - (r[i].top + r[i].height / 2); |
| + if (text) { |
| + const range = d.createRange(); |
| + range.selectNodeContents(text); |
| + out.LabelGap = range.getBoundingClientRect().left - s.right; |
| + } |
| + const paths = svg.querySelectorAll("path"); |
| + if (paths.length) out.Fill = getComputedStyle(paths[paths.length - 1]).fill; |
| + return out; |
| + }); |
| + return JSON.stringify({ |
| + Buttons: btns.length, |
| + Gap: btns.length > 1 ? r[1].top - r[0].bottom : -1, |
| + Sp3: sp3, |
| + Widths: r.map(x => x.width), |
| + Container: inner, |
| + Marks: marks, |
| + }); |
| + })()`, &raw)); err != nil { |
| + t.Fatalf("%s: reading the frame: %v", scheme, err) |
| + } |
| + var got reading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: %v in %s", scheme, err, raw) |
| + } |
| + if got.Buttons != 2 { |
| + t.Fatalf("%s: %d provider buttons, want Google and Apple", scheme, got.Buttons) |
| + } |
| + // The shipped card spaces its controls by --rst-sp-3; two |
| + // buttons with no gap read as one control split in half. |
| + if math.Abs(got.Gap-got.Sp3) > 0.5 { |
| + t.Errorf("%s: the buttons are %.1fpx apart, want the card's --rst-sp-3 (%.1fpx)", scheme, got.Gap, got.Sp3) |
| + } |
| + for i, w := range got.Widths { |
| + if math.Abs(w-got.Container) > 0.5 { |
| + t.Errorf("%s: button %d is %.1fpx wide in a %.1fpx stack; the two only align if both fill it", scheme, i, w, got.Container) |
| + } |
| + } |
| + for i, m := range got.Marks { |
| + want := []string{"Continue with Google", "Continue with Apple"}[i] |
| + if m.Label != want { |
| + t.Errorf("%s: button %d says %q, want %q", scheme, i, m.Label, want) |
| + } |
| + if !m.FirstIsSVG { |
| + t.Errorf("%s: %q has no logo before its label", scheme, m.Label) |
| + continue |
| + } |
| + if m.Hidden != "true" || m.Focusable != "false" { |
| + t.Errorf("%s: %q's logo is aria-hidden=%q focusable=%q; the label already names the provider, so the logo must be silent and untabbable", |
| + scheme, m.Label, m.Hidden, m.Focusable) |
| + } |
| + if math.Abs(m.IconH-1.1*m.FontPx) > 1 { |
| + t.Errorf("%s: %q's logo is %.1fpx tall on %.1fpx text; want about 1.1em", scheme, m.Label, m.IconH, m.FontPx) |
| + } |
| + if math.Abs(m.CentreOff) > 1 { |
| + t.Errorf("%s: %q's logo sits %.1fpx off the button's vertical centre", scheme, m.Label, m.CentreOff) |
| + } |
| + if math.Abs(m.LabelGap-m.BtnGap) > 1 { |
| + t.Errorf("%s: %q's logo is %.1fpx from its label; rst-btn's own gap is %.1fpx", scheme, m.Label, m.LabelGap, m.BtnGap) |
| + } |
| + } |
| + // Apple's mark is one path in currentColor: a fixed fill would |
| + // be black on the dark scheme's dark button, i.e. invisible. |
| + if a := got.Marks[1]; a.FirstIsSVG && a.Fill != a.Colour { |
| + t.Errorf("%s: the Apple logo is filled %s on a button whose text is %s; it must follow the text colour", scheme, a.Fill, a.Colour) |
| + } |
| + } |
| + rig.Screen("main", "the signin page") |
| +} |
| + |
| +// TestFramePaddingIsAsTheRuleSays: the computed padding of a frame |
| +// document's body is 1rem on a component's frame and nothing on a |
| +// shell's, the stage's, the modal's and a staged sign-in's, read in |
| +// the browser rather than off the bytes, since the rule is a selector |
| +// and a selector is only proven by what it selects. |
| +func TestFramePaddingIsAsTheRuleSays(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + for _, c := range []struct { |
| + name, state, want string |
| + }{ |
| + {"button", mustDefault(t, "button"), "16px"}, |
| + {"layout-stage", mustDefault(t, "layout-stage"), "0px"}, |
| + {"layout-sidebar", mustDefault(t, "layout-sidebar"), "0px"}, |
| + {"modal", mustDefault(t, "modal"), "0px"}, |
| + {"signin", "asking-for-an-address", "0px"}, |
| + } { |
| + file := frameFile(theme, "en", c.name, c.state) |
| + var got string |
| + mustRun(t, ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+href(file)), |
| + chromedp.WaitReady("body"), |
| + chromedp.Evaluate(`getComputedStyle(document.body).paddingTop`, &got), |
| + ) |
| + if got != c.want { |
| + t.Errorf("%s: the body's padding-top computes %s, want %s", file, got, c.want) |
| + } |
| + } |
| + rig.Screen("body", "the last frame document") |
| +} |
| + |
| +// slowTheme serves the tree with every theme stylesheet held back, so |
| +// a scheme set only once the stylesheet arrived would be seen setting |
| +// it late. |
| +type slowTheme struct { |
| + next http.Handler |
| + delay time.Duration |
| +} |
| + |
| +func (h slowTheme) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if strings.Contains(r.URL.Path, "/assets/css/themes/") { |
| + time.Sleep(h.delay) |
| + } |
| + h.next.ServeHTTP(w, r) |
| +} |
| + |
| +// firstFrameJS runs before every document's own scripts and records |
| +// what the root carried when <body> was inserted, which is before any |
| +// paint and before any deferred script, so only a script in the head |
| +// can have set it; and again at the first animation frame. |
| +const firstFrameJS = `(function () { |
| + var theme = function () { return document.documentElement.getAttribute("data-theme") || ""; }; |
| + // On the document, not its root: a new document has no root yet |
| + // when this runs. |
| + new MutationObserver(function (records, self) { |
| + if (!document.body) return; |
| + window.__atBody = theme(); |
| + self.disconnect(); |
| + }).observe(document, { childList: true, subtree: true }); |
| + requestAnimationFrame(function () { |
| + window.__firstFrame = { theme: theme(), scheme: getComputedStyle(document.documentElement).colorScheme }; |
| + }); |
| +})();` |
| + |
| +// schemeMarkJS reports the scheme menu's buttons and which is marked. |
| +const schemeMarkJS = `JSON.stringify([...document.querySelectorAll("[rst-scheme] button")].map(b => ({ |
| + V: b.form && b.form.elements.scheme ? b.form.elements.scheme.value : "", |
| + Cur: b.getAttribute("aria-current") || "" |
| +})))` |
| + |
| +// TestADemoOpenedInItsOwnTabKeepsTheScheme: a returning visitor with |
| +// Dark stored opens a shell demo in a tab of its own, with the theme |
| +// stylesheet slow to arrive: data-theme="dark" is on <html> at the |
| +// first animation frame, color-scheme computes dark once styled, and |
| +// the one frame document that holds a scheme menu marks Dark rather |
| +// than the System the markup ships with; after going into the shell's |
| +// page and Back, and after leaving the menu's document and coming |
| +// Back, the scheme and the mark are still dark. fiesta.js restores a |
| +// stored scheme only when it is in the head without defer, and these |
| +// documents load it deferred, so the restore is frame.js's. |
| +func TestADemoOpenedInItsOwnTabKeepsTheScheme(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return slowTheme{newTree(t, nil), 400 * time.Millisecond} }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + sidebar := frameFile(theme, "en", "layout-sidebar", mustDefault(t, "layout-sidebar")) |
| + menu := frameFile(theme, "en", "scheme-menu", mustDefault(t, "scheme-menu")) |
| + |
| + // The visitor's choice, stored on the origin before the demo opens. |
| + mustRun(t, ctx, |
| + chromedp.Navigate(rig.Origin+href(theme+"/en/index.html")), |
| + chromedp.WaitReady("body"), |
| + chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); "ok"`, new(string)), |
| + addInit(firstFrameJS), |
| + ) |
| + |
| + type root struct{ Theme, Scheme string } |
| + // readRoot reads the root once the document is whole, its slow |
| + // stylesheet included, since color-scheme is the stylesheet's. |
| + readRoot := func(where string) root { |
| + t.Helper() |
| + until(t, ctx, where, `document.readyState === "complete"`) |
| + var got root |
| + at(t, ctx, `JSON.stringify({Theme: document.documentElement.getAttribute("data-theme") || "", Scheme: getComputedStyle(document.documentElement).colorScheme})`, &got) |
| + if got.Theme != "dark" { |
| + t.Errorf("%s: <html> carries data-theme=%q, want dark", where, got.Theme) |
| + } |
| + if got.Scheme != "dark" { |
| + t.Errorf("%s: color-scheme computes %q, want dark", where, got.Scheme) |
| + } |
| + return got |
| + } |
| + readMark := func(where string) { |
| + t.Helper() |
| + var buttons []struct{ V, Cur string } |
| + at(t, ctx, schemeMarkJS, &buttons) |
| + if len(buttons) == 0 { |
| + t.Fatalf("%s: no scheme menu button; the mark would be read off nothing", where) |
| + } |
| + for _, b := range buttons { |
| + if (b.V == "dark") != (b.Cur == "true") { |
| + t.Errorf("%s: the %s button is aria-current=%q; Dark alone is marked", where, b.V, b.Cur) |
| + } |
| + } |
| + } |
| + |
| + // The shell demo, opened directly. |
| + mustRun(t, ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+href(sidebar)), |
| + chromedp.WaitReady("body"), |
| + ) |
| + until(t, ctx, "the sidebar demo", `!!window.__firstFrame`) |
| + var atBody string |
| + at(t, ctx, `JSON.stringify(window.__atBody || "")`, &atBody) |
| + if atBody != "dark" { |
| + t.Errorf("when <body> was inserted <html> carried data-theme=%q, want dark: no script in the head set the scheme, so the first paint was in the OS's", atBody) |
| + } |
| + var first root |
| + at(t, ctx, `JSON.stringify(window.__firstFrame)`, &first) |
| + if first.Theme != "dark" { |
| + t.Errorf("at the first animation frame <html> carried data-theme=%q, want dark", first.Theme) |
| + } |
| + t.Logf("the sidebar demo: data-theme=%q at <body>, %q at the first animation frame with color-scheme %q", atBody, first.Theme, first.Scheme) |
| + readRoot("the sidebar demo once loaded") |
| + |
| + // Into the shell's page by its nav, and Back. The page is asked to |
| + // go back on a tick and the new document polled for: chromedp's |
| + // NavigateBack waits for a load this engine does not always report, |
| + // and its node waits never resolve after a history navigation |
| + // (check/menus_browser_test.go). |
| + mustRun(t, ctx, |
| + chromedp.Click(`[rst-shell-nav] a`, chromedp.ByQuery), |
| + chromedp.WaitReady(`[rst-shell-sidebar="page"]`, chromedp.ByQuery), |
| + ) |
| + readRoot("the sidebar's page") |
| + mustRun(t, ctx, chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0); true`, nil)) |
| + until(t, ctx, "the sidebar demo after Back", `!!document.querySelector('[rst-shell-sidebar="index"]')`) |
| + readRoot("the sidebar demo after Back") |
| + |
| + // The one frame document with a scheme menu: the stored scheme is |
| + // marked, not the one the markup ships with; and still after Back. |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+href(menu)), chromedp.WaitReady("body")) |
| + readRoot("the scheme menu's document") |
| + readMark("the scheme menu's document") |
| + mustRun(t, ctx, |
| + chromedp.Navigate(rig.Origin+href(theme+"/en/index.html")), |
| + chromedp.WaitReady("body"), |
| + chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0); true`, nil), |
| + ) |
| + until(t, ctx, "the scheme menu's document after Back", `!!document.querySelector("[rst-scheme]")`) |
| + readRoot("the scheme menu's document after Back") |
| + readMark("the scheme menu's document after Back") |
| + // A fresh navigation before the screen: after a history |
| + // navigation chromedp's node waits never resolve, so the flush of |
| + // the page's problems is taken on the document loaded anew. |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+href(menu)), chromedp.WaitReady("body")) |
| + rig.Screen("body", "the scheme menu's document in Dark") |
| +} |