The design system is the sidebar shell it documents: rail, filter, bar, phone index
Every gallery page is now the sidebar shell Fiesta ships, drawn by
Rastrillo's page template over Fiesta's seams: one rail of the whole
vocabulary with a section per page kind (its Overview item first, then
its entries, the current page's section open and marked, an example's
own entry marked too) and the five shell demos at its foot, each the
layout example's frame document opened in a new tab; the filter over
it; the pinned bar with the brand, the theme tabs, Fiesta's scheme menu
and the language menu; below 800px the Overview as the shell's index
page with its rows and the gallery's intro, every other page a content
page with the shell's back strip returning to its row, and the same
controls as a cog menu (Rastrillo's inlined Lucide settings glyph) in
the index's header row or the back strip, both copies drawn from one
value. gallery.css carries Rastrillo's chrome rules for all of this
with the scheme toggle's rules dropped, the in-place rule widened to
the scheme menu as well as the language menu, and the phone bleed's
border rule written for the stage the widget has today. gallery.js
carries Rastrillo's filter, place and moved sections, with the moved
lookup extended to a rail link whose page stem is the fragment, and a
bootstrap for the scheme fiesta.js owns: the stored scheme on the root
at parse time, every scheme menu copy marked at readiness, on pageshow
after re-reading storage, and after a choice in either copy.
Seams, in nav.go: navItem.Current; searchTerms, keyed by example name
and carried onto the entry whose page stem it names; shellNames;
galleryNav completed; stemOf; sectionOverview(p, section); demos(p);
indexRows(nav); phoneIndex filled for the Overview. Task 7 appends the
modal route and the demo application to demos.
The gate: BROWSER_PKGS gains internal/gallery@4 and
internal/gallery/history, 50-suite@12 is 50-suite@15, and FULL_CHROMIUM
gains internal/gallery/history, whose two legs need a real back/forward
cache restore and a real new tab.
Tests, new to Fiesta, each seen red by assertion against the part 3
template (no shell root, no rail, no rows nav, no prefs card, no bar
after the rail, no shell.js), then green: TestTheGalleryIsTheShippedShell,
TestTheSidebarIsTheShellTheGalleryDocuments (the shell's structural
pieces drawn under the same parents as the shell-sidebar example's),
TestTheRailIsTheSameOnEveryPage, TestTheRailSaysWhichPageYouAreOn,
TestTheSidebarLinksEverythingOnThePageExactlyOnce,
TestEverySectionOfTheRailRoutesToItsOwnPage, TestEveryDemoLinkOpensInANewTab,
TestThePhoneIndexSaysItsDemosOpenANewTab, TestEveryWayUpNamesItsRow,
TestTheTwoCopiesOfTheControlsAgree, TestTheFilterBoxCarriesTheSearchIcon,
TestEverySearchTermNamesAnAnchor, TestTheChromeCarriesTheThreeSwitchers,
TestNoGalleryPageLoadsTheEnhancementScripts. Browser drives, each
failing by name on the shell element it waits for (present) before any
wait: TestTheSidebarFilterDrivesTheWholeJourney,
TestSearchFindsWhatPeopleCallThings, TestTheRailWorksWithNoScript,
TestFocusIsNeverUnderThePinnedBar, TestNothingPaintsOverTheBarOrTheBackStrip,
TestTheBarsLanguageMenuStaysOnScreen, TestTheGallerysOwnControlsAreExactlyTheFloor,
TestTheThemeTabsSitInLineWithTheOtherControls,
TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint,
TestTheDisplaySettingsMenuOnAPhone, TestTheDisplaySettingsKeepYourPlace,
TestThePhoneIndexAndTheWayBack, TestTheIndexTitleIsVisible,
TestA11yScansThePhoneViews, TestPositionSurvivesASwitch,
TestAMovedSectionsOldLinkReachesIt,
TestAMovedSectionsOldLinkReachesItFromAnOpenPage,
TestEveryPageKindFitsAPhoneAt320; in history,
TestTheSchemeFollowsAPageBackFromTheCache and
TestTheSwitchersAddressIsNeverLeftChanged (the last legs of Rastrillo's
TestPositionSurvivesASwitch, in the full-Chromium package because a
Ctrl-click has to open a tab and the last Back has to be a cache
restore).
Already passing when written, each proved live by a named mutation that
turned it red and was reverted: TestGalleryScriptLoadsBeforeTheBody
(gallery.js written defer), TestNoGalleryPageAsksForPrerender (a
speculationrules script in the head),
TestNoPageCarriesAPageWideViewSwitcher (a nav.ds-viewall in the
template), TestThePinnedBarFitsItsReservation (--ds-bar-h 2rem: the band
needs 53px), TestThePrevNextPairSitsAtTheEndsOfItsRow (.ds-updown__next
without grid-column: 2: next ends 502px from the end).
Carried strings only: gallery.nav.*, gallery.demos.shell,
gallery.newtab, gallery.filter*, gallery.prefs, gallery.language*,
gallery.up*, gallery.brand, and gallery.overview.intro for the phone
index's lead. gallery.nav.label ("Pages") is no longer rendered.
Human
Prompt
Task 3 of the Fiesta part 4 plan: the shell (rail, filter, bar, phone index, display settings, keeping your place), in parallel with Tasks 4, 5 and 6 in one worktree, editing only the Files block.
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
Every gallery page becomes the sidebar shell: the rail with its filter and Demos, the pinned bar, the phone index with its way back, the display settings menu, keeping your place and the moved-section lookup in gallery.js, the scheme bootstrap for fiesta.js's menu; the history package and the fifteen-role gate; 22 browser drives and 17 untagged gates.
Agent notes
Gated at 682572c and at 87698c1 in detached worktrees with only these files: every step green except Fiesta's TestTheSchemeMenuRepaintsTheFrames, whose chooseScheme opens the first [rst-scheme] > summary on the page, now the display settings menu's hidden copy; scoping it to .ds-top__controls makes it pass (verified in scratch). That file is Task 2's and Task 7's.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
16 files changed,
+4152
−73
.amadan/ci.d/{50-suite@12 => 50-suite@15}+0 −0Makefile+5 −3internal/gallery/bar_browser_test.go+703 −0internal/gallery/gallery.css+245 −40internal/gallery/gallery.js+321 −4internal/gallery/history/cache_browser_test.go+262 −0internal/gallery/history/rig_browser_test.go+65 −0internal/gallery/index_browser_test.go+295 −0internal/gallery/moved_browser_test.go+86 −0internal/gallery/narrow_browser_test.go+55 −0internal/gallery/nav.go+143 −14internal/gallery/page_tmpl.go+56 −12internal/gallery/place_browser_test.go+299 −0internal/gallery/prefs_browser_test.go+247 −0internal/gallery/rail_browser_test.go+402 −0internal/gallery/rail_test.go+968 −0
CI failed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.142s
=== 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.026s
=== 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.528s
ok amadan.net/rastrillo/fiesta/check 7.864s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 119.235s
ok amadan.net/rastrillo/fiesta/example 0.208s
ok amadan.net/rastrillo/fiesta/internal/axe 0.223s
ok amadan.net/rastrillo/fiesta/internal/build 1.843s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.006s
ok amadan.net/rastrillo/fiesta/internal/gallery 103.930s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.005s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.398s
ok amadan.net/rastrillo/fiesta/internal/site 4.759s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.010s
ok amadan.net/rastrillo/fiesta/internal/www 100.779s
--- 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' .
[90m1:43AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m1:43AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m1:43AM[0m [32mINF[0m [1mscanned ~6219307 bytes (6.22 MB) in 4.88s[0m
[90m1:43AM[0m [32mINF[0m [1mno leaks found[0m
--- 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 16.607s
--- 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 266.713s
--- 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 284.552s
--- 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 289.392s
--- 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 102 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 162.551s
--- 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 31 of 102 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 129.463s
--- 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 35 of 102 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 134.895s
--- 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 35 of 102 tests
--- FAIL: TestTheSchemeMenuRepaintsTheFrames (194.38s)
a11y_browser_test.go:469: field: 7 frames loaded and painted
a11y_browser_test.go:489: context deadline exceeded
FAIL
FAIL amadan.net/rastrillo/fiesta/internal/gallery 249.405s
FAIL
make[1]: *** [Makefile:190: browser.internal.gallery~4of4] Error 1
make: *** [Makefile:64: suite] Error 2
amadan: step suite shard 11 of 15 failed: exit status 2
--- 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)
ok amadan.net/rastrillo/fiesta/internal/gallery/history 32.022s
--- 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
ok amadan.net/rastrillo/fiesta/internal/www 165.824s
--- 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 151.327s
amadan: job failed at step suite shard 11 of 15 — its output is above, not at the tail of this log
Intent
diff --git a/.amadan/ci.d/50-suite@12 b/.amadan/ci.d/50-suite@15| similarity index 100% |
| rename from .amadan/ci.d/50-suite@12 |
| rename to .amadan/ci.d/50-suite@15 |
diff --git a/Makefile b/Makefile| index 57b1c8f..7298694 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -54,7 +54,7 @@ ci: gofmt no-rastrillo size suite |
| # check/drives@2 would become browser.check.drives~1of2 and ~2of2. |
| # The tasks that add a browser package append here and rename |
| # 50-suite@N to match. |
| -BROWSER_PKGS := internal/harness check@3 internal/gallery@2 internal/www@2 |
| +BROWSER_PKGS := internal/harness check@3 internal/gallery@4 internal/gallery/history internal/www@2 |
| go-roles = $(foreach p,$(2),$(if $(findstring @,$(p)),$(call go-slices,$(1),$(subst /,.,$(word 1,$(subst @, ,$(p)))),$(word 2,$(subst @, ,$(p)))),$(1).$(subst /,.,$(p)))) |
| go-slices = $(foreach k,$(shell seq 1 $(3)),$(1).$(2)~$(k)of$(3)) |
| BROWSER_ROLES := $(call go-roles,browser,$(BROWSER_PKGS)) |
| @@ -181,8 +181,10 @@ browser: $(BROWSER_ROLES) |
| # The browser packages that need full Chromium rather than a headless |
| # shell, where both are installed (hack/gotest.sh). check's drives |
| # include the busy button, which needs the back/forward cache the |
| -# shell never restores. |
| -export FULL_CHROMIUM := check |
| +# shell never restores; the gallery's history package holds the legs |
| +# that need that restore or a real new tab, and nothing else, so the |
| +# cost of full Chromium falls on them alone. |
| +export FULL_CHROMIUM := check internal/gallery/history |
| browser.%: | $(BIN)/tmp |
| ./hack/gotest.sh browser '$*' |
diff --git a/internal/gallery/bar_browser_test.go b/internal/gallery/bar_browser_test.go| new file mode 100644 |
| index 0000000..bf0fc8e |
| --- /dev/null |
| +++ b/internal/gallery/bar_browser_test.go |
| @@ -0,0 +1,703 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/input" |
| + "github.com/chromedp/cdproto/runtime" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The pinned bar: what it reserves, what lands under it, what paints |
| +// over it, its language menu, the controls' heights, the prev/next |
| +// pair under main, and the scheme a returning visitor sees before the |
| +// first paint. |
| + |
| +// focusTopJS is the top of whatever has focus, or of the element a |
| +// fragment targets, in the page's coordinates. Inside a preview frame |
| +// the element's offset is in the frame's own pixels and the frame may |
| +// be drawn scaled, so the offset is multiplied by the frame's rendered |
| +// width over its layout width before the frame's top is added. |
| +const focusTopJS = `((el) => { |
| + let top = 0, k = 1; |
| + el = el || document.activeElement; |
| + while (el && el.tagName === "IFRAME") { |
| + const r = el.getBoundingClientRect(); |
| + top += r.top; k *= r.width / el.offsetWidth; |
| + const d = el.contentDocument; |
| + if (!d || !d.activeElement || d.activeElement === d.body) break; |
| + el = d.activeElement; |
| + } |
| + const r = el.getBoundingClientRect(); |
| + const main = document.getElementById("main"), outer = document.activeElement; |
| + return JSON.stringify({Top: el.tagName === "IFRAME" ? top : top + r.top * k, What: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""), |
| + Bar: document.querySelector(".ds-top").getBoundingClientRect().bottom, |
| + End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2, |
| + InMain: !!outer && outer !== document.body && main.contains(outer)}); |
| +})` |
| + |
| +type focusReading struct { |
| + Top, Bar float64 |
| + What string |
| + End bool |
| + // InMain says whether what has focus is inside main (a frame's |
| + // own controls count through their frame): the Tab walks stop |
| + // when focus leaves it for the bar, the rail or the browser. |
| + InMain bool |
| +} |
| + |
| +func readFocus(t *testing.T, ctx context.Context, where, target string) focusReading { |
| + t.Helper() |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(focusTopJS+"("+target+")", &raw)) |
| + var r focusReading |
| + if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + return r |
| +} |
| + |
| +// TestFocusIsNeverUnderThePinnedBar: focus and fragment targets stop |
| +// below the pinned bar, never under it (WCAG 2.4.11): forty Tabs on |
| +// from the first control in main and forty back, the skip link's |
| +// target, and every rail fragment of the page. The sign-in page, whose |
| +// twelve framed forms give the walk its forty stops inside main, and |
| +// Tokens, whose groups the rail links by fragment. A walk stops where |
| +// focus leaves main for the chrome or the browser, and the forward |
| +// walk is held to at least twenty stops so it cannot stop short. A |
| +// target near the end of the page may stop lower, because the browser |
| +// clamps the scroll at the end, but never higher. |
| +func TestFocusIsNeverUnderThePinnedBar(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + fragments := 0 |
| + for _, file := range []string{"signin.html", "tokens.html"} { |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, w := range []int64{1440, 800} { |
| + where := fmt.Sprintf("%s/%s/%s at %dpx", theme, locale, file, w) |
| + mustRun(t, ctx, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady(`main`, chromedp.ByQuery)) |
| + present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail") |
| + eagerly(t, ctx, where) |
| + under := func(how string, r focusReading) { |
| + if r.Top < r.Bar-1 { |
| + t.Errorf("%s: %s puts %s at %.1fpx, under the bar ending at %.1fpx", where, how, r.What, r.Top, r.Bar) |
| + } |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("main [rst-page]").querySelector("a[href], button, summary, input:not([type=hidden]), iframe").focus(); true`, nil)) |
| + stops := 0 |
| + for i := 0; i < 40; i++ { |
| + mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) |
| + r := readFocus(t, ctx, where, "") |
| + if !r.InMain { |
| + break |
| + } |
| + stops++ |
| + under(fmt.Sprintf("tab %d", i+1), r) |
| + } |
| + if file == "signin.html" && stops < 20 { |
| + t.Errorf("%s: the forward walk left main after %d stops; the page was chosen for its forty", where, stops) |
| + } |
| + for i := 0; i < 40; i++ { |
| + mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift))) |
| + r := readFocus(t, ctx, where, "") |
| + if !r.InMain { |
| + break |
| + } |
| + under(fmt.Sprintf("shift+tab %d", i+1), r) |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`scrollTo(0, 2000); document.querySelector("[rst-skip]").click(); true`, nil)) |
| + under("the skip link", readFocus(t, ctx, where, `document.getElementById("main")`)) |
| + var hrefs []string |
| + mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll("#ds-nav a[href*='#']")].filter(a => a.pathname === location.pathname).map(a => a.hash.slice(1))`, &hrefs)) |
| + for _, id := range hrefs { |
| + fragments++ |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil)) |
| + r := readFocus(t, ctx, where, fmt.Sprintf(`document.getElementById(%q)`, id)) |
| + under("#"+id, r) |
| + if !r.End && r.Top > r.Bar+40 { |
| + t.Errorf("%s: #%s lands at %.1fpx, far below the bar's %.1fpx with the page not at its end; the scroll padding is not what the fragment stopped at", where, id, r.Top, r.Bar) |
| + } |
| + } |
| + } |
| + } |
| + } |
| + if fragments == 0 { |
| + t.Fatal("the rail links no fragment of any page read; the fragment leg checked nothing") |
| + } |
| +} |
| + |
| +// stripBadJS scans down the page in steps of at most half the strip's |
| +// own height, sampling roughly every 32px across the strip's width at |
| +// its top, middle and bottom edges, and reports every sampled point |
| +// where elementFromPoint lands outside the strip. |
| +const stripBadJS = `((sel) => { |
| + const strip = document.querySelector(sel); |
| + const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2); |
| + const bad = []; |
| + for (let y = 0; y < document.documentElement.scrollHeight; y += step) { |
| + scrollTo(0, y); |
| + const r = strip.getBoundingClientRect(); |
| + for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) { |
| + for (let x = r.left; x <= r.right; x += 32) { |
| + const hit = document.elementFromPoint(x, yy); |
| + if (!hit || !strip.contains(hit)) bad.push({X: Math.round(x), Y: Math.round(yy), Hit: hit ? hit.tagName + "." + hit.className : "nothing"}); |
| + } |
| + } |
| + } |
| + return JSON.stringify(bad); |
| +})` |
| + |
| +// stripH2PointsJS finds, at the same scroll steps and sample points, |
| +// every point a <main> heading's own box covers, by geometry: the |
| +// control for the scan's density. |
| +const stripH2PointsJS = `((sel) => { |
| + const strip = document.querySelector(sel); |
| + const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2); |
| + const pts = []; |
| + for (let y = 0; y < document.documentElement.scrollHeight; y += step) { |
| + scrollTo(0, y); |
| + const r = strip.getBoundingClientRect(); |
| + const h2s = [...document.querySelectorAll("main h2")].map(h2 => h2.getBoundingClientRect()); |
| + for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) { |
| + for (let x = r.left; x <= r.right; x += 32) { |
| + if (h2s.some(hr => x >= hr.left && x <= hr.right && yy >= hr.top && yy <= hr.bottom)) pts.push({X: Math.round(x), Y: Math.round(yy)}); |
| + } |
| + } |
| + } |
| + return JSON.stringify(pts); |
| +})` |
| + |
| +type stripPoint struct { |
| + X, Y float64 |
| + Hit string |
| +} |
| + |
| +func evalStripPoints(t *testing.T, ctx context.Context, where, js, selector string) []stripPoint { |
| + t.Helper() |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(js+"("+fmt.Sprintf("%q", selector)+")", &raw)) |
| + var pts []stripPoint |
| + if err := json.Unmarshal([]byte(raw), &pts); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + return pts |
| +} |
| + |
| +// TestNothingPaintsOverTheBarOrTheBackStrip: nothing in the content |
| +// paints over the pinned chrome: at 1440px the bar, and at 390px the |
| +// shell's back strip, are what elementFromPoint finds at their edges |
| +// everywhere down a page of frames. A second pass raises every <main> |
| +// heading above the strip and checks the scan catches every point a |
| +// heading's box covers: the control for the scan's own density. |
| +func TestNothingPaintsOverTheBarOrTheBackStrip(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + for _, c := range []struct { |
| + w int64 |
| + strip string |
| + }{{1440, ".ds-top"}, {390, "[rst-shell-back]"}} { |
| + where := fmt.Sprintf("signin.html at %dpx", c.w) |
| + mustRun(t, ctx, chromedp.EmulateViewport(c.w, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail") |
| + mustRun(t, ctx, chromedp.WaitVisible(c.strip, chromedp.ByQuery)) |
| + eagerly(t, ctx, where) |
| + before := evalStripPoints(t, ctx, where, stripBadJS, c.strip) |
| + if len(before) > 0 { |
| + if len(before) > 6 { |
| + before = before[:6] |
| + } |
| + t.Errorf("%s: something paints over %s: %v", where, c.strip, before) |
| + } |
| + h2Points := evalStripPoints(t, ctx, where, stripH2PointsJS, c.strip) |
| + if len(h2Points) == 0 { |
| + t.Fatalf("%s: no heading passes under %s at any scroll step, so nothing checks the scan can see one", where, c.strip) |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.head.insertAdjacentHTML("beforeend", "<style>main h2{position:relative;z-index:11}</style>"); true`, nil)) |
| + after := evalStripPoints(t, ctx, where, stripBadJS, c.strip) |
| + caught := make(map[[2]int]bool, len(after)) |
| + for _, p := range after { |
| + caught[[2]int{int(p.X), int(p.Y)}] = true |
| + } |
| + var missed []stripPoint |
| + for _, p := range h2Points { |
| + if !caught[[2]int{int(p.X), int(p.Y)}] { |
| + missed = append(missed, p) |
| + } |
| + } |
| + if len(missed) > 0 { |
| + if len(missed) > 6 { |
| + missed = missed[:6] |
| + } |
| + t.Errorf("%s: a heading raised above %s went uncaught at %d of %d points its own box covers: %v", where, c.strip, len(missed), len(h2Points), missed) |
| + } |
| + } |
| +} |
| + |
| +// barFit reads the bar's box, the union of what is inside it, whether |
| +// that content shares one line, and the height the content alone |
| +// needs. The bar's own box is fixed at --ds-bar-h, so it cannot |
| +// measure its own overflow; its children can. |
| +const barFit = `(() => { |
| + const bar = document.querySelector(".ds-top"), b = bar.getBoundingClientRect(); |
| + let top = Infinity, bottom = -Infinity, left = Infinity, right = -Infinity, maxTop = -Infinity, minBottom = Infinity; |
| + for (const el of bar.querySelectorAll(".ds-top__brand, .ds-top__controls > *")) { |
| + const r = el.getBoundingClientRect(); |
| + if (!r.width && !r.height) continue; |
| + top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); |
| + left = Math.min(left, r.left); right = Math.max(right, r.right); |
| + maxTop = Math.max(maxTop, r.top); minBottom = Math.min(minBottom, r.bottom); |
| + } |
| + const prevStyle = bar.getAttribute("style"); |
| + bar.style.blockSize = "auto"; |
| + const need = bar.getBoundingClientRect().height; |
| + if (prevStyle === null) bar.removeAttribute("style"); else bar.setAttribute("style", prevStyle); |
| + return JSON.stringify({Top: b.top, Bottom: b.bottom, Left: b.left, Right: b.right, |
| + UTop: top, UBottom: bottom, ULeft: left, URight: right, Over: bar.scrollWidth - bar.clientWidth, |
| + Need: need, MaxTop: maxTop, MinBottom: minBottom}); |
| +})()` |
| + |
| +type barReading struct { |
| + Top, Bottom, Left, Right, UTop, UBottom, ULeft, URight, Over, Need, MaxTop, MinBottom float64 |
| +} |
| + |
| +// barHSlack is how far above the measured maximum --ds-bar-h may sit: |
| +// values are rounded up to the next quarter rem (4px), so up to 4px of |
| +// slack is the rounding itself rather than a reservation nobody needs. |
| +const barHSlack = 4.5 |
| + |
| +// TestThePinnedBarFitsItsReservation: every theme and locale, scripts |
| +// on and off, at both edges of both bands: what is in the bar lies |
| +// inside it, nothing in it overflows sideways, it is still at the top |
| +// after scrolling to the end, and from 1024px it never wraps. A |
| +// failure names the height the band needs, which is the number to |
| +// write into --ds-bar-h; the last check does the comparison the other |
| +// way, catching a reservation nobody's content has needed since. |
| +func TestThePinnedBarFitsItsReservation(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) |
| + defer cancel() |
| + need := map[string]float64{} |
| + reservation := map[string]float64{} |
| + for _, scripts := range []bool{true, false} { |
| + tab := ctx |
| + if !scripts { |
| + tab = noScripts(t, ctx) |
| + } |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + url := rig.Origin + pageAt(theme, locale, "form.html") |
| + mustRun(t, tab, chromedp.EmulateViewport(1440, 900), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| + present(t, tab, theme+"/"+locale, "[rst-shell-sidebar] > .ds-rail") |
| + if !scripts { |
| + requireScriptsOff(t, tab, fmt.Sprintf("%s/%s scripts off", theme, locale)) |
| + } |
| + for _, w := range []int64{1440, 1024, 1023, 800} { |
| + where := fmt.Sprintf("%s/%s at %dpx, scripts %v", theme, locale, w, scripts) |
| + band := "1024 and up" |
| + if w < 1024 { |
| + band = "800 to 1023" |
| + } |
| + var raw string |
| + var after float64 |
| + mustRun(t, tab, |
| + chromedp.EmulateViewport(w, 900), |
| + chromedp.Evaluate(`scrollTo(0, 0)`, nil), |
| + chromedp.Evaluate(barFit, &raw), |
| + chromedp.Evaluate(`scrollTo(0, document.documentElement.scrollHeight); document.querySelector(".ds-top").getBoundingClientRect().top`, &after), |
| + ) |
| + var r barReading |
| + if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + need[band] = max(need[band], r.Need) |
| + reservation[band] = r.Bottom - r.Top |
| + if r.UTop < r.Top-0.5 || r.UBottom > r.Bottom+0.5 || r.ULeft < r.Left-0.5 || r.URight > r.Right+0.5 { |
| + t.Errorf("%s: the bar's contents [%v…%v × %v…%v] spill out of its box [%v…%v × %v…%v]; this band needs --ds-bar-h of at least %.0fpx", where, r.ULeft, r.URight, r.UTop, r.UBottom, r.Left, r.Right, r.Top, r.Bottom, r.Need) |
| + } |
| + if r.Over > 0.5 { |
| + t.Errorf("%s: the bar overflows sideways by %.1fpx", where, r.Over) |
| + } |
| + if after < -0.5 || after > 0.5 { |
| + t.Errorf("%s: scrolled to the end, the bar's top is at %.1fpx, not pinned at 0", where, after) |
| + } |
| + if w >= 1024 && r.MaxTop >= r.MinBottom-0.5 { |
| + t.Errorf("%s: the brand and the controls do not share one line (the latest top %.1fpx is not above the earliest bottom %.1fpx); the bar has wrapped", where, r.MaxTop, r.MinBottom) |
| + } |
| + } |
| + } |
| + } |
| + } |
| + for _, band := range []string{"1024 and up", "800 to 1023"} { |
| + if over := reservation[band] - need[band]; over > barHSlack { |
| + t.Errorf("--ds-bar-h for %s reserves %.1fpx but the measured maximum content needs only %.1fpx, %.1fpx more than this drive's rounding slack", band, reservation[band], need[band], over) |
| + } |
| + } |
| + t.Logf("tallest bar contents per band (the --ds-bar-h each needs): %v", need) |
| +} |
| + |
| +// withoutAnchorPositioning replaces the page's tokens.css with a copy |
| +// whose anchor-positioning @supports condition can never hold, which |
| +// is what an engine without the feature gets: the menu panel |
| +// positioned absolutely under its summary instead of fixed against it. |
| +const withoutAnchorPositioning = `(async () => { |
| + const link = document.querySelector('link[href*="/tokens."][href$=".css"]'); |
| + const css = await (await fetch(link.href)).text(); |
| + const style = document.createElement("style"); |
| + style.textContent = css.replaceAll("@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: fiesta-no-such-value)"); |
| + link.replaceWith(style); |
| + return true; |
| +})()` |
| + |
| +// requireAnchorPositioning is the control for a leg that says which |
| +// positioning the panel got: fixed with anchor positioning, not |
| +// without. |
| +func requireAnchorPositioning(t *testing.T, ctx context.Context, where, selector string, want bool) { |
| + t.Helper() |
| + var pos string |
| + mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+fmt.Sprintf("%q", selector)+`)).position`, &pos)) |
| + if (pos == "fixed") != want { |
| + t.Fatalf("%s: the menu panel is position: %s; this leg needs anchor positioning %v", where, pos, want) |
| + } |
| +} |
| + |
| +// TestTheBarsLanguageMenuStaysOnScreen: the bar's language menu, |
| +// opened, lies inside the viewport and every language is reachable by |
| +// keyboard: left to right and right to left, with anchor positioning |
| +// and without, with scripts and without, at both edges of both bands. |
| +// On the sign-in page, whose twelve frames make it tall enough at |
| +// every width to draw the scrollbar the measurement is taken against. |
| +func TestTheBarsLanguageMenuStaysOnScreen(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + for _, scripts := range []bool{true, false} { |
| + tab := ctx |
| + if !scripts { |
| + tab = noScripts(t, ctx) |
| + } |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, anchored := range []bool{true, false} { |
| + for _, w := range []int64{1440, 1024, 1023, 800} { |
| + where := fmt.Sprintf("%s/%s at %dpx, scripts %v, anchor positioning %v", theme, locale, w, scripts, anchored) |
| + mustRun(t, tab, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "signin.html")), chromedp.WaitReady("body")) |
| + present(t, tab, where, "[rst-shell-sidebar] > .ds-rail") |
| + if !scripts { |
| + requireScriptsOff(t, tab, where) |
| + } |
| + if !anchored { |
| + mustRun(t, tab, chromedp.Evaluate(withoutAnchorPositioning, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) |
| + } |
| + requireAnchorPositioning(t, tab, where, ".ds-top__controls [rst-locale] > [rst-dropdown-menu]", anchored) |
| + requireDrawnScrollbar(t, tab, where) |
| + var raw string |
| + mustRun(t, tab, chromedp.Evaluate(`(() => { |
| + const d = document.querySelector(".ds-top__controls [rst-locale]"); |
| + d.open = true; |
| + const p = d.querySelector("[rst-dropdown-menu]").getBoundingClientRect(); |
| + d.querySelector("summary").focus(); |
| + return JSON.stringify({L: p.left, R: p.right, T: p.top, B: p.bottom, W: document.documentElement.clientWidth, H: document.documentElement.clientHeight}); |
| + })()`, &raw)) |
| + var p struct{ L, R, T, B, W, H float64 } |
| + if err := json.Unmarshal([]byte(raw), &p); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + if p.L < -0.5 || p.T < -0.5 || p.R > p.W+0.5 || p.B > p.H+0.5 { |
| + t.Errorf("%s: the open panel [%v…%v × %v…%v] is not inside the %vx%v client box", where, p.L, p.R, p.T, p.B, p.W, p.H) |
| + } |
| + for i, want := range Locales { |
| + var got string |
| + mustRun(t, tab, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`(() => { |
| + const a = document.activeElement, r = a.getBoundingClientRect(); |
| + const de = document.documentElement, inside = r.top >= 0 && r.bottom <= de.clientHeight && r.left >= 0 && r.right <= de.clientWidth; |
| + return a.closest(".ds-top__controls [rst-locale] [rst-dropdown-menu]") && inside ? a.lang : "not a visible language: " + a.outerHTML.slice(0, 60); |
| + })()`, &got)) |
| + if got != want { |
| + t.Errorf("%s: tab %d lands on %s, want the %s link on screen", where, i+1, got, want) |
| + break |
| + } |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheGallerysOwnControlsAreExactlyTheFloor: the gallery's own |
| +// controls carry ds- classes the system's floors cannot match, so each |
| +// reads --rst-target itself. Held exactly, not "at least": each control |
| +// is the larger of the floor and its own height with the floor lifted, |
| +// so the floor never adds to a control already over it. The rail's |
| +// section headings, the filter box and the prev/next links with a |
| +// mouse at 1280px; the prev/next links at 600px; the display settings |
| +// button and the prev/next links on a coarse pointer at 390px, where |
| +// the floor is 44 and the rail is not drawn. The preview widget's tabs |
| +// and Copy buttons are the widget's own gate. |
| +func TestTheGallerysOwnControlsAreExactlyTheFloor(t *testing.T) { |
| + const read = `(() => { |
| + const floor = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize); |
| + const h = e => Math.round(e.getBoundingClientRect().height * 10) / 10; |
| + const each = sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => { |
| + const got = h(e); e.style.minBlockSize = "0px"; const own = h(e); e.style.minBlockSize = ""; |
| + return {Got: got, Want: Math.max(floor, own), W: e.getBoundingClientRect().width}; }); |
| + return JSON.stringify({Floor: floor, Heads: each(".ds-nav > details > summary"), Filter: each(".ds-search input"), Steps: each(".ds-updown__prev, .ds-updown__next"), Prefs: each(".ds-prefs > summary")}); |
| + })()` |
| + theme := fiesta.ThemeNames()[0] |
| + for _, c := range []struct { |
| + name string |
| + rig func(*testing.T) *harness.Rig |
| + w, h int64 |
| + floor float64 |
| + rail bool |
| + }{ |
| + {"a mouse at 1280px", func(t *testing.T) *harness.Rig { |
| + return harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + }, 1280, 800, 32, true}, |
| + {"a mouse at 600px", func(t *testing.T) *harness.Rig { |
| + return harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + }, 600, 800, 44, false}, |
| + {"a coarse pointer at 390px", func(t *testing.T) *harness.Rig { return phoneRig(t) }, 390, 844, 44, false}, |
| + } { |
| + rig := c.rig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + var raw string |
| + mustRun(t, ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, c.name, ".ds-updown") |
| + mustRun(t, ctx, chromedp.Evaluate(read, &raw)) |
| + cancel() |
| + type reading struct{ Got, Want, W float64 } |
| + var got struct { |
| + Floor float64 |
| + Heads, Filter, Steps, Prefs []reading |
| + } |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: decoding %s: %v", c.name, raw, err) |
| + } |
| + if got.Floor != c.floor { |
| + t.Errorf("%s: --rst-target is %vpx, want %v", c.name, got.Floor, c.floor) |
| + } |
| + if len(got.Steps) == 0 || c.rail && (len(got.Heads) == 0 || len(got.Filter) == 0) || !c.rail && c.floor == 44 && c.w == 390 && len(got.Prefs) == 0 { |
| + t.Fatalf("%s: read %d rail headings, %d filter boxes, %d prev/next links and %d display settings buttons; the selectors no longer match the page", c.name, len(got.Heads), len(got.Filter), len(got.Steps), len(got.Prefs)) |
| + } |
| + for what, rs := range map[string][]reading{"a rail section heading": got.Heads, "the filter box": got.Filter, "a prev/next link": got.Steps, "the display settings button": got.Prefs} { |
| + for _, h := range rs { |
| + if math.Abs(h.Got-h.Want) > 0.05 { |
| + t.Errorf("%s: %s is %vpx tall, want %v, the floor or its own height", c.name, what, h.Got, h.Want) |
| + break |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheThemeTabsSitInLineWithTheOtherControls: the theme switcher is |
| +// the system's seg-tabs, which leaves a block's gap under itself. In |
| +// the bar's centred row that margin would lift the tabs above the |
| +// menus beside them; in the phone card's column it would more than |
| +// double the space under them. |
| +func TestTheThemeTabsSitInLineWithTheOtherControls(t *testing.T) { |
| + theme := fiesta.ThemeNames()[0] |
| + t.Run("the bar at 1280", func(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + for _, locale := range []string{"en", "ar"} { |
| + where := theme + "/" + locale + "/index.html at 1280" |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, where, ".ds-top__controls [rst-scheme]") |
| + var mid [2]float64 |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const m = s => { const r = document.querySelector(".ds-top__controls " + s).getBoundingClientRect(); return r.top + r.height / 2; }; |
| + return [m("[rst-seg-tabs]"), m("[rst-scheme] > summary")]; })()`, &mid)) |
| + if math.Abs(mid[0]-mid[1]) > 1 { |
| + t.Errorf("%s: the theme tabs' centre line is at %.1fpx and the scheme menu's at %.1fpx; want them level", where, mid[0], mid[1]) |
| + } |
| + } |
| + }) |
| + t.Run("the phone card at 390", func(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + for _, locale := range []string{"en", "ar"} { |
| + where := theme + "/" + locale + "/index.html at 390" |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, where, ".ds-prefs > summary") |
| + mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery), chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs]", chromedp.ByQuery)) |
| + requireCoarse(t, ctx) |
| + settleMotion(t, ctx, where) |
| + var gaps []float64 |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const kids = [...document.querySelector(".ds-prefs > [rst-dropdown-menu]").children].filter(k => k.checkVisibility()).map(k => k.getBoundingClientRect()); |
| + return kids.slice(1).map((r, i) => r.top - kids[i].bottom); })()`, &gaps)) |
| + if len(gaps) < 2 { |
| + t.Fatalf("%s: the card holds %d groups; it needs three for the gaps between them to be compared", where, len(gaps)+1) |
| + } |
| + for i, g := range gaps[1:] { |
| + if math.Abs(g-gaps[0]) > 0.5 { |
| + t.Errorf("%s: the gap under the theme tabs is %.1fpx and gap %d is %.1fpx; want every group the same distance apart", where, gaps[0], i+2, g) |
| + } |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// TestThePrevNextPairSitsAtTheEndsOfItsRow: previous is flush at the |
| +// inline start and never crosses the middle; next is flush at the |
| +// inline end and begins at or past the middle, so a missing previous |
| +// leaves its space. The two ends and one middle page of the sequence, |
| +// in both directions. |
| +func TestThePrevNextPairSitsAtTheEndsOfItsRow(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| + defer cancel() |
| + const measure = `(() => { |
| + const box = document.querySelector(".ds-updown"); |
| + if (!box) return JSON.stringify({error: "no prev/next strip on this page"}); |
| + const dir = getComputedStyle(document.documentElement).direction; |
| + const b = box.getBoundingClientRect(); |
| + const read = (sel) => { |
| + const el = box.querySelector(sel); |
| + if (!el) return null; |
| + const r = el.getBoundingClientRect(); |
| + return {startGap: Math.round(dir === "rtl" ? b.right - r.right : r.left - b.left), endGap: Math.round(dir === "rtl" ? r.left - b.left : b.right - r.right), size: Math.round(r.width)}; |
| + }; |
| + return JSON.stringify({dir: dir, width: Math.round(b.width), prev: read(".ds-updown__prev"), next: read(".ds-updown__next")}); |
| + })()` |
| + type edge struct { |
| + StartGap int `json:"startGap"` |
| + EndGap int `json:"endGap"` |
| + Size int `json:"size"` |
| + } |
| + type strip struct { |
| + Error string `json:"error"` |
| + Dir string `json:"dir"` |
| + Width int `json:"width"` |
| + Prev *edge `json:"prev"` |
| + Next *edge `json:"next"` |
| + } |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + theme := fiesta.ThemeNames()[0] |
| + for _, locale := range []string{"en", "ar"} { |
| + seq := s.sequence(theme, locale) |
| + for _, pos := range []int{0, 1, len(seq) - 1} { |
| + name := strings.TrimPrefix(seq[pos].Href, mountPath+"/") |
| + var raw string |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+seq[pos].Href), chromedp.WaitReady("body")) |
| + present(t, ctx, name, ".ds-updown") |
| + mustRun(t, ctx, chromedp.Evaluate(measure, &raw)) |
| + var got strip |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the measurement: %v", name, err) |
| + } |
| + if got.Error != "" { |
| + t.Errorf("%s: %s", name, got.Error) |
| + continue |
| + } |
| + if got.Dir != Dir(locale) { |
| + t.Errorf("%s: the page renders dir=%q, want %q", name, got.Dir, Dir(locale)) |
| + continue |
| + } |
| + if got.Width < 200 { |
| + t.Errorf("%s: the strip is %dpx wide; there is nothing to measure", name, got.Width) |
| + continue |
| + } |
| + half := got.Width / 2 |
| + if (got.Prev != nil) != (pos > 0) { |
| + t.Errorf("%s: previous link present=%v, want %v", name, got.Prev != nil, pos > 0) |
| + } |
| + if (got.Next != nil) != (pos < len(seq)-1) { |
| + t.Errorf("%s: next link present=%v, want %v", name, got.Next != nil, pos < len(seq)-1) |
| + } |
| + if p := got.Prev; p != nil { |
| + if p.StartGap > 2 { |
| + t.Errorf("%s: previous starts %dpx from the strip's inline start, want 0 (%s, strip %dpx)", name, p.StartGap, got.Dir, got.Width) |
| + } |
| + if p.StartGap+p.Size > half+2 { |
| + t.Errorf("%s: previous runs %dpx into a %dpx strip, past its half (%d)", name, p.StartGap+p.Size, got.Width, half) |
| + } |
| + } |
| + if n := got.Next; n != nil { |
| + if n.EndGap > 2 { |
| + t.Errorf("%s: next ends %dpx from the strip's inline end, want 0 (%s, strip %dpx)", name, n.EndGap, got.Dir, got.Width) |
| + } |
| + if start := got.Width - n.EndGap - n.Size; start < half-2 { |
| + t.Errorf("%s: next begins %dpx into a %dpx strip, before its half (%d); the missing previous has not left its space", name, start, got.Width, half) |
| + } |
| + } |
| + if got.Prev == nil && got.Next == nil { |
| + t.Errorf("%s: the strip is empty", name) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// markedJS reads every scheme menu on the page: for each, the value of |
| +// the one button carrying aria-current="true", or "" for none and |
| +// "many" for more than one. |
| +const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => { |
| + const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true"); |
| + return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : ""; |
| +})` |
| + |
| +// TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint: a returning |
| +// visitor with Dark stored sees a dark page from the first frame, even |
| +// with the theme stylesheet slow to arrive, and after load the Dark |
| +// button carries aria-current in both the bar's and the display |
| +// settings menu's scheme menu while System carries none. fiesta.js |
| +// skips its own restore when data-theme is already on the root, so |
| +// the marking is the gallery script's. The control is a fresh visit, |
| +// where System is marked in both. |
| +func TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint(t *testing.T) { |
| + theme := fiesta.ThemeNames()[0] |
| + slow := func(t *testing.T) http.Handler { |
| + inner := treeHandler(t) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if strings.Contains(r.URL.Path, "/assets/css/themes/") { |
| + time.Sleep(500 * time.Millisecond) |
| + } |
| + inner.ServeHTTP(w, r) |
| + }) |
| + } |
| + for _, c := range []struct{ name, store, want string }{{"Dark stored", `localStorage.setItem("fiesta-scheme", "dark");`, "dark"}, {"a fresh visit", "", "system"}} { |
| + rig := harness.New(t, func(string) http.Handler { return slow(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + mustRun(t, ctx, |
| + addInit(c.store+` requestAnimationFrame(() => { window.__firstFrame = document.documentElement.getAttribute("data-theme") || "system"; });`), |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), |
| + chromedp.WaitReady("body"), |
| + ) |
| + present(t, ctx, c.name, ".ds-prefs [rst-scheme]") |
| + until(t, ctx, c.name, `document.readyState === "complete" && window.__firstFrame !== undefined`) |
| + var first string |
| + var marked []string |
| + mustRun(t, ctx, chromedp.Evaluate(`window.__firstFrame`, &first), chromedp.Evaluate(markedJS, &marked)) |
| + cancel() |
| + if first != c.want { |
| + t.Errorf("%s: the first frame painted with data-theme %q, want %q", c.name, first, c.want) |
| + } |
| + if len(marked) != 2 { |
| + t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", c.name, len(marked)) |
| + } |
| + for i, m := range marked { |
| + if m != c.want { |
| + t.Errorf("%s: scheme menu %d marks %q current, want %q", c.name, i, m, c.want) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/gallery/gallery.css b/internal/gallery/gallery.css| index 84d6952..d94a503 100644 |
| --- a/internal/gallery/gallery.css |
| +++ b/internal/gallery/gallery.css |
| @@ -1,61 +1,266 @@ |
| /* gallery.css: the gallery's own chrome, and nothing an application |
| - receives. The header, the routes, a state's label, the previous and |
| - next pair, the token grid and the icon grid. Everything is drawn |
| - from the design system's own tokens, so the chrome follows the theme |
| - and the scheme like the examples inside it do. The preview widget, |
| - the code block and the highlight colours are view.css's; the two are |
| - served joined as one gallery.css. */ |
| + receives. The section rhythm, the routes, the previous and next |
| + pair, the token and icon grids, the rail and its filter, the pinned |
| + bar, the phone index and the display settings menu. Every value is a |
| + token, so the page's own furniture changes theme with everything |
| + else. The preview widget, the code block and the highlight colours |
| + are view.css's; the two are served joined as one gallery.css. |
| -/* The header: the brand, the theme tabs, the scheme menu, the language |
| - menu and the page links, wrapping as they need to at any width. The |
| - seg-tabs' own margin is for a page body, not a bar. */ |
| -.ds-top { align-items: center; border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-3) var(--rst-sp-4); padding: var(--rst-sp-3) var(--rst-sp-4); } |
| -.ds-top [rst-seg-tabs] { margin-block-end: 0; } |
| -.ds-top__controls { align-items: center; display: flex; flex-wrap: wrap; gap: var(--rst-sp-3) var(--rst-sp-4); } |
| -.ds-top__brand { color: var(--rst-text); font-weight: 650; text-decoration: none; } |
| -.ds-pages { display: flex; flex-wrap: wrap; gap: var(--rst-sp-3); } |
| -.ds-pages a[aria-current] { font-weight: 650; } |
| -/* Every link in the bar is a target like any control: the floor in |
| - force, centred on its line. */ |
| -.ds-top__brand, .ds-pages a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| + It is Rastrillo's gallery.css for the same chrome, with the scheme |
| + toggle's rules gone (Fiesta's scheme control is the system's own |
| + dropdown, lifted from the scheme-menu example) and data-gallery-js |
| + where Rastrillo's reads data-rst-js: the marker gallery.js sets on |
| + <html>, which is the whole of the scriptless story for the filter |
| + below. */ |
| +.ds-head { border-top: 1px solid var(--rst-line); margin: var(--rst-sp-6) 0 var(--rst-sp-3); padding-top: var(--rst-sp-5); } |
| +.ds-head h2 { font-size: 1.35rem; letter-spacing: -0.01em; margin: 0; } |
| +.ds-lead { color: var(--rst-text-muted); margin: 0 0 var(--rst-sp-4); max-width: 62ch; } |
| +.ds-sub { font-size: var(--rst-fs-base); margin: var(--rst-sp-5) 0 var(--rst-sp-2); } |
| /* A heading is one word on the index, the tokens and the icons pages, |
| and a word has to be able to break at 320px: the page may not scroll |
| sideways for anything. */ |
| -main[rst-page] h1, main[rst-page] h2 { overflow-wrap: anywhere; } |
| -.ds-lead { color: var(--rst-text-muted); margin: 0 0 var(--rst-sp-4); max-width: 62ch; } |
| -.ds-note { border-inline-start: 2px solid var(--rst-line-strong); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-4); max-width: 62ch; padding-inline-start: var(--rst-sp-3); } |
| -.ds-sub { font-size: var(--rst-fs-base); margin: var(--rst-sp-5) 0 var(--rst-sp-2); } |
| +[rst-page] h1, [rst-page] h2 { overflow-wrap: anywhere; } |
| /* An example page's family, under its heading: the family's name and |
| its sentence, as a kicker back to the page that files it. */ |
| .ds-kicker { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-2); max-width: 62ch; } |
| .ds-kicker b { color: var(--rst-text); font-weight: 600; } |
| -/* The routes off the Overview and a family page's examples: a link |
| - and the sentence beside it. */ |
| -.ds-routes { display: grid; gap: var(--rst-sp-3); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; } |
| -.ds-routes a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| -.ds-routes span { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-sm); } |
| -/* The previous and next pair at the foot of a page, one at each end |
| - of its row. */ |
| -.ds-updown { display: grid; gap: var(--rst-sp-3); grid-template-columns: 1fr 1fr; margin-block-start: var(--rst-sp-5); } |
| -.ds-updown a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| -.ds-updown__prev { grid-column: 1; justify-self: start; } |
| -.ds-updown__next { grid-column: 2; justify-self: end; text-align: end; } |
| - |
| -/* One state: its label, then the widget view.css draws. */ |
| -.ds-sample { margin-block: var(--rst-sp-5); } |
| -.ds-state { font-size: var(--rst-fs-base); margin: 0 0 var(--rst-sp-2); } |
| +/* One state: a card, its label, the note on it, the widget view.css |
| + draws, and the routes it stands for. The label is styled by class, |
| + not by level: the outline moved once when the pages got real titles, |
| + and a rule keyed to h2 would silently unstyle the label the next |
| + time it moves. */ |
| +.ds-sample { background: var(--rst-surface-2); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); margin: var(--rst-sp-3) 0; padding: var(--rst-sp-4); } |
| +.ds-state { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 650; letter-spacing: 0.06em; margin: 0 0 var(--rst-sp-3); text-transform: uppercase; } |
| +.ds-note { border-inline-start: 2px solid var(--rst-line-strong); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: var(--rst-sp-3) 0 0; max-width: 62ch; padding-inline-start: var(--rst-sp-3); } |
| +/* A grouped widget's note names its state on a line of its own, so no |
| + joining punctuation has to be written, and translated, between the |
| + two. */ |
| +.ds-note__state { display: block; font-weight: 600; } |
| .ds-routes-note { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: var(--rst-sp-3) 0 0; max-width: 62ch; } |
| - |
| +/* The Overview's link to the demo is the whole sentence, not a link in |
| + running text, so it takes the floor. inline-block, because an inline |
| + box's padding is added to its font's content area, not its line, so |
| + the same padding came out 28px. */ |
| +.ds-demo > .ds-note > a { display: inline-block; padding-block: max(0px, (var(--rst-target) - 1lh) / 2); } |
| /* The token grid, and the icon grid that reuses it: one chip per |
| - token, its name and value beside it. */ |
| + token, its name and value beside it. The icon sits inside its chip |
| + at the 1em tokens.css gives it, which is the size a component draws |
| + it at rather than a poster of it. The call under an icon's name is |
| + long enough to overrun a 13rem column on a 320px screen, so that is |
| + the one place in the grid that breaks inside a word. */ |
| .ds-toks { display: grid; gap: var(--rst-sp-3); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; } |
| .ds-tok { align-items: center; display: flex; gap: var(--rst-sp-3); min-inline-size: 0; } |
| .ds-tok__text { min-inline-size: 0; } |
| .ds-tok__name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .ds-tok__value { color: var(--rst-text-muted); display: block; overflow-wrap: anywhere; } |
| -.ds-chip { background: var(--rst-surface); block-size: 2.25rem; border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); flex: none; inline-size: 2.25rem; } |
| +.ds-chip { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); block-size: 2.25rem; flex: none; inline-size: 2.25rem; } |
| .ds-chip--fill { background: var(--rst-accent); border-color: var(--rst-accent); } |
| .ds-chip--icon { align-items: center; color: var(--rst-text); display: flex; justify-content: center; } |
| .ds-bar { background: var(--rst-accent); block-size: 1.25rem; border-radius: 2px; flex: none; } |
| .ds-type { display: block; flex: none; inline-size: 3.25rem; line-height: 1.15; text-align: center; } |
| +.ds-swatch-note { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-4); max-width: 62ch; } |
| +/* The Overview's opening paragraph and the routes under it, which a |
| + family page reuses for its examples. .ds-files is the same row, a |
| + name, a sentence and a number, on the Getting started page. The |
| + paragraph is the one piece of copy on this site somebody wrote by |
| + hand rather than derived from the code, so it is set a step larger |
| + than body text and given the same 62ch measure everything else here |
| + reads at. A route's link takes the target floor like any control. */ |
| +.ds-intro { font-size: 1.05rem; margin: 0 0 var(--rst-sp-5); max-width: 62ch; } |
| +.ds-routes, .ds-files { display: grid; gap: var(--rst-sp-4); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; } |
| +.ds-routes > li, .ds-files > li { border-inline-start: 2px solid var(--rst-line-strong); padding-inline-start: var(--rst-sp-3); } |
| +.ds-routes a, .ds-files a { align-items: center; display: inline-flex; font-weight: 600; min-block-size: var(--rst-target); } |
| +.ds-routes span, .ds-files span { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-sm); max-width: 62ch; } |
| +/* Prev/next at the foot. Two grid columns rather than a flex row with |
| + space-between, because the ends of the sequence are missing a link |
| + and not missing a column: the Overview's Next has to stay on the |
| + inline end rather than sliding across to where Previous would have |
| + been. */ |
| +.ds-updown { border-block-start: 1px solid var(--rst-line); display: grid; gap: var(--rst-sp-3); grid-template-columns: 1fr 1fr; margin-block-start: var(--rst-sp-6); padding-block-start: var(--rst-sp-4); } |
| +/* A grid of one so the floor's height centres the text; the links |
| + were their 21px line, 24px on a phone. */ |
| +.ds-updown__prev, .ds-updown__next { align-content: center; display: grid; min-block-size: var(--rst-target); } |
| +.ds-updown__prev { grid-column: 1; justify-self: start; } |
| +.ds-updown__next { grid-column: 2; justify-self: end; text-align: end; } |
| +.ds-shell { margin: var(--rst-sp-5) 0; } |
| +.ds-shell__name { font-size: 1.05rem; margin: 0 0 var(--rst-sp-1); } |
| + |
| +/* The rail is the shipped sidebar layout's, below 800px the phone |
| + index on the Overview and hidden on every other page, all by |
| + tokens.css's own rules. What is left here is what a rail of links |
| + has no attribute for: a collapsible section per group, and the |
| + filter over them. */ |
| +.ds-rail { gap: var(--rst-sp-2); } |
| +/* border-box: on the content box the floor came on top of the padding, |
| + and the headings grew from 28 to 43px. */ |
| +.ds-nav > details > summary { align-items: center; box-sizing: border-box; color: var(--rst-text-faint); cursor: pointer; display: flex; font-size: var(--rst-fs-xs); font-weight: 650; gap: 0.35rem; letter-spacing: 0.06em; list-style: none; min-block-size: var(--rst-target); padding-block: 0.35rem; text-transform: uppercase; } |
| +.ds-nav > details > summary::-webkit-details-marker { display: none; } |
| +/* The disclosure glyph is the icon set's own chevron inside an |
| + [rst-caret], which tokens.css already flips 180 degrees on [open] |
| + and already stills under prefers-reduced-motion. It is aria-hidden |
| + because it sits beside the section's own name. */ |
| +.ds-nav > details > summary > [rst-caret] { flex: none; } |
| +.ds-nav > details[aria-current] > summary { color: var(--rst-accent); } |
| +/* A section with nothing to list is a link to its page rather than a |
| + disclosure over an empty box. It wears the summary's typography so |
| + the rail still reads as one list. */ |
| +.ds-nav__page { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 650; letter-spacing: 0.06em; padding-block: 0.35rem; text-transform: uppercase; } |
| +.ds-nav__page[aria-current] { color: var(--rst-accent); } |
| +.ds-nav__page:hover { color: var(--rst-text); } |
| +.ds-nav > details > summary:hover { color: var(--rst-text); } |
| +.ds-nav > details > summary:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; } |
| +/* The filter hides a link by setting [hidden] on it, and the shell |
| + paints every rail link display: block, which the browser's own |
| + [hidden] rule loses to. This selector outranks it rather than |
| + shouting !important at it. */ |
| +[rst-shell-sidebar] .ds-nav a[hidden], .ds-nav details[hidden] { display: none; } |
| +/* No script, no filter, and no box sitting there looking like one. The |
| + nav under it is a complete list of every page either way. */ |
| +.ds-search { display: none; } |
| +:root[data-gallery-js] .ds-search { display: block; } |
| +/* The magnifier sits inside the field at its inline start, over the |
| + field's padding, and lets a tap through to the field under it. It is |
| + the icon set's own search glyph, aria-hidden, because the field's |
| + label already names it. */ |
| +:root[data-gallery-js] .ds-search { position: relative; } |
| +/* 16px like the system's search box's icon: tokens.css sizes an icon |
| + that way only inside its own controls, and this box is the gallery's. */ |
| +.ds-search > .icon { block-size: 1rem; color: var(--rst-text-faint); inline-size: 1rem; inset-block-start: 50%; inset-inline-start: 0.55rem; pointer-events: none; position: absolute; transform: translateY(-50%); } |
| +.ds-search input { background: var(--rst-bg); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); box-sizing: border-box; color: var(--rst-text); font: inherit; font-size: var(--rst-fs-sm); inline-size: 100%; min-block-size: var(--rst-target); padding-block: 0.35rem; padding-inline: 1.9rem 0.5rem; } |
| +.ds-search input:focus-visible { border-color: var(--rst-accent); outline: 2px solid var(--rst-accent); outline-offset: -1px; } |
| +.ds-nav__empty { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: var(--rst-sp-3) 0 0; } |
| +/* The rail's filter box is 12.5px like everything else in here, and a |
| + sub-16px field is exactly what makes mobile Safari zoom the whole |
| + page when it takes focus. tokens.css lifts the app's own controls on |
| + a coarse pointer; this one carries a ds- class of its own, so it |
| + outranks that rule and has to say the same thing again. */ |
| +@media (pointer: coarse) { |
| + .ds-search input { font-size: max(1rem, 1em); } |
| +} |
| + |
| +/* The frame. At 800px and up the bar is pinned over main. A grid |
| + item's containing block is its grid area and a sticky box cannot |
| + leave it, so a bar in a one-row area has no room to stick; it gets |
| + the rail's shape instead, an area spanning every row, with the first |
| + row reserving its height. Lines are numbered, not sided, so in |
| + Arabic the rail is on the right and the bar over main on the left |
| + with no rule here. |
| + |
| + --ds-bar-h is the bar's border-box height per band, the measured |
| + tallest over every theme and language. From 1024px the no-wrap rule |
| + below keeps the bar to one row everywhere, so the reservation is |
| + that one line's height. Below 1024px the controls can still wrap to |
| + a second line, and the reservation is the measured two-row maximum. |
| + TestThePinnedBarFitsItsReservation measures what is inside the bar |
| + against it. */ |
| +:root { --ds-bar-h: 3.25rem; } |
| +@media (min-width: 800px) and (max-width: 1023.98px) { :root { --ds-bar-h: 6rem; } } |
| +.ds-top { align-items: center; border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-2) var(--rst-sp-3); justify-content: space-between; padding: var(--rst-sp-2) var(--rst-sp-4); } |
| +/* Padding rather than a min-block-size, because from 1024px the brand |
| + elides its text, which a flex or grid box cannot do: the link grows |
| + to the floor round its one line, and the bar centres it. */ |
| +.ds-top__brand { color: var(--rst-text); font-weight: 650; padding-block: max(0px, (var(--rst-target) - 1lh) / 2); text-decoration: none; } |
| +.ds-top__controls { align-items: center; display: flex; flex-wrap: wrap; gap: var(--rst-sp-3); justify-content: flex-end; } |
| +.ds-top__controls [rst-dropdown] { position: relative; } |
| +/* The theme switcher is the system's seg-tabs, which leaves a block's |
| + gap under itself. Here it sits in a row and in a column that space |
| + their own items: in the bar's centred row the margin lifted the tabs |
| + above the menus beside them, and in the phone card it more than |
| + doubled the gap under them. */ |
| +.ds-top__controls [rst-seg-tabs], .ds-prefs [rst-seg-tabs] { margin: 0; } |
| +.ds-top__controls [rst-dropdown] > summary { padding-block: 0.25rem; } |
| +/* The bar's own box is fixed at --ds-bar-h and cannot grow to a second |
| + row without overflowing its grid area (above), so from 1024px it |
| + must not wrap at all. The brand is what yields: eliding its text |
| + leaves its accessible name untouched, while every control has to |
| + stay whole for its hit target and its label to both still work. */ |
| +@media (min-width: 1024px) { |
| + .ds-top { flex-wrap: nowrap; } |
| + .ds-top__controls { flex-shrink: 0; } |
| + .ds-top__brand { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +} |
| +@media (min-width: 800px) { |
| + body > [rst-shell-sidebar] { grid-template-rows: var(--ds-bar-h) 1fr; } |
| + body > [rst-shell-sidebar] > [rst-shell-rail] { grid-column: 1; grid-row: 1 / -1; } |
| + /* Opaque, and above page content (auto, the positioned preview boxes |
| + and transformed frames included) but below the skip link's 60. */ |
| + .ds-top { align-self: start; background: var(--rst-bg); block-size: var(--ds-bar-h); box-sizing: border-box; grid-column: 2; grid-row: 1 / -1; inset-block-start: 0; position: sticky; z-index: 10; } |
| + body > [rst-shell-sidebar] > [rst-shell-main] { grid-column: 2; grid-row: 2; } |
| + /* Fragments and focus stop below the pinned bar (WCAG technique C43). |
| + Only here: below 800px what is pinned is the shell's back strip, |
| + and its clearance is tokens.css's to set; a rule on html at every |
| + width would override that. */ |
| + html { scroll-padding-block-start: calc(var(--ds-bar-h) + var(--rst-sp-2)); } |
| +} |
| +/* Pieces only the phone index shows, written on the Overview alone. At |
| + 800px and up the Overview is a page with the rail beside it, as the |
| + shell's index is, and these would be a second list under the tree |
| + and a second set of controls beside the bar's. */ |
| +.ds-index-lead, .ds-rail > .ds-index { display: none; } |
| +@media (max-width: 799.98px) { |
| + /* On a phone a section page is the way back and the content, and the |
| + controls are in the display settings menu at the top of every page |
| + (below). */ |
| + .ds-top { display: none; } |
| + [rst-shell-sidebar~="index"] .ds-index-lead { color: var(--rst-text-muted); display: block; margin: 0 0 var(--rst-sp-4); padding-inline: 0.25rem; } |
| + [rst-shell-sidebar~="index"] > .ds-rail > .ds-index { display: flex; } |
| + /* The rail on the index has no gap of its own (the rows sit flush |
| + under the heading), so the field would touch the first row. */ |
| + [rst-shell-sidebar~="index"] .ds-search { margin-block-end: var(--rst-sp-3); } |
| + /* A query swaps the rows for the tree it filters. Two rules, so an |
| + engine without :has() drops both and shows rows and tree one after |
| + the other: long, but with no route missing. */ |
| + [rst-shell-sidebar~="index"]:not(:has(#ds-filter:not(:placeholder-shown))) > .ds-rail > .ds-nav { display: none; } |
| + [rst-shell-sidebar~="index"]:has(#ds-filter:not(:placeholder-shown)) > .ds-rail > .ds-index { display: none; } |
| +} |
| +@media (max-width: 799.98px) { |
| + /* On a phone the stage is the screen's width. The bleed is what sits |
| + between the widget and the screen's edge: the page's padding, and |
| + inside a sample card its padding and border as well. Written per |
| + context rather than measured, so a change to either shows up as a |
| + sideways scroll in the reflow and phone drives instead of a quietly |
| + narrower stage. The tabs and the code stay in the text column. */ |
| + .ds-view { --ds-bleed: var(--rst-sp-4); margin-inline: calc(-1 * var(--ds-bleed)); } |
| + .ds-sample .ds-view { --ds-bleed: calc(2 * var(--rst-sp-4) + 1px); } |
| + .ds-view__tabs, .ds-view__code { margin-inline: var(--ds-bleed); } |
| + /* Content-box sizing under max-inline-size: 100%: a border here |
| + would make a full-width box 2px wider than the screen. */ |
| + .ds-view__box, .ds-view__stage { border-inline-width: 0; border-radius: 0; } |
| +} |
| + |
| +/* Display settings, on a phone. Below 800px the pinned bar is gone, |
| + and the theme, scheme and language controls are one menu at the top |
| + of every page: in the index's header row beside the heading, where |
| + the shell's own profile menu sits, and at the inline end of a content |
| + page's back strip. It is an rst-dropdown, so fiesta.js closes it on |
| + an outside click and on Escape, and its card is the system's |
| + anchored panel. At 800px and up the bar has the controls and the |
| + menu is not drawn. */ |
| +.ds-prefs { display: none; } |
| +/* border-box, so the system's target floor is the whole button rather |
| + than 44px of icon inside its padding. */ |
| +.ds-prefs > summary { border-radius: var(--rst-radius-sm); box-sizing: border-box; color: var(--rst-text); justify-content: center; padding: 0.4rem; } |
| +.ds-prefs > summary:hover, .ds-prefs[open] > summary { background: var(--rst-accent-soft); } |
| +.ds-prefs > summary:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; } |
| +/* Scripts off, a tap outside the card still closes it: the open |
| + summary grows a layer over the viewport, under the card, so the tap |
| + is a click on the summary. The system's menu cards do the same. */ |
| +.ds-prefs[open] > summary::before { content: ""; cursor: default; inset: 0; position: fixed; z-index: 1; } |
| +/* Taller than a menu's usual 20rem cap: with the language list open in |
| + place the card holds twelve 44px rows, and the screen is the room it |
| + has, not a row of a toolbar. */ |
| +.ds-prefs > [rst-dropdown-menu] { align-items: flex-start; box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rst-sp-3); inline-size: max-content; max-block-size: calc(100dvh - 6rem); max-inline-size: calc(100vw - 1.5rem); padding: var(--rst-sp-3); } |
| +/* The scheme menu and the language menu inside the card open in place: |
| + a popover inside a popover has nowhere to go on a phone. Each is the |
| + card's full width so its rows read as rows. */ |
| +.ds-prefs :is([rst-locale], [rst-scheme]) { align-self: stretch; } |
| +.ds-prefs :is([rst-locale], [rst-scheme])[open] > [rst-dropdown-menu] { border: 0; box-shadow: none; inset: auto; margin: 0; max-block-size: none; min-width: 0; padding-block: 0; padding-inline: 0.5rem 0; position: static; } |
| +@media (max-width: 799.98px) { |
| + .ds-prefs { display: block; } |
| + /* Lifted into the index's header row, the place and size of the |
| + shell's profile menu: the rail is positioned on the index, and the |
| + heading keeps that corner clear. */ |
| + [rst-shell-sidebar~="index"] > .ds-rail > .ds-prefs { align-items: center; block-size: var(--rst-shell-strip); display: flex; inset-block-start: 0; inset-inline-end: var(--rst-sp-4); position: absolute; } |
| + [rst-shell-back] > .ds-prefs { margin-inline-start: auto; } |
| +} |
diff --git a/internal/gallery/gallery.js b/internal/gallery/gallery.js| index 6097003..272583d 100644 |
| --- a/internal/gallery/gallery.js |
| +++ b/internal/gallery/gallery.js |
| @@ -1,7 +1,324 @@ |
| /* gallery.js: the gallery page's own script, the chrome's half, and no |
| part of the design system. It is served joined with view.js, the |
| preview widget's half, as the one gallery.js every page loads |
| - blocking in <head>. The rail filter, keeping your place across a |
| - theme or language switch, following a moved section's old link and |
| - the scheme bootstrap belong here; today the file is this comment, |
| - and the frame painting is view.js's. */ |
| + blocking in <head>. It is Rastrillo's gallery script for the same |
| + chrome: the rail filter, keeping your place across a theme or |
| + language switch, and following a moved section's old link; and in |
| + place of Rastrillo's own colour toggle, a bootstrap for the scheme |
| + fiesta.js owns. |
| + |
| + Same rules as the scripts it loads beside: first-party, |
| + dependency-free, no network, and inert-safe: the page it enhances is |
| + a complete document with scripts off. |
| + |
| + Why it is a blocking <script> in <head> rather than a deferred one |
| + at the foot: two of the things it does have to happen before the |
| + first paint. A stored Dark applied after the body has parsed is a |
| + visible flash of the system scheme on every load, and the marker |
| + that hides the filter box and the unpainted frames has to be on the |
| + root before either could paint. It does no work beyond setting two |
| + attributes before DOMContentLoaded, so blocking on it costs one |
| + parse. |
| + |
| + The filter box is display: none until this file sets |
| + data-gallery-js on <html>, which is the whole of the scriptless |
| + story: with scripts off the control is never shown and the rail is |
| + a complete list of every page. A visible control that cannot do |
| + anything would be the misleading version. */ |
| +(function () { |
| + "use strict"; |
| + |
| + var root = document.documentElement; |
| + root.setAttribute("data-gallery-js", "on"); |
| + |
| + function ready(fn) { |
| + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| + else fn(); |
| + } |
| + |
| + // The scheme, before the first paint. fiesta.js owns the choice: |
| + // its menu writes data-theme on <html> and, on a page that carries |
| + // data-rst-scheme-store="local", remembers it under fiesta-scheme. |
| + // It restores a stored choice itself when it runs, but it is |
| + // deferred, which is after the first paint, and it skips its own |
| + // restore, menu marking included, when data-theme is already on the |
| + // root. So this file, at parse time, puts the stored scheme on the |
| + // root; then at readiness, and again when a page comes back from |
| + // 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. |
| + var KEY = "fiesta-scheme"; |
| + var remembers = root.getAttribute("data-rst-scheme-store") === "local"; |
| + |
| + function stored() { |
| + if (!remembers) return null; |
| + try { |
| + var v = localStorage.getItem(KEY); |
| + return v === "light" || v === "dark" ? v : null; |
| + } catch (e) { |
| + return null; |
| + } |
| + } |
| + |
| + function restore() { |
| + var s = stored(); |
| + if (s) root.setAttribute("data-theme", s); |
| + else if (remembers) root.removeAttribute("data-theme"); |
| + } |
| + |
| + // Not toggleAttribute: aria-current="" reads as false. |
| + function mark() { |
| + var s = remembers ? stored() || "system" : 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"); |
| + for (var j = 0; j < buttons.length; j++) { |
| + var f = buttons[j].form, v = f && f.elements.scheme && f.elements.scheme.value; |
| + if (v === s) buttons[j].setAttribute("aria-current", "true"); |
| + else buttons[j].removeAttribute("aria-current"); |
| + } |
| + } |
| + } |
| + |
| + if (!root.hasAttribute("data-theme")) restore(); |
| + ready(mark); |
| + addEventListener("pageshow", function (event) { |
| + if (!event.persisted) return; |
| + restore(); |
| + mark(); |
| + }); |
| + // A choice made in one copy of the menu marks the other copy too; |
| + // fiesta.js marks only the menu the form was in. |
| + document.addEventListener("rst-scheme", function () { requestAnimationFrame(mark); }); |
| + |
| + // ── The nav filter ────────────────────────────────────────────────── |
| + // |
| + // The sidebar is a complete list of every page before this runs and |
| + // stays one if it never does: the box is display:none until |
| + // data-gallery-js is set, so scripts off gets the nav and no dead |
| + // control. |
| + // |
| + // It says nothing of its own: the one sentence it can put on screen, |
| + // No matches, is rendered by the page in the page's language and |
| + // starts out hidden, and this file only takes the attribute off it. |
| + ready(function () { |
| + var input = document.querySelector("[data-ds-filter]"); |
| + var nav = input && document.getElementById(input.getAttribute("aria-controls")); |
| + if (!input || !nav) return; |
| + var empty = document.querySelector("[data-ds-filter-empty]"); |
| + var sections = nav.querySelectorAll("details"); |
| + |
| + // Folded both sides: "seccion" finds "Sección", and the rail is |
| + // headings in twelve languages. NFD splits a letter from its |
| + // accents; the range is the combining marks, and dropping them is |
| + // the whole of it. |
| + function fold(s) { |
| + return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); |
| + } |
| + |
| + var links = nav.querySelectorAll("a"); |
| + // An entry's synonyms (data-ds-terms) match beside its name, and |
| + // the plain page links filter like every entry, so No matches and |
| + // a visible Overview link never show together. |
| + for (var i = 0; i < links.length; i++) links[i].dsText = fold(links[i].textContent + " " + (links[i].getAttribute("data-ds-terms") || "")); |
| + var pages = nav.querySelectorAll(":scope > a"); |
| + // A section's own name is searchable too: "shells" has to land |
| + // somewhere, and the reader typing it means the whole section. |
| + for (var i = 0; i < sections.length; i++) { |
| + var head = sections[i].querySelector("summary"); |
| + sections[i].dsText = head ? fold(head.textContent) : ""; |
| + } |
| + |
| + // What the reader had open before they typed. A query opens |
| + // whatever it found something in; clearing it hands the reader |
| + // their own arrangement back. |
| + var chosen = null; |
| + |
| + function run(query) { |
| + var q = fold(query.trim()); |
| + if (q && chosen === null) { |
| + chosen = []; |
| + for (var i = 0; i < sections.length; i++) chosen.push(sections[i].open); |
| + } |
| + var found = false; |
| + for (var s = 0; s < sections.length; s++) { |
| + var section = sections[s]; |
| + var kids = section.children; |
| + var shown = 0; |
| + // The section's own name matching stands for everything under |
| + // it: a reader who types "form" means the Form page, not the |
| + // one entry in it whose name happens to contain the word. |
| + var whole = !q || section.dsText.indexOf(q) >= 0; |
| + for (var k = 0; k < kids.length; k++) { |
| + var el = kids[k]; |
| + if (el.tagName !== "A") continue; |
| + var hit = whole || el.dsText.indexOf(q) >= 0; |
| + el.hidden = !hit; |
| + if (hit) shown++; |
| + } |
| + section.hidden = shown === 0; |
| + if (shown) found = true; |
| + section.open = q ? shown > 0 : chosen ? chosen[s] : section.open; |
| + } |
| + for (var p = 0; p < pages.length; p++) { |
| + pages[p].hidden = q && pages[p].dsText.indexOf(q) < 0; |
| + if (q && !pages[p].hidden) found = true; |
| + } |
| + if (!q) chosen = null; |
| + if (empty) empty.hidden = !q || found; |
| + } |
| + |
| + input.addEventListener("input", function () { |
| + run(input.value); |
| + }); |
| + |
| + // Escape clears a query that is there and is left alone when there |
| + // is not, so the key still belongs to whatever is around the box. |
| + // Focus never moves: nothing here touches it. |
| + input.addEventListener("keydown", function (event) { |
| + if (event.key !== "Escape" || input.value === "") return; |
| + event.preventDefault(); |
| + input.value = ""; |
| + run(""); |
| + }); |
| + |
| + // A back-navigation restores the box with a value already in it: |
| + // filter to what it says, not to what the page was rendered saying. |
| + if (input.value) run(input.value); |
| + }); |
| + |
| + // ── Keeping your place ────────────────────────────────────────────── |
| + // |
| + // A theme or language link, in the pinned bar or in the phone's |
| + // display settings menu, carries the section being read, worked out |
| + // at the click; the address bar is never rewritten. |
| + // 1. A fragment this document put in place, while its target has not |
| + // moved (2px): the case geometry cannot answer, the last section |
| + // of a page that clamps, or a glyph in a row of icons sharing a |
| + // top. |
| + // 2. Else the anchor at the reading line (the scroll padding plus a |
| + // pixel, where a fragment lands): the greatest top at or above |
| + // it, first in document order. |
| + // 3. Above every anchor, nothing. |
| + // The rule-1 record is written only when this document scrolls to a |
| + // target: at load on a fresh, untouched navigation (redoing the |
| + // parser's scroll, which late layout may have moved), and a frame |
| + // after a plain, uncancelled click on a fragment link here, the only |
| + // signal when that fragment is already in the address. History drops |
| + // it, because Back restores where the reader had scrolled. |
| + var record = null, touched = false; |
| + |
| + function anchor(id) { |
| + var el = id && document.getElementById(id); |
| + return el && el.hasAttribute("data-ds-anchor") ? el : null; |
| + } |
| + |
| + function fragment(hash) { |
| + try { |
| + return decodeURIComponent(hash.slice(1)); |
| + } catch (e) { |
| + return hash.slice(1); |
| + } |
| + } |
| + |
| + function keep(el) { |
| + record = { id: el.id, top: el.getBoundingClientRect().top }; |
| + } |
| + |
| + ["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (type) { |
| + addEventListener(type, function () { touched = true; }, { capture: true, passive: true }); |
| + }); |
| + |
| + addEventListener("load", function () { |
| + var nav = performance.getEntriesByType("navigation")[0], el = anchor(fragment(location.hash)); |
| + if (!el || touched || !nav || nav.type !== "navigate") return; |
| + el.scrollIntoView({ block: "start" }); |
| + requestAnimationFrame(function () { keep(el); }); |
| + }); |
| + |
| + document.addEventListener("click", function (event) { |
| + var a = event.target.closest && event.target.closest("a[href]"), el = a && anchor(fragment(a.hash)); |
| + if (!el || event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || a.target || a.hasAttribute("download") || |
| + a.origin + a.pathname + a.search !== location.origin + location.pathname + location.search) return; |
| + requestAnimationFrame(function () { |
| + if (!event.defaultPrevented) keep(el); |
| + }); |
| + }); |
| + |
| + addEventListener("popstate", function () { record = null; }); |
| + if (window.navigation) navigation.addEventListener("navigate", function (event) { |
| + if (event.navigationType === "traverse") record = null; |
| + }); |
| + |
| + function place() { |
| + var el = record && fragment(location.hash) === record.id && anchor(record.id); |
| + if (el && Math.abs(el.getBoundingClientRect().top - record.top) <= 2) return el.id; |
| + var line = (parseFloat(getComputedStyle(root).scrollPaddingBlockStart) || 0) + 1; |
| + var all = document.querySelectorAll("[data-ds-anchor]"), best = "", top = -Infinity; |
| + for (var i = 0; i < all.length; i++) { |
| + var r = all[i].getBoundingClientRect(); |
| + if ((r.width || r.height) && r.top <= line && r.top > top) { |
| + best = all[i].id; |
| + top = r.top; |
| + } |
| + } |
| + return best; |
| + } |
| + |
| + // A link's address is restored by a timer scheduled before it is |
| + // changed, so nothing failing between can leave it set. Not a |
| + // microtask: that checkpoint comes before activation reads href. |
| + // Again on pageshow, for a page back from the cache whose timer never |
| + // ran. |
| + ready(function () { |
| + var links = document.querySelectorAll(".ds-top__controls a[href], .ds-prefs a[href]"); |
| + if (!links.length) return; |
| + for (var i = 0; i < links.length; i++) links[i].dsHref = links[i].getAttribute("href"); |
| + function canonical() { |
| + for (var i = 0; i < links.length; i++) links[i].setAttribute("href", links[i].dsHref); |
| + } |
| + addEventListener("pageshow", canonical); |
| + document.addEventListener("click", function (event) { |
| + var a = event.target.closest && event.target.closest("a[href]"), id; |
| + if (!a || a.dsHref === undefined) return; |
| + setTimeout(canonical, 0); |
| + if ((id = place())) a.setAttribute("href", a.dsHref + "#" + encodeURIComponent(id)); |
| + }); |
| + }); |
| + |
| + // ── A section that moved pages ────────────────────────────────────── |
| + // |
| + // A fragment naming nothing here that the rail links on another |
| + // page: the section moved, and the old link in a bookmark or a search |
| + // result is followed to where it went. The rail is the lookup, so no |
| + // page keeps a list and the next move works the same way. Two |
| + // lookups: Rastrillo's, a rail link whose fragment is the one asked |
| + // for; and Fiesta's own, a rail link whose page stem is the fragment, |
| + // since part 3 kept every example on one page under its name |
| + // (index.html#button is button.html now). Only a fragment with no |
| + // element at all is looked up, so a hash that is on the page is left |
| + // alone; only a link to another path is followed, so a page can |
| + // never send a reader to itself; a link that opens a new tab is not |
| + // followed in this one; and the address is replaced, so Back does |
| + // not return to a page that would send them on again. On hashchange |
| + // as well as at readiness: a reader already on the page who opens |
| + // the old link changes only the fragment, and nothing is loaded. |
| + function moved() { |
| + var id = fragment(location.hash), nav = id && !document.getElementById(id) && document.getElementById("ds-nav"); |
| + var links = nav ? nav.querySelectorAll("a[href]") : []; |
| + for (var i = 0; i < links.length; i++) { |
| + var a = links[i]; |
| + if (a.target || a.pathname === location.pathname) continue; |
| + var stem = a.pathname.slice(a.pathname.lastIndexOf("/") + 1); |
| + if (fragment(a.hash) === id || (!a.hash && stem === id + ".html")) { |
| + location.replace(a.href); |
| + return; |
| + } |
| + } |
| + } |
| + ready(moved); |
| + addEventListener("hashchange", moved); |
| +})(); |
diff --git a/internal/gallery/history/cache_browser_test.go b/internal/gallery/history/cache_browser_test.go| new file mode 100644 |
| index 0000000..f55de3d |
| --- /dev/null |
| +++ b/internal/gallery/history/cache_browser_test.go |
| @@ -0,0 +1,262 @@ |
| +//go:build browser |
| + |
| +package history |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/input" |
| + "github.com/chromedp/cdproto/target" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// markedJS reads every scheme menu on the page: for each, the value of |
| +// the one button carrying aria-current="true", or "" for none and |
| +// "many" for more than one. |
| +const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => { |
| + const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true"); |
| + return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : ""; |
| +})` |
| + |
| +// TestTheSchemeFollowsAPageBackFromTheCache: the scheme follows a page |
| +// back from the back/forward cache. On a phone shell.js sends Back |
| +// through history, so a page comes back from the cache without |
| +// re-running anything: open an example page from the index in System, |
| +// go Back to the index, choose Dark in its display settings menu, go |
| +// Forward, and the page must be dark, previews and all, with every |
| +// scheme menu on it marking Dark. The control first: the Forward |
| +// really was a cache restore. A leg that silently reloaded the page |
| +// would pass on start-up code alone. |
| +// |
| +// The index's rows open family pages, which frame nothing, so the |
| +// page under test is the first example of the Form family, one more |
| +// hop in: index, Form, the example, then Back twice. |
| +func TestTheSchemeFollowsAPageBackFromTheCache(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| + defer cancel() |
| + where := "day/en at 390px" |
| + if err := chromedp.Run(ctx, |
| + addInit(`addEventListener("pageshow", e => { window.__restored = e.persisted; });`), |
| + chromedp.EmulateViewport(390, 844), |
| + chromedp.Navigate(rig.Origin+indexHref("day", "en")), |
| + chromedp.WaitReady("body")); err != nil { |
| + t.Fatalf("%s: loading the index: %v", where, err) |
| + } |
| + var hasIndex bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector(".ds-index") && !!document.getElementById("nav-form")`, &hasIndex)); err != nil || !hasIndex { |
| + t.Fatalf("%s: the Overview is not the phone index with a Form row (%v)", where, err) |
| + } |
| + requireCoarse(t, ctx) |
| + for _, c := range []struct{ scheme, attr string }{{"dark", "dark"}, {"system", ""}} { |
| + step := where + ", " + c.scheme |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("nav-form").click(); true`, nil)); err != nil { |
| + t.Fatalf("%s: opening Form: %v", step, err) |
| + } |
| + until(t, ctx, step+", Form", `location.pathname.endsWith("/form.html") && document.readyState === "complete"`) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector("main .ds-routes a").click(); true`, nil)); err != nil { |
| + t.Fatalf("%s: opening Form's first example: %v", step, err) |
| + } |
| + until(t, ctx, step+", the example", `!location.pathname.endsWith("/form.html") && document.readyState === "complete" && document.querySelectorAll(".ds-view__frame").length > 0`) |
| + var started float64 |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin`, &started)); err != nil { |
| + t.Fatalf("%s: reading the example's navigation: %v", step, err) |
| + } |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { |
| + t.Fatalf("%s: going back: %v", step, err) |
| + } |
| + until(t, ctx, step+", Form again", `location.pathname.endsWith("/form.html")`) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { |
| + t.Fatalf("%s: going back to the index: %v", step, err) |
| + } |
| + until(t, ctx, step+", the index", `location.pathname.endsWith("/index.html") && !!document.querySelector("#ds-prefs [rst-scheme] form")`) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { |
| + document.getElementById("ds-prefs").open = true; |
| + const m = document.querySelector("#ds-prefs [rst-scheme]"); m.open = true; |
| + [...m.querySelectorAll("form")].find(f => f.elements.scheme.value === "`+c.scheme+`").querySelector("button").click(); |
| + history.forward(); return true; })()`, nil)); err != nil { |
| + t.Fatalf("%s: choosing %s and going forward: %v", step, c.scheme, err) |
| + } |
| + until(t, ctx, step+", Form forward", `location.pathname.endsWith("/form.html")`) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`history.forward(); true`, nil)); err != nil { |
| + t.Fatalf("%s: forward to the example: %v", step, err) |
| + } |
| + until(t, ctx, step+", the example again", `!location.pathname.endsWith("/form.html") && !location.pathname.endsWith("/index.html") && window.__restored !== undefined`) |
| + var raw string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`JSON.stringify({Restored: window.__restored, |
| + Started: performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin, |
| + Why: JSON.stringify((performance.getEntriesByType("navigation")[0] || {}).notRestoredReasons || null)})`, &raw)); err != nil { |
| + t.Fatalf("%s: reading the restore: %v", step, err) |
| + } |
| + var r struct { |
| + Restored bool |
| + Started float64 |
| + Why string |
| + } |
| + if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", step, raw, err) |
| + } |
| + if !r.Restored || r.Started != started { |
| + t.Fatalf("%s: Forward was not a back/forward cache restore (persisted %v, navigation %s then %s; the engine says %s). The tree handler must not send Cache-Control: no-store", step, r.Restored, strconv.FormatFloat(started, 'f', 0, 64), strconv.FormatFloat(r.Started, 'f', 0, 64), r.Why) |
| + } |
| + // Frames that have not loaded are skipped, but at least one |
| + // must have: every() over none is true. |
| + until(t, ctx, step+", painted", fmt.Sprintf(`(() => { |
| + const loaded = [...document.querySelectorAll(".ds-view__frame")].filter(f => { const d = f.contentDocument; return d && d.URL === f.src; }); |
| + const marked = %s; |
| + return (document.documentElement.getAttribute("data-theme") || "") === %q && loaded.length > 0 && |
| + loaded.every(f => (f.contentDocument.documentElement.getAttribute("data-theme") || "") === %q) && |
| + marked.length === 2 && marked.every(m => m === %q); |
| + })()`, markedJS, c.attr, c.attr, c.scheme)) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`history.go(-2); true`, nil)); err != nil { |
| + t.Fatalf("%s: back to the index: %v", step, err) |
| + } |
| + until(t, ctx, step+", index again", `location.pathname.endsWith("/index.html")`) |
| + } |
| +} |
| + |
| +// The switcher's own address is never left changed: the last legs of |
| +// Rastrillo's TestPositionSurvivesASwitch, here because a Ctrl-click |
| +// has to open a real tab and the last Back has to be a real cache |
| +// restore, neither of which the headless shell gives. |
| + |
| +// newTab reports the next page a click opens in a tab of its own. |
| +// chromedp.WaitNewTarget would wait forever here: it takes only |
| +// targets with an opener, and a tab opened by Ctrl- or middle-clicking |
| +// a link has none. |
| +func newTab(ctx context.Context, url func(string) bool) <-chan target.ID { |
| + ch := make(chan target.ID, 1) |
| + lctx, cancel := context.WithCancel(ctx) |
| + chromedp.ListenTarget(lctx, func(ev any) { |
| + e, ok := ev.(*target.EventTargetCreated) |
| + if !ok || e.TargetInfo.Type != "page" || e.TargetInfo.Attached || !url(e.TargetInfo.URL) { |
| + return |
| + } |
| + select { |
| + case ch <- e.TargetInfo.TargetID: |
| + default: |
| + } |
| + cancel() |
| + }) |
| + return ch |
| +} |
| + |
| +// clickWith is a real mouse click on sel, with a modifier or another |
| +// button, at the match's centre rather than through MouseClickNode, |
| +// which scrolls the page to the node first and so moves the very place |
| +// a click from the pinned bar is meant to carry. |
| +func clickWith(t *testing.T, ctx context.Context, where, sel string, opts ...chromedp.MouseOption) { |
| + t.Helper() |
| + var at []float64 |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const el = document.querySelector(`+strconv.Quote(sel)+`), r0 = el.getBoundingClientRect(); |
| + if (r0.top < 0 || r0.bottom > innerHeight) el.scrollIntoView({block: "nearest"}); |
| + const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()`, &at)); err != nil || len(at) != 2 { |
| + t.Fatalf("%s: finding %s: %v", where, sel, err) |
| + } |
| + if err := chromedp.Run(ctx, chromedp.MouseClickXY(at[0], at[1], opts...)); err != nil { |
| + t.Fatalf("%s: clicking %s: %v", where, sel, err) |
| + } |
| +} |
| + |
| +// TestTheSwitchersAddressIsNeverLeftChanged: a Ctrl-click from deep in |
| +// the page opens the computed place in a new tab and leaves the link |
| +// canonical; a middle-click from the same place opens the link as |
| +// rendered, the other page's top; a plain click from deep in the page |
| +// carries the place; and a page back from the back/forward cache, left |
| +// by a click that carried a place, still has canonical links. The |
| +// last Back is held to be a cache restore, since a fresh load renders |
| +// canonical links whatever the switcher does. |
| +func TestTheSwitchersAddressIsNeverLeftChanged(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + where := "canonical" |
| + page := rig.Origin + pageHref("day", "en", "tokens.html") |
| + open := func() { |
| + t.Helper() |
| + if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate("about:blank"), chromedp.Navigate(page), chromedp.WaitReady("body")); err != nil { |
| + t.Fatalf("%s: loading: %v", where, err) |
| + } |
| + var ok bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector(".ds-top__controls [rst-seg-tabs]") && document.querySelectorAll("[data-ds-anchor]").length > 2`, &ok)); err != nil || !ok { |
| + t.Fatalf("%s: the page has no theme tabs in a bar over anchored groups (%v)", where, err) |
| + } |
| + until(t, ctx, where, `document.readyState === "complete"`) |
| + } |
| + open() |
| + var mid string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`[...document.querySelectorAll("[data-ds-anchor]")][2].id`, &mid)); err != nil { |
| + t.Fatalf("%s: reading the groups: %v", where, err) |
| + } |
| + deep := `document.getElementById("` + mid + `").scrollIntoView({block: "start"}); true` |
| + theme := `.ds-top__controls [rst-seg-tabs] a:not([aria-current])` |
| + canonical := `![...document.querySelectorAll(".ds-top__controls a")].some(a => a.getAttribute("href").includes("#"))` |
| + other := func(url string) bool { return strings.Contains(url, "/tokens.html") && !strings.Contains(url, "/day/") } |
| + tabURL := func(how string, opened <-chan target.ID) string { |
| + t.Helper() |
| + select { |
| + case id := <-opened: |
| + tab, closeTab := chromedp.NewContext(ctx, chromedp.WithTargetID(id)) |
| + defer closeTab() |
| + until(t, tab, where+", the "+how+" tab", `location.href.includes("/tokens.html") && !location.href.includes("/day/")`) |
| + var url string |
| + if err := chromedp.Run(tab, chromedp.Location(&url)); err != nil { |
| + t.Fatalf("%s: reading the %s tab: %v", where, how, err) |
| + } |
| + return url |
| + case <-time.After(10 * time.Second): |
| + t.Fatalf("%s: %s a theme link opened no tab", where, how) |
| + } |
| + return "" |
| + } |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(deep, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + opened := newTab(ctx, other) |
| + clickWith(t, ctx, where, theme, chromedp.ButtonModifiers(input.ModifierCtrl)) |
| + if url := tabURL("Ctrl-clicking", opened); !strings.HasSuffix(url, "#"+mid) { |
| + t.Errorf("%s: the Ctrl-clicked tab opened %s, want the computed place #%s", where, url, mid) |
| + } |
| + until(t, ctx, where+", restored", canonical) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(deep, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + middle := newTab(ctx, other) |
| + clickWith(t, ctx, where, theme, chromedp.ButtonMiddle) |
| + if url := tabURL("middle-clicking", middle); strings.Contains(url, "#") { |
| + t.Errorf("%s: a middle-click opened %s; it reads the link as rendered, the top of the page", where, url) |
| + } |
| + const started = `performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin` |
| + var left float64 |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(started, &left), chromedp.Evaluate(deep, nil), chromedp.Evaluate(`document.querySelector('`+theme+`').click(); true`, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + until(t, ctx, where+", deep", `!location.pathname.includes("/day/") && document.readyState === "complete"`) |
| + var hash string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`location.hash`, &hash)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if hash != "#"+mid { |
| + t.Errorf("%s: a plain click from #%s landed at %q", where, mid, hash) |
| + } |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + until(t, ctx, where+", back", `location.pathname.includes("/day/") && `+canonical) |
| + var back float64 |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(started, &back)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if back != left { |
| + t.Fatalf("%s: the last Back was not a back/forward cache restore (navigation %s then %s), so its canonical links prove nothing. The tree handler must not send Cache-Control: no-store", where, strconv.FormatFloat(left, 'f', 0, 64), strconv.FormatFloat(back, 'f', 0, 64)) |
| + } |
| +} |
diff --git a/internal/gallery/history/rig_browser_test.go b/internal/gallery/history/rig_browser_test.go| new file mode 100644 |
| index 0000000..7a168bc |
| --- /dev/null |
| +++ b/internal/gallery/history/rig_browser_test.go |
| @@ -0,0 +1,65 @@ |
| +//go:build browser |
| + |
| +// Package history holds the gallery's browser legs that need a real |
| +// back/forward cache restore or a real new-tab click: the headless |
| +// shell the cloud runner links as chromium never gives either, so |
| +// these legs need full Chromium (the Makefile's FULL_CHROMIUM), and |
| +// they are a package of their own so that cost falls on only them. |
| +package history |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/gallery" |
| + "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The gallery's addresses and the shared drive helpers, under the |
| +// names these legs are written with. One line each: the |
| +// implementations are galleryrig's. The page files are written here |
| +// by name, since the page-kind table is the gallery package's own. |
| + |
| +const ( |
| + mountPath = "/gallery" |
| + mountPrefix = mountPath + "/" |
| +) |
| + |
| +func pageHref(theme, locale, file string) string { |
| + return mountPrefix + theme + "/" + locale + "/" + file |
| +} |
| + |
| +// indexHref is the Overview's address: the one page every index drive |
| +// navigates to by name. |
| +func indexHref(theme, locale string) string { return pageHref(theme, locale, "index.html") } |
| + |
| +func treeHandler(t *testing.T) http.Handler { |
| + t.Helper() |
| + files, err := gallery.Render(fiesta.FS(), gallery.Options{Mount: mountPath}) |
| + if err != nil { |
| + t.Fatalf("Render: %v", err) |
| + } |
| + return galleryrig.Tree(t, files, mountPrefix) |
| +} |
| + |
| +func addInit(js string) chromedp.Action { return galleryrig.AddInit(js) } |
| + |
| +func phoneRig(t *testing.T) *harness.Rig { |
| + t.Helper() |
| + return galleryrig.PhoneRig(t, func() http.Handler { return treeHandler(t) }) |
| +} |
| + |
| +func requireCoarse(t *testing.T, ctx context.Context) { |
| + t.Helper() |
| + galleryrig.RequireCoarse(t, ctx) |
| +} |
| + |
| +func until(t *testing.T, ctx context.Context, where, expr string) { |
| + t.Helper() |
| + galleryrig.Until(t, ctx, where, expr) |
| +} |
diff --git a/internal/gallery/index_browser_test.go b/internal/gallery/index_browser_test.go| new file mode 100644 |
| index 0000000..d2bafd3 |
| --- /dev/null |
| +++ b/internal/gallery/index_browser_test.go |
| @@ -0,0 +1,295 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "slices" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/runtime" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/axe" |
| +) |
| + |
| +// The phone index and the way back: below 800px the Overview is the |
| +// shell's index page and every other page a content page with a back |
| +// strip, as the sidebar shell does it for an app. |
| + |
| +// boxOf says whether the first element matching sel takes up room. |
| +const boxOf = `(sel => { const e = document.querySelector(sel); if (!e) return false; const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; })` |
| + |
| +// focusID names whatever has focus the way the expected sequences |
| +// below are written: an input or a row by id, a link by its href, a |
| +// summary by its tag. |
| +const focusID = `(() => { const a = document.activeElement; |
| + if (!a || a === document.body) return "body"; |
| + if (a.id) return "#" + a.id; |
| + if (a.tagName === "A") return a.getAttribute("href"); |
| + return a.tagName.toLowerCase(); })()` |
| + |
| +// TestThePhoneIndexAndTheWayBack: at 390px the Overview is the index |
| +// (the rail is the page; main and the bar have no box; the one visible |
| +// h1 is the gallery's name; the rows are the page kinds in table order |
| +// then Demos, each a 44px target), Tab runs the display settings |
| +// button, the filter and the rows, a row opens its page as a content |
| +// page whose Back names its row, Back returns to the index with that |
| +// row focused, and a theme link in the menu lands on the other theme's |
| +// index. Scripts off: Back is the link, the row is the :target, and |
| +// the next Tab lands on the row after it. |
| +func TestThePhoneIndexAndTheWayBack(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| + defer cancel() |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + theme := fiesta.ThemeNames()[0] |
| + for _, locale := range []string{"en", "ar"} { |
| + where := theme + "/" + locale + " at 390px" |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, where, ".ds-index") |
| + mustRun(t, ctx, chromedp.WaitVisible(`.ds-index`, chromedp.ByQuery)) |
| + requireCoarse(t, ctx) |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { |
| + const box = `+boxOf+`; |
| + const h1s = [...document.querySelectorAll("h1")].filter(h => h.getBoundingClientRect().height > 0).map(h => h.textContent.trim()); |
| + const rows = [...document.querySelectorAll(".ds-index > a")].map(a => ({T: a.textContent.trim(), H: a.getBoundingClientRect().height, ID: a.id})); |
| + return JSON.stringify({Rail: box("[rst-shell-rail]"), Main: box("main"), Bar: box(".ds-top"), Lead: box(".ds-index-lead"), H1: h1s, |
| + Group: (document.querySelector(".ds-index > [rst-shell-group]") || {}).textContent || "", Rows: rows}); |
| + })()`, &raw)) |
| + var ix struct { |
| + Rail, Main, Bar, Lead bool |
| + H1 []string |
| + Group string |
| + Rows []struct { |
| + T, ID string |
| + H float64 |
| + } |
| + } |
| + if err := json.Unmarshal([]byte(raw), &ix); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + if !ix.Rail || ix.Main || ix.Bar || !ix.Lead { |
| + t.Errorf("%s: rail %v, main %v, bar %v, lead %v; the index is the rail and its lead alone", where, ix.Rail, ix.Main, ix.Bar, ix.Lead) |
| + } |
| + if len(ix.H1) != 1 || ix.H1[0] != s.say(locale, "gallery.brand") { |
| + t.Errorf("%s: visible h1s %q, want exactly the index title", where, ix.H1) |
| + } |
| + var want []string |
| + for _, pk := range s.pageKinds() { |
| + if pk.Kind != "overview" { |
| + want = append(want, s.titleOf(locale, pk)) |
| + } |
| + } |
| + if ix.Group != s.say(locale, "gallery.nav.demos") { |
| + t.Errorf("%s: the rows' group label is %q, want Demos", where, ix.Group) |
| + } |
| + if len(ix.Rows) < len(want) { |
| + t.Fatalf("%s: %d rows, want at least %d", where, len(ix.Rows), len(want)) |
| + } |
| + for i, w := range want { |
| + if ix.Rows[i].T != w { |
| + t.Errorf("%s: row %d is %q, want %q", where, i, ix.Rows[i].T, w) |
| + } |
| + } |
| + for _, r := range ix.Rows { |
| + if r.H < 44 { |
| + t.Errorf("%s: the row %q is %.1fpx tall, under a 44px target", where, r.T, r.H) |
| + } |
| + } |
| + var seq, expect []string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.activeElement && document.activeElement.blur(); scrollTo(0, 0); true`, nil), |
| + chromedp.Evaluate(`["summary", "#ds-filter", ...[...document.querySelectorAll(".ds-index > a")].map(a => a.id ? "#" + a.id : a.getAttribute("href"))]`, &expect)) |
| + for range expect { |
| + var at string |
| + mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(focusID, &at)) |
| + seq = append(seq, at) |
| + } |
| + if strings.Join(seq, " ") != strings.Join(expect, " ") { |
| + t.Errorf("%s: Tab order\n got %v\nwant %v", where, seq, expect) |
| + } |
| + var filter struct { |
| + IconIn bool |
| + Gap float64 |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter").getBoundingClientRect(), g = document.querySelector(".ds-search > .icon").getBoundingClientRect(); |
| + const rtl = document.documentElement.dir === "rtl", row = document.querySelector(".ds-index > a").getBoundingClientRect(); |
| + const start = rtl ? i.right - g.right : g.left - i.left; |
| + return JSON.stringify({IconIn: g.width > 0 && g.top >= i.top && g.bottom <= i.bottom && start >= 0 && start < 16, Gap: row.top - i.bottom}); })()`, &raw)) |
| + if err := json.Unmarshal([]byte(raw), &filter); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + if !filter.IconIn || filter.Gap < 8 { |
| + t.Errorf("%s: the search icon is inside the field at its inline start %v, and the field is %.1fpx above the first row; want the icon there and a gap of at least 8px", where, filter.IconIn, filter.Gap) |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-form").click(); true`, nil)) |
| + until(t, ctx, where+", Form loaded", `location.pathname.endsWith("/form.html") && document.readyState === "complete"`) |
| + var page string |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const box = `+boxOf+`; |
| + const back = document.querySelector("[rst-skip] + [rst-shell-back] > a"); |
| + return JSON.stringify({Back: !!back && box("[rst-shell-back]"), Href: back ? back.getAttribute("href") : "", |
| + Rail: box("[rst-shell-rail]"), Bar: box(".ds-top"), |
| + Switch: [...document.querySelectorAll("[rst-scheme] button, [rst-seg-tabs] a, [rst-locale]")].some(e => e.checkVisibility() && e.getBoundingClientRect().width > 0)}); |
| + })()`, &page)) |
| + var cp struct { |
| + Back, Rail, Bar, Switch bool |
| + Href string |
| + } |
| + if err := json.Unmarshal([]byte(page), &cp); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, page, err) |
| + } |
| + if !cp.Back || cp.Rail || cp.Bar || cp.Switch { |
| + t.Errorf("%s: Form shows back %v, rail %v, bar %v, a switcher %v; a content page is the way back and the content", where, cp.Back, cp.Rail, cp.Bar, cp.Switch) |
| + } |
| + if want := pageAt(theme, locale, "index.html") + "#nav-form"; cp.Href != want { |
| + t.Errorf("%s: Back goes to %q, want %q", where, cp.Href, want) |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-shell-back] a").click(); true`, nil)) |
| + until(t, ctx, where+", back on the index", `location.pathname.endsWith("/index.html") && document.activeElement && document.activeElement.id === "nav-form"`) |
| + until(t, ctx, where+", the slide over", `document.readyState === "complete"`) |
| + settleMotion(t, ctx, where+", the slide over") |
| + tapAt(t, ctx, where, ".ds-prefs > summary") |
| + until(t, ctx, where+", the menu open", `document.querySelector(".ds-prefs").open`) |
| + tapAt(t, ctx, where, `.ds-prefs [rst-seg-tabs] a[href*="/signal/"]`) |
| + until(t, ctx, where+", switched to signal", `location.pathname.includes("/signal/") && (() => { const r = document.querySelector(".ds-prefs > summary").getBoundingClientRect(); return r.height > 0 && r.top >= 0 && r.top < innerHeight; })()`) |
| + } |
| + |
| + off := noScripts(t, ctx) |
| + where := theme + "/en at 390px, scripts off" |
| + mustRun(t, off, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) |
| + present(t, off, where, "[rst-shell-back]") |
| + mustRun(t, off, chromedp.WaitVisible(`[rst-shell-back]`, chromedp.ByQuery)) |
| + requireScriptsOff(t, off, where) |
| + mustRun(t, off, chromedp.Evaluate(`document.querySelector("[rst-shell-back] a").click(); true`, nil)) |
| + until(t, off, where, `location.hash === "#nav-form" && document.querySelector(":target") && document.querySelector(":target").id === "nav-form"`) |
| + requireScriptsOff(t, off, where+", back on the index") |
| + var next, after string |
| + mustRun(t, off, |
| + chromedp.Evaluate(`document.getElementById("nav-form").nextElementSibling.id || document.getElementById("nav-form").nextElementSibling.getAttribute("href")`, &after), |
| + chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(focusID, &next)) |
| + if strings.TrimPrefix(next, "#") != after { |
| + t.Errorf("%s: the Tab after Back lands on %s, want the row after Form, %s", where, next, after) |
| + } |
| +} |
| + |
| +// TestTheIndexTitleIsVisible: the Overview names the gallery where a |
| +// reader can see it: as the index's heading on a phone, and as the |
| +// bar's brand on a desktop. An h1 that has a box is not enough: the |
| +// shell hides a brand span that has no element in it, so the heading |
| +// could render as an empty row with its title hidden. Read as painted |
| +// text, a range with a width, in two scripts. |
| +func TestTheIndexTitleIsVisible(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + theme := fiesta.ThemeNames()[0] |
| + const painted = `(sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => { |
| + const r = document.createRange(); r.selectNodeContents(e); |
| + const box = r.getBoundingClientRect(); |
| + return box.width > 0 && box.height > 0 ? e.innerText.trim() : ""; |
| + }).filter(Boolean))` |
| + for _, locale := range []string{"en", "ar"} { |
| + want := s.say(locale, "gallery.brand") |
| + for _, c := range []struct { |
| + w, h int |
| + sel string |
| + }{{390, 844, "h1"}, {1280, 800, ".ds-top__brand"}} { |
| + where := fmt.Sprintf("%s/%s index at %dpx", theme, locale, c.w) |
| + var got []string |
| + mustRun(t, ctx, chromedp.EmulateViewport(int64(c.w), int64(c.h)), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, where, c.sel) |
| + mustRun(t, ctx, chromedp.Evaluate(painted+`("`+c.sel+`")`, &got)) |
| + if !slices.Contains(got, want) { |
| + t.Errorf("%s: painted %s text %q, want %q among it", where, c.sel, got, want) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// scanWithout runs axe over the page with the named rules disabled, |
| +// loading the engine where the page has none. The WCAG 2.2 AA tag set |
| +// otherwise, as axe.Scan uses. |
| +func scanWithout(t *testing.T, ctx context.Context, where string, rules ...string) []axe.Violation { |
| + t.Helper() |
| + var loaded bool |
| + mustRun(t, ctx, chromedp.Evaluate(`typeof window.axe !== "undefined"`, &loaded)) |
| + if !loaded { |
| + mustRun(t, ctx, chromedp.Evaluate(axe.Script(), nil)) |
| + } |
| + tags, _ := json.Marshal(axe.DefaultTags()) |
| + off, _ := json.Marshal(rules) |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(`(async (tags, rules) => { |
| + const off = {}; rules.forEach(r => { off[r] = {enabled: false}; }); |
| + const res = await window.axe.run(document, {runOnly: {type: "tag", values: tags}, resultTypes: ["violations"], iframes: false, rules: off}); |
| + return JSON.stringify({passed: res.passes.length, violations: res.violations.map(v => ({ID: v.id, Impact: v.impact, Help: v.help, Nodes: v.nodes.map(n => n.target.map(t => Array.isArray(t) ? t.join(" >> ") : String(t)).join(" >> "))}))}); |
| + })(`+string(tags)+`, `+string(off)+`)`, &raw, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) |
| + var out struct { |
| + Passed int |
| + Violations []axe.Violation |
| + } |
| + if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| + t.Fatalf("%s: decoding axe's result: %v", where, err) |
| + } |
| + if out.Passed < 5 { |
| + t.Fatalf("%s: %d rules ran to completion; the scan saw nothing", where, out.Passed) |
| + } |
| + return out.Violations |
| +} |
| + |
| +// TestA11yScansThePhoneViews: axe on both phone views, every theme and |
| +// both schemes, motion settled first; and again with the display |
| +// settings open and the language list inside it. target-size is off |
| +// for the open card: the page outside it is under the summary's |
| +// light-dismiss layer, which axe cannot see. |
| +func TestA11yScansThePhoneViews(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) |
| + defer cancel() |
| + total := 0 |
| + incomplete := map[string]int{} |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, view := range []struct{ name, file, ready string }{{"index", "index.html", ".ds-index"}, {"content page", "form.html", "[rst-shell-back]"}} { |
| + for _, scheme := range []string{"light", "dark"} { |
| + where := fmt.Sprintf("%s/en %s at 390px (%s)", theme, view.name, scheme) |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", view.file)), chromedp.WaitReady("body")) |
| + present(t, ctx, where, view.ready) |
| + mustRun(t, ctx, chromedp.WaitVisible(view.ready, chromedp.ByQuery)) |
| + requireCoarse(t, ctx) |
| + settleMotion(t, ctx, where) |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); true`, scheme), nil)) |
| + res, err := axe.Scan(ctx, "", nil) |
| + if err != nil { |
| + t.Fatalf("%s: %v", where, err) |
| + } |
| + for _, id := range res.Incomplete { |
| + incomplete[id]++ |
| + } |
| + total += report(t, where, res.Violations) |
| + where += ", display settings open" |
| + mustRun(t, ctx, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), |
| + chromedp.Click(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] [rst-dropdown-menu] a", chromedp.ByQuery)) |
| + settleMotion(t, ctx, where) |
| + total += report(t, where, scanWithout(t, ctx, where, "target-size")) |
| + } |
| + } |
| + } |
| + var ids []string |
| + for id, n := range incomplete { |
| + ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| + } |
| + sort.Strings(ids) |
| + t.Logf("needs review, axe could not decide: %v", ids) |
| + if total == 0 { |
| + t.Logf("clean: both phone views, menu closed and open, in %d themes and both schemes", len(fiesta.ThemeNames())) |
| + } |
| +} |
diff --git a/internal/gallery/moved_browser_test.go b/internal/gallery/moved_browser_test.go| new file mode 100644 |
| index 0000000..68831e4 |
| --- /dev/null |
| +++ b/internal/gallery/moved_browser_test.go |
| @@ -0,0 +1,86 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// A section that moved pages. A fragment that names nothing on this |
| +// page is looked up in the rail, which links every section on every |
| +// page, and followed by location.replace to the page that has it. Two |
| +// lookups: a rail link whose fragment is the one asked for (an icon |
| +// asked of the Tokens page), and, Fiesta's own, a rail link whose page |
| +// stem is the fragment (#button on a page that is not button.html, |
| +// which is where part 3 kept the button). |
| + |
| +// TestAMovedSectionsOldLinkReachesIt: index.html#button lands on |
| +// button.html and tokens.html#icon-check on icons.html#icon-check with |
| +// the icon on screen, each replacing rather than pushing the address. |
| +// A fragment that is on the page, one the rail does not know, and the |
| +// skip link's target are left alone. |
| +func TestAMovedSectionsOldLinkReachesIt(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + index := rig.Origin + pageAt(theme, "en", "index.html") |
| + tokens := rig.Origin + pageAt(theme, "en", "tokens.html") |
| + |
| + start := time.Now() |
| + where := "index.html#button" |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(index+"#button"), chromedp.WaitReady("body")) |
| + present(t, ctx, where, "#ds-nav") |
| + until(t, ctx, where, `location.pathname.endsWith("/button.html") && location.hash === "" && document.readyState === "complete"`) |
| + var depth int |
| + mustRun(t, ctx, chromedp.Evaluate(`history.length`, &depth)) |
| + if depth != 2 { |
| + t.Errorf("%s: history is %d deep after the redirect, want 2 (about:blank, then the page); the old address was pushed rather than replaced", where, depth) |
| + } |
| + settle := 2*time.Since(start) + time.Second |
| + |
| + where = "tokens.html#icon-check" |
| + mustRun(t, ctx, chromedp.Navigate(tokens+"#icon-check")) |
| + until(t, ctx, where, `location.pathname.endsWith("/icons.html") && location.hash === "#icon-check" && document.readyState === "complete" && (() => { const r = document.getElementById("icon-check").getBoundingClientRect(); return r.top >= 0 && r.top < innerHeight; })()`) |
| + |
| + for _, c := range []struct{ hash, want string }{ |
| + {"#tokens-accent", "a group that is on Tokens"}, |
| + {"#nope", "a fragment the rail does not know"}, |
| + {"#main", "the skip link's target, which is not a section"}, |
| + } { |
| + where := "tokens.html" + c.hash + ", " + c.want |
| + mustRun(t, ctx, chromedp.Navigate(tokens+c.hash)) |
| + until(t, ctx, where, `document.readyState === "complete"`) |
| + time.Sleep(settle) |
| + var href string |
| + mustRun(t, ctx, chromedp.Evaluate(`location.href`, &href)) |
| + if href != tokens+c.hash { |
| + t.Errorf("%s: the page moved to %s; %s is left alone", where, href, c.want) |
| + } |
| + } |
| +} |
| + |
| +// TestAMovedSectionsOldLinkReachesItFromAnOpenPage: the same old link |
| +// followed with the page already open. Only the fragment changes, so |
| +// nothing is loaded and readiness never fires again; the lookup has to |
| +// run on hashchange too. |
| +func TestAMovedSectionsOldLinkReachesItFromAnOpenPage(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + where := "tokens.html open, then #icon-check" |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, where, "#ds-nav") |
| + until(t, ctx, where+", Tokens loaded", `document.readyState === "complete"`) |
| + mustRun(t, ctx, chromedp.Evaluate(`location.hash = "#icon-check"; true`, nil)) |
| + until(t, ctx, where, `location.pathname.endsWith("/icons.html") && location.hash === "#icon-check" && document.readyState === "complete" && (() => { const r = document.getElementById("icon-check").getBoundingClientRect(); return r.top >= 0 && r.top < innerHeight; })()`) |
| +} |
diff --git a/internal/gallery/narrow_browser_test.go b/internal/gallery/narrow_browser_test.go| new file mode 100644 |
| index 0000000..7f69706 |
| --- /dev/null |
| +++ b/internal/gallery/narrow_browser_test.go |
| @@ -0,0 +1,55 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// TestEveryPageKindFitsAPhoneAt320: WCAG 2.2 AA 1.4.10 Reflow on one |
| +// page of every kind in the shell, in English and Arabic: the index, |
| +// Tokens, Icons, a family page and an example page at a 320 CSS-pixel |
| +// viewport must not need horizontal scrolling. Fiesta's own drive |
| +// over every example page in a11y_browser_test.go stays beside this |
| +// one; this reads the shell's other pages, in the other direction too. |
| +func TestEveryPageKindFitsAPhoneAt320(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + measured := 0 |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, file := range []string{"index.html", "tokens.html", "icons.html", "form.html", "button.html"} { |
| + where := theme + "/" + locale + "/" + file |
| + mustRun(t, ctx, |
| + chromedp.ActionFunc(func(c context.Context) error { |
| + return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) |
| + }), |
| + chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), |
| + chromedp.WaitReady("body"), |
| + ) |
| + present(t, ctx, where, "[rst-shell-sidebar]") |
| + var got struct { |
| + Doc, Client, Body int |
| + Over []string |
| + } |
| + at(t, ctx, measureJS, &got) |
| + if got.Doc > got.Client || got.Body > got.Client { |
| + t.Errorf("%s scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s", |
| + where, got.Doc, got.Body, got.Client, strings.Join(got.Over, "\n ")) |
| + } |
| + measured++ |
| + } |
| + } |
| + t.Logf("%d pages measured", measured) |
| +} |
diff --git a/internal/gallery/nav.go b/internal/gallery/nav.go| index c66b40b..e265c46 100644 |
| --- a/internal/gallery/nav.go |
| +++ b/internal/gallery/nav.go |
| @@ -2,6 +2,7 @@ package gallery |
| import ( |
| "html/template" |
| + "strings" |
| "amadan.net/rastrillo/fiesta" |
| ) |
| @@ -9,21 +10,47 @@ import ( |
| // The rail's model: one section per page kind with the entries the |
| // page anchors, read off the same data the page renders from, so an |
| // entry pointing at nothing and a section with no way to reach it are |
| -// both impossible. The types and the three readers are here; the shell |
| -// that draws them, the section overview items, the current marks, the |
| -// search terms, the Demos and the phone index are the shell task's. |
| +// both impossible. The rail is the same on every page apart from the |
| +// current marks: one list of the whole vocabulary rather than a table |
| +// of contents per page, which TestTheRailIsTheSameOnEveryPage holds |
| +// literally. The phone index is read off the rail, so the rows and |
| +// the tree cannot list different sections. |
| // navItem is one line in the rail. Code marks an entry whose label is |
| // an identifier (an example's name, an icon's slug), drawn in the mono |
| // face. Blank marks an entry that leaves the page and opens in a new |
| // tab. Aria is the accessible name where it has to differ from the |
| // visible label; Terms the synonyms the filter matches beside it. |
| +// Current marks the entry that is the page being rendered: an |
| +// example's own line on its page. |
| type navItem struct { |
| - Label, Href string |
| - Code, Blank bool |
| - Aria, Terms string |
| + Label, Href string |
| + Code, Blank, Current bool |
| + Aria, Terms string |
| } |
| +// searchTerms are the words people call an example by that its name |
| +// does not contain, keyed by example name and matched by the filter |
| +// beside the name. Rastrillo keys them by anchor id; Fiesta's rail |
| +// entries are example pages, so the key is the page's stem. |
| +// TestEverySearchTermNamesAnAnchor holds every key to a real example |
| +// and every rail link to its terms. |
| +var searchTerms = map[string]string{ |
| + "field-check": "checkbox toggle switch", |
| + "modal": "dialog popup", |
| + "choice-field": "radio", |
| + "list-grid": "table", |
| + "box": "card panel", |
| + "person": "avatar user", |
| + "button": "button submit cta", |
| +} |
| + |
| +// shellNames are the shells the Demos group opens, in the order the |
| +// layout examples are read: the five whose frame document is an app |
| +// laid out whole. The -page pair members are the content pages of the |
| +// sidebar and console shells and are reached from inside those. |
| +var shellNames = []string{"column", "topbar", "sidebar", "console", "stage"} |
| + |
| // navSection is one group in the rail: a page of the directory, or |
| // the Demos. Href is the section's own page and Current says whether |
| // that page is the one being rendered. |
| @@ -121,21 +148,123 @@ func (s *site) controls(p page) controls { |
| } |
| // galleryNav is the rail for one page: one section per page kind, in |
| -// table order, each with the entries its page anchors. The section |
| -// overview items, the current marks, the search terms and the Demos |
| -// are the shell task's to add. |
| +// table order, each with its overview item first and then the entries |
| +// its page anchors, with the search terms on the entries that have |
| +// them; then the Demos. The section whose kind is the page's is |
| +// Current (on an example page that is its family's), and on an |
| +// example page the example's own entry is Current too. |
| func (s *site) galleryNav(p page) []navSection { |
| - var out []navSection |
| + self := s.href(p.Theme, p.Locale, p.File) |
| + out := make([]navSection, 0, len(s.pageKinds())+1) |
| for _, pk := range s.pageKinds() { |
| - section := navSection{Title: s.titleOf(p.Locale, pk), Href: s.href(p.Theme, p.Locale, pk.File), Kind: pk.Kind} |
| + section := navSection{ |
| + Title: s.titleOf(p.Locale, pk), |
| + Href: s.href(p.Theme, p.Locale, pk.File), |
| + Kind: pk.Kind, |
| + Current: pk.Kind == p.Kind, |
| + } |
| if pk.Nav != nil { |
| - section.Items = pk.Nav(p) |
| + // The section overview link, first, before the entries. A |
| + // section that lists anything draws its title as a |
| + // <summary>, which discloses rather than navigates, so |
| + // without this the section would have no way to its own |
| + // page. A section with nothing to list is already a plain |
| + // link to its page and needs no second one under it. |
| + section.Items = append([]navItem{s.sectionOverview(p, section)}, pk.Nav(p)...) |
| + for i := 1; i < len(section.Items); i++ { |
| + item := §ion.Items[i] |
| + item.Terms = searchTerms[stemOf(item.Href)] |
| + item.Current = item.Href == self |
| + } |
| } |
| out = append(out, section) |
| } |
| + return append(out, s.demos(p)) |
| +} |
| + |
| +// stemOf is the file name a page link ends in, without its extension |
| +// and fragment: the example's name for an example page's link, and "" |
| +// for anything else. |
| +func stemOf(href string) string { |
| + file := href[strings.LastIndex(href, "/")+1:] |
| + if i := strings.Index(file, "#"); i >= 0 { |
| + return "" |
| + } |
| + return strings.TrimSuffix(file, ".html") |
| +} |
| + |
| +// sectionOverview is one section's route to the top of its own page: |
| +// the first item under it, labelled Overview, which is also the word |
| +// the rail's filter matches on, and named for a screen reader by the |
| +// section it belongs to. |
| +// |
| +// The visible label is deliberately the same on every section and the |
| +// accessible name deliberately is not. Twelve links reading "Overview" |
| +// in one navigation landmark are unambiguous nested under their |
| +// headings and ambiguous read out in a list, so the accessible name |
| +// carries the section: "Tokens overview". It contains the visible |
| +// label, so WCAG 2.2 SC 2.5.3 Label in Name is satisfied without |
| +// changing the word a sighted reader sees or types into the filter. |
| +func (s *site) sectionOverview(p page, section navSection) navItem { |
| + return navItem{ |
| + Label: s.say(p.Locale, "gallery.nav.overview"), |
| + Href: section.Href, |
| + Aria: s.say(p.Locale, "gallery.nav.overview.aria", "section", section.Title), |
| + } |
| +} |
| + |
| +// demos is the group at the foot of the rail: the five shells, each |
| +// the layout example's default frame document opened in a tab of its |
| +// own, which is the shell laid out whole as the app it stands for. The |
| +// modal route and the demo application are appended by the task that |
| +// writes those documents. |
| +func (s *site) demos(p page) navSection { |
| + out := navSection{Title: s.say(p.Locale, "gallery.nav.demos")} |
| + for _, shell := range shellNames { |
| + name := "layout-" + shell |
| + state, ok := s.m.Default(name) |
| + if !ok { |
| + // The shells are frozen in examples/ and named here; a |
| + // manifest without one is a programming error, as a kind |
| + // fileOf does not know is. |
| + panic("gallery: the manifest has no " + name + " example for the Demos group") |
| + } |
| + out.Items = append(out.Items, navItem{ |
| + Label: s.say(p.Locale, "gallery.demos.shell", "shell", shell), |
| + Href: s.href(p.Theme, p.Locale, "frames/"+name+"/"+state+".html"), |
| + Blank: true, |
| + }) |
| + } |
| return out |
| } |
| +// indexRows reads the phone index off the rail, so the rows and the |
| +// tree cannot list different sections: one row per page kind but the |
| +// Overview, which is the index itself, and the Demos section as is. |
| +// Rows are plain links, not the tree's disclosures, because shell.js |
| +// returns focus to the first nav link whose path is the page just |
| +// left, and in the tree that link is inside a closed <details> that |
| +// cannot take focus. anchorID never writes a nav- id, and the kinds |
| +// are unique, so the ids cannot collide with anything else on the |
| +// page. |
| +func indexRows(nav []navSection) ([]indexRow, navSection) { |
| + rows := make([]indexRow, 0, len(nav)) |
| + for _, s := range nav[:len(nav)-1] { |
| + if s.Kind == "overview" { |
| + continue |
| + } |
| + rows = append(rows, indexRow{ID: "nav-" + s.Kind, Title: s.Title, Href: s.Href}) |
| + } |
| + return rows, nav[len(nav)-1] |
| +} |
| + |
| // phoneIndex sets the Overview's phone index, Rows and Demos, on the |
| -// page being built. Nothing yet: the shell task fills it. |
| -func (s *site) phoneIndex(p *page) {} |
| +// page being built: the Overview is the shell's index page below |
| +// 800px, and every other page a content page with a way back to its |
| +// row, so only the Overview carries them. |
| +func (s *site) phoneIndex(p *page) { |
| + if p.Kind != "overview" { |
| + return |
| + } |
| + p.Rows, p.Demos = indexRows(p.Nav) |
| +} |
diff --git a/internal/gallery/page_tmpl.go b/internal/gallery/page_tmpl.go| index 2f49839..05bd55b 100644 |
| --- a/internal/gallery/page_tmpl.go |
| +++ b/internal/gallery/page_tmpl.go |
| @@ -1,15 +1,28 @@ |
| package gallery |
| -// pageTemplate is the frame every gallery page is drawn in: the head, |
| -// the header with the brand, the theme tabs, the scheme menu, the |
| -// language menu and the page navigation, the page's own heading and |
| -// body, the previous/next pair and the Copy live region. It is part |
| -// 3's header carried over with the language menu in place of the |
| -// direction link; the shell task rewrites it as the sidebar shell. |
| +// Every gallery page is the sidebar shell Fiesta ships: the gallery |
| +// documents the shell by being one. pageTemplate is Rastrillo's page |
| +// template over Fiesta's seams: say in place of P and T, the scheme |
| +// menu lifted from the scheme-menu example in place of the three |
| +// buttons, the page's Icon and Asset methods, every link the gallery |
| +// owns marked data-gallery-nav, and the scheme store attribute on |
| +// <html> so fiesta.js remembers the choice. |
| // |
| -// gallery.js blocks in <head>, as rastrillo's does, so that a frame is |
| -// hidden before it can paint in the wrong scheme; the scheme menu's own |
| -// scripts are deferred, as the example that ships them has them. |
| +// The head loads tokens.css, the theme, shell.css and gallery.css, then |
| +// gallery.js blocking, so a stored scheme is on the root and the |
| +// frame-hiding rule is armed before the first paint; fiesta.js and |
| +// anchor.js deferred, as the scheme-menu example loads them; shell.js |
| +// deferred and render-blocking, as the layouts load it, so the slide's |
| +// direction is set at the first render. |
| +// |
| +// The Overview's phone heading leaves the shell's brand span empty and |
| +// puts the gallery's name in the title span after it. The shell hides |
| +// a brand span with no element in it, as a blank brand, and shows the |
| +// title instead; a brand link would add a tab stop that goes to the |
| +// page it is on. The ds-prefs menu is written once per page, in the |
| +// index's header row or in a content page's back strip, and both it |
| +// and the bar draw their controls from one value, so the two cannot |
| +// differ. |
| // |
| // The live region at the foot carries the Copy button's three words |
| // and the join as data attributes, so the script writes no words of |
| @@ -20,6 +33,7 @@ const pageTemplate = `{{define "ds-controls"}}<nav rst-seg-tabs aria-label="{{sa |
| <summary>{{say "gallery.language"}}<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span><span class="rst-sr-only">{{say "gallery.language.current" "language" .LocaleName}}</span></summary> |
| <div rst-dropdown-menu>{{range .Locales}}<a data-gallery-nav href="{{.Href}}" lang="{{.Code}}" dir="{{.Dir}}"{{if .Current}} aria-current="true"{{end}}>{{.Name}}</a>{{end}}</div> |
| </details>{{end}} |
| +{{define "ds-prefs"}}<details rst-dropdown class="ds-prefs" id="ds-prefs" name="ds-prefs"><summary aria-label="{{say "gallery.prefs"}}">` + cogIcon + `</summary><div rst-dropdown-menu>{{template "ds-controls" .}}</div></details>{{end}} |
| {{define "ds-page"}}<!doctype html> |
| <html lang="{{.Lang}}" dir="{{.Dir}}" {{.HTMLAttrs}}> |
| <head> |
| @@ -28,25 +42,55 @@ const pageTemplate = `{{define "ds-controls"}}<nav rst-seg-tabs aria-label="{{sa |
| <title>{{.Title}}</title> |
| <link rel="stylesheet" href="{{.Asset "css/tokens.css"}}"> |
| <link rel="stylesheet" href="{{.Asset (print "css/themes/" .Theme ".css")}}"> |
| +<link rel="stylesheet" href="{{.Asset "css/shell.css"}}"> |
| <link rel="stylesheet" href="{{.Asset "gallery.css"}}"> |
| <script src="{{.Asset "gallery.js"}}"></script> |
| {{range .Scripts}}<script defer src="{{$.Asset (print "js/" .)}}"></script> |
| -{{end}}</head> |
| +{{end}}<script defer blocking="render" src="{{.Asset "js/shell.js"}}"></script> |
| +</head> |
| <body> |
| +<div rst-shell-sidebar="{{if .Rows}}index{{else}}page{{end}}"> |
| <a rst-skip href="#main">{{say "gallery.skip"}}</a> |
| +{{with .Up}}<div rst-shell-back><a data-gallery-nav href="{{.}}" rel="up" aria-label="{{say "gallery.up" "name" (say "gallery.up.label")}}">{{say "gallery.up.label"}}</a>{{template "ds-prefs" $.Bar}}</div> |
| +{{end}}<aside class="ds-rail" rst-shell-rail> |
| +{{if .Rows}}<h1 rst-shell-title><span></span><span>{{say "gallery.brand"}}</span></h1> |
| +{{template "ds-prefs" .Bar}} |
| +<p class="ds-index-lead">{{say "gallery.overview.intro"}}</p> |
| +{{end}} <search class="ds-search"> |
| + <label class="rst-sr-only" for="ds-filter">{{say "gallery.filter"}}</label> |
| + {{icon "search"}}<input id="ds-filter" type="search" placeholder="{{say "gallery.filter"}}" autocomplete="off" aria-controls="ds-nav" data-ds-filter> |
| + </search> |
| + <p class="ds-nav__empty" data-ds-filter-empty role="status" hidden>{{say "gallery.filter.empty"}}</p> |
| +{{if .Rows}} <nav class="ds-index" rst-shell-nav aria-label="{{say "gallery.up.label"}}">{{range .Rows}}<a id="{{.ID}}" data-gallery-nav href="{{.Href}}">{{.Title}}</a>{{end}}<p rst-shell-group>{{.Demos.Title}}</p>{{range .Demos.Items}}<a data-gallery-nav href="{{.Href}}" target="_blank" rel="noopener">{{.Label}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span></a>{{end}}</nav> |
| +{{end}} <nav class="ds-nav" rst-shell-nav id="ds-nav" aria-label="{{say "gallery.nav.sections"}}"> |
| +{{range .Nav}}{{if .Items}} <details{{if .Current}} open aria-current="page"{{end}}><summary><span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span>{{.Title}}</summary>{{range .Items}}<a data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}{{if .Aria}} aria-label="{{.Aria}}"{{end}}{{if .Code}} class="rst-mono"{{end}}{{if .Blank}} target="_blank" rel="noopener"{{end}}{{if .Terms}} data-ds-terms="{{.Terms}}"{{end}}>{{.Label}}{{if .Blank}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span>{{end}}</a>{{end}}</details> |
| +{{else}} <a class="ds-nav__page" data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Title}}</a> |
| +{{end}}{{end}} </nav> |
| +</aside> |
| <header class="ds-top"> |
| <a class="ds-top__brand" data-gallery-nav href="{{.Home}}">{{say "gallery.brand"}}</a> |
| <div class="ds-top__controls">{{template "ds-controls" .Bar}}</div> |
| -<nav class="ds-pages" aria-label="{{say "gallery.nav.label"}}">{{range .Nav}}<a data-gallery-nav href="{{.Href}}"{{if eq .Kind $.Kind}} aria-current="page"{{end}}>{{.Title}}</a>{{end}}</nav> |
| </header> |
| -<main rst-page id="main"> |
| +<main rst-shell-main id="main"> |
| +<div rst-page> |
| <header rst-page-header><div rst-page-header-titles><h1{{with .TitleID}} id="{{.}}"{{end}}{{if .Mono}} class="rst-mono"{{end}}>{{.Name}}</h1></div></header> |
| {{.Body}} |
| <nav class="ds-updown" aria-label="{{say "gallery.steps.label"}}">{{with .Prev}}<a class="ds-updown__prev" data-gallery-nav href="{{.Href}}">{{.Label}}</a>{{end}}{{with .Next}}<a class="ds-updown__next" data-gallery-nav href="{{.Href}}">{{.Label}}</a>{{end}}</nav> |
| <p class="rst-sr-only" role="status" data-ds-copy-status data-copy="{{say "gallery.copy"}}" data-copied="{{say "gallery.copied"}}" data-failed="{{say "gallery.copy.failed"}}" data-join=", "></p> |
| +</div> |
| </main> |
| +</div> |
| </body> |
| </html> |
| {{end}}` |
| +// cogIcon is the display settings button's glyph, Lucide's settings |
| +// (ISC, as the icon set), drawn the way the set draws its own. Fiesta's |
| +// icons/ has no settings glyph and is frozen, and this one button is |
| +// the gallery's furniture, not something an app is offered, so it is |
| +// drawn here rather than given a slug. aria-hidden: the button's name |
| +// is its aria-label. |
| +const cogIcon = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` + |
| + `<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>` |
| + |
| func init() { registerTemplate(pageTemplate) } |
diff --git a/internal/gallery/place_browser_test.go b/internal/gallery/place_browser_test.go| new file mode 100644 |
| index 0000000..acb07b3 |
| --- /dev/null |
| +++ b/internal/gallery/place_browser_test.go |
| @@ -0,0 +1,299 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/runtime" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// Keeping your place: a theme or language link carries the section |
| +// being read, worked out at the click, and the address bar is never |
| +// rewritten. |
| + |
| +// geometryJS is the rule a switch falls back to, written again here so |
| +// the drive checks the script rather than agreeing with it: among the |
| +// anchors whose top is at or above the reading line, those with the |
| +// greatest top, the first of them in document order. |
| +const geometryJS = `(() => { |
| + const line = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBlockStart) + 1; |
| + let best = null, top = -Infinity; |
| + for (const a of document.querySelectorAll("[data-ds-anchor]")) { |
| + const r = a.getBoundingClientRect(); |
| + if (!r.width && !r.height) continue; |
| + if (r.top <= line && r.top > top) { best = a; top = r.top; } |
| + } |
| + return best ? best.id : ""; |
| +})()` |
| + |
| +const landingJS = `(() => { |
| + const id = decodeURIComponent(location.hash.slice(1)), el = id && document.getElementById(id); |
| + return JSON.stringify({Hash: location.hash, Path: location.pathname, Top: el ? el.getBoundingClientRect().top : -1, |
| + Line: parseFloat(getComputedStyle(document.documentElement).scrollPaddingBlockStart) + 1, |
| + End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2, Y: scrollY}); |
| +})()` |
| + |
| +type landing struct { |
| + Hash, Path string |
| + Top, Line, Y float64 |
| + End bool |
| +} |
| + |
| +// place drives one tab through switches on desktop, where the |
| +// switchers are. |
| +type place struct { |
| + t *testing.T |
| + ctx context.Context |
| +} |
| + |
| +func (p *place) run(where string, actions ...chromedp.Action) { |
| + p.t.Helper() |
| + if err := chromedp.Run(p.ctx, actions...); err != nil { |
| + p.t.Fatalf("%s: %v", where, err) |
| + } |
| +} |
| + |
| +// frames waits two animation frames: the record is written one frame |
| +// after this document puts a target in place. |
| +func (p *place) frames(where string) { |
| + p.t.Helper() |
| + p.run(where, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, |
| + func(e *runtime.EvaluateParams) *runtime.EvaluateParams { return e.WithAwaitPromise(true) })) |
| +} |
| + |
| +// open loads url as a document of its own, by way of about:blank, since |
| +// navigating to the page already open with only a fragment added |
| +// scrolls within it and loads nothing. |
| +func (p *place) open(where, url string) { |
| + p.t.Helper() |
| + p.run(where, chromedp.EmulateViewport(1280, 900), chromedp.Navigate("about:blank"), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| + present(p.t, p.ctx, where, ".ds-top") |
| + until(p.t, p.ctx, where, `document.readyState === "complete"`) |
| + p.frames(where) |
| +} |
| + |
| +func (p *place) geometry(where string) string { |
| + p.t.Helper() |
| + var id string |
| + p.run(where, chromedp.Evaluate(geometryJS, &id)) |
| + return id |
| +} |
| + |
| +// follow clicks a rail link to an anchor on this page, a real followed |
| +// fragment. |
| +func (p *place) follow(where, id string) { |
| + p.t.Helper() |
| + p.run(where, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil)) |
| + p.frames(where) |
| +} |
| + |
| +// switchTheme clicks the bar's link to the next theme and waits for |
| +// that theme's page; switchLanguage does the same through the language |
| +// menu. Both report where the new page landed. |
| +func (p *place) switchTheme(where string) landing { |
| + p.t.Helper() |
| + var next string |
| + p.run(where, chromedp.Evaluate(`(() => { const a = document.querySelector('.ds-top__controls [rst-seg-tabs] a:not([aria-current])'); a.click(); return a.textContent; })()`, &next)) |
| + until(p.t, p.ctx, where, `!!document.querySelector('link[href*="/themes/`+next+`."][href$=".css"]')`) |
| + return p.landed(where) |
| +} |
| + |
| +func (p *place) switchLanguage(where, code string) landing { |
| + p.t.Helper() |
| + p.run(where, chromedp.Evaluate(`(() => { const d = document.querySelector(".ds-top__controls [rst-locale]"); d.open = true; d.querySelector('a[lang="`+code+`"]').click(); return true; })()`, nil)) |
| + until(p.t, p.ctx, where, `document.documentElement.lang === "`+code+`"`) |
| + return p.landed(where) |
| +} |
| + |
| +func (p *place) landed(where string) landing { |
| + p.t.Helper() |
| + until(p.t, p.ctx, where, `document.readyState === "complete"`) |
| + var raw string |
| + p.run(where, chromedp.Evaluate(landingJS, &raw)) |
| + var l landing |
| + if err := json.Unmarshal([]byte(raw), &l); err != nil { |
| + p.t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + return l |
| +} |
| + |
| +// lands asserts a switch landed on id: the fragment names it, and its |
| +// top is on the reading line, or the page is scrolled to its end with |
| +// the top below the line, because the browser clamps at the end. |
| +func (p *place) lands(where string, l landing, id string) { |
| + p.t.Helper() |
| + if l.Hash != "#"+id { |
| + p.t.Errorf("%s: landed at %q, want #%s", where, l.Hash, id) |
| + return |
| + } |
| + if math.Abs(l.Top-l.Line) > 2 && !(l.End && l.Top > l.Line) { |
| + p.t.Errorf("%s: #%s's top is at %.1fpx, not on the reading line at %.1fpx", where, id, l.Top, l.Line) |
| + } |
| +} |
| + |
| +// TestPositionSurvivesASwitch: the rules of place, on the Tokens page, |
| +// whose groups the rail links by fragment, and the Icons page, whose |
| +// glyphs share a row. A section on the reading line survives a theme |
| +// switch and a language switch; a followed fragment is the place |
| +// whatever geometry says, the last group included, until a screen's |
| +// scroll lets it go; a fresh load at the last group records it and a |
| +// reload does not; following the same fragment again after a layout |
| +// change renews the record; history is not a destination; a cancelled |
| +// click renews nothing; a fragment that is not an anchor is never |
| +// carried. The legs that need a real new tab or a real back/forward |
| +// cache restore (the switcher's own address staying canonical) are |
| +// the history package's. |
| +func TestPositionSurvivesASwitch(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| + defer cancel() |
| + p := &place{t: t, ctx: ctx} |
| + theme := fiesta.ThemeNames()[0] |
| + page := func(theme, locale string) string { return rig.Origin + pageAt(theme, locale, "tokens.html") } |
| + |
| + var ids []string |
| + p.open("finding the groups", page(theme, "en")) |
| + p.run("finding the groups", chromedp.Evaluate(`[...document.querySelectorAll("[data-ds-anchor]")].map(a => a.id)`, &ids)) |
| + if len(ids) < 4 { |
| + t.Fatalf("the Tokens page anchors %d groups; the legs below need four", len(ids)) |
| + } |
| + mid, other, last := ids[2], ids[1], ids[len(ids)-1] |
| + |
| + p.open("scrolled", page(theme, "en")) |
| + p.run("scrolled", chromedp.Evaluate(`document.getElementById("`+mid+`").scrollIntoView({block: "start"}); true`, nil)) |
| + p.lands("scrolled, then theme", p.switchTheme("scrolled, then theme"), mid) |
| + p.lands("scrolled, then language", p.switchLanguage("scrolled, then language", "ja"), mid) |
| + |
| + p.open("followed", page(theme, "en")) |
| + p.follow("followed", mid) |
| + p.lands("followed, then theme", p.switchTheme("followed, then theme"), mid) |
| + p.lands("followed, then language", p.switchLanguage("followed, then language", "ga"), mid) |
| + |
| + // The two cases geometry cannot answer. The last group: the page |
| + // clamps before it reaches the line, so geometry would pick the |
| + // one above it. |
| + p.open("last", page(theme, "en")) |
| + p.follow("last", last) |
| + if g := p.geometry("last"); g == last { |
| + t.Fatalf("last: geometry also picks %s, so this leg cannot tell the record from rule 2; the last group is taller than the viewport", last) |
| + } |
| + p.lands("the last group", p.switchTheme("the last group"), last) |
| + p.open("last, scrolled", page(theme, "en")) |
| + p.follow("last, scrolled", last) |
| + p.run("last, scrolled", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| + want := p.geometry("last, scrolled") |
| + p.lands("last, scrolled, then theme", p.switchTheme("last, scrolled, then theme"), want) |
| + |
| + // Icons: a row of glyphs shares one top. Following the first glyph |
| + // of the second row lands on it, not on the row's last. |
| + icons := rig.Origin + pageAt(theme, "en", "icons.html") |
| + p.open("icons", icons) |
| + var glyph string |
| + p.run("icons", chromedp.Evaluate(`(() => { const all = [...document.querySelectorAll(".ds-icons [data-ds-anchor], .ds-toks [data-ds-anchor]")]; |
| + const firstTop = all[0].getBoundingClientRect().top; return all.find(a => a.getBoundingClientRect().top > firstTop + 1).id; })()`, &glyph)) |
| + p.follow("icons", glyph) |
| + p.lands("icons, a row's first glyph", p.switchTheme("icons, a row's first glyph"), glyph) |
| + |
| + // The record's start, a fresh load at the last group. |
| + p.open("loaded at the last group", page(theme, "en")+"#"+last) |
| + if g := p.geometry("loaded at the last group"); g == last { |
| + t.Fatalf("loaded at the last group: geometry also picks %s, so this leg cannot tell the load record from rule 2", last) |
| + } |
| + p.lands("loaded at the last group, then theme", p.switchTheme("loaded at the last group, then theme"), last) |
| + p.open("reloaded", page(theme, "en")+"#"+last) |
| + p.run("reloaded", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil), chromedp.Reload(), chromedp.WaitReady("body")) |
| + until(t, ctx, "reloaded", `document.readyState === "complete"`) |
| + p.frames("reloaded") |
| + want = p.geometry("reloaded") |
| + p.lands("reloaded, then theme", p.switchTheme("reloaded, then theme"), want) |
| + |
| + // The record's renewal: following the same fragment again after a |
| + // layout change above it (no hashchange fires) puts it back. |
| + p.open("renewed", page(theme, "en")) |
| + p.follow("renewed", last) |
| + p.run("renewed", chromedp.Evaluate(`document.querySelector("main h1").style.marginBlockEnd = "400px"; true`, nil)) |
| + p.follow("renewed", last) |
| + p.lands("renewed, then theme", p.switchTheme("renewed, then theme"), last) |
| + |
| + // History is not a destination: Back restores a scrolled position, |
| + // not the target's. |
| + p.open("history", page(theme, "en")) |
| + p.follow("history", last) |
| + p.run("history", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| + p.follow("history", other) |
| + p.run("history", chromedp.Evaluate(`history.back(); true`, nil)) |
| + until(t, ctx, "history, back", `location.hash === "#`+last+`"`) |
| + p.frames("history, back") |
| + want = p.geometry("history, back") |
| + if want == last { |
| + t.Fatalf("history, back: geometry also picks %s, so this leg cannot tell rule 1 from rule 2; scroll further", last) |
| + } |
| + p.lands("history, back, then theme", p.switchTheme("history, back, then theme"), want) |
| + p.open("history, forward", page(theme, "en")) |
| + p.follow("history, forward", last) |
| + p.run("history, forward", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| + p.follow("history, forward", other) |
| + p.run("history, forward", chromedp.Evaluate(`scrollBy(0, innerHeight); history.back(); true`, nil)) |
| + until(t, ctx, "history, forward, back", `location.hash === "#`+last+`"`) |
| + p.run("history, forward", chromedp.Evaluate(`history.forward(); true`, nil)) |
| + until(t, ctx, "history, forward", `location.hash === "#`+other+`"`) |
| + p.frames("history, forward") |
| + want = p.geometry("history, forward") |
| + p.lands("history, forward, then theme", p.switchTheme("history, forward, then theme"), want) |
| + p.open("history, round trip", page(theme, "en")) |
| + p.follow("history, round trip", last) |
| + p.run("history, round trip", chromedp.Evaluate(`history.back(); true`, nil)) |
| + until(t, ctx, "history, round trip, back", `location.hash === ""`) |
| + p.run("history, round trip", chromedp.Evaluate(`history.forward(); true`, nil)) |
| + until(t, ctx, "history, round trip, forward", `location.hash === "#`+last+`"`) |
| + p.frames("history, round trip, forward") |
| + want = p.geometry("history, round trip, forward") |
| + if want == last { |
| + t.Fatalf("history, round trip: geometry also picks %s, so this leg cannot tell rule 1 from rule 2", last) |
| + } |
| + p.lands("history, round trip, then theme", p.switchTheme("history, round trip, then theme"), want) |
| + |
| + // Only a followed link renews: a click another listener cancels |
| + // moves nothing. |
| + where := "only a followed link, cancelled" |
| + p.open(where, page(theme, "en")) |
| + p.follow(where, last) |
| + p.run(where, chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| + p.run(where, chromedp.Evaluate(`document.addEventListener("click", e => e.preventDefault(), {once: true}); document.querySelector('#ds-nav a[href$="#`+last+`"]').click(); true`, nil)) |
| + p.frames(where) |
| + want = p.geometry(where) |
| + p.lands(where+", then theme", p.switchTheme(where+", then theme"), want) |
| + |
| + // A fragment that is not an anchor (the skip link's #main, a typo) |
| + // is never carried, and never decides the place. |
| + for _, frag := range []string{"main", "nope"} { |
| + where := "loaded at #" + frag |
| + p.open(where, page(theme, "en")+"#"+frag) |
| + want := p.geometry(where) |
| + l := p.switchTheme(where + ", then theme") |
| + if want == "" && l.Hash != "" || want != "" && l.Hash != "#"+want { |
| + t.Errorf("%s: the switch carried %q, want %q", where, l.Hash, map[bool]string{true: "", false: "#" + want}[want == ""]) |
| + } |
| + } |
| + // A plain click from above every anchor lands at the other page's |
| + // top, with the switcher's own address left canonical. |
| + where = "from the top" |
| + p.open(where, page(theme, "en")) |
| + top := p.switchTheme(where) |
| + var canonical bool |
| + p.run(where, chromedp.Evaluate(`![...document.querySelectorAll(".ds-top__controls a")].some(a => a.getAttribute("href").includes("#"))`, &canonical)) |
| + if top.Hash != "" || top.Y != 0 || !canonical { |
| + t.Errorf("%s: a plain click from above every anchor landed at %q, scrolled %.0fpx, links canonical %v; want the other page's top and no fragment left on a switcher", where, top.Hash, top.Y, canonical) |
| + } |
| +} |
diff --git a/internal/gallery/prefs_browser_test.go b/internal/gallery/prefs_browser_test.go| new file mode 100644 |
| index 0000000..2bee07c |
| --- /dev/null |
| +++ b/internal/gallery/prefs_browser_test.go |
| @@ -0,0 +1,247 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "math" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/accessibility" |
| + "github.com/chromedp/cdproto/cdp" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The display settings menu on a phone: the theme, scheme and language |
| +// controls as one cog menu at the top of every page. |
| + |
| +// prefsJS reads the display settings menu on a phone: where its button |
| +// sits (against the root's box, since tokens.css reserves a stable |
| +// scrollbar gutter that RTL puts on the left), whether the card is |
| +// open and shown, and for every theme link, the scheme menu's summary |
| +// and buttons, the language summary and every language, whether it can |
| +// be brought on screen inside the card and is then the topmost thing |
| +// at its centre. |
| +const prefsJS = `(() => { |
| + const q = s => document.querySelector(s), h = document.documentElement.getBoundingClientRect(); |
| + const rtl = document.documentElement.dir === "rtl", d = q(".ds-prefs"), s = d.querySelector("summary"), r = s.getBoundingClientRect(); |
| + const card = d.querySelector(":scope > [rst-dropdown-menu]"); |
| + const controls = [...card.querySelectorAll("[rst-seg-tabs] a, [rst-scheme] > summary, [rst-scheme] [rst-dropdown-menu] button, [rst-locale] > summary, [rst-locale] [rst-dropdown-menu] a")] |
| + .filter(el => el.checkVisibility()).map(el => { |
| + el.scrollIntoView({block: "nearest", inline: "nearest"}); |
| + const b = el.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); |
| + return {Text: el.textContent.trim(), On: b.top >= 0 && b.bottom <= innerHeight && b.left >= h.left && b.right <= h.right, |
| + Top: !!hit && (hit === el || el.contains(hit)), H: Math.round(b.height)}; |
| + }); |
| + const strip = q("[rst-shell-back]"), sr = strip && strip.checkVisibility() ? strip.getBoundingClientRect() : null; |
| + return JSON.stringify({Open: d.open, Shown: card.checkVisibility(), Top: Math.round(r.top), Mid: r.top + r.height / 2, |
| + StripH: sr ? sr.height : 0, StripMid: sr ? sr.top + sr.height / 2 : 0, End: Math.round(rtl ? r.left - h.left : h.right - r.right), |
| + W: Math.round(r.width), H: Math.round(r.height), InStrip: !!strip && strip.contains(d), Controls: controls, |
| + Themes: card.querySelectorAll("[rst-seg-tabs] a").length, Schemes: card.querySelectorAll("[rst-scheme] [rst-dropdown-menu] button").length, |
| + Locales: card.querySelectorAll("[rst-locale] [rst-dropdown-menu] a").length, |
| + Focus: document.activeElement === s, Path: location.pathname, Hash: location.hash}); |
| +})()` |
| + |
| +type prefsControl struct { |
| + Text string |
| + On, Top bool |
| + H int |
| +} |
| + |
| +type prefsReading struct { |
| + Open, Shown, InStrip, Focus bool |
| + Top, End, W, H int |
| + Mid, StripH, StripMid float64 |
| + Themes, Schemes, Locales int |
| + Controls []prefsControl |
| + Path, Hash string |
| +} |
| + |
| +func readPrefs(t *testing.T, ctx context.Context, where string) prefsReading { |
| + t.Helper() |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(prefsJS, &raw)) |
| + var p prefsReading |
| + if err := json.Unmarshal([]byte(raw), &p); err != nil { |
| + t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| + } |
| + return p |
| +} |
| + |
| +// tapAt taps the centre of sel the way a finger does: scrolled to only |
| +// if it is off screen, then a press and release at a point. chromedp's |
| +// selector click scrolls its target into view even when it is on |
| +// screen, and a button in the sticky strip sits inside the scroll |
| +// padding, so any scroll-into-view moves the page. |
| +func tapAt(t *testing.T, ctx context.Context, where, sel string) { |
| + t.Helper() |
| + var xy []float64 |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const el = document.querySelector(`+"`"+sel+"`"+`); const o = el.getBoundingClientRect(); if (o.top < 0 || o.bottom > innerHeight) el.scrollIntoView({block: "nearest"}); const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()`, &xy)); err != nil || len(xy) != 2 { |
| + t.Fatalf("%s: finding %s: %v", where, sel, err) |
| + } |
| + mustRun(t, ctx, chromedp.MouseClickXY(xy[0], xy[1])) |
| +} |
| + |
| +// axNameOf is the accessible name the browser computes for sel, read |
| +// off the accessibility tree rather than off an attribute. |
| +func axNameOf(t *testing.T, ctx context.Context, where, sel string) string { |
| + t.Helper() |
| + var nodes []*cdp.Node |
| + if err := chromedp.Run(ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)); err != nil || len(nodes) == 0 { |
| + t.Fatalf("%s: no node for %s: %v", where, sel, err) |
| + } |
| + var name string |
| + mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { |
| + ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c) |
| + if err == nil && len(ax) > 0 && ax[0].Name != nil { |
| + _ = json.Unmarshal(ax[0].Name.Value, &name) |
| + } |
| + return err |
| + })) |
| + return name |
| +} |
| + |
| +// TestTheDisplaySettingsMenuOnAPhone: one menu at the top of every |
| +// page, in the index's header row and at the inline end of a content |
| +// page's back strip, in English and Arabic, scripts on and off, at 390 |
| +// on a coarse pointer. Its button is a 44px icon named with the |
| +// approved string. Opened, every theme link, the scheme menu and its |
| +// three buttons, and every language can be brought on screen in the |
| +// card and is above the page; the two nested menus open in place, one |
| +// at a time. An outside tap closes it and reaches nothing under it; |
| +// with scripts on, Escape closes it and hands focus back. |
| +func TestTheDisplaySettingsMenuOnAPhone(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| + defer cancel() |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + theme := fiesta.ThemeNames()[0] |
| + for _, scripts := range []bool{true, false} { |
| + c := ctx |
| + if !scripts { |
| + c = noScripts(t, ctx) |
| + } |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, file := range []string{"index.html", "form.html"} { |
| + where := fmt.Sprintf("%s/%s/%s at 390px, scripts %v", theme, locale, file, scripts) |
| + mustRun(t, c, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady("body")) |
| + present(t, c, where, ".ds-prefs > summary") |
| + mustRun(t, c, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery)) |
| + requireCoarse(t, c) |
| + if !scripts { |
| + requireScriptsOff(t, c, where) |
| + } |
| + settleMotion(t, c, where) |
| + p := readPrefs(t, c, where) |
| + index := file == "index.html" |
| + if p.Open || p.Shown || p.W < 44 || p.H < 44 || p.End > 24 || index && p.Top > 32 || !index && !p.InStrip { |
| + t.Errorf("%s: closed %v shown %v, a %dx%dpx button %dpx from the top and %dpx from the inline end, in the back strip %v; want a closed 44px button at the top inline-end (in the strip on a content page)", |
| + where, !p.Open, p.Shown, p.W, p.H, p.Top, p.End, p.InStrip) |
| + } |
| + if index && math.Abs(p.Mid-28) > 1 || !index && (math.Abs(p.StripH-56) > 0.5 || math.Abs(p.Mid-p.StripMid) > 1) { |
| + t.Errorf("%s: the button's centre line is at %.1fpx; the back strip is %.1fpx tall with its centre at %.1fpx; want the button centred in a 56px header strip", where, p.Mid, p.StripH, p.StripMid) |
| + } |
| + if name, want := axNameOf(t, c, where, ".ds-prefs > summary"), s.say(locale, "gallery.prefs"); name != want { |
| + t.Errorf("%s: the button is named %q, want %q", where, name, want) |
| + } |
| + mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), |
| + chromedp.Click(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] [rst-dropdown-menu] a", chromedp.ByQuery)) |
| + settleMotion(t, c, where) |
| + open := readPrefs(t, c, where) |
| + // The language list open: the themes, both nested |
| + // summaries and every language; the scheme buttons sit |
| + // in the closed menu beside it. |
| + wantControls := open.Themes + 2 + open.Locales |
| + if !open.Open || !open.Shown || open.Themes != 3 || open.Schemes != 3 || open.Locales != 12 || len(open.Controls) != wantControls { |
| + t.Errorf("%s: open %v shown %v with %d themes, %d schemes, %d languages and %d controls reachable; want three themes, three schemes, twelve languages, %d reachable with the language list open", |
| + where, open.Open, open.Shown, open.Themes, open.Schemes, open.Locales, len(open.Controls), wantControls) |
| + } |
| + for _, ctl := range open.Controls { |
| + if !ctl.On || !ctl.Top || ctl.H < 44 { |
| + t.Errorf("%s: %q in the open card: on screen %v, topmost %v, %dpx tall; want on screen, above the page, a 44px tap", where, ctl.Text, ctl.On, ctl.Top, ctl.H) |
| + } |
| + } |
| + // The scheme menu open in its place: its three buttons |
| + // reachable, and the language list folded away since |
| + // the two share a name. |
| + mustRun(t, c, chromedp.Click(".ds-prefs [rst-scheme] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-scheme] [rst-dropdown-menu] button", chromedp.ByQuery)) |
| + settleMotion(t, c, where) |
| + schemes := readPrefs(t, c, where) |
| + if want := schemes.Themes + 2 + schemes.Schemes; len(schemes.Controls) != want { |
| + t.Errorf("%s: with the scheme menu open %d controls are reachable, want %d (the themes, both summaries and the three schemes)", where, len(schemes.Controls), want) |
| + } |
| + for _, ctl := range schemes.Controls { |
| + if !ctl.On || !ctl.Top || ctl.H < 44 { |
| + t.Errorf("%s: %q with the scheme menu open: on screen %v, topmost %v, %dpx tall", where, ctl.Text, ctl.On, ctl.Top, ctl.H) |
| + } |
| + } |
| + var x float64 = 24 |
| + if locale == "ar" { |
| + x = 366 |
| + } |
| + mustRun(t, c, chromedp.MouseClickXY(x, 780)) |
| + until(t, c, where+", after an outside tap", `!document.querySelector(".ds-prefs").open`) |
| + if after := readPrefs(t, c, where); after.Path != open.Path || after.Hash != open.Hash { |
| + t.Errorf("%s: the outside tap went through to the page: %s%s -> %s%s", where, open.Path, open.Hash, after.Path, after.Hash) |
| + } |
| + if scripts { |
| + mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), |
| + chromedp.Focus(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + if p := readPrefs(t, c, where); p.Open || !p.Focus { |
| + t.Errorf("%s: Escape from a theme link: open %v, focus on the button %v", where, p.Open, p.Focus) |
| + } |
| + } else { |
| + mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), |
| + chromedp.Click(".ds-prefs > summary", chromedp.ByQuery)) |
| + until(t, c, where+", toggled shut", `!document.querySelector(".ds-prefs").open`) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheDisplaySettingsKeepYourPlace: on a phone, a theme link in the |
| +// menu carries the section being read, as the desktop bar's does, so a |
| +// reader partway down a page who switches theme lands on the same |
| +// state. Then Back: the page comes back with the scheme chosen after |
| +// it, and every scheme menu on it marks that scheme. |
| +func TestTheDisplaySettingsKeepYourPlace(t *testing.T) { |
| + rig := phoneRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + e := mustEntry(t, "signin") |
| + at := e.States[2].ID |
| + where := theme + "/en/signin.html at 390px" |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")+"#"+at), chromedp.WaitReady("body")) |
| + present(t, ctx, where, ".ds-prefs > summary") |
| + mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery)) |
| + until(t, ctx, where+", loaded", `document.readyState === "complete"`) |
| + eagerly(t, ctx, where) |
| + settleMotion(t, ctx, where) |
| + // The previews finish loading after the jump and move the state; |
| + // put it back on the reading line, where a reader would be. |
| + mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("`+at+`").scrollIntoView({block: "start"}); true`, nil)) |
| + settleMotion(t, ctx, where) |
| + tapAt(t, ctx, where, ".ds-prefs > summary") |
| + until(t, ctx, where+", the menu open", `document.querySelector(".ds-prefs").open`) |
| + settleMotion(t, ctx, where+", the menu open") |
| + tapAt(t, ctx, where, `.ds-prefs [rst-seg-tabs] a[href*="/signal/"]`) |
| + until(t, ctx, where+", switched to signal", `location.pathname.includes("/signal/") && location.hash === "#`+at+`"`) |
| + settleMotion(t, ctx, where+", switched to signal") |
| + tapAt(t, ctx, where, ".ds-prefs > summary") |
| + until(t, ctx, where+", the menu open on signal", `document.querySelector(".ds-prefs").open`) |
| + settleMotion(t, ctx, where+", the menu open on signal") |
| + tapAt(t, ctx, where, ".ds-prefs [rst-scheme] > summary") |
| + until(t, ctx, where+", the scheme menu open", `document.querySelector(".ds-prefs [rst-scheme]").open`) |
| + tapAt(t, ctx, where, `.ds-prefs [rst-scheme] form:has(input[value="dark"]) button`) |
| + until(t, ctx, where+", Dark chosen", `document.documentElement.getAttribute("data-theme") === "dark"`) |
| + mustRun(t, ctx, chromedp.Evaluate(`history.back(); true`, nil)) |
| + until(t, ctx, where+", back in Dark", `location.pathname.includes("/day/") && document.documentElement.getAttribute("data-theme") === "dark" && `+markedJS+`.every(m => m === "dark")`) |
| +} |
diff --git a/internal/gallery/rail_browser_test.go b/internal/gallery/rail_browser_test.go| new file mode 100644 |
| index 0000000..f71259c |
| --- /dev/null |
| +++ b/internal/gallery/rail_browser_test.go |
| @@ -0,0 +1,402 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The drives for the rail and its filter. The page ships one complete |
| +// list of links and the whole feature is what happens to that list |
| +// after a keystroke, which no reading of the bytes can see. |
| + |
| +// pageAt is one page's address at the mount. |
| +func pageAt(theme, locale, file string) string { return href(theme + "/" + locale + "/" + file) } |
| + |
| +// present fails the leg by name when sel is not on the page: the |
| +// drives below wait on the shell's own elements, and a page without |
| +// them should fail as an assertion, not as a wait that ran out. |
| +func present(t *testing.T, ctx context.Context, where, sel string) { |
| + t.Helper() |
| + var ok bool |
| + mustRun(t, ctx, chromedp.Evaluate(`!!document.querySelector(`+fmt.Sprintf("%q", sel)+`)`, &ok)) |
| + if !ok { |
| + t.Fatalf("%s: the page has no %s", where, sel) |
| + } |
| +} |
| + |
| +// railState is one reading of the whole rail, taken at one instant. |
| +type railState struct { |
| + Links int `json:"links"` |
| + Shown []string `json:"shown"` |
| + Pages []string `json:"pages"` |
| + Folded int `json:"folded"` |
| + Open []bool `json:"open"` |
| + Empty bool `json:"empty"` |
| + Focused string `json:"focused"` |
| + Value string `json:"value"` |
| + BoxSeen bool `json:"boxSeen"` |
| +} |
| + |
| +// railProbe reads the whole rail in one evaluation. Every "is it |
| +// there" question is asked of getComputedStyle, never of the .hidden |
| +// property: gallery.js sets the property, and what takes a rail link |
| +// off the screen is a rule in gallery.css outranking the shell's |
| +// display: block. A probe reading .hidden would pass with that rule |
| +// deleted and sixty "hidden" links painted down the page. |
| +const railProbe = `(() => { |
| + const seen = el => getComputedStyle(el).display !== "none"; |
| + const links = Array.from(document.querySelectorAll("#ds-nav details a")); |
| + const pages = Array.from(document.querySelectorAll("#ds-nav > a")); |
| + const secs = Array.from(document.querySelectorAll("#ds-nav details")); |
| + const box = document.querySelector("[data-ds-filter]"); |
| + const empty = document.querySelector("[data-ds-filter-empty]"); |
| + return { |
| + links: links.length, |
| + shown: links.filter(seen).map(a => a.getAttribute("href")), |
| + pages: pages.filter(seen).map(a => a.getAttribute("href")), |
| + folded: secs.filter(d => !seen(d)).length, |
| + open: secs.map(d => d.open), |
| + empty: seen(empty), |
| + focused: document.activeElement ? document.activeElement.id : "", |
| + value: box.value, |
| + boxSeen: seen(box.closest(".ds-search")), |
| + }; |
| +})()` |
| + |
| +// showing reports whether the rail is showing a link to href. |
| +func showing(state railState, href string) bool { |
| + for _, s := range state.Shown { |
| + if s == href { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// discloseIndex is where one page kind's section sits among the rail's |
| +// <details>, which is not its position in pageKinds(): a page kind |
| +// with nothing to list draws as a plain link and takes no slot here. |
| +func discloseIndex(t *testing.T, s *site, kind string) int { |
| + t.Helper() |
| + at := 0 |
| + for _, pk := range s.pageKinds() { |
| + if pk.Kind == kind { |
| + return at |
| + } |
| + if pk.Nav != nil { |
| + at++ |
| + } |
| + } |
| + t.Fatalf("no page kind %s", kind) |
| + return -1 |
| +} |
| + |
| +// TestTheSidebarFilterDrivesTheWholeJourney: type an example's name, a |
| +// section's, junk, press Escape, and the collapse a reader made by hand |
| +// which a search has to borrow and give back. The bug classes, each of |
| +// which leaves a page that renders perfectly: the filter matches |
| +// nothing or everything because the fold is applied to one side only; |
| +// a section whose every entry is hidden stays on screen as a heading |
| +// over a gap; No matches never appears or never goes; Escape does not |
| +// clear, or clears and leaves the rail filtered; a section the reader |
| +// collapsed is left open; the box is visible with scripts off. |
| +// |
| +// The fold is proved on the query side: an accented query finds a |
| +// plain label. The label side (a Spanish heading found by an |
| +// unaccented query) waits for the catalogs that answer in Spanish. |
| +func TestTheSidebarFilterDrivesTheWholeJourney(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| + defer cancel() |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + theme := fiesta.ThemeNames()[0] |
| + // A family page: the rail is the same on every page, and this is |
| + // one whose own section is long, so a filter that folded the |
| + // current section away would show up here. |
| + url := rig.Origin + pageAt(theme, "en", "form.html") |
| + section := func(kind string) string { return s.href(theme, "en", s.fileOf(kind)) } |
| + on := func(name string) string { return s.href(theme, "en", name+".html") } |
| + shells, _ := s.familyByKey("shells") |
| + |
| + var fresh, scriptless, badge, family, titled, junk, cleared, handled, restored, folded, foldedTitle railState |
| + var navWithNoJS int |
| + typing := func(q string) chromedp.Tasks { |
| + return chromedp.Tasks{ |
| + chromedp.Focus(`#ds-filter`, chromedp.ByQuery), |
| + chromedp.Evaluate(`(() => { |
| + const box = document.querySelector("[data-ds-filter]"); |
| + box.value = ""; |
| + box.dispatchEvent(new Event("input", {bubbles: true})); |
| + })()`, nil), |
| + chromedp.SendKeys(`#ds-filter`, q, chromedp.ByQuery), |
| + } |
| + } |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| + present(t, ctx, "form.html", "#ds-filter") |
| + mustRun(t, ctx, |
| + chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery), |
| + chromedp.Evaluate(railProbe, &fresh), |
| + // The scriptless half, asked of the real engine: take the |
| + // marker away and the box goes with it, while every link in |
| + // the rail stays where it was. |
| + chromedp.Evaluate(`(() => { |
| + document.documentElement.removeAttribute("data-gallery-js"); |
| + const seen = el => getComputedStyle(el).display !== "none"; |
| + const links = Array.from(document.querySelectorAll("#ds-nav details a")); |
| + const pages = Array.from(document.querySelectorAll("#ds-nav > a")); |
| + const state = { |
| + links: links.length, |
| + shown: links.filter(seen).map(a => a.getAttribute("href")), |
| + pages: pages.filter(seen).map(a => a.getAttribute("href")), |
| + folded: 0, open: [], empty: false, focused: "", value: "", |
| + boxSeen: seen(document.querySelector(".ds-search")), |
| + }; |
| + document.documentElement.setAttribute("data-gallery-js", "on"); |
| + return state; |
| + })()`, &scriptless), |
| + chromedp.Evaluate(`document.querySelectorAll("#ds-nav details a").length`, &navWithNoJS), |
| + typing("badge"), chromedp.Evaluate(railProbe, &badge), |
| + // A section's name stands for the run under it, while the rest |
| + // of the rail keeps only what matches on its own: "form" is the |
| + // Form page's name and part of form-error's, which is in |
| + // Display. |
| + typing("form"), chromedp.Evaluate(railProbe, &family), |
| + // A section's own name: the whole section, and nothing from any |
| + // other. "shells" is not a substring of any entry anywhere else. |
| + typing("shells"), chromedp.Evaluate(railProbe, &titled), |
| + typing("zzqqxx"), chromedp.Evaluate(railProbe, &junk), |
| + chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(railProbe, &cleared), |
| + // The same key as an untrusted event. Chromium clears a search |
| + // field on Escape by itself, so the real keypress proves the |
| + // journey and nothing about this file; a synthetic event gets no |
| + // default action, so what clears the box here is gallery.js. |
| + typing("zzqqxx"), |
| + chromedp.Evaluate(`document.querySelector("[data-ds-filter]").dispatchEvent( |
| + new KeyboardEvent("keydown", {key: "Escape", bubbles: true, cancelable: true}))`, nil), |
| + chromedp.Evaluate(railProbe, &handled), |
| + // A section the reader collapses by hand is opened by a query |
| + // that finds something in it, and folded back when the query |
| + // goes. |
| + chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll("#ds-nav details")[%d].open = false`, discloseIndex(t, s, "display")), nil), |
| + typing("badge"), chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(railProbe, &restored), |
| + // The fold, from the query's side: an accent typed into the box |
| + // finds the plain label, and an accented section name finds |
| + // the section. |
| + typing("surfáces"), chromedp.Evaluate(railProbe, &folded), |
| + typing("tókens"), chromedp.Evaluate(railProbe, &foldedTitle), |
| + ) |
| + |
| + if !fresh.BoxSeen { |
| + t.Error("the filter box is not visible to a reader who has JavaScript") |
| + } |
| + if fresh.Links < 50 { |
| + t.Errorf("the rail holds %d links; the page has far more than that to link", fresh.Links) |
| + } |
| + if len(fresh.Shown) != fresh.Links { |
| + t.Errorf("%d of %d rail links are hidden before anything is typed", fresh.Links-len(fresh.Shown), fresh.Links) |
| + } |
| + if fresh.Empty { |
| + t.Error("the no-matches line is on screen before anything is typed") |
| + } |
| + var open int |
| + for _, o := range fresh.Open { |
| + if o { |
| + open++ |
| + } |
| + } |
| + if open != 1 { |
| + t.Errorf("%d of %d sidebar sections arrive open, want exactly 1 (the page you are on)", open, len(fresh.Open)) |
| + } |
| + if at := discloseIndex(t, s, "form"); len(fresh.Open) > at && !fresh.Open[at] { |
| + t.Errorf("the Form section (disclosure %d of %d) is not the one open on the form page", at, len(fresh.Open)) |
| + } |
| + if scriptless.BoxSeen { |
| + t.Error("the filter box is still visible without data-gallery-js; with scripts off it would look like a control that works") |
| + } |
| + if len(scriptless.Shown) != fresh.Links || navWithNoJS != fresh.Links { |
| + t.Errorf("with the marker off the rail shows %d of %d links; the nav is complete with or without a script", len(scriptless.Shown), fresh.Links) |
| + } |
| + if !showing(badge, on("badge")) { |
| + t.Error(`typing "badge" hid the badge example`) |
| + } |
| + if showing(badge, on("meter")) { |
| + t.Error(`typing "badge" left the meter example on screen`) |
| + } |
| + if showing(badge, section("tokens")+"#tokens-accent") { |
| + t.Error(`typing "badge" left a token group on screen`) |
| + } |
| + if badge.Folded == 0 { |
| + t.Error(`typing "badge" folded no section away; Tokens, Shells and Demos have nothing in them that matches`) |
| + } |
| + if badge.Empty { |
| + t.Error(`typing "badge" said there were no matches`) |
| + } |
| + if !showing(family, section("form")) { |
| + t.Error(`typing "form" hid the Form section's own overview link`) |
| + } |
| + if !showing(family, on("field-url")) { |
| + t.Error(`typing "form" hid field-url, which is in the section that matched`) |
| + } |
| + if showing(family, section("list-screen")) { |
| + t.Error(`typing "form" left the List screen section on screen with nothing under it that matches`) |
| + } |
| + if !showing(family, on("form-error")) { |
| + t.Error(`typing "form" hid form-error, which matches on its own name`) |
| + } |
| + for _, e := range shells.Examples { |
| + if !showing(titled, on(e.Name)) { |
| + t.Errorf(`typing a section's own name ("shells") did not reveal %s: %v`, e.Name, titled.Shown) |
| + } |
| + } |
| + if want := len(shells.Examples) + 1; len(titled.Shown) != want { |
| + t.Errorf(`typing "shells" left %d entries on screen, want the section's %d: %v`, len(titled.Shown), want, titled.Shown) |
| + } |
| + if !showing(titled, section("shells")) { |
| + t.Errorf(`typing "shells" hid the section's own overview link: %v`, titled.Shown) |
| + } |
| + if len(junk.Shown) != 0 { |
| + t.Errorf("junk left %d entries on screen: %v", len(junk.Shown), junk.Shown) |
| + } |
| + if len(junk.Pages) != 0 || len(fresh.Pages) == 0 { |
| + t.Errorf("junk left %d of %d page links on screen; a page link filters like any other entry", len(junk.Pages), len(fresh.Pages)) |
| + } |
| + if !junk.Empty { |
| + t.Error("junk matched nothing and the page never said so") |
| + } |
| + if junk.Focused != "ds-filter" { |
| + t.Errorf("focus moved to %q while typing; the filter must not take it anywhere", junk.Focused) |
| + } |
| + if cleared.Value != "" { |
| + t.Errorf("Escape left %q in the box", cleared.Value) |
| + } |
| + if len(cleared.Shown) != cleared.Links { |
| + t.Errorf("Escape cleared the box and left %d of %d entries hidden", cleared.Links-len(cleared.Shown), cleared.Links) |
| + } |
| + if cleared.Empty { |
| + t.Error("Escape cleared the box and left the no-matches line up") |
| + } |
| + if cleared.Focused != "ds-filter" { |
| + t.Errorf("Escape moved focus to %q", cleared.Focused) |
| + } |
| + if handled.Value != "" { |
| + t.Errorf("a synthetic Escape left %q in the box; gallery.js is not handling the key, the browser is", handled.Value) |
| + } |
| + if len(handled.Shown) != handled.Links { |
| + t.Errorf("a synthetic Escape cleared the box and left %d of %d entries hidden", handled.Links-len(handled.Shown), handled.Links) |
| + } |
| + if at := discloseIndex(t, s, "display"); len(restored.Open) <= at || restored.Open[at] { |
| + t.Error("a section the reader collapsed by hand was left open by a search that has been cleared") |
| + } |
| + if len(restored.Shown) != restored.Links { |
| + t.Errorf("after the search was cleared, %d of %d entries are still hidden", restored.Links-len(restored.Shown), restored.Links) |
| + } |
| + if !showing(folded, section("tokens")+"#tokens-surfaces-and-lines") { |
| + t.Errorf(`"surfáces" did not find Surfaces and lines; the query is not folded: %v`, folded.Shown) |
| + } |
| + if !showing(foldedTitle, section("tokens")) || showing(foldedTitle, section("form")) { |
| + t.Errorf(`"tókens" did not find the Tokens section alone: %v`, foldedTitle.Shown) |
| + } |
| +} |
| + |
| +// TestSearchFindsWhatPeopleCallThings: the filter finds an example by |
| +// what a reader calls it, on desktop and on the phone index: |
| +// "checkbox" finds field-check, "dialog" the modal, "button" the |
| +// button, and junk says No matches with no page link left showing |
| +// beside it. On the index, a query swaps the rows for the tree it |
| +// filters, and a result opens the example's page. |
| +func TestSearchFindsWhatPeopleCallThings(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + visible := func(sel string) string { |
| + return `(() => { const e = document.querySelector('` + sel + `'); return !!e && e.getBoundingClientRect().height > 0; })()` |
| + } |
| + typeInto := func(c context.Context, where, q string) { |
| + mustRun(t, c, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter"); i.value = ""; i.dispatchEvent(new Event("input")); return true; })()`, nil), |
| + chromedp.SendKeys(`#ds-filter`, q, chromedp.ByQuery)) |
| + } |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) |
| + present(t, ctx, "form.html", "#ds-filter") |
| + mustRun(t, ctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery)) |
| + for q, name := range map[string]string{"checkbox": "field-check", "dialog": "modal", "button": "button", "CHECKBOX": "field-check"} { |
| + where := "desktop, " + q |
| + typeInto(ctx, where, q) |
| + until(t, ctx, where, visible(`#ds-nav a[href$="/`+name+`.html"]`)) |
| + } |
| + typeInto(ctx, "desktop, junk", "zzqxv") |
| + until(t, ctx, "desktop, junk", visible(`[data-ds-filter-empty]`)+` && ![...document.querySelectorAll("#ds-nav a")].some(a => a.getBoundingClientRect().height > 0)`) |
| + |
| + phone := phoneRig(t) |
| + pctx, pcancel := context.WithTimeout(phone.Context(), 120*time.Second) |
| + defer pcancel() |
| + where := "the phone index" |
| + mustRun(t, pctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(phone.Origin+pageAt(theme, "en", "index.html")), chromedp.WaitReady("body")) |
| + present(t, pctx, where, ".ds-index") |
| + mustRun(t, pctx, chromedp.WaitVisible(`#ds-filter`, chromedp.ByQuery), chromedp.SendKeys(`#ds-filter`, "checkbox", chromedp.ByQuery)) |
| + requireCoarse(t, pctx) |
| + until(t, pctx, where+", rows swapped for the tree", `document.querySelector(".ds-index").getBoundingClientRect().height === 0 && `+visible(`#ds-nav a[href$="/field-check.html"]`)) |
| + mustRun(t, pctx, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter"); i.value = ""; i.dispatchEvent(new Event("input")); return true; })()`, nil)) |
| + until(t, pctx, where+", rows back", visible(`.ds-index`)) |
| + mustRun(t, pctx, chromedp.SendKeys(`#ds-filter`, "checkbox", chromedp.ByQuery), |
| + chromedp.Evaluate(`document.querySelector('#ds-nav a[href$="/field-check.html"]').click(); true`, nil)) |
| + until(t, pctx, where+", field-check's page", `location.pathname.endsWith("/field-check.html") && document.readyState === "complete"`) |
| +} |
| + |
| +// TestTheRailWorksWithNoScript: with JavaScript off on a family page |
| +// the filter box is not shown, the page's own section arrives open |
| +// with every entry drawn, no rail link is hidden, and following an |
| +// entry lands on its page. The rail is a complete list of every page |
| +// before any script runs, and stays one when none does. |
| +func TestTheRailWorksWithNoScript(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx := noScripts(t, rig.Context()) |
| + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + where := "day/en/form.html with scripts off" |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) |
| + requireScriptsOff(t, ctx, where) |
| + present(t, ctx, where, "#ds-nav") |
| + var raw struct { |
| + BoxSeen bool |
| + Links int |
| + Hidden []string |
| + Open []string |
| + Boxed int |
| + Section int |
| + } |
| + at(t, ctx, `(() => { |
| + const seen = el => getComputedStyle(el).display !== "none"; |
| + const links = [...document.querySelectorAll("#ds-nav a")]; |
| + const open = [...document.querySelectorAll("#ds-nav details[open]")]; |
| + const section = open.length === 1 ? open[0].querySelectorAll("a").length : 0; |
| + const boxed = open.length === 1 ? [...open[0].querySelectorAll("a")].filter(a => a.getBoundingClientRect().height > 0).length : 0; |
| + return JSON.stringify({BoxSeen: seen(document.querySelector(".ds-search")), Links: links.length, |
| + Hidden: links.filter(a => !seen(a)).map(a => a.getAttribute("href")), |
| + Open: open.map(d => d.querySelector("summary").textContent.trim()), Boxed: boxed, Section: section}); |
| + })()`, &raw) |
| + if raw.BoxSeen { |
| + t.Error("the filter box is shown with scripts off; it would look like a control that works") |
| + } |
| + if raw.Links < 50 || len(raw.Hidden) != 0 { |
| + t.Errorf("the rail holds %d links and hides %v with scripts off; every entry is drawn", raw.Links, raw.Hidden) |
| + } |
| + if len(raw.Open) != 1 || raw.Section == 0 || raw.Boxed != raw.Section { |
| + t.Errorf("the open sections are %v with %d of %d entries drawn with a box; want the Form section alone, every entry visible", raw.Open, raw.Boxed, raw.Section) |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector('#ds-nav a[href$="/field-text.html"]').click(); true`, nil)) |
| + until(t, ctx, where+", field-text followed", `location.pathname.endsWith("/field-text.html") && document.readyState === "complete"`) |
| + requireScriptsOff(t, ctx, where+", on field-text") |
| +} |
diff --git a/internal/gallery/rail_test.go b/internal/gallery/rail_test.go| new file mode 100644 |
| index 0000000..867ce7b |
| --- /dev/null |
| +++ b/internal/gallery/rail_test.go |
| @@ -0,0 +1,968 @@ |
| +package gallery |
| + |
| +import ( |
| + "html/template" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The shell every page is drawn in: the sidebar shell Fiesta ships, |
| +// with the rail and its filter, the pinned bar, the phone index and |
| +// its way back, the display settings menu, and the scripts a gallery |
| +// page loads. These read the rendered bytes; the drives in the |
| +// browser-tagged files beside this one read what the scripts do to |
| +// them. Where a gate below is Rastrillo's, its name is kept and its |
| +// expectations are read off pageKinds() and the manifest, so a page |
| +// kind or an example added later is covered the day it lands. |
| + |
| +// railOf cuts the sidebar's nav out of a page. Everything below reads |
| +// this slice rather than the whole document, so a stray anchor |
| +// somewhere in a sample cannot make the rail look complete. |
| +func railOf(t *testing.T, name, page string) string { |
| + t.Helper() |
| + _, after, ok := strings.Cut(page, `<nav class="ds-nav" rst-shell-nav id="ds-nav"`) |
| + if !ok { |
| + t.Errorf("%s: no sidebar nav", name) |
| + return "" |
| + } |
| + rail, _, ok := strings.Cut(after, "</nav>") |
| + if !ok { |
| + t.Errorf("%s: the sidebar nav never closes", name) |
| + return "" |
| + } |
| + return rail |
| +} |
| + |
| +// pageNames lists the pages a whole-tree gate reads, sorted: every |
| +// page-kind page of every theme and locale, and every page of the |
| +// default theme in English, the example pages included. The example |
| +// pages are one template over one body each, so reading the hundred |
| +// or so of one directory proves the shell on them; a regexp gate over |
| +// all 3,888 took over a minute. |
| +func pageNames(s *site, files map[string][]byte) []string { |
| + var out []string |
| + for _, dir := range dirs() { |
| + if dir == fiesta.ThemeNames()[0]+"/en/" { |
| + for name := range files { |
| + if strings.HasPrefix(name, dir) && strings.HasSuffix(name, ".html") && !isFrame(name) { |
| + out = append(out, name) |
| + } |
| + } |
| + continue |
| + } |
| + for _, pk := range s.pageKinds() { |
| + out = append(out, dir+pk.File) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// kindPage is one page-kind page of one directory, by file, with a |
| +// fatal if the renderer did not produce it. |
| +func kindPage(t *testing.T, files map[string][]byte, theme, locale, file string) string { |
| + t.Helper() |
| + name := theme + "/" + locale + "/" + file |
| + page := string(files[name]) |
| + if page == "" { |
| + t.Fatalf("no %s in the rendered tree", name) |
| + } |
| + return page |
| +} |
| + |
| +var ( |
| + anchorMarker = regexp.MustCompile(`id="([^"]*)" data-ds-anchor`) |
| + navHref = regexp.MustCompile(`<a[^>]*\shref="([^"]*)"`) |
| + elementID = regexp.MustCompile(`\bid="([^"]*)"`) |
| + shellRoot = regexp.MustCompile(`<div rst-shell-sidebar="([a-z]+)">`) |
| + backLink = regexp.MustCompile(`<a rst-skip href="#main">[^<]*</a>\n<div rst-shell-back><a data-gallery-nav href="([^"]*)" rel="up" aria-label="([^"]*)">([^<]*)</a>`) |
| + prefsCard = regexp.MustCompile(`(?s)<details rst-dropdown class="ds-prefs" id="ds-prefs" name="ds-prefs"><summary aria-label="([^"]*)">(.*?)</summary><div rst-dropdown-menu>(.*?)</div></details>`) |
| + barCtl = regexp.MustCompile(`(?s)<div class="ds-top__controls">(.*?)</header>`) |
| + indexNav = regexp.MustCompile(`(?s)<nav class="ds-index" rst-shell-nav aria-label="([^"]*)">(.*?)</nav>`) |
| + schemeMenu = regexp.MustCompile(`(?s)<details rst-dropdown rst-scheme.*?</details>`) |
| +) |
| + |
| +// TestTheGalleryIsTheShippedShell: every page's body opens the |
| +// sidebar shell, "index" on the Overview and "page" everywhere else, |
| +// with the back control right after the skip link on a content page |
| +// and never on the index; the index pieces (the shell title, the lead |
| +// and the rows) on the Overview alone; one display settings menu per |
| +// page, in the index's header row or in the back strip; shell.css |
| +// linked hashed and shell.js loaded deferred and render-blocking, as |
| +// the layouts load it. |
| +func TestTheGalleryIsTheShippedShell(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + label := s.say("en", "gallery.up.label") |
| + aria := template.HTMLEscapeString(s.say("en", "gallery.up", "name", label)) |
| + checked := 0 |
| + for _, name := range pageNames(s, files) { |
| + page := string(files[name]) |
| + index := strings.HasSuffix(name, "/index.html") |
| + want := map[bool]string{true: "index", false: "page"}[index] |
| + if m := shellRoot.FindAllStringSubmatch(page, -1); len(m) != 1 || m[0][1] != want { |
| + t.Errorf("%s: shell root %v, want one rst-shell-sidebar=%q", name, m, want) |
| + } |
| + backs := backLink.FindAllStringSubmatch(page, -1) |
| + if n := strings.Count(page, "<div rst-shell-back>"); index && n != 0 || !index && (n != 1 || len(backs) != 1) { |
| + t.Errorf("%s: %d back controls (%d right after the skip link), want %d", name, n, len(backs), map[bool]int{false: 1}[index]) |
| + } else if !index && (backs[0][2] != aria || backs[0][3] != template.HTMLEscapeString(label)) { |
| + t.Errorf("%s: the back control reads %q named %q, want %q named %q", name, backs[0][3], backs[0][2], label, aria) |
| + } |
| + for _, piece := range []string{`<h1 rst-shell-title>`, `<p class="ds-index-lead">`, `<nav class="ds-index"`} { |
| + if strings.Contains(page, piece) != index { |
| + t.Errorf("%s: %s present=%v; it belongs on the Overview and nowhere else", name, piece, !index) |
| + } |
| + } |
| + if strings.Contains(page, "<div rst-shell-rail-foot") { |
| + t.Errorf("%s: the rail has a foot; the controls are in the display settings menu", name) |
| + } |
| + if n := strings.Count(page, `class="ds-prefs"`); n != 1 { |
| + t.Errorf("%s: %d display settings menus, want one", name, n) |
| + } |
| + where := `<div rst-shell-back><a ` |
| + if index { |
| + where = `<h1 rst-shell-title><span></span><span>` + template.HTMLEscapeString(s.say("en", "gallery.brand")) + `</span></h1>` + "\n" + `<details rst-dropdown class="ds-prefs"` |
| + } |
| + if !strings.Contains(page, where) { |
| + t.Errorf("%s: the display settings menu is not where a phone looks for it (%s)", name, where) |
| + } |
| + if i := strings.Index(page, "<div rst-shell-back>"); !index && i >= 0 { |
| + strip := page[i:] |
| + if strings.Index(strip, `class="ds-prefs"`) > strings.Index(strip, "</div>\n") { |
| + t.Errorf("%s: the display settings menu is not inside the back strip", name) |
| + } |
| + } |
| + for _, want := range []string{ |
| + `<link rel="stylesheet" href="` + s.asset("css/shell.css") + `">`, |
| + `<script defer blocking="render" src="` + s.asset("js/shell.js") + `"></script>`, |
| + } { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("%s: no %s; the slide, Back through history and the focus return are shell.css and shell.js", name, want) |
| + } |
| + } |
| + checked++ |
| + } |
| + if checked < 36*len(s.pageKinds())+len(s.m.Examples) { |
| + t.Fatalf("%d pages checked; the tree has more", checked) |
| + } |
| +} |
| + |
| +// shellSkeleton is the shape of a document's shell: for every element |
| +// carrying one of the shell's structural attributes, its tag, the |
| +// attribute, and the nearest such attribute above it. The gallery's |
| +// skeleton has to be drawn from the shell-sidebar example's: the same |
| +// tags carrying the same attributes under the same parents. |
| +func shellSkeleton(doc node) map[string]bool { |
| + structural := []string{"rst-shell-sidebar", "rst-skip", "rst-shell-back", "rst-shell-rail", "rst-shell-title", "rst-shell-nav", "rst-shell-main", "rst-page"} |
| + mark := func(n node) string { |
| + for _, a := range structural { |
| + if _, ok := n.attr(a); ok { |
| + return a |
| + } |
| + } |
| + return "" |
| + } |
| + out := map[string]bool{} |
| + for _, n := range doc.find(func(n node) bool { return mark(n) != "" }) { |
| + parent := "" |
| + for p := n.n.Parent; p != nil && parent == ""; p = p.Parent { |
| + parent = mark(node{p}) |
| + } |
| + out[n.n.Data+"["+mark(n)+"] under "+parent] = true |
| + } |
| + return out |
| +} |
| + |
| +// TestTheSidebarIsTheShellTheGalleryDocuments: the gallery is laid out |
| +// in the vocabulary it documents. Every structural piece of a page's |
| +// shell (the root, the skip link, the back strip, the rail, its title |
| +// and navs, main and the page column) sits under the same parent, on |
| +// the same tag, as in the shell-sidebar example, and the rail, the |
| +// nav and main are there by name; the filter is a real search input |
| +// in a search landmark naming the nav it filters, with the no-matches |
| +// line rendered by the page and hidden; one disclosure per page kind |
| +// with entries plus Demos, exactly one open; every section named in |
| +// the page's language; the disclosure glyph the icon set's chevron. |
| +func TestTheSidebarIsTheShellTheGalleryDocuments(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + example, ok := files["day/en/frames/shell-sidebar/default.html"] |
| + if !ok { |
| + t.Fatal("no shell-sidebar frame to read the shell's skeleton from") |
| + } |
| + reference := shellSkeleton(parse(t, example)) |
| + if len(reference) < 6 { |
| + t.Fatalf("the shell-sidebar example's skeleton has %d pieces; the comparison below would read too little", len(reference)) |
| + } |
| + disclosing := 1 |
| + for _, pk := range s.pageKinds() { |
| + if pk.Nav != nil { |
| + disclosing++ |
| + } |
| + } |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + for _, pk := range s.pageKinds() { |
| + name := theme + "/" + locale + "/" + pk.File |
| + page := kindPage(t, files, theme, locale, pk.File) |
| + skeleton := shellSkeleton(parse(t, []byte(page))) |
| + for piece := range skeleton { |
| + if !reference[piece] { |
| + t.Errorf("%s: the shell has %s, which the shell-sidebar example does not", name, piece) |
| + } |
| + } |
| + for _, want := range []string{ |
| + `<aside class="ds-rail" rst-shell-rail>`, |
| + `<nav class="ds-nav" rst-shell-nav id="ds-nav"`, |
| + `<main rst-shell-main id="main">`, |
| + } { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("%s: the page is not laid out in the sidebar shell (no %s)", name, want) |
| + } |
| + } |
| + for _, want := range []string{ |
| + `<search class="ds-search">`, |
| + `<input id="ds-filter" type="search"`, |
| + `aria-controls="ds-nav"`, |
| + `data-ds-filter`, |
| + `data-ds-filter-empty role="status" hidden`, |
| + } { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("%s: the filter box is not there as specified (no %s)", name, want) |
| + } |
| + } |
| + rail := railOf(t, name, page) |
| + if n := strings.Count(rail, "<details"); n != disclosing { |
| + t.Errorf("%s: %d sidebar disclosures, want %d", name, n, disclosing) |
| + } |
| + want := 0 |
| + if pk.Nav != nil { |
| + want = 1 |
| + } |
| + if n := strings.Count(rail, "<details open"); n != want { |
| + t.Errorf("%s: %d sidebar sections arrive open, want %d (the one this page is)", name, n, want) |
| + } |
| + for _, pk2 := range s.pageKinds() { |
| + title := template.HTMLEscapeString(s.titleOf(locale, pk2)) |
| + if pk2.Nav == nil { |
| + if !strings.Contains(rail, `class="ds-nav__page"`) || !strings.Contains(rail, `>`+title+`</a>`) { |
| + t.Errorf("%s: no sidebar link named %q for the page with nothing to list", name, s.titleOf(locale, pk2)) |
| + } |
| + continue |
| + } |
| + if !strings.Contains(rail, `</span>`+title+`</summary>`) { |
| + t.Errorf("%s: no sidebar section named %q", name, s.titleOf(locale, pk2)) |
| + } |
| + } |
| + if title := template.HTMLEscapeString(s.say(locale, "gallery.nav.demos")); !strings.Contains(rail, `</span>`+title+`</summary>`) { |
| + t.Errorf("%s: no sidebar section named %q", name, s.say(locale, "gallery.nav.demos")) |
| + } |
| + if n := strings.Count(rail, `<span rst-caret aria-hidden="true"><svg`); n != disclosing { |
| + t.Errorf("%s: %d rail disclosures draw a chevron, want %d", name, n, disclosing) |
| + } |
| + for _, glyph := range []string{"▸", "▾", `content: "\25b8"`, `content: "\25be"`} { |
| + if strings.Contains(page, glyph) { |
| + t.Errorf("%s still draws a disclosure glyph as the character %q", name, glyph) |
| + } |
| + } |
| + if want := `aria-label="` + template.HTMLEscapeString(s.say(locale, "gallery.nav.sections")) + `"`; !strings.Contains(page, want) { |
| + t.Errorf("%s: the sidebar nav is not named in this page's language", name) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheRailIsTheSameOnEveryPage: the rail is the same on every page |
| +// of a directory, byte for byte apart from which section and entry |
| +// carry aria-current="page". That is the promise the rail makes to a |
| +// reader: the whole vocabulary is one list, and moving between the |
| +// pages does not move the list under them. |
| +func TestTheRailIsTheSameOnEveryPage(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + current := strings.NewReplacer(` open aria-current="page"`, "", ` aria-current="page"`, "") |
| + for _, dir := range dirs() { |
| + var first, firstName string |
| + for _, name := range pageNames(s, files) { |
| + if !strings.HasPrefix(name, dir) { |
| + continue |
| + } |
| + rail := current.Replace(railOf(t, name, string(files[name]))) |
| + if rail == "" { |
| + continue |
| + } |
| + if first == "" { |
| + first, firstName = rail, name |
| + continue |
| + } |
| + if rail != first { |
| + t.Errorf("%s and %s carry different rails", firstName, name) |
| + } |
| + } |
| + if first == "" { |
| + t.Errorf("%s: no rail on any page", dir) |
| + } |
| + } |
| +} |
| + |
| +// TestTheRailSaysWhichPageYouAreOn: on a page-kind page exactly one |
| +// section of the rail is marked current and it is the page's own, |
| +// open where it discloses and a plain link where it does not; on an |
| +// example page its family's section is open and current and the |
| +// example's own entry is current too, and nothing else is. |
| +func TestTheRailSaysWhichPageYouAreOn(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + for _, pk := range s.pageKinds() { |
| + name := theme + "/" + locale + "/" + pk.File |
| + rail := railOf(t, name, kindPage(t, files, theme, locale, pk.File)) |
| + if n := strings.Count(rail, `aria-current="page"`); n != 1 { |
| + t.Errorf("%s: %d things in the rail are marked current, want 1", name, n) |
| + continue |
| + } |
| + title := template.HTMLEscapeString(s.titleOf(locale, pk)) |
| + open := regexp.MustCompile(`(?s)<details open aria-current="page"><summary><span rst-caret aria-hidden="true"><svg.*?</svg></span>` + regexp.QuoteMeta(title) + `</summary>`) |
| + link := `<a class="ds-nav__page" data-gallery-nav href="` + s.href(theme, locale, pk.File) + `" aria-current="page">` + title + `</a>` |
| + if !strings.Contains(rail, link) && !open.MatchString(rail) { |
| + t.Errorf("%s: the current section of the rail is not %q", name, s.titleOf(locale, pk)) |
| + } |
| + } |
| + } |
| + } |
| + theme, locale := fiesta.ThemeNames()[0], "en" |
| + for _, e := range s.m.Examples { |
| + name := theme + "/" + locale + "/" + e.Name + ".html" |
| + rail := railOf(t, name, string(files[name])) |
| + if n := strings.Count(rail, `aria-current="page"`); n != 2 { |
| + t.Errorf("%s: %d things in the rail are marked current, want 2 (the family's section and the example's entry)", name, n) |
| + continue |
| + } |
| + f, _ := s.familyByKey(e.Family) |
| + open := regexp.MustCompile(`(?s)<details open aria-current="page"><summary><span rst-caret aria-hidden="true"><svg.*?</svg></span>` + regexp.QuoteMeta(template.HTMLEscapeString(f.Title)) + `</summary>`) |
| + if !open.MatchString(rail) { |
| + t.Errorf("%s: the open section of the rail is not the %s family", name, e.Family) |
| + } |
| + if want := `<a data-gallery-nav href="` + s.href(theme, locale, e.Name+".html") + `" aria-current="page" class="rst-mono"`; !strings.Contains(rail, want) { |
| + t.Errorf("%s: the rail does not mark the example's own entry current (no %s)", name, want) |
| + } |
| + } |
| +} |
| + |
| +// TestTheSidebarLinksEverythingOnThePageExactlyOnce: the rail is a |
| +// reading of the page. On a page that anchors things (Tokens, Icons) |
| +// the fragments the rail links into it, in rail order, are exactly |
| +// the anchored elements on the page, in page order; on a family page |
| +// the section's entries are exactly the page's own list of examples, |
| +// in order. Everything the rail links is a page of this tree, and the |
| +// links out of the gallery's pages are the five shell demos alone. |
| +func TestTheSidebarLinksEverythingOnThePageExactlyOnce(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + anchored, listed := 0, 0 |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + for _, pk := range s.pageKinds() { |
| + name := theme + "/" + locale + "/" + pk.File |
| + page := kindPage(t, files, theme, locale, pk.File) |
| + rail := railOf(t, name, page) |
| + if rail == "" { |
| + continue |
| + } |
| + self := s.href(theme, locale, pk.File) |
| + var anchors []string |
| + for _, m := range anchorMarker.FindAllStringSubmatch(page, -1) { |
| + anchors = append(anchors, m[1]) |
| + } |
| + var fragments []string |
| + for _, m := range navHref.FindAllStringSubmatch(rail, -1) { |
| + if strings.HasPrefix(m[1], self+"#") { |
| + fragments = append(fragments, strings.TrimPrefix(m[1], self+"#")) |
| + } |
| + } |
| + if len(anchors) > 0 { |
| + anchored++ |
| + } |
| + if strings.Join(fragments, " ") != strings.Join(anchors, " ") { |
| + t.Errorf("%s: the sidebar links the fragments %v, the page anchors %v", name, fragments, anchors) |
| + } |
| + if _, isFamily := s.familyByKey(pk.Kind); isFamily { |
| + listed++ |
| + doc := parse(t, []byte(page)) |
| + var examples []string |
| + if routes, ok := doc.first(byClass("ds-routes")); ok { |
| + for _, a := range routes.find(func(n node) bool { return n.n.Data == "a" }) { |
| + href, _ := a.attr("href") |
| + examples = append(examples, href) |
| + } |
| + } |
| + var items []string |
| + for _, g := range railSections(rail) { |
| + if g.Title == template.HTMLEscapeString(s.titleOf(locale, pk)) { |
| + for i, m := range navHref.FindAllStringSubmatch(g.Body, -1) { |
| + if i > 0 { |
| + items = append(items, m[1]) |
| + } |
| + } |
| + } |
| + } |
| + if len(examples) == 0 || strings.Join(items, " ") != strings.Join(examples, " ") { |
| + t.Errorf("%s: the section lists %v, the page lists %v", name, items, examples) |
| + } |
| + } |
| + away := 0 |
| + for _, m := range navHref.FindAllStringSubmatch(rail, -1) { |
| + path, _, _ := strings.Cut(strings.TrimPrefix(m[1], "/"), "#") |
| + if !strings.HasPrefix(m[1], "/") { |
| + t.Errorf("%s: the sidebar links %q, which is not an absolute path", name, m[1]) |
| + continue |
| + } |
| + if _, ok := files[path]; !ok { |
| + t.Errorf("%s: the sidebar links %q, which is not a page of this tree", name, m[1]) |
| + continue |
| + } |
| + if isFrame(path) { |
| + away++ |
| + } |
| + } |
| + if want := len(shellNames); away != want { |
| + t.Errorf("%s: the sidebar has %d links out of the gallery's pages, want %d (one per shell demo)", name, away, want) |
| + } |
| + } |
| + } |
| + } |
| + if anchored == 0 || listed == 0 { |
| + t.Fatalf("%d anchored pages and %d family pages read; the check above read one side only", anchored, listed) |
| + } |
| +} |
| + |
| +// railSection is one group of the rail as the page renders it: a |
| +// <details> holding a title and a run of links, or the plain link a |
| +// section with nothing to list renders as instead. |
| +var railSection = regexp.MustCompile(`(?s)<details([^>]*)><summary>.*?</summary>(.*?)</details>|<a class="ds-nav__page" data-gallery-nav href="([^"]*)"[^>]*>(.*?)</a>`) |
| + |
| +// railTitle reads the section's own name out of either shape: past the |
| +// caret icon in a <summary>, or the whole text of the plain link. |
| +var railTitle = regexp.MustCompile(`(?s)<summary>(?:<span rst-caret aria-hidden="true">.*?</span>)?(.*?)</summary>`) |
| + |
| +type railGroup struct { |
| + Title string |
| + Body string // the links inside a disclosure; empty for a plain link |
| + Href string // set only for the plain-link shape |
| + Open bool // true for the disclosure shape |
| +} |
| + |
| +// railSections splits one rendered rail into its groups, in order. |
| +func railSections(rail string) []railGroup { |
| + var out []railGroup |
| + for _, m := range railSection.FindAllString(rail, -1) { |
| + sub := railSection.FindStringSubmatch(m) |
| + if strings.HasPrefix(m, "<details") { |
| + title := "" |
| + if t := railTitle.FindStringSubmatch(m); t != nil { |
| + title = t[1] |
| + } |
| + out = append(out, railGroup{Title: title, Body: sub[2], Open: true}) |
| + continue |
| + } |
| + out = append(out, railGroup{Title: sub[4], Href: sub[3]}) |
| + } |
| + return out |
| +} |
| + |
| +// TestEverySectionOfTheRailRoutesToItsOwnPage: every section of the |
| +// rail has a route to the top of its own page, and it is the first |
| +// thing under the section's name: the plain link where the section |
| +// lists nothing, and otherwise an Overview item named for a screen |
| +// reader by its section, whose accessible name contains its visible |
| +// label (WCAG 2.2 SC 2.5.3). Exactly one route per page kind, and |
| +// none in Demos, which is not a page of this gallery. |
| +func TestEverySectionOfTheRailRoutesToItsOwnPage(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + label := s.say(locale, "gallery.nav.overview") |
| + for _, pk := range s.pageKinds() { |
| + name := theme + "/" + locale + "/" + pk.File |
| + rail := railOf(t, name, kindPage(t, files, theme, locale, pk.File)) |
| + if rail == "" { |
| + continue |
| + } |
| + groups := railSections(rail) |
| + if want := len(s.pageKinds()) + 1; len(groups) != want { |
| + t.Errorf("%s: the rail has %d sections, want %d (one per page kind, plus Demos)", name, len(groups), want) |
| + continue |
| + } |
| + overviews := 0 |
| + for i, pk2 := range s.pageKinds() { |
| + g := groups[i] |
| + want := s.href(theme, locale, pk2.File) |
| + title := template.HTMLEscapeString(s.titleOf(locale, pk2)) |
| + if g.Title != title { |
| + t.Errorf("%s: rail section %d is %q, want %q", name, i, g.Title, title) |
| + continue |
| + } |
| + if n := strings.Count(rail, `href="`+want+`"`); n != 1 { |
| + t.Errorf("%s: the rail carries %d links to %s, want exactly 1", name, n, want) |
| + } |
| + if !g.Open { |
| + if g.Href != want { |
| + t.Errorf("%s: the %s section links %q, want %q", name, pk2.Kind, g.Href, want) |
| + } |
| + continue |
| + } |
| + first := navHref.FindStringIndex(g.Body) |
| + if first == nil { |
| + t.Errorf("%s: the %s section of the rail has no links at all", name, pk2.Kind) |
| + continue |
| + } |
| + item := g.Body[first[0]:] |
| + if i := strings.Index(item, "</a>"); i >= 0 { |
| + item = item[:i+len("</a>")] |
| + } |
| + spoken := s.say(locale, "gallery.nav.overview.aria", "section", s.titleOf(locale, pk2)) |
| + wantItem := `<a data-gallery-nav href="` + want + `" aria-label="` + template.HTMLEscapeString(spoken) + `">` + template.HTMLEscapeString(label) + `</a>` |
| + if item != wantItem { |
| + t.Errorf("%s: the first item under %s is\n %s\nwant\n %s", name, pk2.Kind, item, wantItem) |
| + continue |
| + } |
| + overviews++ |
| + if !strings.Contains(strings.ToLower(spoken), strings.ToLower(label)) { |
| + t.Errorf("%s: the %s section's accessible name %q does not contain its visible label %q (WCAG 2.2 SC 2.5.3)", name, pk2.Kind, spoken, label) |
| + } |
| + } |
| + var bodies string |
| + for _, g := range groups { |
| + bodies += g.Body |
| + } |
| + if n := strings.Count(bodies, ` aria-label="`); n != overviews { |
| + t.Errorf("%s: %d rail links carry an accessible name, want %d (one per section that discloses, and none in Demos)", name, n, overviews) |
| + } |
| + if demos := groups[len(groups)-1]; demos.Title != template.HTMLEscapeString(s.say(locale, "gallery.nav.demos")) { |
| + t.Errorf("%s: the last rail section is %q, want Demos", name, demos.Title) |
| + } else if strings.Contains(demos.Body, ` aria-label="`) { |
| + t.Errorf("%s: the Demos section carries an overview link; it is not a page of this gallery", name) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// demoHrefs are the five shell demos' addresses in one directory: the |
| +// layout examples' default frame documents. |
| +func demoHrefs(t *testing.T, s *site, theme, locale string) map[string]bool { |
| + t.Helper() |
| + out := map[string]bool{} |
| + for _, shell := range shellNames { |
| + state, ok := s.m.Default("layout-" + shell) |
| + if !ok { |
| + t.Fatalf("no layout-%s example", shell) |
| + } |
| + out[s.href(theme, locale, "frames/layout-"+shell+"/"+state+".html")] = true |
| + } |
| + return out |
| +} |
| + |
| +// TestEveryDemoLinkOpensInANewTab: every link the renderer owns that |
| +// leaves the gallery's pages for a shell demo opens in a new tab, and |
| +// the rail carries one to each of the five on every page. A reader is |
| +// a long way down a page with a filter typed into the rail; losing |
| +// that to look at a demo is a poor trade. The switchers are not in |
| +// this set: a theme or a language is the same page again. |
| +func TestEveryDemoLinkOpensInANewTab(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + away := regexp.MustCompile(`<a[^>]*\shref="([^"]*)"[^>]*>`) |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + demos := demoHrefs(t, s, theme, locale) |
| + seen := map[string]int{} |
| + pages := 0 |
| + for _, name := range pageNames(s, files) { |
| + if !strings.HasPrefix(name, theme+"/"+locale+"/") { |
| + continue |
| + } |
| + pages++ |
| + for _, m := range away.FindAllStringSubmatch(string(files[name]), -1) { |
| + if !demos[m[1]] { |
| + continue |
| + } |
| + seen[m[1]]++ |
| + if !strings.Contains(m[0], `target="_blank"`) || !strings.Contains(m[0], `rel="noopener"`) { |
| + t.Errorf("%s: %s does not open in a new tab", name, m[0]) |
| + } |
| + } |
| + } |
| + for href := range demos { |
| + if seen[href] < pages { |
| + t.Errorf("%s/%s: %s is linked %d times, want at least %d (the rail, on every page)", theme, locale, href, seen[href], pages) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestThePhoneIndexSaysItsDemosOpenANewTab: the phone index's Demos |
| +// rows open a new tab and say so to a screen reader; a reader who is |
| +// not told finds Back dead and their filter gone. |
| +func TestThePhoneIndexSaysItsDemosOpenANewTab(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + blank := regexp.MustCompile(`<a data-gallery-nav href="[^"]*" target="_blank" rel="noopener">(.*?)</a>`) |
| + for _, locale := range Locales { |
| + m := indexNav.FindStringSubmatch(kindPage(t, files, fiesta.ThemeNames()[0], locale, "index.html")) |
| + if m == nil { |
| + t.Fatalf("%s: no rows nav on the Overview", locale) |
| + } |
| + rows := blank.FindAllStringSubmatch(m[2], -1) |
| + if len(rows) == 0 { |
| + t.Fatalf("%s: no Demos row opens a new tab, so this checked nothing", locale) |
| + } |
| + say := `<span class="rst-sr-only"> (` + template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span>` |
| + for _, r := range rows { |
| + if !strings.HasSuffix(r[1], say) { |
| + t.Errorf("%s: the Demos row %q opens a new tab without saying %s", locale, r[1], say) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestEveryWayUpNamesItsRow: every content page's back control |
| +// returns to its own row on the index, index.html#nav-<kind>, a |
| +// fragment naming a link in the rows nav whose href is that page; an |
| +// example page's way up is its family's row. The rows are the rail's |
| +// sections in its order, but the Overview and Demos, and the rows end |
| +// with the Demos group. |
| +func TestEveryWayUpNamesItsRow(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + overview := kindPage(t, files, theme, locale, "index.html") |
| + m := indexNav.FindStringSubmatch(overview) |
| + if m == nil { |
| + t.Fatalf("%s/%s: no rows nav on the Overview", theme, locale) |
| + } |
| + if want := template.HTMLEscapeString(s.say(locale, "gallery.up.label")); m[1] != want { |
| + t.Errorf("%s/%s: the rows nav is labelled %q, want the back control's word %q, so Back to it lands on a list of that name", theme, locale, m[1], want) |
| + } |
| + rows := m[2] |
| + for _, pk := range s.pageKinds() { |
| + if pk.Kind == "overview" { |
| + continue |
| + } |
| + b := backLink.FindStringSubmatch(kindPage(t, files, theme, locale, pk.File)) |
| + if want := s.href(theme, locale, "index.html") + "#nav-" + pk.Kind; b == nil || b[1] != want { |
| + t.Errorf("%s/%s/%s: way up %v, want %s", theme, locale, pk.File, b, want) |
| + } |
| + if row := `<a id="nav-` + pk.Kind + `" data-gallery-nav href="` + s.href(theme, locale, pk.File) + `">`; !strings.Contains(rows, row) { |
| + t.Errorf("%s/%s: no row %s", theme, locale, row) |
| + } |
| + } |
| + for _, e := range s.m.Examples { |
| + b := backLink.FindStringSubmatch(string(files[theme+"/"+locale+"/"+e.Name+".html"])) |
| + if want := s.href(theme, locale, "index.html") + "#nav-" + e.Family; b == nil || b[1] != want { |
| + t.Errorf("%s/%s/%s.html: way up %v, want its family's row %s", theme, locale, e.Name, b, want) |
| + } |
| + } |
| + var tree, listed []string |
| + for _, g := range railSections(railOf(t, "overview", overview)) { |
| + tree = append(tree, g.Title) |
| + } |
| + for _, r := range regexp.MustCompile(`<a id="nav-[^"]+" data-gallery-nav href="[^"]*">([^<]*)</a>`).FindAllStringSubmatch(rows, -1) { |
| + listed = append(listed, r[1]) |
| + } |
| + if len(tree) < 3 { |
| + t.Fatalf("%s/%s: the rail has %d sections", theme, locale, len(tree)) |
| + } |
| + if want := tree[1 : len(tree)-1]; strings.Join(listed, "|") != strings.Join(want, "|") { |
| + t.Errorf("%s/%s: rows %v, want the tree's sections but the Overview and Demos, %v", theme, locale, listed, want) |
| + } |
| + if !strings.Contains(rows, `<p rst-shell-group>`+tree[len(tree)-1]+`</p>`) { |
| + t.Errorf("%s/%s: the rows do not end with the Demos group", theme, locale) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheTwoCopiesOfTheControlsAgree: every page writes its controls |
| +// twice, in the bar for a wide screen and in the display settings menu |
| +// for a phone, and only one is ever shown. Same themes, the same |
| +// scheme menu, same languages in the same order, no fragment on either |
| +// (gallery.js adds the reader's place at the click), and no id in |
| +// either, since an id written twice is two elements one fragment |
| +// cannot both reach. The menu's button is the cog, named with the |
| +// approved string. |
| +func TestTheTwoCopiesOfTheControlsAgree(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + for _, pk := range s.pageKinds() { |
| + where := theme + "/" + locale + "/" + pk.File |
| + page := kindPage(t, files, theme, locale, pk.File) |
| + card, bar := prefsCard.FindStringSubmatch(page), barCtl.FindStringSubmatch(page) |
| + if card == nil || bar == nil { |
| + t.Fatalf("%s: display settings found %v, bar found %v", where, card != nil, bar != nil) |
| + } |
| + if want := template.HTMLEscapeString(s.say(locale, "gallery.prefs")); card[1] != want { |
| + t.Errorf("%s: the display settings button is named %q, want %q", where, card[1], want) |
| + } |
| + if !strings.HasPrefix(card[2], `<svg class="icon"`) || !strings.Contains(card[2], `aria-hidden="true"`) || strings.Contains(card[2], "<span") { |
| + t.Errorf("%s: the display settings button is not the bare, hidden icon its name stands for: %s", where, card[2]) |
| + } |
| + if !strings.Contains(card[2], `<circle cx="12" cy="12" r="3"/>`) || !strings.Contains(card[2], `<path d="M9.671 4.136`) { |
| + t.Errorf("%s: the display settings button is not the cog: %s", where, card[2]) |
| + } |
| + ch, bh := navHref.FindAllStringSubmatch(card[3], -1), navHref.FindAllStringSubmatch(bar[1], -1) |
| + if len(ch) == 0 || len(ch) != len(bh) { |
| + t.Errorf("%s: %d links in the menu, %d in the bar", where, len(ch), len(bh)) |
| + continue |
| + } |
| + for i := range ch { |
| + if strings.Contains(bh[i][1], "#") || ch[i][1] != bh[i][1] { |
| + t.Errorf("%s link %d: menu %s, bar %s; want the same address and no fragment", where, i, ch[i][1], bh[i][1]) |
| + } |
| + } |
| + a, b := schemeMenu.FindString(card[3]), schemeMenu.FindString(bar[1]) |
| + if a == "" || a != b || strings.Count(a, "<form") != 3 { |
| + t.Errorf("%s: the scheme menu in the menu and in the bar differ, or one is missing", where) |
| + } |
| + for what, html := range map[string]string{"menu": card[3], "bar": bar[1]} { |
| + if elementID.MatchString(html) { |
| + t.Errorf("%s: the %s's controls carry an id", where, what) |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheFilterBoxCarriesTheSearchIcon: the filter box carries the |
| +// search icon at its inline start, drawn by the icon set and hidden |
| +// from the accessible name, which is the field's label. |
| +func TestTheFilterBoxCarriesTheSearchIcon(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + box := regexp.MustCompile(`(?s)<search class="ds-search">(.*?)</search>`) |
| + for _, pk := range s.pageKinds() { |
| + m := box.FindStringSubmatch(kindPage(t, files, fiesta.ThemeNames()[0], "en", pk.File)) |
| + if m == nil { |
| + t.Fatalf("%s: no filter box", pk.File) |
| + } |
| + icon, input := strings.Index(m[1], string(s.iconMarkup("search"))), strings.Index(m[1], "<input") |
| + if icon < 0 || input < icon { |
| + t.Errorf("%s: the filter box has no search icon before its field: %s", pk.File, m[1]) |
| + } |
| + } |
| +} |
| + |
| +// TestEverySearchTermNamesAnAnchor: every synonym names an example |
| +// that exists, and rides on the rail's link to its page. A synonym |
| +// whose example was renamed would leave its words pointing at |
| +// nothing, and a reader typing "checkbox" would see No matches again. |
| +func TestEverySearchTermNamesAnAnchor(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + theme := fiesta.ThemeNames()[0] |
| + rail := railOf(t, "overview", kindPage(t, files, theme, "en", "index.html")) |
| + for name, terms := range searchTerms { |
| + if _, ok := s.m.Entry(name); !ok { |
| + t.Errorf("searchTerms names %q, which is no example", name) |
| + } |
| + if !regexp.MustCompile(`<a data-gallery-nav href="` + regexp.QuoteMeta(s.href(theme, "en", name+".html")) + `"[^>]* data-ds-terms="` + regexp.QuoteMeta(terms) + `"`).MatchString(rail) { |
| + t.Errorf("the rail link to %s.html does not carry its terms %q", name, terms) |
| + } |
| + } |
| + if len(searchTerms) == 0 { |
| + t.Fatal("no search terms") |
| + } |
| +} |
| + |
| +// TestTheChromeCarriesTheThreeSwitchers: on every page-kind page the |
| +// bar sits after the rail and before main, which opens with the page |
| +// column; its brand links the Overview; its controls hold one theme |
| +// link per theme with exactly one current, Fiesta's scheme menu with |
| +// its three forms and System current, and one language link per |
| +// locale (twelve, counted as links, with exactly one current), each |
| +// named; and every switcher link is this page in another theme or |
| +// language, never the Overview. |
| +func TestTheChromeCarriesTheThreeSwitchers(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + localeLink := regexp.MustCompile(`<a data-gallery-nav href="([^"]*)" lang="[^"]*" dir="[^"]*"( aria-current="true")?>`) |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + for _, pk := range s.pageKinds() { |
| + name := theme + "/" + locale + "/" + pk.File |
| + page := kindPage(t, files, theme, locale, pk.File) |
| + at := strings.Index(page, `<header class="ds-top">`) |
| + if at < 0 || !strings.Contains(page[:at], "</aside>") { |
| + t.Errorf("%s: no bar after the rail", name) |
| + continue |
| + } |
| + bar, rest, ok := strings.Cut(page[at:], "</header>") |
| + if !ok { |
| + t.Errorf("%s: the bar never closes", name) |
| + continue |
| + } |
| + if !strings.HasPrefix(strings.TrimSpace(rest), `<main rst-shell-main id="main">`) { |
| + t.Errorf("%s: main does not follow the bar", name) |
| + } |
| + if _, m, _ := strings.Cut(rest, `<main rst-shell-main id="main">`); !strings.HasPrefix(strings.TrimSpace(m), `<div rst-page>`) { |
| + t.Errorf("%s: main does not open with the content column", name) |
| + } |
| + if want := `<a class="ds-top__brand" data-gallery-nav href="` + s.href(theme, locale, "index.html") + `">`; !strings.Contains(bar, want) { |
| + t.Errorf("%s: the bar's brand does not link the Overview", name) |
| + } |
| + _, chrome, ok := strings.Cut(bar, `<div class="ds-top__controls">`) |
| + if !ok { |
| + t.Errorf("%s: the bar has no controls", name) |
| + continue |
| + } |
| + if n := strings.Count(chrome, `aria-current="page"`); n != 1 { |
| + t.Errorf("%s: %d themes marked current, want 1", name, n) |
| + } |
| + locales := localeLink.FindAllStringSubmatch(chrome, -1) |
| + current := 0 |
| + for _, m := range locales { |
| + if m[2] != "" { |
| + current++ |
| + } |
| + } |
| + if len(locales) != len(Locales) || current != 1 { |
| + t.Errorf("%s: %d language links with %d marked current, want %d and 1", name, len(locales), current, len(Locales)) |
| + } |
| + if n := strings.Count(chrome, `<a data-gallery-nav href="`); n != len(fiesta.ThemeNames())+len(Locales) { |
| + t.Errorf("%s: the chrome has %d in-tree links, want one per theme and one per locale", name, n) |
| + } |
| + menu := schemeMenu.FindString(chrome) |
| + if menu == "" || strings.Count(menu, "<form") != 3 || !strings.Contains(menu, `value="system"`) { |
| + t.Errorf("%s: the chrome has no scheme menu with its three forms", name) |
| + } |
| + if n := strings.Count(menu, `aria-current="true"`); n != 1 || !regexp.MustCompile(`value="system">(?:<input[^>]*>)*<button type="submit" aria-current="true"`).MatchString(menu) { |
| + t.Errorf("%s: System is not the one scheme marked current with no JavaScript", name) |
| + } |
| + for _, label := range []string{s.say(locale, "gallery.theme.label"), s.say(locale, "gallery.language")} { |
| + if !strings.Contains(chrome, template.HTMLEscapeString(label)) { |
| + t.Errorf("%s: the chrome does not name %q", name, label) |
| + } |
| + } |
| + for _, m := range navHref.FindAllStringSubmatch(chrome, -1) { |
| + if !strings.HasSuffix(m[1], "/"+pk.File) { |
| + t.Errorf("%s: the chrome links %q, which is not this page in another theme or language", name, m[1]) |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestGalleryScriptLoadsBeforeTheBody: gallery.js is a blocking script |
| +// in <head>, not deferred, so a stored scheme is on the root and the |
| +// frame-hiding rule is armed before the first paint. |
| +func TestGalleryScriptLoadsBeforeTheBody(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + page := kindPage(t, files, fiesta.ThemeNames()[0], "en", "index.html") |
| + tag := `<script src="` + s.asset("gallery.js") + `"></script>` |
| + i := strings.Index(page, tag) |
| + if i < 0 { |
| + t.Fatalf("no blocking gallery.js tag on the page (want %s)", tag) |
| + } |
| + if body := strings.Index(page, "<body>"); i > body { |
| + t.Error("gallery.js loads after <body>; the scheme it restores will flash") |
| + } |
| + if strings.Contains(page, `<script defer src="`+s.asset("gallery.js")+`"></script>`) { |
| + t.Error("gallery.js is deferred; see its header comment for why it is not") |
| + } |
| +} |
| + |
| +// TestNoGalleryPageLoadsTheEnhancementScripts: a gallery page loads |
| +// exactly gallery.js, fiesta.js, anchor.js and shell.js. The frames |
| +// load what their markup hooks; the page's own buttons do not light |
| +// and its fields are not enhanced. |
| +func TestNoGalleryPageLoadsTheEnhancementScripts(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + want := map[string]bool{"gallery.js": true, "js/fiesta.js": true, "js/anchor.js": true, "js/shell.js": true} |
| + plain := map[string]string{} |
| + for name, hashed := range s.hashed { |
| + plain[s.url("assets/"+hashed)] = name |
| + } |
| + checked := 0 |
| + for _, name := range pageNames(s, files) { |
| + got := map[string]bool{} |
| + attrsOf(t, files[name], func(tag string, attrs map[string]string) { |
| + if tag == "script" { |
| + got[plain[attrs["src"]]] = true |
| + } |
| + }) |
| + for script := range got { |
| + if !want[script] { |
| + t.Errorf("%s loads %s, which nothing on the page uses", name, script) |
| + } |
| + } |
| + for script := range want { |
| + if !got[script] { |
| + t.Errorf("%s does not load %s", name, script) |
| + } |
| + } |
| + checked++ |
| + } |
| + if checked == 0 { |
| + t.Fatal("no page was checked") |
| + } |
| +} |
| + |
| +// TestNoGalleryPageAsksForPrerender: no page asks to be prerendered. |
| +// The gallery is static files on a site with no headers file, and an |
| +// inline speculation ruleset would need a CSP the system avoids. The |
| +// slide and the focus return work without it. |
| +func TestNoGalleryPageAsksForPrerender(t *testing.T) { |
| + files := render(t, "") |
| + for name, body := range files { |
| + if strings.Contains(string(body), "speculationrules") || strings.Contains(string(body), "_speculation-rules") { |
| + t.Errorf("%s asks for prerendering", name) |
| + } |
| + } |
| + if len(files) == 0 { |
| + t.Fatal("no files rendered") |
| + } |
| +} |
| + |
| +// TestNoPageCarriesAPageWideViewSwitcher: no page carries a page-wide |
| +// view switcher, and neither half of the stylesheet or the script |
| +// draws one or reads the view it once remembered. Each example's own |
| +// tabs are the one view control. |
| +func TestNoPageCarriesAPageWideViewSwitcher(t *testing.T) { |
| + files := render(t, "") |
| + pages := 0 |
| + for name, b := range files { |
| + if !strings.HasSuffix(name, ".html") { |
| + continue |
| + } |
| + pages++ |
| + if page := string(b); strings.Contains(page, "ds-viewall") || strings.Contains(page, "data-ds-view=") { |
| + t.Errorf("%s carries the page-wide view switcher", name) |
| + } |
| + } |
| + if pages == 0 { |
| + t.Fatal("no pages rendered, so this checked nothing") |
| + } |
| + for name, src := range map[string][]byte{"gallery.css": galleryCSS, "view.css": viewCSS, "gallery.js": galleryJS, "view.js": viewJS} { |
| + for _, gone := range []string{"ds-viewall", "rst-ds-view", "data-ds-view", "dsView"} { |
| + if strings.Contains(string(src), gone) { |
| + t.Errorf("%s mentions %s, which only the page-wide switcher used", name, gone) |
| + } |
| + } |
| + } |
| +} |