The preview widget: Desktop, Mobile and HTML, the wrapper line, grouped rows and Copy
The preview widget is Rastrillo's: three radios in labels sharing a name unique to the widget, none checked, a container query on .ds-view picking the opening view from the stage's width (Desktop from 40.5rem of stage for a component, 54rem for a page), the frame laid out at a virtual 900px or 1200px and scaled by clamp(0.72, 100cqw / --ds-w, 1), panning inside its box below the floor, Mobile at min(390px, 100cqw) and never scaled. The Template tab is dropped: Fiesta has no calls to show. Heights come from a measured table (heights.go), not from fit(): --ds-h and --ds-hm per <example>/<state>, <example>/row and demo, default 220px, Mobile 1.25x unless the tables say otherwise. The table is the twelve-locale sweep at 1500px, Desktop and Mobile, on this branch with the shell, the frame padding and the stage in, with the Mobile entries the same sweep at a 320px viewport needs on top. Both height drives run in reporting mode (heightsEnforced is false): a short box is logged, and a drive fails only when it measured nothing or when its self-check, a box set 100px short of its content, is not read as short. The integration task calibrates the table and flips the switch. The HTML tab shows the body with its outer wrapper element(s) removed, under the line "Put this inside <wrapper>" naming the container with its opening markup; unwrap refuses a body that does not open and close with its wrapper's tags, so a seeded document that lost its wrapper fails the render by name. The wrapper's markup is a span with rst-mono rather than a <code>, because gallery_test.go's htmlTab takes the first <code> after a state's marker as the tab. status-pill, badge and meter are one widget each: a row document, frames/<name>/row.html, listed by frameFiles so the tree gate, the frame count and the axe scan see it; one code block per state in the panel; and under the widget a block per state naming the state on a line of its own with its note, which is where the prose test finds each state's note. Every code block gets Rastrillo's Copy button, drawn only where the clipboard API exists, named from text on screen (tab, heading, state), announcing Copied or the failure in the live region. view.js carries Rastrillo's arm, seek and watch: a frame is hidden until its document is parsed and styled, hidden again while a navigation inside it loads, the look giving up after 5s. A frame with no label takes the example's name as its title rather than a bare colon. Each layout page ends with "Open the <shell> shell" opening its own frame document in a new tab; the -page pair members get none. Fiesta's marks are kept where part 3 established them: data-gallery-js and data-gallery-painted where Rastrillo reads data-rst-js and data-ds-painted. The phone bleed is written against the page column's own padding; .ds-sample has none here. Red by assertion first, against stubs of the seams: untagged TestEveryExampleIsFramedDesktopMobileAndHTML, TestTheHTMLTabNeverCarriesTheWrapper, TestUnwrapStripsTheWrapperAndRefusesABodyWithoutIt, TestABodyWithoutItsWrapperFailsTheRender, TestAGroupedPartialNeedsNoWrapper, TestAGroupedNoteNamesItsStateApart, TestEveryPreviewIsAFileOneFrameNames, TestEveryCodeBlockOffersTheCopyButton, TestEveryFrameTitleIsUniqueOnThePage, TestEveryShellHasABlurb; in the browser, each failing on the widget's absence by name, TestPreviewWidgetDrivesTheWholeJourney, TestThePreviewWidgetIsUsableOnAPhone (en, ru), TestThePreviewDefaultIsMonotoneInStageWidth, TestMobileIsThePageAtItsRealSize, TestDesktopPansFromTheInlineStart, TestTheCopyButtonCopiesAnnouncesAndFailsSafely, TestNoCodeBlockScrollsSidewaysOnAPhone (en, ar, 320 and 390), TestA11yScansTheCodePanels, TestCodeIsLeftToRightOnAnArabicPage, TestAFrameNeverShowsTheOtherSchemeWhileItLoads, TestAFrameShowsNothingUntilItsDocumentIsStyled, TestAFrameNeverMovesTheReader, TestAFrameHidesWhileItNavigates, TestAFrameStopsLookingForItsNextDocument, TestTheGallerySwitchLandsOnTheNextFrame, TestPreviewFrameHeightsFitTheirContent and TestPreviewFrameHeightsFitAtThePhonesNarrowest. Already passing, each proved live by a named mutation: TestRenderWritesEveryStateInEveryTheme and TestTreeShapeIsComplete pick the three row documents up through frameFiles (the row written under rows.html, which example()'s equality check reports as "frameFiles names frames/status-pill/row.html, which example did not write"); TestTheTextOnScreenIsTheTextCopied, whose expectation derives from unwrap, has a control independent of it (unwrap returning the body whole is caught 138 times as "unwrap left the wrapper in"); TestTheHTMLTabRendersExactlyLikeThePreview is a port whose controls run inside it (a space moved into a sentence, and into a closed disclosure, each caught); TestTheFrameMirrorsRightToLeft is a port over the shell already on the branch (the page's dir written as ltr puts the rail at 0px and the brand at the controls' left). Preservation: TestFramesKeepTheReadersPlace holds under the new class; its negative control, frame.js's host check pointed at the old class in a copy served over the hashed asset, took focus into a frame and scrolled the page 498px. The monotone drive's control, a window that grows while its stage shrinks, is required once a rail is on the page and logged as absent otherwise; with the shell in it reads 685px of stage at 700px and 479px at 800px. The frame-scheme drives serve the held document without frame.js, which copies the parent's scheme before first paint; the window they hold open is the one a document loaded from elsewhere has. The stop-looking drive sends the frame to a data: URL, as Rastrillo's does, since a path the tree lacks is a readable 404 that loads at once.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+4050
−129
internal/gallery/code_browser_test.go+518 −0internal/gallery/example_page.go+74 −19internal/gallery/frame_scheme_browser_test.go+598 −0internal/gallery/heights.go+252 −0internal/gallery/heights_browser_test.go+324 −0internal/gallery/realsize_browser_test.go+186 −0internal/gallery/view.css+258 −30internal/gallery/view.go+234 −30internal/gallery/view.js+199 −50internal/gallery/view_browser_test.go+778 −0internal/gallery/view_test.go+629 −0
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.108s
=== 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.022s
=== 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.163s
ok amadan.net/rastrillo/fiesta/check 5.441s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 91.616s
ok amadan.net/rastrillo/fiesta/example 0.233s
ok amadan.net/rastrillo/fiesta/internal/axe 0.169s
ok amadan.net/rastrillo/fiesta/internal/build 1.508s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.019s
ok amadan.net/rastrillo/fiesta/internal/gallery 84.951s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.016s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.373s
ok amadan.net/rastrillo/fiesta/internal/site 3.117s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.021s
ok amadan.net/rastrillo/fiesta/internal/www 78.615s
--- 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' .
[90m3:38AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m3:38AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m3:38AM[0m [32mINF[0m [1mscanned ~6409826 bytes (6.41 MB) in 4.71s[0m
[90m3:38AM[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 12.692s
--- 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 224.181s
--- shard 6 of 15 ---
==> shard 6 of 15: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 237.440s
--- 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 240.388s
--- 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 134 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 118.463s
--- 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 42 of 134 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 176.825s
--- 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 45 of 134 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 159.641s
--- 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 46 of 134 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 362.777s
--- 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 34.306s
--- 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 130.865s
--- 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 119.958s
Intent
diff --git a/internal/gallery/code_browser_test.go b/internal/gallery/code_browser_test.go| new file mode 100644 |
| index 0000000..018b451 |
| --- /dev/null |
| +++ b/internal/gallery/code_browser_test.go |
| @@ -0,0 +1,518 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| + "amadan.net/rastrillo/fiesta/internal/axe" |
| + "amadan.net/rastrillo/fiesta/internal/codeview" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The code blocks in a real engine: the Copy button, the soft wrap on |
| +// a phone, axe over the open panels, left-to-right code on an Arabic |
| +// page, and the HTML tab laying out exactly like the preview. |
| + |
| +// clipboardStub replaces the clipboard with one the leg controls: |
| +// every write is recorded, and window.__clipMode decides whether it |
| +// resolves or rejects. |
| +const clipboardStub = `window.__clipCalls = []; window.__clipMode = "resolve"; |
| +Object.defineProperty(Navigator.prototype, "clipboard", {configurable: true, get() { return { |
| + writeText: s => { window.__clipCalls.push(s); return window.__clipMode === "resolve" ? Promise.resolve() : Promise.reject(new DOMException("denied", "NotAllowedError")); } |
| +}; }});` |
| + |
| +// noClipboard is a plain-HTTP origin's clipboard: not there at all. |
| +const noClipboard = `Object.defineProperty(Navigator.prototype, "clipboard", {configurable: true, get() { return undefined; }});` |
| + |
| +// requireCopyButtons fails a leg by name when the page has no Copy |
| +// button once its scripts have run. |
| +func requireCopyButtons(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + until(t, ctx, where+", scripts run", `document.readyState !== "loading"`) |
| + var n int |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-copy").length`, &n)) |
| + if n == 0 { |
| + t.Fatalf("%s: no Copy button on the page", where) |
| + } |
| +} |
| + |
| +// TestTheCopyButtonCopiesAnnouncesAndFailsSafely: on the field page, |
| +// every copyable block has a button named uniquely from text already |
| +// on screen; a resolved write puts exactly the block's text on the |
| +// clipboard, says Copied in the live region and on the button, and |
| +// reverts; a refused write announces the failure and selects the |
| +// block; a grouped page's rows are named apart; and with no clipboard |
| +// API no button is drawn. |
| +func TestTheCopyButtonCopiesAnnouncesAndFailsSafely(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() |
| + field := pageURL(rig, "en", "field.html") |
| + mustRun(t, ctx, addInit(clipboardStub)) |
| + openPage(t, ctx, "field with a stubbed clipboard", field, 1280, 900) |
| + requireCopyButtons(t, ctx, "field") |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector(".ds-view__tab--h input").click(); true`, nil)) |
| + assertNames(t, ctx, "field") |
| + |
| + const first = `document.querySelector(".ds-view__code--h .ds-copy")` |
| + mustRun(t, ctx, chromedp.Evaluate(first+`.click(); true`, nil)) |
| + until(t, ctx, "a resolved copy", `document.querySelector("[data-ds-copy-status]").textContent === document.querySelector("[data-ds-copy-status]").dataset.copied`) |
| + var got struct { |
| + Calls []string |
| + Text string |
| + Label string |
| + } |
| + at(t, ctx, `JSON.stringify({Calls: window.__clipCalls, |
| + Text: `+first+`.nextElementSibling.querySelector("code").textContent, |
| + Label: `+first+`.firstChild.nodeValue})`, &got) |
| + if len(got.Calls) != 1 || got.Calls[0] != got.Text { |
| + t.Errorf("the clipboard got %q, want exactly the block's text %q", got.Calls, got.Text) |
| + } |
| + if !strings.HasPrefix(got.Text, "<div rst-field>") { |
| + t.Errorf("the first block on the field page copied %q; it should be the field without its wrapper", got.Text) |
| + } |
| + if got.Label != say("gallery.copied") { |
| + t.Errorf("the button reads %q after a copy, want %q", got.Label, say("gallery.copied")) |
| + } |
| + until(t, ctx, "the label returns", first+`.firstChild.nodeValue === document.querySelector("[data-ds-copy-status]").dataset.copy`) |
| + |
| + mustRun(t, ctx, chromedp.Evaluate(`window.__clipMode = "reject"; window.__errors = 0; |
| + addEventListener("error", () => window.__errors++); addEventListener("unhandledrejection", () => window.__errors++); |
| + `+first+`.click(); true`, nil)) |
| + until(t, ctx, "a refused copy", `document.querySelector("[data-ds-copy-status]").textContent === document.querySelector("[data-ds-copy-status]").dataset.failed`) |
| + until(t, ctx, "the block selected", `getSelection().toString() === `+first+`.nextElementSibling.querySelector("code").textContent && window.__errors === 0`) |
| + |
| + // A grouped page: four blocks under one heading, named apart by |
| + // their row labels. |
| + openPage(t, ctx, "status-pill", pageURL(rig, "en", "status-pill.html"), 1280, 900) |
| + requireCopyButtons(t, ctx, "status-pill") |
| + assertNames(t, ctx, "status-pill") |
| + |
| + // No clipboard API, no button: a fresh tab with the init script |
| + // standing in for a missing clipboard, scripts on, since the page's |
| + // own script is what decides. |
| + tab2, cancel2 := chromedp.NewContext(rig.Context()) |
| + defer cancel2() |
| + var buttons, blocks int |
| + mustRun(t, tab2, addInit(noClipboard), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(field), chromedp.WaitReady(`[data-ds-copy-status]`, chromedp.ByQuery)) |
| + until(t, tab2, "field with no clipboard, scripts run", `document.readyState === "complete"`) |
| + mustRun(t, tab2, |
| + chromedp.Evaluate(`document.querySelectorAll(".ds-copy").length`, &buttons), |
| + chromedp.Evaluate(`document.querySelectorAll("pre.ds-src").length`, &blocks)) |
| + if buttons != 0 || blocks == 0 { |
| + t.Errorf("with no clipboard API: %d buttons over %d blocks, want none over some", buttons, blocks) |
| + } |
| +} |
| + |
| +// assertNames holds one page's copy buttons to the two promises: one |
| +// per copyable block, and names unique, each starting with the visible |
| +// label so a voice user saying "Copy" reaches them. |
| +func assertNames(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + var got struct { |
| + Names []string |
| + Blocks int |
| + Label string |
| + } |
| + at(t, ctx, `JSON.stringify({ |
| + Names: [...document.querySelectorAll(".ds-copy")].map(b => b.textContent), |
| + Blocks: document.querySelectorAll("pre.ds-src:not([data-ds-nocopy])").length, |
| + Label: document.querySelector("[data-ds-copy-status]").dataset.copy})`, &got) |
| + if len(got.Names) == 0 || len(got.Names) != got.Blocks { |
| + t.Fatalf("%s: %d copy buttons for %d copyable blocks", where, len(got.Names), got.Blocks) |
| + } |
| + seen := map[string]bool{} |
| + for _, n := range got.Names { |
| + if seen[n] { |
| + t.Errorf("%s: two copy buttons are both named %q", where, n) |
| + } |
| + seen[n] = true |
| + if !strings.HasPrefix(n, got.Label+" ") { |
| + t.Errorf("%s: %q does not start with the visible label %q", where, n, got.Label) |
| + } |
| + } |
| + t.Logf("%s: %d copy buttons, for instance %q", where, len(got.Names), got.Names[len(got.Names)-1]) |
| +} |
| + |
| +// TestNoCodeBlockScrollsSidewaysOnAPhone: with every widget's HTML tab |
| +// chosen, no source block on the heaviest pages scrolls sideways at |
| +// 320px or 390px, in English and in Arabic: the soft wrap holds a |
| +// select's option list, a list bar and the stage layout's whole |
| +// document with its one-line SVG. Each pass first requires that what |
| +// it chose is on screen. |
| +func TestNoCodeBlockScrollsSidewaysOnAPhone(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() |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, file := range []string{"field-select.html", "list-bar.html", "layout-stage.html", "status-pill.html"} { |
| + for _, w := range []int64{320, 390} { |
| + where := fmt.Sprintf("day/%s %s at %dpx, every HTML tab chosen", locale, file, w) |
| + openPage(t, ctx, where, pageURL(rig, locale, file), w, 844) |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-view__tab--h input").forEach(i => i.checked = true); true`, nil)) |
| + var got struct { |
| + Views, Blocks int |
| + Blind, Wide []string |
| + Page float64 |
| + } |
| + at(t, ctx, `(() => { |
| + const shown = p => p.checkVisibility() && p.getBoundingClientRect().height > 0; |
| + const views = [...document.querySelectorAll(".ds-view")]; |
| + const blind = []; |
| + let blocks = 0; |
| + views.forEach(v => { |
| + const n = [...v.querySelectorAll(".ds-view__code--h .ds-src")].filter(shown).length; |
| + if (n === 0) blind.push(v.querySelector("iframe").title); |
| + blocks += n; |
| + }); |
| + const wide = [...document.querySelectorAll(".ds-src")].filter(p => shown(p) && p.scrollWidth > p.clientWidth).map(p => p.textContent.slice(0, 50)); |
| + return JSON.stringify({Views: views.length, Blocks: blocks, Blind: blind, Wide: wide, Page: document.documentElement.scrollWidth - document.documentElement.clientWidth}); |
| + })()`, &got) |
| + if got.Views == 0 || len(got.Blind) > 0 { |
| + t.Fatalf("%s: %d widgets, and %d show no source block: %q", where, got.Views, len(got.Blind), got.Blind) |
| + } |
| + if len(got.Wide) > 0 { |
| + t.Errorf("%s: %d of %d source blocks scroll sideways: %q", where, len(got.Wide), got.Blocks, got.Wide) |
| + } |
| + if got.Page > 1 { |
| + t.Errorf("%s: the page scrolls %.0fpx sideways with the code open", where, got.Page) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestA11yScansTheCodePanels: axe with the HTML panels open, which the |
| +// page scans never see because the panels are display: none until a |
| +// tab is chosen. Each leg first asserts which highlight elements are |
| +// on the page, so the coverage cannot quietly shrink. |
| +func TestA11yScansTheCodePanels(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() |
| + total := 0 |
| + for _, file := range []string{"field.html", "badge.html"} { |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, scheme := range []string{"light", "dark"} { |
| + where := fmt.Sprintf("%s/en %s, every HTML tab chosen (%s)", theme, file, scheme) |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(theme+"/en/"+file)), chromedp.WaitReady("body")) |
| + requireWidget(t, ctx, where) |
| + var shown []string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-view__tab--h input").forEach(i => i.checked = true); |
| + ["ds-t", "ds-a", "ds-v", "ds-x", "ds-s"].filter(n => [...document.querySelectorAll(n)].some(e => e.getBoundingClientRect().width > 0))`, &shown)) |
| + for _, w := range []string{"ds-t", "ds-a", "ds-v"} { |
| + if !slices.Contains(shown, w) { |
| + t.Fatalf("%s: no visible %s; this scan would not be checking that colour", where, w) |
| + } |
| + } |
| + var bg string |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| + res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| + if err != nil { |
| + t.Fatalf("%s: %v", where, err) |
| + } |
| + total += report(t, where, res.Violations) |
| + t.Logf("%s on %s: needs review, axe could not decide: %v", where, bg, res.Incomplete) |
| + } |
| + } |
| + } |
| + if total == 0 { |
| + t.Log("clean: the HTML panels on the field and badge pages in every theme and scheme") |
| + } |
| +} |
| + |
| +// TestCodeIsLeftToRightOnAnArabicPage: a <pre> inherits the page's |
| +// dir="rtl", and in a right-to-left run the bidi algorithm mirrors |
| +// brackets and moves punctuation, so what a reader sees is not what |
| +// Copy puts on the clipboard. Computed style, every block and every |
| +// wrapper-line name, on four Arabic pages; the counts are the control. |
| +func TestCodeIsLeftToRightOnAnArabicPage(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() |
| + var blocks, wrapped int |
| + for _, file := range []string{"field.html", "status-pill.html", "layout-column.html", "format-money.html"} { |
| + where := "day/ar " + file |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(pageURL(rig, "ar", file)), chromedp.WaitReady("body")) |
| + var got struct { |
| + Page string |
| + Src, Wrap int |
| + Bad []string |
| + } |
| + at(t, ctx, `(() => { |
| + if (document.documentElement.dir !== "rtl") return JSON.stringify({Page: document.documentElement.dir}); |
| + const bad = []; |
| + const read = sel => { const es = [...document.querySelectorAll(sel)]; es.forEach(e => { |
| + const s = getComputedStyle(e); |
| + if (s.direction !== "ltr" || !["start", "left"].includes(s.textAlign)) bad.push(sel + " " + s.direction + "/" + s.textAlign + ": " + e.textContent.slice(0, 40)); |
| + }); return es.length; }; |
| + return JSON.stringify({Page: "rtl", Src: read(".ds-src"), Wrap: read(".ds-wrap .rst-mono"), Bad: bad.slice(0, 6)}); |
| + })()`, &got) |
| + if got.Page != "rtl" { |
| + t.Fatalf("%s: the page is dir=%q, so this leg reads nothing a right-to-left page does to code", where, got.Page) |
| + } |
| + blocks += got.Src |
| + wrapped += got.Wrap |
| + if len(got.Bad) > 0 { |
| + t.Errorf("%s: code laid out right to left: %q", where, got.Bad) |
| + } |
| + } |
| + if blocks == 0 || wrapped == 0 { |
| + t.Fatalf("read %d source blocks and %d wrapper-line names over the ar pages; want both above zero", blocks, wrapped) |
| + } |
| + t.Logf("%d source blocks and %d wrapper-line names, all left to right", blocks, wrapped) |
| +} |
| + |
| +// ── The HTML tab lays out like the preview ─────────────────────────── |
| + |
| +// layoutProbe reads what a reader sees: every element's boxes, every |
| +// run of text's boxes, in document order, and the page's visible text, |
| +// with every animation stopped at its start. |
| +const layoutProbe = `(() => { |
| + for (const a of document.getAnimations()) { a.pause(); a.currentTime = 0; } |
| + const round = r => [r.x, r.y, r.width, r.height]; |
| + const boxes = []; |
| + for (const el of document.body.querySelectorAll("*")) { |
| + boxes.push(el.tagName + " " + JSON.stringify([...el.getClientRects()].map(round))); |
| + } |
| + const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); |
| + for (let n = walk.nextNode(); n; n = walk.nextNode()) { |
| + if (!n.data.trim()) continue; |
| + const range = document.createRange(); |
| + range.selectNodeContents(n); |
| + boxes.push("#text " + JSON.stringify(n.data.trim()) + " " + JSON.stringify([...range.getClientRects()].map(round))); |
| + } |
| + return JSON.stringify({boxes, text: document.body.innerText}); |
| +})()` |
| + |
| +type layoutReading struct { |
| + Boxes []string |
| + Text string |
| +} |
| + |
| +// disclosureState puts a document's disclosures into state k and |
| +// resolves once the page has drawn it: -1 closes every <details>, -2 |
| +// opens every one, and k >= 0 opens the k-th one, the disclosures it |
| +// sits in, and whatever else it takes for it to be seen, with every |
| +// other closed. |
| +const disclosureState = `k => new Promise(done => { |
| + const all = [...document.querySelectorAll("details")]; |
| + const chain = d => { const c = []; for (; d; d = d.parentElement && d.parentElement.closest("details")) c.unshift(d); return c; }; |
| + const shown = d => (d.querySelector(":scope > summary") || d).getClientRects().length > 0; |
| + for (const d of all) d.open = false; |
| + if (k === -2) for (const d of all) d.open = true; |
| + if (k >= 0) { |
| + const target = all[k], open = chain(target); |
| + for (const d of open) d.open = true; |
| + const names = new Set(open.map(d => d.name).filter(Boolean)); |
| + for (const d of all) { |
| + if (shown(target)) break; |
| + const more = chain(d).filter(x => !open.includes(x)); |
| + if (!more.length || target.contains(d) || more.some(x => x.name && names.has(x.name))) continue; |
| + for (const x of more) x.open = true; |
| + if (shown(target)) { |
| + open.push(...more); |
| + for (const x of more) if (x.name) names.add(x.name); |
| + } else { |
| + for (const x of more) x.open = false; |
| + } |
| + } |
| + } |
| + requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 0))); |
| +})` |
| + |
| +// TestTheHTMLTabRendersExactlyLikeThePreview: codeview.Format puts a |
| +// child on a line of its own only where whitespace cannot change what |
| +// renders. Every component state whose formatted markup differs from |
| +// the seeded body is rendered twice, as the frame holds it and as the |
| +// HTML tab lays it out, each inside the body's own wrapper, and every |
| +// element and run of text has to land in the same place within half a |
| +// pixel, at a desktop frame's width and a phone's, as loaded and in |
| +// every disclosure state. The Copy button copies the formatted text, |
| +// so this is what makes pasting it safe. |
| +func TestTheHTMLTabRendersExactlyLikeThePreview(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + theme := fiesta.ThemeNames()[0] |
| + docs := map[string]string{} |
| + type job struct{ name, raw, fmt string } |
| + var jobs []job |
| + serve := func(name string, e example.Entry, body string) string { |
| + t.Helper() |
| + doc, err := s.frame(exampleDocument(name, e.Scripts, "", body), theme, "en", e) |
| + if err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + return doc |
| + } |
| + var abbr example.Entry |
| + var abbrBody string |
| + for _, e := range s.m.Examples { |
| + if e.Document { |
| + continue |
| + } |
| + for _, st := range e.States { |
| + raw, err := example.Markup(fiesta.FS(), e.Name, st.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inner, err := unwrap(raw, e.Wrapper) |
| + if err != nil { |
| + t.Fatalf("%s/%s: %v", e.Name, st.ID, err) |
| + } |
| + i := strings.Index(raw, inner) |
| + if i < 0 { |
| + t.Fatalf("%s/%s: the unwrapped body is not a slice of the body", e.Name, st.ID) |
| + } |
| + formatted := raw[:i] + codeview.Format(inner) + raw[i+len(inner):] |
| + if e.Name == "format-abbr" { |
| + abbr, abbrBody = e, raw |
| + } |
| + if formatted == raw { |
| + continue |
| + } |
| + name := e.Name + "/" + st.ID |
| + base := "/fmt/" + name |
| + docs[base+"-raw.html"] = serve(name, e, raw) |
| + docs[base+"-fmt.html"] = serve(name, e, formatted) |
| + jobs = append(jobs, job{name, base + "-raw.html", base + "-fmt.html"}) |
| + } |
| + } |
| + // The control: the short-forms sample with a line break put between |
| + // "(" and its <abbr>, mid-sentence, where a space shows. A probe |
| + // that cannot tell that copy from the preview is measuring nothing. |
| + broken := strings.Replace(abbrBody, "(<abbr", "(\n<abbr", 1) |
| + if abbrBody == "" || broken == abbrBody { |
| + t.Fatal("the control's sentence is not in the short-forms sample") |
| + } |
| + docs["/fmt/control-raw.html"] = serve("control", abbr, abbrBody) |
| + docs["/fmt/control.html"] = serve("control", abbr, broken) |
| + // The second control is the same space where a reading taken on |
| + // load cannot see it: in content that is display: none until a |
| + // disclosure opens. |
| + hidden := `<style>details:not([open]) + div { display: none; }</style><details><summary>More</summary></details><div><p>Read the <abbr title="frequently asked questions">FAQ</abbr>first.</p></div>` |
| + docs["/fmt/hidden-raw.html"] = serve("hidden control", abbr, hidden) |
| + docs["/fmt/hidden.html"] = serve("hidden control", abbr, strings.Replace(hidden, "</abbr>first", "</abbr>\nfirst", 1)) |
| + if len(jobs) < 50 { |
| + t.Fatalf("only %d states change when formatted; the sample walk or the formatter has stopped", len(jobs)) |
| + } |
| + |
| + tree := treeHandler(t) |
| + rig := harness.New(t, func(string) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if doc, ok := docs[r.URL.Path]; ok { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Write([]byte(doc)) |
| + return |
| + } |
| + tree.ServeHTTP(w, r) |
| + }) |
| + }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 15*time.Minute) |
| + defer cancel() |
| + |
| + read := func(url string) []layoutReading { |
| + probe := func() layoutReading { |
| + var out layoutReading |
| + at(t, ctx, layoutProbe, &out) |
| + return out |
| + } |
| + var n int |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+url), |
| + chromedp.Evaluate(`new Promise(r => document.fonts.ready.then(() => requestAnimationFrame(() => requestAnimationFrame(r))))`, nil, awaitPromise), |
| + chromedp.Evaluate(`document.querySelectorAll("details").length`, &n)) |
| + out := []layoutReading{probe()} |
| + for k := -2; k < n; k++ { |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(%s)(%d)`, disclosureState, k), nil, awaitPromise)) |
| + out = append(out, probe()) |
| + } |
| + return out |
| + } |
| + mustRun(t, ctx, chromedp.EmulateViewport(900, 900)) |
| + if want, got := read("/fmt/control-raw.html"), read("/fmt/control.html"); sameReadings(want, got) == "" { |
| + t.Fatal("a space inside a sentence moved no box; this drive cannot see a formatter that changes rendering") |
| + } |
| + if want, got := read("/fmt/hidden-raw.html"), read("/fmt/hidden.html"); sameReadings(want, got) == "" { |
| + t.Fatal("a space inside a closed disclosure moved no box once opened; this drive cannot see a formatter that changes what a menu shows") |
| + } |
| + var compared, states int |
| + for _, width := range []int64{900, 390} { |
| + mustRun(t, ctx, chromedp.EmulateViewport(width, 900)) |
| + for _, j := range jobs { |
| + want, got := read(j.raw), read(j.fmt) |
| + compared++ |
| + states += len(want) |
| + if diff := sameReadings(want, got); diff != "" { |
| + t.Errorf("%s at %dpx: %s", j.name, width, diff) |
| + } |
| + } |
| + } |
| + t.Logf("compared %d samples in %d states, each rendered as written and as formatted", compared, states) |
| +} |
| + |
| +// sameReadings compares a document's readings state by state, and says |
| +// how the first difference shows; "" when there is none. |
| +func sameReadings(want, got []layoutReading) string { |
| + if len(want) != len(got) { |
| + return fmt.Sprintf("%d disclosure states in the preview, %d in the formatted copy", len(want), len(got)) |
| + } |
| + for s := range want { |
| + state := "as loaded" |
| + switch { |
| + case s == 1: |
| + state = "with every disclosure open" |
| + case s == 2: |
| + state = "with every disclosure closed" |
| + case s > 2: |
| + state = fmt.Sprintf("with disclosure %d open, and what shows it", s-3) |
| + } |
| + if got[s].Text != want[s].Text { |
| + return fmt.Sprintf("%s, the visible text differs\npreview: %q\n tab: %q", state, want[s].Text, got[s].Text) |
| + } |
| + if len(got[s].Boxes) != len(want[s].Boxes) { |
| + return fmt.Sprintf("%s, %d boxes in the preview, %d in the formatted copy", state, len(want[s].Boxes), len(got[s].Boxes)) |
| + } |
| + for k := range want[s].Boxes { |
| + if !sameBoxes(want[s].Boxes[k], got[s].Boxes[k]) { |
| + return fmt.Sprintf("%s, a box moved\npreview: %s\n tab: %s", state, want[s].Boxes[k], got[s].Boxes[k]) |
| + } |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// sameBoxes compares two probe lines: the same element or text, and |
| +// every rectangle within half a pixel. |
| +func sameBoxes(a, b string) bool { |
| + if a == b { |
| + return true |
| + } |
| + ia, ib := strings.LastIndex(a, " ["), strings.LastIndex(b, " [") |
| + if ia < 0 || ib < 0 || a[:ia] != b[:ib] { |
| + return false |
| + } |
| + var ra, rb [][]float64 |
| + if json.Unmarshal([]byte(a[ia+1:]), &ra) != nil || json.Unmarshal([]byte(b[ib+1:]), &rb) != nil || len(ra) != len(rb) { |
| + return false |
| + } |
| + for i := range ra { |
| + for k := range ra[i] { |
| + if math.Abs(ra[i][k]-rb[i][k]) > 0.5 { |
| + return false |
| + } |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/gallery/example_page.go b/internal/gallery/example_page.go| index 1ad83d6..8fee8a1 100644 |
| --- a/internal/gallery/example_page.go |
| +++ b/internal/gallery/example_page.go |
| @@ -13,12 +13,18 @@ import ( |
| // learn of a new document the day a later task adds one here. |
| // frameFiles is the paths, under the theme and locale directory, of |
| -// one example's frame documents: one per state. |
| +// one example's frame documents: one per state, and for a grouped |
| +// example the row document its one widget frames as well. The |
| +// per-state documents of a grouped example are still written, because |
| +// the accessibility gate scans one state at a time. |
| func (s *site) frameFiles(e example.Entry) []string { |
| var out []string |
| for _, st := range e.States { |
| out = append(out, "frames/"+e.Name+"/"+st.ID+".html") |
| } |
| + if grouped[e.Name] { |
| + out = append(out, "frames/"+e.Name+"/row.html") |
| + } |
| return out |
| } |
| @@ -32,29 +38,70 @@ type stateView struct { |
| Routes string |
| } |
| +// groupNote is one state under a grouped widget: its id, its label on |
| +// a line of its own, and its note where the prose has one. The two |
| +// are separate elements rather than one string joined with ": ", |
| +// because that joiner is copy no review saw, and an ASCII colon is |
| +// wrong punctuation in ja, zh-Hans and yue. |
| +type groupNote struct { |
| + ID, State, Note string |
| +} |
| + |
| // exampleBody is one example's page: its family as a kicker, its own |
| // sentence, the two sentences every example page says, then every |
| // state as a frame of its own document with the note on the state |
| // under its label, the warning where the prose has one, the widget, |
| -// and the routes it stands for. |
| +// and the routes it stands for; or, for a grouped example, one widget |
| +// and a block per state under it. A layout's page ends with the button |
| +// that opens the shell's document in a tab of its own. |
| const exampleBody = `{{define "ds-body-example"}}<p class="ds-kicker"><b>{{.Family.Title}}</b> {{.Family.Blurb}}</p> |
| {{if .Blurb}}<p rst-page-header-sub>{{.Blurb}}</p> |
| {{end}}<p class="ds-lead">{{say "gallery.example.live"}} {{say "gallery.example.html"}}</p> |
| -{{range .States}}<section class="ds-sample" id="{{.ID}}" data-ds-anchor data-gallery-state="{{$.Entry.Name}}/{{.ID}}"> |
| +{{with .Group}}<section class="ds-sample ds-sample--rows" id="{{$.Entry.Name}}" data-ds-anchor> |
| +{{template "ds-view" .}} |
| +{{range $.Notes}}<div class="ds-group-note" id="{{.ID}}" data-ds-anchor data-gallery-state="{{$.Entry.Name}}/{{.ID}}"><b class="ds-note__state">{{.State}}</b> |
| +{{if .Note}}<p class="ds-note">{{.Note}}</p> |
| +{{end}}</div> |
| +{{end}}</section> |
| +{{else}}{{range .States}}<section class="ds-sample" id="{{.ID}}" data-ds-anchor data-gallery-state="{{$.Entry.Name}}/{{.ID}}"> |
| <h2 class="ds-state">{{.Label}}</h2> |
| {{if .Note}}<p class="ds-note">{{.Note}}</p> |
| {{end}}{{with .Warning}}<div rst-callout rst-tone="warning"><span rst-callout-ic aria-hidden="true">{{icon "alert-triangle"}}</span><div rst-callout-body><strong>{{.Title}}</strong><p>{{.Body}}</p></div></div> |
| {{end}}{{template "ds-view" .Preview}} |
| {{if .Routes}}<p class="ds-routes-note">{{.Routes}}</p> |
| {{end}}</section> |
| +{{end}}{{end}}{{if .Shell}}<p><a rst-btn data-gallery-nav href="{{.ShellHref}}" target="_blank" rel="noopener">{{say "gallery.shell.open" "shell" .Shell}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span></a></p> |
| {{end}}{{end}}` |
| +// shellOf is the shell a layout example documents, by the name its |
| +// open button says: column, topbar, sidebar, console or stage. The |
| +// -page members of the sidebar and console pairs are the same shell's |
| +// content page and get no button; a component is no shell at all. |
| +func shellOf(e example.Entry) string { |
| + if !e.Document || strings.HasSuffix(e.Name, "-page") { |
| + return "" |
| + } |
| + return strings.TrimPrefix(e.Name, "layout-") |
| +} |
| + |
| // example renders one example's page body and its frame documents, |
| // keyed by the paths frameFiles names. |
| func (s *site) example(p page, e example.Entry) (body []byte, frames map[string][]byte, err error) { |
| ex := s.prose.Examples[e.Name] |
| frames = map[string][]byte{} |
| - var states []stateView |
| + data := struct { |
| + page |
| + Family family |
| + Entry example.Entry |
| + Blurb string |
| + States []stateView |
| + Group *previewView |
| + Notes []groupNote |
| + Shell string |
| + ShellHref string |
| + }{page: p, Entry: e, Blurb: s.proseIn(p.Locale, "examples."+e.Name+".blurb", ex.Blurb)} |
| + f, _ := s.familyByKey(e.Family) |
| + data.Family = s.familyIn(p.Locale, f) |
| for _, st := range e.States { |
| doc, err := example.Document(s.src, e.Name, st.ID) |
| if err != nil { |
| @@ -66,6 +113,11 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| } |
| rel := "frames/" + e.Name + "/" + st.ID + ".html" |
| frames[rel] = []byte(fr) |
| + note := s.proseIn(p.Locale, "examples."+e.Name+".states."+st.ID, ex.States[st.ID]) |
| + if grouped[e.Name] { |
| + data.Notes = append(data.Notes, groupNote{ID: st.ID, State: s.label(p.Locale, e, st), Note: note}) |
| + continue |
| + } |
| code := doc |
| if !e.Document { |
| if code, err = example.Markup(s.src, e.Name, st.ID); err != nil { |
| @@ -76,12 +128,7 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| if err != nil { |
| return nil, nil, fmt.Errorf("%s/%s: %w", e.Name, st.ID, err) |
| } |
| - sv := stateView{ |
| - ID: st.ID, |
| - Label: s.label(p.Locale, e, st), |
| - Note: s.proseIn(p.Locale, "examples."+e.Name+".states."+st.ID, ex.States[st.ID]), |
| - Preview: view, |
| - } |
| + sv := stateView{ID: st.ID, Label: s.label(p.Locale, e, st), Note: note, Preview: view} |
| if len(st.SampleURLs) > 0 { |
| sv.Routes = s.say(p.Locale, "gallery.routes", "routes", strings.Join(st.SampleURLs, ", ")) |
| } |
| @@ -92,16 +139,24 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| Body: s.proseIn(p.Locale, slot+".body", w.Body), |
| } |
| } |
| - states = append(states, sv) |
| + data.States = append(data.States, sv) |
| } |
| - f, _ := s.familyByKey(e.Family) |
| - out, err := s.body(p.Locale, "ds-body-example", struct { |
| - page |
| - Family family |
| - Entry example.Entry |
| - Blurb string |
| - States []stateView |
| - }{p, s.familyIn(p.Locale, f), e, s.proseIn(p.Locale, "examples."+e.Name+".blurb", ex.Blurb), states}) |
| + if grouped[e.Name] { |
| + view, rowDoc, err := s.groupedPreview(p, e) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + fr, err := s.frame(rowDoc, p.Theme, p.Locale, e) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("%s/row: %w", e.Name, err) |
| + } |
| + frames["frames/"+e.Name+"/row.html"] = []byte(fr) |
| + data.Group = &view |
| + } |
| + if data.Shell = shellOf(e); data.Shell != "" { |
| + data.ShellHref = s.href(p.Theme, p.Locale, "frames/"+e.Name+"/"+e.Default+".html") |
| + } |
| + out, err := s.body(p.Locale, "ds-body-example", data) |
| if err != nil { |
| return nil, nil, err |
| } |
diff --git a/internal/gallery/frame_scheme_browser_test.go b/internal/gallery/frame_scheme_browser_test.go| new file mode 100644 |
| index 0000000..f4dcf9b |
| --- /dev/null |
| +++ b/internal/gallery/frame_scheme_browser_test.go |
| @@ -0,0 +1,598 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "fmt" |
| + "image/png" |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + cdppage "github.com/chromedp/cdproto/page" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// Frames hidden until styled, and shown in the page's scheme: the |
| +// arm, seek and watch half of view.js, driven with a frame document |
| +// held at each of the moments a reader could see the wrong thing. |
| +// Rastrillo's drives; the held document is served without frame.js, |
| +// because frame.js copies the parent's scheme before the first paint |
| +// and these legs are about the window a document without one opens, |
| +// which is every document a navigation inside a frame loads from |
| +// elsewhere. |
| + |
| +// gate is one held response: a handler blocks on it until the leg has |
| +// looked at the state the hold keeps open. Opened at most once. A nil |
| +// gate holds nothing. |
| +type gate struct { |
| + c chan struct{} |
| + once sync.Once |
| +} |
| + |
| +func newGate() *gate { return &gate{c: make(chan struct{})} } |
| + |
| +func (g *gate) open() { |
| + if g != nil { |
| + g.once.Do(func() { close(g.c) }) |
| + } |
| +} |
| + |
| +func (g *gate) wait() { |
| + if g != nil { |
| + <-g.c |
| + } |
| +} |
| + |
| +// holds are the places heldFrames can hold the first frame on a page: |
| +// start, its document before a byte of it is sent; sheet, the |
| +// stylesheets it links; parse, the rest of its document after the |
| +// body's first paragraph; load, an image in that rest; next and |
| +// nextLoad, the same two for the document a navigation inside the |
| +// frame fetches with ?next. |
| +type holds struct{ start, sheet, parse, load, next, nextLoad *gate } |
| + |
| +func (h *holds) release() { |
| + for _, g := range []*gate{h.start, h.sheet, h.parse, h.load, h.next, h.nextLoad} { |
| + g.open() |
| + } |
| +} |
| + |
| +const held, heldNext = mountPath + "/held.png", mountPath + "/held-next.png" |
| + |
| +var ( |
| + firstFrame = regexp.MustCompile(`<iframe class="ds-view__frame"[^>]*\ssrc="([^"]+)"`) |
| + frameScript = regexp.MustCompile(`<script src="[^"]*/frame\.[0-9a-f]{16}\.js"></script>\n?`) |
| +) |
| + |
| +// heldFrames serves the gallery with the first preview frame on target |
| +// held at h. Its document is streamed up to its body and one paragraph |
| +// before the rest, so a parse hold has a body to look at. |
| +func heldFrames(t *testing.T, target string, h *holds) (*harness.Rig, string) { |
| + t.Helper() |
| + files := render(t, mountPath) |
| + m := firstFrame.FindSubmatch(files[strings.TrimPrefix(target, mountPath+"/")]) |
| + if m == nil { |
| + t.Fatalf("no preview frame on %s", target) |
| + } |
| + frameSrc := string(m[1]) |
| + rig := harness.New(t, func(string) http.Handler { |
| + tree := galleryrig.Tree(t, files, mountPath+"/") |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch r.URL.Path { |
| + case held: |
| + h.load.wait() |
| + http.NotFound(w, r) |
| + return |
| + case heldNext: |
| + h.nextLoad.wait() |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if r.URL.RawQuery == "held" { |
| + h.sheet.wait() |
| + } |
| + if r.URL.Path != frameSrc { |
| + tree.ServeHTTP(w, r) |
| + return |
| + } |
| + rec := httptest.NewRecorder() |
| + tree.ServeHTTP(rec, r) |
| + body := frameScript.ReplaceAllString(rec.Body.String(), "") |
| + hold, img := h.parse, held |
| + if r.URL.RawQuery == "next" { |
| + hold, img = h.next, heldNext |
| + } else { |
| + h.start.wait() |
| + if h.sheet != nil { |
| + body = strings.ReplaceAll(body, `.css">`, `.css?held">`) |
| + } |
| + } |
| + loc := bodyTag.FindStringIndex(body) |
| + if loc == nil { |
| + t.Errorf("the held document has no <body>") |
| + return |
| + } |
| + tag := `<img src="` + img + `" alt="" width="1" height="1">` |
| + body = body[:loc[1]] + tag + body[loc[1]:] |
| + cut := loc[1] + len(tag) |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Write([]byte(body[:cut] + "<p>Held.</p>")) |
| + w.(http.Flusher).Flush() |
| + hold.wait() |
| + w.Write([]byte(body[cut:])) |
| + }) |
| + }) |
| + rig.Allow(http.MethodGet, held, http.StatusNotFound) |
| + rig.Allow(http.MethodGet, heldNext, http.StatusNotFound) |
| + return rig, frameSrc |
| +} |
| + |
| +// openChosen sets the gallery's scheme to chosen with the OS in os |
| +// (system is no stored choice), then opens target without waiting for |
| +// its load, which a held frame holds back. |
| +func openChosen(t *testing.T, ctx context.Context, rig *harness.Rig, os, chosen, target string) { |
| + t.Helper() |
| + mustRun(t, ctx, |
| + emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: os}}), |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/index.html")), |
| + chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "`+chosen+`"); if ("`+chosen+`" === "system") localStorage.removeItem("fiesta-scheme"); true`, nil), |
| + chromedp.Navigate("about:blank"), |
| + ) |
| + if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| + _, _, _, _, err := cdppage.Navigate(rig.Origin + target).Do(ctx) |
| + return err |
| + })); err != nil { |
| + t.Fatalf("opening the page: %v", err) |
| + } |
| +} |
| + |
| +// requireHeldPage waits for a page opened without waiting on its load |
| +// (a held frame holds that back) to be parsed, then requires the |
| +// widget on it. |
| +func requireHeldPage(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + until(t, ctx, where+", the page parsed", `document.readyState !== "loading" && !!document.querySelector(".ds-view__frame")`) |
| + requireWidget(t, ctx, where) |
| +} |
| + |
| +// schemeApplied is the page's own script having written the chosen |
| +// scheme on <html>, so a reading of the page's colour is of the chosen |
| +// one and not of the moment before the deferred script ran. |
| +func schemeApplied(chosen string) string { |
| + if chosen == "system" { |
| + return `!document.documentElement.hasAttribute("data-theme")` |
| + } |
| + return `document.documentElement.getAttribute("data-theme") === ` + strconv.Quote(chosen) |
| +} |
| + |
| +func twoFrames(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise)) |
| +} |
| + |
| +// frameReading is one look at the first frame: the background its own |
| +// document paints, the page's, whether the frame is visible, its own |
| +// color-scheme, the pixel the reader gets inside the frame's box, and |
| +// how many pixels across the whole box differ from the page's |
| +// background. |
| +type frameReading struct { |
| + Frame, Page, Seen, Shown, Scheme string |
| + X, Y float64 |
| + Box [4]float64 |
| + Off int |
| +} |
| + |
| +func readFrame(t *testing.T, ctx context.Context, where string, r *frameReading) { |
| + t.Helper() |
| + var shot []byte |
| + at(t, ctx, `(() => { |
| + const f = document.querySelector(".ds-view__frame"), b = f.getBoundingClientRect(), x = f.parentElement.getBoundingClientRect(); |
| + const rgb = s => s.replace(/\s+/g, ""); |
| + return JSON.stringify({Frame: rgb(getComputedStyle(f.contentDocument.body).backgroundColor), |
| + Page: rgb(getComputedStyle(document.body).backgroundColor), Shown: getComputedStyle(f).visibility, |
| + Scheme: getComputedStyle(f).colorScheme, X: b.left + b.width / 2, Y: b.top + 8, |
| + Box: [x.left, x.top, x.right, Math.min(x.bottom, innerHeight)]}); |
| + })()`, r) |
| + mustRun(t, ctx, chromedp.CaptureScreenshot(&shot)) |
| + img, err := png.Decode(bytes.NewReader(shot)) |
| + if err != nil { |
| + t.Fatalf("%s: decoding the screenshot: %v", where, err) |
| + } |
| + pixel := func(x, y int) string { |
| + cr, cg, cb, _ := img.At(x, y).RGBA() |
| + return fmt.Sprintf("rgb(%d,%d,%d)", cr>>8, cg>>8, cb>>8) |
| + } |
| + r.Seen = pixel(int(r.X), int(r.Y)) |
| + // Inset past the box's border, its rounded corners and the resize |
| + // grip in its end corner, which are the page's own drawing. |
| + const inset = 6 |
| + for y := int(r.Box[1]) + inset; y < int(r.Box[3])-inset; y++ { |
| + for x := int(r.Box[0]) + inset; x < int(r.Box[2])-inset; x++ { |
| + if y > int(r.Box[3])-20 && (x > int(r.Box[2])-20 || x < int(r.Box[0])+20) { |
| + continue |
| + } |
| + if pixel(x, y) != r.Page { |
| + r.Off++ |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestAFrameNeverShowsTheOtherSchemeWhileItLoads: a frame never shows |
| +// the reader the other scheme, and shows as soon as it can be painted. |
| +// Held half-parsed, the reader sees the page's background (the |
| +// control proves the frame's own document is in the other scheme); |
| +// parsed with an image still loading, it shows, painted; loaded, it |
| +// still shows. |
| +func TestAFrameNeverShowsTheOtherSchemeWhileItLoads(t *testing.T) { |
| + for _, c := range []struct{ os, chosen string }{{"light", "dark"}, {"dark", "light"}} { |
| + where := "the OS in " + c.os + ", the gallery in " + c.chosen |
| + t.Run(c.os+"-os-"+c.chosen+"-gallery", func(t *testing.T) { |
| + h := &holds{parse: newGate(), load: newGate()} |
| + defer h.release() |
| + target := href(fiesta.ThemeNames()[0] + "/en/field.html") |
| + rig, frameSrc := heldFrames(t, target, h) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + openChosen(t, ctx, rig, c.os, c.chosen, target) |
| + requireHeldPage(t, ctx, where) |
| + first := `document.querySelector(".ds-view__frame")` |
| + until(t, ctx, where+", the frame half-parsed", |
| + `(() => { const d = `+first+`.contentDocument; return `+schemeApplied(c.chosen)+` && !!d && d.URL.endsWith(`+strconv.Quote(frameSrc)+`) && d.readyState === "loading" && !!d.body; })()`) |
| + twoFrames(t, ctx, where) |
| + var during frameReading |
| + readFrame(t, ctx, where+", while it parses", &during) |
| + if during.Frame == during.Page { |
| + t.Fatalf("%s: the half-parsed frame's document is already painted %s, the gallery's colour; the window this leg is about was never open", where, during.Frame) |
| + } |
| + if during.Seen != during.Page { |
| + t.Errorf("%s: while the frame parses the reader sees %s in its box, the frame's own %s scheme, on a page painted %s", where, during.Seen, c.os, during.Page) |
| + } |
| + |
| + h.parse.open() |
| + until(t, ctx, where+", parsed with its load held", first+`.contentDocument.readyState === "interactive"`) |
| + twoFrames(t, ctx, where) |
| + var parsed frameReading |
| + readFrame(t, ctx, where+", parsed", &parsed) |
| + if parsed.Frame != parsed.Page || parsed.Seen != parsed.Page || parsed.Shown != "visible" { |
| + t.Errorf("%s: once parsed, with an image still loading, the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", where, parsed.Frame, parsed.Seen, parsed.Shown, parsed.Page) |
| + } |
| + |
| + h.load.open() |
| + until(t, ctx, where+", loaded", first+`.contentDocument.readyState === "complete"`) |
| + twoFrames(t, ctx, where) |
| + var after frameReading |
| + readFrame(t, ctx, where+", loaded", &after) |
| + if after.Frame != after.Page || after.Seen != after.Page || after.Shown != "visible" { |
| + t.Errorf("%s: once loaded the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", where, after.Frame, after.Seen, after.Shown, after.Page) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAFrameShowsNothingUntilItsDocumentIsStyled: on System, a frame |
| +// shows the reader nothing but the page's own background while it |
| +// still holds about:blank and while its document is parsed and |
| +// unstyled; with the sheets let through it shows, in the page's |
| +// colour. The frame's color-scheme is normal, so Safari paints the |
| +// blank frame transparent rather than white. |
| +func TestAFrameShowsNothingUntilItsDocumentIsStyled(t *testing.T) { |
| + for _, os := range []string{"dark", "light"} { |
| + where := "the OS in " + os + ", the gallery on System" |
| + t.Run(os+"-os-system-gallery", func(t *testing.T) { |
| + h := &holds{start: newGate(), sheet: newGate()} |
| + defer h.release() |
| + target := href(fiesta.ThemeNames()[0] + "/en/field.html") |
| + rig, frameSrc := heldFrames(t, target, h) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + openChosen(t, ctx, rig, os, "system", target) |
| + requireHeldPage(t, ctx, where) |
| + first := `document.querySelector(".ds-view__frame")` |
| + until(t, ctx, where+", the frame still blank", |
| + `(() => { const f = `+first+`; return !!f && !!f.contentDocument && f.contentDocument.URL === "about:blank" && !document.documentElement.hasAttribute("data-theme"); })()`) |
| + twoFrames(t, ctx, where) |
| + var blank frameReading |
| + readFrame(t, ctx, where+", still about:blank", &blank) |
| + if blank.Shown != "hidden" || blank.Off != 0 { |
| + t.Errorf("%s: while the frame still holds about:blank it is %s, and %d pixels in its box are not the page's %s", where, blank.Shown, blank.Off, blank.Page) |
| + } |
| + if blank.Scheme != "normal" { |
| + t.Errorf("%s: the frame element's color-scheme is %q; anything that can resolve dark has Safari paint the blank frame white", where, blank.Scheme) |
| + } |
| + |
| + h.start.open() |
| + until(t, ctx, where+", parsed with its stylesheets held", |
| + `(() => { const d = `+first+`.contentDocument; return !!d && d.URL.endsWith(`+strconv.Quote(frameSrc)+`) && d.readyState !== "loading"; })()`) |
| + twoFrames(t, ctx, where) |
| + var bare frameReading |
| + readFrame(t, ctx, where+", parsed, unstyled", &bare) |
| + if bare.Frame != "rgba(0,0,0,0)" { |
| + t.Fatalf("%s: the parsed frame's body is already %s; its stylesheets were not held, and the window this leg is about was never open", where, bare.Frame) |
| + } |
| + if bare.Shown != "hidden" || bare.Off != 0 { |
| + t.Errorf("%s: parsed with its stylesheets still loading, the frame is %s, and %d pixels in its box are not the page's %s; its unstyled document would show", where, bare.Shown, bare.Off, bare.Page) |
| + } |
| + |
| + h.sheet.open() |
| + until(t, ctx, where+", shown", `getComputedStyle(`+first+`).visibility === "visible"`) |
| + twoFrames(t, ctx, where) |
| + var shown frameReading |
| + readFrame(t, ctx, where+", styled", &shown) |
| + if shown.Frame != shown.Page || shown.Seen != shown.Page { |
| + t.Errorf("%s: once styled the frame must show in the page's scheme: its document is %s, the reader sees %s, the page is %s", where, shown.Frame, shown.Seen, shown.Page) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAFrameNeverMovesTheReader: a frame never takes the reader's |
| +// focus, or moves the page, on its own. The stage layout's document |
| +// puts autofocus on its email field, which is right for the page and |
| +// wrong inside the gallery; frame.js holds it inert until after load, |
| +// and lets it go after, so a reader can still use it. |
| +func TestAFrameNeverMovesTheReader(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] |
| + stage := `document.querySelector('.ds-view__frame[src=` + strconv.Quote(href(frameFile(theme, "en", "layout-stage", mustDefault(t, "layout-stage")))) + `]')` |
| + // A short window, so the field inside the stage is under the fold |
| + // and focusing it would have to scroll the page; the frame is still |
| + // within the distance a lazy frame loads at. |
| + openPage(t, ctx, "layout-stage at 1280x300", pageURL(rig, "en", "layout-stage.html"), 1280, 300) |
| + until(t, ctx, "the stage layout loaded", |
| + `(() => { const f = `+stage+`, d = f && f.contentDocument; return !!d && d.URL.endsWith("/default.html") && d.readyState === "complete" && f.hasAttribute("data-gallery-painted"); })()`) |
| + twoFrames(t, ctx, "the stage layout") |
| + var got struct { |
| + Autofocus, Inert bool |
| + Y, FieldTop float64 |
| + Active string |
| + } |
| + mustRun(t, ctx, chromedp.Sleep(200*time.Millisecond)) |
| + at(t, ctx, `(async () => { const f = `+stage+`, i = f.contentDocument.querySelector("[autofocus]"); |
| + const k = new DOMMatrix(getComputedStyle(f).transform).a || 1; |
| + return JSON.stringify({Autofocus: (await (await fetch(f.src)).text()).includes(" autofocus"), Y: scrollY, |
| + Inert: i ? i.inert : false, FieldTop: i ? f.getBoundingClientRect().top + i.getBoundingClientRect().top * k : -1, |
| + Active: document.activeElement === document.body ? "body" : document.activeElement.outerHTML.slice(0, 120)}); })()`, &got, awaitPromise) |
| + if !got.Autofocus || got.FieldTop < 0 { |
| + t.Fatalf("the stage layout is served with no autofocus field; this leg needs a framed document that asks for focus") |
| + } |
| + if got.FieldTop < 300 { |
| + t.Fatalf("the autofocus field is at %.0fpx in a 300px window; focusing it would not have to scroll, so the leg proves nothing", got.FieldTop) |
| + } |
| + if got.Active != "body" || got.Y != 0 { |
| + t.Errorf("the stage layout's frame took the reader: focus is on %s, and the page moved to %v", got.Active, got.Y) |
| + } |
| + if got.Inert { |
| + t.Errorf("the stage layout's field is still inert after its frame loaded; the reader cannot use it") |
| + } |
| +} |
| + |
| +// TestAFrameHidesWhileItNavigates: a navigation inside a frame gets the |
| +// same hiding and the same showing as the first load. Two legs, one per |
| +// document the navigation leaves: one whose load has fired, and one |
| +// shown at parse whose image is still held. |
| +func TestAFrameHidesWhileItNavigates(t *testing.T) { |
| + const os, chosen = "light", "dark" |
| + for _, c := range []struct { |
| + name string |
| + loaded bool |
| + }{{"from a loaded document", true}, {"from a document still loading", false}} { |
| + t.Run(strings.ReplaceAll(c.name, " ", "-"), func(t *testing.T) { |
| + h := &holds{load: newGate(), next: newGate(), nextLoad: newGate()} |
| + defer h.release() |
| + ready := `.contentDocument.readyState === "interactive"` |
| + if c.loaded { |
| + h.load.open() |
| + ready = `.contentDocument.readyState === "complete"` |
| + } |
| + target := href(fiesta.ThemeNames()[0] + "/en/field.html") |
| + rig, frameSrc := heldFrames(t, target, h) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + openChosen(t, ctx, rig, os, chosen, target) |
| + requireHeldPage(t, ctx, c.name) |
| + first := `document.querySelector(".ds-view__frame")` |
| + until(t, ctx, c.name+": the first document shown", |
| + schemeApplied(chosen)+` && !!`+first+`.contentDocument && `+first+`.contentDocument.URL.endsWith(`+strconv.Quote(frameSrc)+`) && `+first+ready+` && `+first+`.hasAttribute("data-gallery-painted")`) |
| + |
| + mustRun(t, ctx, chromedp.Evaluate(first+`.contentWindow.location.href = `+strconv.Quote(frameSrc+"?next")+`, true`, nil)) |
| + until(t, ctx, c.name+": the next document half-parsed", |
| + `(() => { const d = `+first+`.contentDocument; return !!d && d.URL.endsWith("?next") && d.readyState === "loading" && !!d.body; })()`) |
| + twoFrames(t, ctx, c.name) |
| + var during frameReading |
| + readFrame(t, ctx, c.name+": the next document, while it parses", &during) |
| + if during.Frame == during.Page { |
| + t.Fatalf("%s: the half-parsed next document is already painted %s, the gallery's colour; the window this leg is about was never open", c.name, during.Frame) |
| + } |
| + if during.Seen != during.Page { |
| + t.Errorf("%s: while the frame's next document parses the reader sees %s in its box, the OS's %s scheme, on a page painted %s", c.name, during.Seen, os, during.Page) |
| + } |
| + |
| + h.next.open() |
| + until(t, ctx, c.name+": the next document parsed with its load held", |
| + first+`.contentDocument.URL.endsWith("?next") && `+first+`.contentDocument.readyState === "interactive"`) |
| + twoFrames(t, ctx, c.name) |
| + var parsed frameReading |
| + readFrame(t, ctx, c.name+": the next document, parsed", &parsed) |
| + if parsed.Frame != parsed.Page || parsed.Seen != parsed.Page || parsed.Shown != "visible" { |
| + t.Errorf("%s: once the next document is parsed, with an image still loading, the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", c.name, parsed.Frame, parsed.Seen, parsed.Shown, parsed.Page) |
| + } |
| + |
| + h.nextLoad.open() |
| + until(t, ctx, c.name+": the next document loaded", first+`.contentDocument.URL.endsWith("?next") && `+first+`.contentDocument.readyState === "complete"`) |
| + twoFrames(t, ctx, c.name) |
| + var after frameReading |
| + readFrame(t, ctx, c.name+": the next document, loaded", &after) |
| + if after.Frame != after.Page || after.Seen != after.Page || after.Shown != "visible" { |
| + t.Errorf("%s: once the next document loads the frame must show in the gallery's scheme: its document is %s, the reader sees %s (visibility %s), the page is %s", c.name, after.Frame, after.Seen, after.Shown, after.Page) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAFrameStopsLookingForItsNextDocument: after pagehide the gallery |
| +// looks for the frame's next document every 16ms; the look ends when |
| +// the frame leaves the page, and gives up after a bound, showing the |
| +// frame unpainted. The frame is sent to a document the gallery cannot |
| +// read (a data: URL) whose load hangs on a held image. |
| +func TestAFrameStopsLookingForItsNextDocument(t *testing.T) { |
| + const os, chosen = "light", "dark" |
| + const count = `(() => { |
| + const set = window.setTimeout; |
| + window.dsLooks = 0; |
| + window.setTimeout = function (fn, ms) { |
| + if (ms === 16) window.dsLooks++; |
| + return set.apply(this, arguments); |
| + }; |
| + })()` |
| + for _, c := range []struct { |
| + name string |
| + remove bool |
| + }{{"a navigation that never loads", false}, {"a frame removed while it navigates", true}} { |
| + t.Run(strings.ReplaceAll(c.name, " ", "-"), func(t *testing.T) { |
| + h := &holds{nextLoad: newGate()} |
| + defer h.release() |
| + target := href(fiesta.ThemeNames()[0] + "/en/field.html") |
| + rig, frameSrc := heldFrames(t, target, h) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, addInit(count)) |
| + openChosen(t, ctx, rig, os, chosen, target) |
| + requireHeldPage(t, ctx, c.name) |
| + first := `document.querySelector(".ds-view__frame")` |
| + until(t, ctx, c.name+": the first document shown", |
| + `!!`+first+`.contentDocument && `+first+`.contentDocument.URL.endsWith(`+strconv.Quote(frameSrc)+`) && `+first+`.contentDocument.readyState === "complete" && `+first+`.hasAttribute("data-gallery-painted")`) |
| + |
| + elsewhere := `<!doctype html><p>Elsewhere.</p><img src="` + rig.Origin + heldNext + `" alt="" width="1" height="1">` |
| + mustRun(t, ctx, chromedp.Evaluate(first+`.src = "data:text/html," + encodeURIComponent(`+strconv.Quote(elsewhere)+`), true`, nil)) |
| + until(t, ctx, c.name+": looking for the next document", `window.dsLooks > 3 && !`+first+`.hasAttribute("data-gallery-painted")`) |
| + |
| + if c.remove { |
| + mustRun(t, ctx, chromedp.Evaluate(first+`.remove(), true`, nil)) |
| + } else { |
| + until(t, ctx, c.name+": the frame shown once the look gives up", |
| + first+`.hasAttribute("data-gallery-painted") && getComputedStyle(`+first+`).visibility === "visible"`) |
| + } |
| + var before, after int |
| + mustRun(t, ctx, |
| + chromedp.Sleep(100*time.Millisecond), |
| + chromedp.Evaluate(`window.dsLooks`, &before), |
| + chromedp.Sleep(500*time.Millisecond), |
| + chromedp.Evaluate(`window.dsLooks`, &after), |
| + ) |
| + if after != before { |
| + t.Errorf("%s: the gallery still looks for the frame's next document, %d times in half a second", c.name, after-before) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// switchReadJS reads, on the next animation frame (a frame later with |
| +// later, for the OS flip), the page's scheme and background and every |
| +// frame's scheme attribute and background, so a flip that lands in |
| +// one frame reads the same as the settled state. |
| +func switchReadJS(later bool) string { |
| + read := `new Promise(r => requestAnimationFrame(() => r(JSON.stringify({ |
| + Theme: document.documentElement.getAttribute("data-theme") || "", |
| + Page: getComputedStyle(document.body).backgroundColor, |
| + Frames: [...document.querySelectorAll(".ds-view__frame")].map(f => { const d = f.contentDocument; return d ? (d.documentElement.getAttribute("data-theme") || "") + " " + getComputedStyle(d.body).backgroundColor : "?"; }) |
| + }))))` |
| + if later { |
| + return `new Promise(r => requestAnimationFrame(() => r(` + read + `)))` |
| + } |
| + return read |
| +} |
| + |
| +// TestTheGallerySwitchLandsOnTheNextFrame: a scheme chosen in the |
| +// header lands in the frame it happens in, on the page and in every |
| +// framed document alike: read on the very next animation frame, the |
| +// page and the frames are already at the values they settle to. |
| +// Through the OS flipping under System, a frame later. |
| +func TestTheGallerySwitchLandsOnTheNextFrame(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 := "field at 1280px" |
| + osScheme := func(scheme string) chromedp.Action { |
| + return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: scheme}}) |
| + } |
| + mustRun(t, ctx, osScheme("light")) |
| + openPage(t, ctx, where, pageURL(rig, "en", "field.html"), 1280, 900) |
| + n := painted(t, ctx, where) |
| + type state struct { |
| + Theme, Page string |
| + Frames []string |
| + } |
| + read := func(leg, js string) state { |
| + t.Helper() |
| + var got state |
| + at(t, ctx, js, &got, awaitPromise) |
| + if len(got.Frames) != n { |
| + t.Fatalf("%s, %s: read %d frames, %d loaded", where, leg, len(got.Frames), n) |
| + } |
| + return got |
| + } |
| + choose := func(scheme string) state { |
| + t.Helper() |
| + return read("choosing "+scheme, `(() => { const menus = [...document.querySelectorAll("[rst-scheme]")], m = menus.find(m => m.checkVisibility()) || menus[0]; m.open = true; m.querySelector('input[value="`+scheme+`"] ~ button').click(); return `+switchReadJS(false)+`; })()`) |
| + } |
| + landed := func(leg string) state { |
| + t.Helper() |
| + got := read(leg, `(async () => { await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); return await `+switchReadJS(false)+`; })()`) |
| + t.Logf("%s, %s, landed: theme %q, page %s, frames %v", where, leg, got.Theme, got.Page, got.Frames[:1]) |
| + return got |
| + } |
| + check := func(leg string, got, want state) { |
| + t.Helper() |
| + if got.Theme != want.Theme { |
| + t.Fatalf("%s, %s: data-theme %q, want %q", where, leg, got.Theme, want.Theme) |
| + } |
| + if got.Page != want.Page { |
| + t.Errorf("%s, %s: the page is %s on the next frame; settled it is %s. The flip faded instead of landing.", where, leg, got.Page, want.Page) |
| + } |
| + for i := range got.Frames { |
| + if got.Frames[i] != want.Frames[i] { |
| + t.Errorf("%s, %s: frame %d is %q on the next frame; settled it is %q. The frames did not follow the switch in the same frame.", where, leg, i, got.Frames[i], want.Frames[i]) |
| + } |
| + } |
| + } |
| + choose("dark") |
| + dark := landed("Dark chosen") |
| + choose("light") |
| + light := landed("Light chosen") |
| + if dark.Page == light.Page || dark.Frames[0] == light.Frames[0] { |
| + t.Fatalf("%s: the page or the frames do not change with the scheme: %s / %s", where, dark.Page, dark.Frames[0]) |
| + } |
| + check("Light to Dark by the switch", choose("dark"), dark) |
| + landed("after Dark") |
| + check("Dark to Light by the switch", choose("light"), light) |
| + landed("after Light") |
| + system := choose("system") |
| + settled := landed("System, OS light") |
| + check("System chosen", system, settled) |
| + mustRun(t, ctx, osScheme("dark")) |
| + flipped := read("OS dark", switchReadJS(true)) |
| + settledDark := landed("System, OS dark") |
| + check("System, OS light to dark", flipped, settledDark) |
| + if settledDark.Page == settled.Page { |
| + t.Fatalf("%s: the OS flip changed nothing: the page is %s either way", where, settled.Page) |
| + } |
| + mustRun(t, ctx, osScheme("light")) |
| + check("System, OS dark to light", read("OS light", switchReadJS(true)), landed("System, OS light again")) |
| +} |
diff --git a/internal/gallery/heights.go b/internal/gallery/heights.go| new file mode 100644 |
| index 0000000..98b761a |
| --- /dev/null |
| +++ b/internal/gallery/heights.go |
| @@ -0,0 +1,252 @@ |
| +package gallery |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| +) |
| + |
| +// The preview heights: how tall each example's own document is, so the |
| +// box the widget draws it in has its height before the frame arrives, |
| +// scripts or no scripts, and a wrong number is a test failure rather |
| +// than a jump on screen. Measured, not guessed: the two height drives |
| +// in heights_browser_test.go read every frame's content off a real |
| +// engine and name the number to write here; FIESTA_SWEEP=full prints |
| +// the whole table. Keyed <example>/<state>, <example>/row for a |
| +// grouped widget, and demo for the demo application. |
| +// |
| +// Seeded once by the widget task to prove the measurement; calibrated |
| +// by the integration task once the shell, the frame padding and the |
| +// stage are in, which is when the drives start failing a short box, |
| +// and again after the samples are translated. |
| + |
| +// previewHeight is what an example gets when the table has nothing to |
| +// say about it. |
| +const previewHeight = 220 |
| + |
| +// previewHeights is each key's Desktop height, in CSS pixels at its |
| +// class's virtual width. A box deliberately taller than its content at |
| +// rest is the one a reader opens something in: a field whose script |
| +// opens a panel, a menu, the modal. |
| +var previewHeights = map[string]int{ |
| + "actor/a-person": 80, |
| + "actor/an-agent-acting-for-a-person": 80, |
| + "api-tokens/four-tokens-two-of-them-live": 930, |
| + "api-tokens/just-created-the-secret-shown-once": 1060, |
| + "api-tokens/no-tokens-yet": 670, |
| + "back-nav/back-to-a-record": 100, |
| + "badge/row": 100, |
| + "box/default": 220, |
| + "bulk-bar/three-rows-selected": 110, |
| + "button/default": 270, |
| + "callout/info": 140, |
| + "callout/negative": 160, |
| + "callout/negative-announced-as-an-alert": 160, |
| + "callout/positive": 160, |
| + "callout/warning": 160, |
| + "choice-field/pick-any": 280, |
| + "choice-field/pick-one": 280, |
| + "confirm-form/destructive": 170, |
| + "confirm-form/ordinary-default-cancel-wording": 170, |
| + "detail-list/four-facts-one-machine-ish": 230, |
| + "dropdown/a-filter-menu-with-one-choice-applied": 90, |
| + "dropdown/menu-idiom": 120, |
| + "empty-state/sentence-only": 160, |
| + "empty-state/with-a-post-call-to-action": 240, |
| + "error-page/403-forbidden": 330, |
| + "error-page/404-not-found": 330, |
| + "error-page/422-unprocessable": 330, |
| + "error-page/500-server-error": 380, |
| + "error-page/503-unavailable": 300, |
| + "error-page/anything-else-the-generic-pair": 300, |
| + "field-check/disabled": 160, |
| + "field-check/off": 160, |
| + "field-check/on": 160, |
| + "field-date/bounded": 190, |
| + "field-date/enhanced-input-type-tomorrow-or-in-3-weeks": 210, |
| + "field-date/plain-the-bare-native-input": 190, |
| + "field-date/required-with-an-error": 210, |
| + "field-daterange/date-and-time-seeded": 210, |
| + "field-daterange/dates-only-with-an-error-on-the-end": 240, |
| + "field-datetime/bounded": 190, |
| + "field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks": 210, |
| + "field-datetime/plain-the-bare-native-input": 190, |
| + "field-datetime/required-with-an-error": 210, |
| + "field-money/bare": 190, |
| + "field-money/with-a-hint": 210, |
| + "field-money/with-an-error": 210, |
| + "field-select/hand-written-with-optgroups": 190, |
| + "field-select/required-with-an-error": 210, |
| + "field-select/three-options-a-native-select": 180, |
| + "field-select/twelve-options-plain-the-enhancement-opted-out": 180, |
| + "field-select/twelve-options-the-filterable-combobox": 210, |
| + "field-text/bare": 190, |
| + "field-text/required": 190, |
| + "field-text/the-headline-field": 190, |
| + "field-text/with-a-hint": 210, |
| + "field-text/with-an-error": 210, |
| + "field-textarea/bare": 230, |
| + "field-textarea/required": 230, |
| + "field-textarea/with-a-hint": 260, |
| + "field-textarea/with-an-error": 260, |
| + "field-time/bounded": 190, |
| + "field-time/enhanced-input-type-half-seven-or-noon": 210, |
| + "field-time/plain-the-bare-native-input": 190, |
| + "field-time/required-with-an-error": 210, |
| + "field-url/bare": 190, |
| + "field-url/with-a-hint": 210, |
| + "field-url/with-an-error": 210, |
| + "field/bare": 190, |
| + "field/required": 190, |
| + "field/short": 190, |
| + "field/the-headline-field": 190, |
| + "field/with-a-hint": 190, |
| + "field/with-an-error": 210, |
| + "field/with-help-under-the-control": 210, |
| + "form-error/a-rejected-submission": 100, |
| + "form-foot/idle-the-button-before-anything-happens": 180, |
| + "form-foot/save-and-cancel": 180, |
| + "form-foot/submit-only": 180, |
| + "form-foot/working-what-busy-js-writes-on-the-way-out": 180, |
| + "form-layout/default": 380, |
| + "format-abbr/default": 150, |
| + "format-addresses/default": 220, |
| + "format-dates/default": 150, |
| + "format-durations/default": 150, |
| + "format-filesizes/default": 150, |
| + "format-identifiers/default": 170, |
| + "format-money/default": 210, |
| + "format-numbers/default": 140, |
| + "format-output/default": 230, |
| + "format-people/default": 120, |
| + "format-ratios/default": 280, |
| + "help/default": 100, |
| + "job-status/failed": 80, |
| + "job-status/finished": 80, |
| + "job-status/running": 90, |
| + "layout-column/default": 570, |
| + "layout-console-page/default": 680, |
| + "layout-console/default": 680, |
| + "layout-sidebar-page/default": 560, |
| + "layout-sidebar/default": 560, |
| + "layout-stage/default": 460, |
| + "layout-topbar/default": 660, |
| + "list-bar-search/empty-accessible-name-from-the-catalog": 120, |
| + "list-bar-search/with-a-query": 120, |
| + "list-bar-search/with-a-query-on-page-4": 120, |
| + "list-bar/search-with-a-filter-dropdown": 110, |
| + "list-grid/default": 280, |
| + "list-row-action/lead-marker-status-and-an-action": 120, |
| + "list-row-action/name-and-meta-line-only": 120, |
| + "list-search-submit/default-label": 130, |
| + "locale-menu/twelve-languages-current-highlighted": 90, |
| + "media-grid/screenshots-a-logo-and-a-banner": 720, |
| + "media-grid/select-mode-one-tile-selected": 400, |
| + "meter/row": 100, |
| + "modal/default": 170, |
| + "notice/a-saved-message": 120, |
| + "page-header/title-only": 120, |
| + "page-header/title-subhead-and-a-primary-action": 150, |
| + "pagination/first-page-default-accessible-name": 120, |
| + "pagination/middle-of-a-long-list": 120, |
| + "person/large-the-show-header-size": 100, |
| + "person/name-and-email": 90, |
| + "person/no-initial": 90, |
| + "row-menu/links-a-form-and-a-destructive-item": 90, |
| + "row-menu/links-only-for-a-list-inside-a-selection-form": 90, |
| + "scheme-menu/standalone": 90, |
| + "scheme-menu/three-choices-dark-current": 90, |
| + "seg-tabs/two-sections": 110, |
| + "selbox/default": 90, |
| + "shell-sidebar/default": 260, |
| + "shell-topbar/default": 210, |
| + "signin/a-passkey": 470, |
| + "signin/after-the-link-is-sent": 390, |
| + "signin/after-the-link-is-sent-in-another-browser": 390, |
| + "signin/an-address-that-does-not-look-right": 390, |
| + "signin/asking-for-an-address": 420, |
| + "signin/coming-back-with-an-email-link": 390, |
| + "signin/coming-back-with-keymail": 390, |
| + "signin/email-and-password": 390, |
| + "signin/google-apple-and-the-rest": 310, |
| + "signin/on-the-way-to-keymail": 390, |
| + "signin/when-keymail-could-not-confirm-it": 410, |
| + "signin/when-keymail-was-offered-and-a-link-went-out": 390, |
| + "stat-band/default": 190, |
| + "stat/a-shortened-number": 140, |
| + "stat/a-smaller-number": 140, |
| + "stat/the-big-number": 190, |
| + "stat/when-going-down-is-good": 180, |
| + "stat/when-going-up-is-bad": 180, |
| + "stat/when-you-cannot-say": 180, |
| + "status-pill/row": 100, |
| + "tblock/default": 270, |
| +} |
| + |
| +// previewMobileHeights overrides the 1.25× factor for the examples |
| +// whose Mobile layout is taller than the factor allows: one that |
| +// reflows onto a different axis at 390px, and, since Mobile lays out |
| +// at the stage's own width below 390px, one whose text wraps taller at |
| +// the narrowest phone frame, a 320px viewport. Seeded from the sweep at |
| +// 1500px with Mobile chosen and at a 320px viewport, over the twelve |
| +// locales, whose samples are all English until they are translated. |
| +var previewMobileHeights = map[string]int{ |
| + "api-tokens/four-tokens-two-of-them-live": 1850, |
| + "api-tokens/just-created-the-secret-shown-once": 2080, |
| + "badge/row": 170, |
| + "button/default": 370, |
| + "field-daterange/date-and-time-seeded": 300, |
| + "field-daterange/dates-only-with-an-error-on-the-end": 330, |
| + "format-numbers/default": 220, |
| + "layout-column/default": 980, |
| + "layout-console-page/default": 1130, |
| + "layout-sidebar-page/default": 1000, |
| + "layout-stage/default": 640, |
| + "layout-topbar/default": 1070, |
| + "list-bar/search-with-a-filter-dropdown": 180, |
| + "list-row-action/lead-marker-status-and-an-action": 200, |
| + "media-grid/screenshots-a-logo-and-a-banner": 1560, |
| + "media-grid/select-mode-one-tile-selected": 810, |
| + "meter/row": 370, |
| + "signin/a-passkey": 730, |
| + "signin/after-the-link-is-sent": 600, |
| + "signin/after-the-link-is-sent-in-another-browser": 570, |
| + "signin/an-address-that-does-not-look-right": 610, |
| + "signin/asking-for-an-address": 660, |
| + "signin/coming-back-with-an-email-link": 560, |
| + "signin/coming-back-with-keymail": 580, |
| + "signin/on-the-way-to-keymail": 550, |
| + "signin/when-keymail-could-not-confirm-it": 660, |
| + "signin/when-keymail-was-offered-and-a-link-went-out": 660, |
| + "stat-band/default": 460, |
| + "status-pill/row": 180, |
| +} |
| + |
| +// previewMobileLocaleHeights is a language's own Mobile height for a |
| +// frame whose content wraps taller in that language than |
| +// previewMobileHeights allows, read for that language's pages only. |
| +var previewMobileLocaleHeights = map[string]map[string]int{} |
| + |
| +// heightOf is one key's Desktop height, or the default. |
| +func heightOf(key string) int { |
| + if h, ok := previewHeights[key]; ok { |
| + return h |
| + } |
| + return previewHeight |
| +} |
| + |
| +// previewStyle writes one widget's two virtual heights. Mobile is a |
| +// quarter taller than Desktop unless the tables say otherwise: one |
| +// factor rather than a second table, since a component this small has |
| +// one column either way, and the exceptions are the ones whose whole |
| +// shape is a row that becomes a column. |
| +func previewStyle(locale, key string) template.CSS { |
| + h := heightOf(key) |
| + m, ok := previewMobileHeights[key] |
| + if !ok { |
| + m = h * 5 / 4 |
| + } |
| + if l, ok := previewMobileLocaleHeights[locale][key]; ok && l > m { |
| + m = l |
| + } |
| + return template.CSS(fmt.Sprintf("--ds-h: %dpx; --ds-hm: %dpx", h, m)) |
| +} |
diff --git a/internal/gallery/heights_browser_test.go b/internal/gallery/heights_browser_test.go| new file mode 100644 |
| index 0000000..f0a9964 |
| --- /dev/null |
| +++ b/internal/gallery/heights_browser_test.go |
| @@ -0,0 +1,324 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "os" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The heights in heights.go are measurements, and this is where they |
| +// are measured: every frame on every example page is asked what its |
| +// document needs and held to the box the renderer gave it, at Desktop |
| +// and with Mobile chosen, at a desktop window and at the narrowest |
| +// phone. A frame smaller than its content is a sample a reader has to |
| +// scroll inside a box the size of a paragraph. |
| +// |
| +// heightsEnforced is the switch between reporting and gating. Until |
| +// the shell, the frame padding and the stage have all landed, what a |
| +// frame measures is still moving, so a shortfall is logged and the |
| +// drives fail only when they measured nothing or when the self-check |
| +// fails: a box made 100px short of its content by hand has to be |
| +// reported short, which proves the measurement. The integration task |
| +// calibrates the table and flips this. |
| +const heightsEnforced = false |
| + |
| +// sweepFull is the opt-in for the whole table: every locale rather than |
| +// the routine few, and the table printed as Go source. |
| +func sweepFull() bool { |
| + switch v := os.Getenv("FIESTA_SWEEP"); v { |
| + case "": |
| + return false |
| + case "full": |
| + return true |
| + default: |
| + panic(fmt.Sprintf("FIESTA_SWEEP=%q: the only value is \"full\"", v)) |
| + } |
| +} |
| + |
| +// wideLocales is the desktop drive's languages: English, or all twelve |
| +// under the sweep. |
| +func wideLocales() []string { |
| + if sweepFull() { |
| + return Locales |
| + } |
| + return []string{"en"} |
| +} |
| + |
| +// narrowLocales is the 320px drive's languages: English, the one |
| +// right-to-left locale and the two that wrap longest, or all twelve |
| +// under the sweep. |
| +func narrowLocales() []string { |
| + if sweepFull() { |
| + return Locales |
| + } |
| + return []string{"en", "ar", "ru", "ga"} |
| +} |
| + |
| +// measureHeights reads every frame on the page, keyed as heights.go |
| +// keys them (<example>/<state>, read off the frame's src): what its |
| +// document needs in its own pixels, and what its box gives it, which |
| +// is --ds-h or --ds-hm resolved, since the frame takes its height back |
| +// off the box in virtual pixels. |
| +const measureHeights = `(() => { |
| + const out = {}; |
| + for (const f of document.querySelectorAll(".ds-view__frame")) { |
| + const m = /\/frames\/([^/]+)\/([^/]+)\.html$/.exec(f.getAttribute("src") || ""); |
| + const key = m ? m[1] + "/" + m[2] : "?"; |
| + let d = null; |
| + try { d = f.contentDocument; } catch (e) {} |
| + const need = d && d.body ? Math.ceil(Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight)) : -1; |
| + out[key] = {Need: need, Box: Math.round(parseFloat(getComputedStyle(f).height))}; |
| + } |
| + return JSON.stringify(out); |
| +})()` |
| + |
| +type heightReading struct{ Need, Box int } |
| + |
| +// readHeights decodes one measurement, failing on what makes it |
| +// worthless: no frame, or a frame with no document in it. |
| +func readHeights(t *testing.T, ctx context.Context, where string) map[string]heightReading { |
| + t.Helper() |
| + var got map[string]heightReading |
| + at(t, ctx, measureHeights, &got) |
| + if len(got) == 0 { |
| + t.Fatalf("%s: no frame on the page", where) |
| + } |
| + for key, r := range got { |
| + if r.Need < 0 { |
| + t.Fatalf("%s %s: the frame has no document in it", where, key) |
| + } |
| + if strings.HasPrefix(key, "?") { |
| + t.Fatalf("%s: a frame's src names no document under frames/", where) |
| + } |
| + } |
| + return got |
| +} |
| + |
| +// shortfall reports one frame's box against its content: an error when |
| +// the heights are enforced, a line in the log until then. The slack |
| +// Rastrillo's drive grants, 48px, covers a sidebar shell's 100dvh rail. |
| +func shortfall(t *testing.T, where, key string, r heightReading) bool { |
| + t.Helper() |
| + if r.Need <= r.Box+48 { |
| + if r.Box > r.Need*4 && r.Box-r.Need > 120 { |
| + t.Logf("%s %s: %dpx of frame for %dpx of document; deliberate headroom, or a number to bring down", where, key, r.Box, r.Need) |
| + } |
| + return false |
| + } |
| + msg := fmt.Sprintf("%s %s: its document needs %dpx and its frame is %dpx; raise its height (previewHeights for Desktop, previewMobileHeights or previewMobileLocaleHeights for Mobile) to at least %d", where, key, r.Need, r.Box, r.Need+20) |
| + if heightsEnforced { |
| + t.Error(msg) |
| + } else { |
| + t.Log("not enforced yet: " + msg) |
| + } |
| + return true |
| +} |
| + |
| +// selfCheck proves the measurement can see a short box: every widget's |
| +// prop (--ds-h or --ds-hm) is set 100px under what its document needs, |
| +// and the reading must then report the shortfall. |
| +func selfCheck(t *testing.T, ctx context.Context, where, prop string, before map[string]heightReading) { |
| + t.Helper() |
| + for key, r := range before { |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll(".ds-view").forEach(v => v.style.setProperty(%q, "%dpx")); true`, prop, r.Need-100), nil)) |
| + after := readHeights(t, ctx, where+", self-check")[key] |
| + if after.Need <= after.Box { |
| + t.Fatalf("%s: with %s set to %dpx under its content, %s reads need %d in a box of %d; the measurement cannot see a short box", where, prop, 100, key, after.Need, after.Box) |
| + } |
| + t.Logf("%s: self-check, %s %dpx short reads need %d in a box of %d", where, prop, 100, after.Need, after.Box) |
| + return |
| + } |
| +} |
| + |
| +// examplePages is every example's page file, in manifest order. |
| +func examplePages(t *testing.T) []string { |
| + t.Helper() |
| + var out []string |
| + for _, e := range manifest(t).Examples { |
| + out = append(out, e.Name+".html") |
| + } |
| + return out |
| +} |
| + |
| +// printTable writes the readings out as Go source: one Desktop height |
| +// per key, the tallest over the locales read plus slack, and the |
| +// Mobile entries where the derived 1.25× does not cover a reading. |
| +func printTable(t *testing.T, desk, mob map[string]map[string]int) { |
| + t.Helper() |
| + keys := map[string]bool{} |
| + for _, m := range desk { |
| + for k := range m { |
| + keys[k] = true |
| + } |
| + } |
| + var sorted []string |
| + for k := range keys { |
| + sorted = append(sorted, k) |
| + } |
| + sort.Strings(sorted) |
| + // Slack, then rounded up to the next ten: twelve languages and three |
| + // type families do not lay out to the same pixel. |
| + up := func(n int) int { return (n + 20 + 9) / 10 * 10 } |
| + var b strings.Builder |
| + b.WriteString("var previewHeights = map[string]int{\n") |
| + h := map[string]int{} |
| + for _, k := range sorted { |
| + n := 0 |
| + for _, m := range desk { |
| + if m[k] > n { |
| + n = m[k] |
| + } |
| + } |
| + h[k] = up(n) |
| + fmt.Fprintf(&b, "\t%q: %d,\n", k, h[k]) |
| + } |
| + b.WriteString("}\nvar previewMobileHeights = map[string]int{\n") |
| + mh := map[string]int{} |
| + for _, k := range sorted { |
| + if n := mob["en"][k]; n > h[k]*5/4 { |
| + mh[k] = up(n) |
| + fmt.Fprintf(&b, "\t%q: %d,\n", k, mh[k]) |
| + } |
| + } |
| + b.WriteString("}\nvar previewMobileLocaleHeights = map[string]map[string]int{\n") |
| + var locales []string |
| + for l := range mob { |
| + if l != "en" { |
| + locales = append(locales, l) |
| + } |
| + } |
| + sort.Strings(locales) |
| + for _, l := range locales { |
| + var lines []string |
| + for _, k := range sorted { |
| + base := h[k] * 5 / 4 |
| + if mh[k] > base { |
| + base = mh[k] |
| + } |
| + if n := mob[l][k]; n > base { |
| + lines = append(lines, fmt.Sprintf("\t\t%q: %d,", k, up(n))) |
| + } |
| + } |
| + if len(lines) > 0 { |
| + fmt.Fprintf(&b, "\t%q: {\n%s\n\t},\n", l, strings.Join(lines, "\n")) |
| + } |
| + } |
| + b.WriteString("}\n") |
| + t.Logf("the table as measured:\n%s", b.String()) |
| +} |
| + |
| +// TestPreviewFrameHeightsFitTheirContent: on every example page of |
| +// day/en (every locale under FIESTA_SWEEP=full), at a 1500px window, |
| +// every frame's document against its box at Desktop and, with Mobile |
| +// chosen, against --ds-hm. A stage frame counts like any other: its |
| +// document is 100dvh tall, so one that fits reads equal to its box. |
| +func TestPreviewFrameHeightsFitTheirContent(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + deadline := 12 * time.Minute |
| + if sweepFull() { |
| + deadline = 18 * time.Minute |
| + } |
| + ctx, cancel := context.WithTimeout(rig.Context(), deadline) |
| + defer cancel() |
| + desk, mob := map[string]map[string]int{}, map[string]map[string]int{} |
| + measured, short := 0, 0 |
| + for _, locale := range wideLocales() { |
| + desk[locale], mob[locale] = map[string]int{}, map[string]int{} |
| + for _, file := range examplePages(t) { |
| + where := fmt.Sprintf("%s/%s at 1500px", locale, file) |
| + openPage(t, ctx, where, pageURL(rig, locale, file), 1500, 1000) |
| + eagerly(t, ctx, where) |
| + desktop := readHeights(t, ctx, where+" Desktop") |
| + for key, r := range desktop { |
| + measured++ |
| + desk[locale][key] = r.Need |
| + if shortfall(t, where+" Desktop", key, r) { |
| + short++ |
| + } |
| + } |
| + clickAll(t, ctx, where, clickedMobile, "Mobile") |
| + mobileSettle(t, ctx, where) |
| + mobile := readHeights(t, ctx, where+" Mobile") |
| + for key, r := range mobile { |
| + mob[locale][key] = r.Need |
| + if shortfall(t, where+" Mobile", key, r) { |
| + short++ |
| + } |
| + } |
| + if file == "button.html" && locale == "en" { |
| + selfCheck(t, ctx, where+" Mobile", "--ds-hm", mobile) |
| + clickAll(t, ctx, where, clickedDesktop, "Desktop") |
| + selfCheck(t, ctx, where+" Desktop", "--ds-h", desktop) |
| + } |
| + } |
| + } |
| + if measured == 0 { |
| + t.Fatal("no frame was measured") |
| + } |
| + t.Logf("%d frames measured over %d locale(s); %d short (enforced: %v)", measured, len(desk), short, heightsEnforced) |
| + if sweepFull() { |
| + printTable(t, desk, mob) |
| + } |
| +} |
| + |
| +// TestPreviewFrameHeightsFitAtThePhonesNarrowest: the same with Mobile |
| +// chosen at a 320px viewport, where a Mobile frame lays out at the |
| +// stage's width and text that fits a line at 390px wraps, over en, ar, |
| +// ru and ga (all twelve under the sweep). The control: every frame is |
| +// laid out under 390px, at its stage's width. |
| +func TestPreviewFrameHeightsFitAtThePhonesNarrowest(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + deadline := 15 * time.Minute |
| + if sweepFull() { |
| + deadline = 19 * time.Minute |
| + } |
| + ctx, cancel := context.WithTimeout(rig.Context(), deadline) |
| + defer cancel() |
| + measured, short := 0, 0 |
| + var raise []string |
| + for _, locale := range narrowLocales() { |
| + for _, file := range examplePages(t) { |
| + where := fmt.Sprintf("%s/%s at 320px", locale, file) |
| + openPage(t, ctx, where, pageURL(rig, locale, file), 320, 844) |
| + eagerly(t, ctx, where) |
| + clickAll(t, ctx, where, clickedMobile, "Mobile") |
| + mobileSettle(t, ctx, where) |
| + var wide []string |
| + mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll(".ds-view")].filter(v => { |
| + const f = v.querySelector(".ds-view__frame"); return !(f.offsetWidth < 390 && Math.abs(f.offsetWidth - v.clientWidth) <= 1); }).map(v => v.querySelector(".ds-view__frame").title)`, &wide)) |
| + if len(wide) > 0 { |
| + t.Fatalf("%s: frames %v are not at the stage's narrow width; the readings would be of the 390px frame", where, wide) |
| + } |
| + got := readHeights(t, ctx, where) |
| + for key, r := range got { |
| + measured++ |
| + if shortfall(t, where, key, r) { |
| + short++ |
| + raise = append(raise, fmt.Sprintf("%s %q: %d,", locale, key, (r.Need+20+9)/10*10)) |
| + } |
| + } |
| + if file == "button.html" && locale == "en" { |
| + selfCheck(t, ctx, where, "--ds-hm", got) |
| + } |
| + } |
| + } |
| + if measured == 0 { |
| + t.Fatal("no frame was measured") |
| + } |
| + sort.Strings(raise) |
| + t.Logf("%d frames measured at 320px over %d locale(s); %d short (enforced: %v)", measured, len(narrowLocales()), short, heightsEnforced) |
| + if len(raise) > 0 { |
| + t.Logf("Mobile entries to raise, as locale key: height:\n\t%s", strings.Join(raise, "\n\t")) |
| + } |
| +} |
diff --git a/internal/gallery/realsize_browser_test.go b/internal/gallery/realsize_browser_test.go| new file mode 100644 |
| index 0000000..634b383 |
| --- /dev/null |
| +++ b/internal/gallery/realsize_browser_test.go |
| @@ -0,0 +1,186 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// realSize reads every showing frame's transform, its laid-out and |
| +// rendered widths, a text field's computed font size where the |
| +// document has one, and the box's inline edges against the document's. |
| +// The field is the first plain one: the headline field is sized on |
| +// purpose at --rst-fs-lg. |
| +const realSize = `JSON.stringify([...document.querySelectorAll(".ds-view")].filter(v => getComputedStyle(v.querySelector(".ds-view__stage")).display !== "none").map(v => { |
| + const box = v.querySelector(".ds-view__box"), f = v.querySelector(".ds-view__frame"), b = box.getBoundingClientRect(), fr = f.getBoundingClientRect(); |
| + const lit = [...v.querySelectorAll(".ds-view__tab")].findIndex(l => getComputedStyle(l).fontWeight === "600"); |
| + let field = null; |
| + try { const i = f.contentDocument.querySelector(':is(input:not([type]), input[type=text], input[type=email], input[type=url]):not(.rst-input--primary, [rst-input~="primary"])'); if (i) field = parseFloat(f.contentDocument.defaultView.getComputedStyle(i).fontSize); } catch (e) {} |
| + return {ID: (v.closest("article, section") || {}).id || "", Lit: lit, Transform: getComputedStyle(f).transform, |
| + Layout: f.offsetWidth, Rendered: fr.width, Field: field, BoxL: b.left, BoxR: b.right, DocW: document.documentElement.clientWidth}; |
| +}))` |
| + |
| +type realReading struct { |
| + ID string |
| + Lit int |
| + Transform string |
| + Layout, Rendered, BoxL, BoxR, DocW float64 |
| + Field *float64 |
| +} |
| + |
| +func readReal(t *testing.T, ctx context.Context, where string) []realReading { |
| + t.Helper() |
| + var rs []realReading |
| + at(t, ctx, realSize, &rs) |
| + if len(rs) == 0 { |
| + t.Fatalf("%s: no frame showing", where) |
| + } |
| + return rs |
| +} |
| + |
| +// TestMobileIsThePageAtItsRealSize: on a phone, Mobile is the page at |
| +// its real size: the frame is laid out at the stage's width up to |
| +// 390px and never scaled, a 16px field is 16px, and the box reaches |
| +// the screen's edges. On a coarse pointer at 390px and 320px and a |
| +// fine one at 390px, in English and Arabic, on a component page and a |
| +// page-class one. The control: on a desktop the Mobile rendering is the |
| +// 390px phone, centred in its stage. |
| +func TestMobileIsThePageAtItsRealSize(t *testing.T) { |
| + coarse := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithCoarsePointer(), harness.WithScrollbars()) |
| + cctx, ccancel := context.WithTimeout(coarse.Context(), 240*time.Second) |
| + defer ccancel() |
| + fine := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) |
| + fctx, fcancel := context.WithTimeout(fine.Context(), 240*time.Second) |
| + defer fcancel() |
| + for _, rig := range []struct { |
| + name string |
| + ctx context.Context |
| + rig *harness.Rig |
| + widths []int64 |
| + }{{"coarse", cctx, coarse, []int64{390, 320}}, {"fine", fctx, fine, []int64{390}}} { |
| + for _, file := range []string{"field.html", "layout-column.html"} { |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, w := range rig.widths { |
| + where := fmt.Sprintf("day/%s %s at %dpx, %s pointer", locale, file, w, rig.name) |
| + openPage(t, rig.ctx, where, pageURL(rig.rig, locale, file), w, 844) |
| + if rig.name == "coarse" { |
| + requireCoarse(t, rig.ctx) |
| + } |
| + eagerly(t, rig.ctx, where) |
| + requireDrawnScrollbar(t, rig.ctx, where) |
| + var sideways float64 |
| + mustRun(t, rig.ctx, chromedp.Evaluate(`document.documentElement.scrollWidth - document.documentElement.clientWidth`, &sideways)) |
| + if sideways > 0.5 { |
| + t.Errorf("%s: the page scrolls %.1fpx sideways", where, sideways) |
| + } |
| + fields := 0 |
| + for _, r := range readReal(t, rig.ctx, where) { |
| + if r.Lit != 1 { |
| + t.Fatalf("%s: %s opens with tab %d lit; on a phone the widget opens on Mobile, and the readings below assume it", where, r.ID, r.Lit) |
| + } |
| + if r.Transform != "matrix(1, 0, 0, 1, 0, 0)" && r.Transform != "none" { |
| + t.Errorf("%s: %s is transformed %s; Mobile is never scaled", where, r.ID, r.Transform) |
| + } |
| + if math.Abs(r.Layout-r.Rendered) > 0.5 || r.Layout > 390 { |
| + t.Errorf("%s: %s lays out at %.1fpx and renders at %.1fpx; want one width, at most 390px", where, r.ID, r.Layout, r.Rendered) |
| + } |
| + if math.Abs(r.BoxL) > 1 || math.Abs(r.BoxR-r.DocW) > 1 { |
| + t.Errorf("%s: %s's box runs %.1f…%.1f in a %.0fpx document; on a phone the stage is the screen's width", where, r.ID, r.BoxL, r.BoxR, r.DocW) |
| + } |
| + if r.Field != nil { |
| + fields++ |
| + if rendered := *r.Field * r.Rendered / r.Layout; math.Abs(*r.Field-16) > 0.1 || math.Abs(rendered-16) > 0.1 { |
| + t.Errorf("%s: %s's text field computes %.2fpx and renders %.2fpx; a phone's field is 16px", where, r.ID, *r.Field, rendered) |
| + } |
| + } |
| + } |
| + if file == "field.html" && fields == 0 { |
| + t.Errorf("%s: no text field measured on the field page; the 16px claim was checked against nothing", where) |
| + } |
| + } |
| + } |
| + } |
| + } |
| + where := "day/en field.html at 1280px, Mobile chosen" |
| + openPage(t, fctx, where, pageURL(fine, "en", "field.html"), 1280, 900) |
| + eagerly(t, fctx, where) |
| + clickAll(t, fctx, where, clickedMobile, "Mobile") |
| + const want = 3 |
| + shown := readReal(t, fctx, where) |
| + if len(shown) < want { |
| + t.Fatalf("%s: %d frames showing, want at least %d to check", where, len(shown), want) |
| + } |
| + for _, r := range shown[:want] { |
| + if r.Layout != 390 { |
| + t.Errorf("%s: %s lays out at %.1fpx, want 390", where, r.ID, r.Layout) |
| + } |
| + } |
| +} |
| + |
| +// TestDesktopPansFromTheInlineStart: Desktop on a phone pans, and from |
| +// the page's start in either direction: in Arabic the frame is |
| +// anchored at the box's right edge and the box scrolls left. |
| +func TestDesktopPansFromTheInlineStart(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() |
| + for _, c := range []struct { |
| + file string |
| + width float64 |
| + }{{"field.html", 900}, {"layout-column.html", 1200}} { |
| + for _, locale := range []string{"ar", "en"} { |
| + where := fmt.Sprintf("day/%s %s at 390px, Desktop chosen", locale, c.file) |
| + openPage(t, ctx, where, pageURL(rig, locale, c.file), 390, 844) |
| + eagerly(t, ctx, where) |
| + clickAll(t, ctx, where, clickedDesktop, "Desktop") |
| + var rs []struct { |
| + ID string |
| + Checked bool |
| + Layout float64 |
| + Pans bool |
| + Start, End float64 |
| + } |
| + at(t, ctx, `(() => { |
| + const rtl = document.documentElement.dir === "rtl", out = []; |
| + for (const v of document.querySelectorAll(".ds-view")) { |
| + const box = v.querySelector(".ds-view__box"), f = v.querySelector(".ds-view__frame"); |
| + const checked = v.querySelector(".ds-view__tab--d input").checked; |
| + const startEdge = () => { const b = box.getBoundingClientRect(), r = f.getBoundingClientRect(); return rtl ? r.right - b.right : r.left - b.left; }; |
| + const endEdge = () => { const b = box.getBoundingClientRect(), r = f.getBoundingClientRect(); return rtl ? b.left - r.left : r.right - b.right; }; |
| + const atStart = startEdge(); |
| + box.scrollLeft = rtl ? -(box.scrollWidth - box.clientWidth) : box.scrollWidth - box.clientWidth; |
| + const atEnd = endEdge(); |
| + box.scrollLeft = 0; |
| + out.push({ID: (v.closest("article, section") || {}).id || "", Checked: checked, Layout: f.offsetWidth, |
| + Pans: box.scrollWidth > box.clientWidth, Start: atStart, End: atEnd}); |
| + } |
| + return JSON.stringify(out); |
| + })()`, &rs) |
| + for _, r := range rs { |
| + if !r.Checked || r.Layout != c.width { |
| + t.Fatalf("%s: %s has Desktop checked %v and lays out at %.0fpx, want %.0fpx", where, r.ID, r.Checked, r.Layout, c.width) |
| + } |
| + } |
| + for _, r := range rs { |
| + if !r.Pans { |
| + t.Errorf("%s: %s's box does not scroll", where, r.ID) |
| + } |
| + if math.Abs(r.Start) > 1 { |
| + t.Errorf("%s: at rest %s's frame starts %.1fpx from its box's inline start", where, r.ID, r.Start) |
| + } |
| + if math.Abs(r.End) > 1 { |
| + t.Errorf("%s: scrolled to the end, %s's frame ends %.1fpx from its box's inline end", where, r.ID, r.End) |
| + } |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/gallery/view.css b/internal/gallery/view.css| index f37e8e8..077ef25 100644 |
| --- a/internal/gallery/view.css |
| +++ b/internal/gallery/view.css |
| @@ -1,39 +1,267 @@ |
| /* view.css: the preview widget's half of the gallery's stylesheet, |
| - served joined to gallery.css as one file. The frame on its stage, |
| - the HTML disclosure and the code block under it, and the five |
| - highlight colours codeview.Highlight writes; |
| - TestHighlightColoursPassAgainstTheCodeBackground reads those off |
| - this file by their exact lines. */ |
| - |
| -/* One state's frame on its stage. */ |
| -.ds-view { margin: var(--rst-sp-2) 0 0; } |
| -.ds-view__stage { border: 1px solid var(--rst-line); border-radius: var(--rst-radius); overflow: hidden; } |
| -/* A frame shows nothing of its own until gallery.js marks it, once its |
| - document has loaded and has the page's scheme written on it. Before |
| - that it holds about:blank, or a document that has painted in the |
| - OS's scheme while the page is on Light or Dark. Hidden, the box |
| - behind it shows this page's background instead. data-gallery-js |
| - because gallery.js is what shows it: with scripts off nothing |
| - would, and the frames show as they come. */ |
| -.ds-view__frame { block-size: 20rem; border: 0; color-scheme: normal; display: block; inline-size: 100%; } |
| -.ds-view--page .ds-view__frame { block-size: 40rem; resize: vertical; } |
| -:root[data-gallery-js] .ds-view__frame:not([data-gallery-painted]) { visibility: hidden; } |
| + served joined to gallery.css as one file. The widget ([Desktop] |
| + [Mobile] [HTML] over one frame), the code block under it with its |
| + wrapper line and Copy button, and the five highlight colours |
| + codeview.Highlight writes; TestHighlightColoursPassAgainstTheCodeBackground |
| + reads those off this file by their exact lines. It is Rastrillo's |
| + widget block and code-block rules, the Template tab's rules dropped, |
| + with Fiesta's marks where Rastrillo reads its own: data-gallery-js |
| + (set by view.js, which is what shows a frame) for data-rst-js, and |
| + data-gallery-painted, the mark part 3 established, for |
| + data-ds-painted. */ |
| -/* The HTML tab is a disclosure: no script, and the browser's own |
| - control. Its summary is a target like the links above. */ |
| -.ds-view__code > summary { align-items: center; cursor: pointer; display: inline-flex; font-weight: 600; min-block-size: var(--rst-target); } |
| -/* Source soft-wraps rather than scrolling sideways: a sample's row |
| - of attributes is one line, and the page is what has to stay inside |
| - 320px. overflow-x stays for the single token wider than the column. |
| - Code is left to right in every language. */ |
| -.ds-src { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); direction: ltr; margin: var(--rst-sp-2) 0 0; overflow-x: auto; padding: var(--rst-sp-4); text-align: start; unicode-bidi: isolate; } |
| +/* ── The code block ────────────────────────────────────────────────── |
| + A source block can still scroll sideways where a single token is |
| + wider than its column, and a box that scrolls must be reachable from |
| + the keyboard (axe's scrollable-region-focusable; WCAG 2.1.1), so |
| + every pre.ds-src carries tabindex="0". */ |
| +.ds-src { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); margin: var(--rst-sp-3) 0; overflow-x: auto; padding: var(--rst-sp-4); } |
| +/* Source soft-wraps rather than scrolling sideways. An inline run (a |
| + meter's whole output, a choice card) is one line however long, |
| + because breaking inside it would change the space a reader sees; so |
| + the block wraps it on screen, and copying takes textContent, which |
| + has no soft breaks in it. */ |
| .ds-src code { overflow-wrap: anywhere; white-space: pre-wrap; } |
| - |
| +/* Code is left to right in every language. Inheriting an Arabic page's |
| + rtl, a block sits against the right edge and the bidi algorithm |
| + mirrors its brackets and moves trailing punctuation to the front, so |
| + what a reader sees is not what Copy puts on the clipboard. isolate |
| + keeps a name inside a sentence (the wrapper line's) from reordering |
| + the words around it. */ |
| +.ds-src, .ds-wrap .rst-mono { direction: ltr; text-align: start; unicode-bidi: isolate; } |
| /* The five token colours, from theme tokens so they follow theme and |
| - scheme, each at 4.5:1 or better against --rst-surface in every |
| - theme and both schemes. */ |
| + scheme, each at 4.5:1 or better against --rst-surface, the block's |
| + background, in every theme and both schemes; the test reads these |
| + lines, one rule per element. */ |
| ds-t { color: var(--rst-accent); } |
| ds-a { color: var(--rst-tone-negative-fg); } |
| ds-v { color: var(--rst-tone-positive-fg); } |
| ds-x { color: var(--rst-text-muted); } |
| ds-s { color: var(--rst-tone-warning-fg); } |
| +.ds-wrap { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-2); } |
| +/* Sits directly before the block it copies. inline-size: fit-content |
| + with margin-inline-start: auto pushes a block-level button to the far |
| + edge without a flex wrapper around it, on the inline axis, so it |
| + lands on the right in a left-to-right page and the left in Arabic |
| + with no direction check anywhere in view.js. It reads the target |
| + floor itself, because tokens.css floors a button only through |
| + rst-btn; border-box so the floor is the whole button, not its text. */ |
| +.ds-copy { align-items: center; background: none; border: 1px solid var(--rst-line); border-radius: var(--rst-radius-sm); box-sizing: border-box; color: var(--rst-text-muted); cursor: pointer; display: flex; font: inherit; font-size: var(--rst-fs-sm); inline-size: fit-content; justify-content: center; margin-inline-start: auto; min-block-size: var(--rst-target); min-inline-size: var(--rst-target); padding-block: 0.2rem; padding-inline: 0.6rem; } |
| +.ds-copy:hover { background: var(--rst-accent-soft); color: var(--rst-text); } |
| +.ds-copy:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; } |
| + |
| +/* ── A grouped widget's states ─────────────────────────────────────── |
| + Under a grouped widget every state has a block of its own: the |
| + state's name on a line of its own, and its note where the prose has |
| + one. The name and the note are two elements with nothing written |
| + between them, since a joiner would be copy no review saw. */ |
| +.ds-group-note { margin-block-start: var(--rst-sp-3); } |
| +.ds-note__state { display: block; font-weight: 600; } |
| +.ds-group-note > .ds-note { margin: var(--rst-sp-1) 0 0; } |
| + |
| +/* ── The preview widget ────────────────────────────────────────────── |
| + [Desktop] [Mobile] [HTML] over one frame. HTML is there only when |
| + the example has code to show, so a widget has two or three tabs. |
| + |
| + No script anywhere in it. The tabs are radio inputs sharing a name, |
| + hidden but focusable inside their labels, and :has() reads which one |
| + is checked from the wrapper, so the panels switch on the browser's |
| + own form behaviour and a reader with JavaScript off has the same |
| + views as everyone else. |
| + |
| + None of them starts checked, and that is the whole of how the |
| + opening view follows the reader. CSS cannot tell "the reader chose |
| + Desktop" from "Desktop is what the markup shipped checked", so a |
| + widget that opens on a checked radio can never let the width pick |
| + the opening view and still take an explicit choice. With nothing |
| + checked the width picks Desktop or Mobile, and any tab, once |
| + clicked, overrides it at any size. |
| + |
| + Which width picks is the stage's, asked with a container query on |
| + .ds-view, and not the viewport's. The viewport is the wrong ruler |
| + because the gallery's rail arrives at 800px and takes its width out |
| + of the column: a reader who widens their window makes the preview |
| + narrower, so a viewport rule switches to the rendering that needs |
| + the most room at exactly the width where it has the least. A |
| + container query measures the box a reader actually gets. |
| + |
| + One number does the deciding, written twice because CSS cannot put a |
| + custom property in a query condition: --ds-kmin, the least the frame |
| + may be scaled to, and the stage width at which the desktop rendering |
| + reaches it, --ds-wd × --ds-kmin. Keep them in step; the drives |
| + assert the identity off each widget's own --ds-wd. |
| + |
| + Two width classes. --ds-wd is the width the desktop rendering is |
| + laid out at: |
| + |
| + .ds-view--page 1200px a whole page: the layout documents, the |
| + two shell idioms, the modal, the demo |
| + application. A page frame wants a window |
| + to be a page frame in. |
| + .ds-view 900px everything else: a field, a pill, a row, |
| + a menu. A component is something inside |
| + a page, and never gets a window's width |
| + in an app either. |
| + |
| + The default is the component one, because nearly every example is a |
| + component. view.go's pageFrame() decides which is which. Laid out at |
| + 1200px, every component was drawn at four-fifths size in the stage |
| + the gallery's column allows; at 900px a field is a field. Not the |
| + full column, because tokens.css switches the shells to their narrow |
| + layout at 800px, and a "desktop" rendering under that width would be |
| + the mobile layout under a lit Desktop tab. |
| + |
| + Where 0.72 comes from: the type in these samples is 14px, 12.5px and |
| + 11.5px, and 12.5px carries most pages, so it is the one to protect; |
| + 9px is about where rendered text stops being read and starts being |
| + texture, and 12.5 × 0.72 = 9.0px exactly. That gives both the floor |
| + and the thresholds: 1200 × 0.72 = 864px = 54rem for a page frame, |
| + 900 × 0.72 = 648px = 40.5rem for a component. The arithmetic is exact |
| + at a 16px root and only there; a reader who scales their root type |
| + up moves the rem values and not the ratio, and the gap falls the safe |
| + way. |
| + |
| + The pair is written as (min-width: Nrem) and its exact negation |
| + rather than a min/max pair: a max-width one pixel under leaves a gap |
| + a fractional width can land in, where neither rule applies and no |
| + tab is lit. The highlight is driven by the same two queries, so the |
| + tab a reader sees lit is always the rendering in front of them. An |
| + engine with :has() but no container queries gets a third rule that |
| + lights Desktop to match the rendering it will be showing. |
| + |
| + The scale: the frame is laid out at a virtual --ds-w (its class's |
| + --ds-wd, or 390px on Mobile) and scaled to fit the reader's column, |
| + so the desktop rendering is the desktop rendering on a phone too. |
| + CSS divides two lengths into a plain number with tan(atan2(a, b)), |
| + and 100cqw is the container's own width, so --ds-k is that fraction; |
| + the 1 stops it scaling anything up and --ds-kmin stops it scaling |
| + anything into illegibility. Where the trig functions are missing the |
| + @supports block never applies, --ds-k stays 1, and the frame is a |
| + virtual-width page clipped to the column: smaller, not broken. |
| + |
| + Below its class's threshold the desktop rendering stops shrinking and |
| + starts panning: --ds-k holds at 0.72, the box stays as wide as the |
| + column, and the scaled page inside it scrolls horizontally within |
| + the box. The scroller is on the box itself, not on the state that |
| + made it necessary: the Mobile rendering clamps at 0.72 too, and |
| + under 281px of stage the opening view cropped 42px at a 320px window |
| + with no way to reach it. A box that can clamp can overflow, and one |
| + that can overflow has to scroll. |
| + |
| + The container is .ds-view and not .ds-view__stage, because the tabs |
| + are the stage's siblings and a query on the stage cannot reach them. |
| + .ds-view carries no padding and no border, so the two are the same |
| + number of pixels wide; the drives measure both and require them |
| + equal. |
| + |
| + --ds-h is the frame's virtual height, written per example by |
| + previewStyle from the measured table in heights.go. It sizes the |
| + box; the frame takes its height back off the box, 100% / --ds-k, |
| + which is the same number until a reader drags the resize grip and |
| + then is whatever they dragged it to. The box is a window on a |
| + document, not a fit to it: a taller sample scrolls inside its frame, |
| + and the grip is there for the ones a reader wants more of. |
| + |
| + min-block-size is insurance: block-size names --ds-k, and a --ds-k an |
| + engine parses but cannot resolve drops that declaration to auto, |
| + which for a box whose only child is absolutely positioned is zero. |
| + Its value is the smallest height the healthy cascade can produce, so |
| + it never binds when things work. The @supports guard tests the same |
| + shape the declaration uses, 100cqw and all. */ |
| +.ds-view { --ds-wd: 900px; --ds-w: var(--ds-wd); --ds-wm: 390px; --ds-h: 220px; --ds-hm: 330px; --ds-kmin: 0.72; container-name: ds-view; container-type: inline-size; margin: var(--rst-sp-3) 0; } |
| +.ds-view--page { --ds-wd: 1200px; } |
| +/* A grouped row's frame sits flush with its tabs, not centred: its |
| + states start at the inline start like the labels above them. */ |
| +.ds-view--rows .ds-view__box { margin-inline-start: 0; } |
| +/* Two pairs, the renderings and the source, that wrap as wholes: three |
| + tabs in one row can be wider than a sample's column on a 320px phone |
| + in a language with long words, and a label that may wrap breaks a |
| + tab into two lines of text. */ |
| +.ds-view__tabs { border: 0; display: flex; flex-wrap: wrap; gap: var(--rst-sp-2); margin: 0 0 var(--rst-sp-2); padding: 0; } |
| +.ds-view__pair { display: inline-flex; } |
| +/* A tab is a target like any control: the floor in force, border-box so |
| + the border and padding are inside it. */ |
| +.ds-view__tab { align-items: center; border: 1px solid var(--rst-line); box-sizing: border-box; color: var(--rst-text-muted); cursor: pointer; display: inline-flex; font-size: var(--rst-fs-sm); min-block-size: var(--rst-target); padding: 0.2rem 0.7rem; white-space: nowrap; } |
| +.ds-view__tab + .ds-view__tab { border-inline-start: 0; } |
| +.ds-view__tab:first-of-type { border-end-start-radius: var(--rst-radius-sm); border-start-start-radius: var(--rst-radius-sm); } |
| +.ds-view__tab:last-of-type { border-end-end-radius: var(--rst-radius-sm); border-start-end-radius: var(--rst-radius-sm); } |
| +.ds-view__tab input { block-size: 1px; clip-path: inset(50%); inline-size: 1px; margin: 0; overflow: hidden; position: absolute; } |
| +.ds-view__tab:hover { background: var(--rst-accent-soft); color: var(--rst-text); } |
| +.ds-view__tab:has(input:checked) { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; } |
| +/* One pair of queries per width class, and the two declarations each |
| + pair drives are written inside it: which rendering opens, and which |
| + tab is lit over it, are one decision, and two rules answering to two |
| + copies of the same condition is the shape a divergence hides in. */ |
| +@container ds-view (min-width: 40.5rem) { .ds-view:not(.ds-view--page):not(:has(input:checked)) .ds-view__tab--d { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; } } |
| +@container ds-view not (min-width: 40.5rem) { |
| + .ds-view:not(.ds-view--page):not(:has(input:checked)) .ds-view__tab--m { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; } |
| + .ds-view:not(.ds-view--page):not(:has(.ds-view__tab--d input:checked)) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); } |
| +} |
| +@container ds-view (min-width: 54rem) { .ds-view--page:not(:has(input:checked)) .ds-view__tab--d { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; } } |
| +@container ds-view not (min-width: 54rem) { |
| + .ds-view--page:not(:has(input:checked)) .ds-view__tab--m { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; } |
| + .ds-view--page:not(:has(.ds-view__tab--d input:checked)) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); } |
| +} |
| +@supports not (container-type: inline-size) { .ds-view:not(:has(input:checked)) .ds-view__tab--d { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; } } |
| +.ds-view__tab:has(input:focus-visible) { outline: 2px solid var(--rst-accent); outline-offset: 2px; } |
| +.ds-view__box { --ds-k: 1; background: var(--rst-bg); block-size: calc(var(--ds-h) * var(--ds-k)); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); inline-size: calc(var(--ds-w) * var(--ds-k)); margin-inline: auto; max-inline-size: 100%; min-block-size: calc(var(--ds-h) * var(--ds-kmin)); overflow: hidden; overflow-x: auto; overscroll-behavior-x: contain; position: relative; resize: vertical; scrollbar-width: thin; } |
| +.ds-view__frame { block-size: calc(100% / var(--ds-k)); border: 0; inline-size: var(--ds-w); inset-block-start: 0; inset-inline-start: 0; min-block-size: 100%; position: absolute; transform: scale(var(--ds-k)); transform-origin: top left; } |
| +/* transform-origin has no logical keywords, so the side is a selector. |
| + Anchored at the physical left, a right-to-left box could not scroll |
| + to the frame's start. */ |
| +[dir="rtl"] .ds-view__frame { transform-origin: top right; } |
| +/* A frame shows nothing of its own until view.js marks it, once its |
| + document is parsed and styled and has the page's scheme written on |
| + it (load if that is missed), and again for each document a |
| + navigation inside the frame loads. Before that, in every scheme: |
| + - the frame holds the about:blank it was created with, which Safari |
| + painted white on a dark page (see the color-scheme rule below); |
| + - its document is parsing, or parsed without its stylesheets; |
| + - with Light or Dark chosen, it has painted in the OS's scheme while |
| + scrolling to a lazy frame, after a reload at a remembered scroll, |
| + or on a click inside one. |
| + Hidden, the box behind it shows this page's --rst-bg instead; a |
| + hidden frame still lazy-loads. data-gallery-js because view.js is |
| + what shows it: with scripts off nothing would, and the frames show |
| + as they come. */ |
| +:root[data-gallery-js] .ds-view__frame:not([data-gallery-painted]) { visibility: hidden; } |
| +/* A browser compares a frame's color-scheme with its document's, and |
| + Safari draws a document whose scheme differs on an opaque canvas. |
| + about:blank declares none, so it is light, and with the frame |
| + inheriting a dark page's scheme every frame was white until its |
| + document came. Normal matches it. A preview declares its own scheme |
| + and paints its whole canvas, so this changes nothing once it paints. |
| + It is all a reader without scripts gets, since nothing hides their |
| + frames. */ |
| +.ds-view__frame { color-scheme: normal; } |
| +@supports (block-size: calc(1px * clamp(0.5, tan(atan2(100cqw, 1200px)), 1))) { |
| + /* Mobile is the stage's width up to 390px: below 390px of stage, |
| + laying the page out at the stage's width is what that screen would |
| + show, and min() keeps a 760px window's Mobile the 390px phone |
| + rather than a 760px tablet. --ds-k is then at least 1, so Mobile is |
| + never scaled. The cqw resolves where --ds-w is used, inside |
| + .ds-view, its container. */ |
| + .ds-view { --ds-wm: min(390px, 100cqw); } |
| + .ds-view__box { --ds-k: clamp(var(--ds-kmin), tan(atan2(100cqw, var(--ds-w))), 1); } |
| +} |
| +/* An explicit Mobile beats the width, at either class and any size. */ |
| +.ds-view:has(.ds-view__tab--m input:checked) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); } |
| +/* HTML replaces the frame with its panel, and only then: the radios |
| + are one group, so one is checked at most. */ |
| +.ds-view__code { display: none; margin-block-start: 0; } |
| +.ds-view:has(.ds-view__tab--h input:checked) .ds-view__stage { display: none; } |
| +.ds-view:has(.ds-view__tab--h input:checked) .ds-view__code--h { display: block; } |
| + |
| +/* 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 column's inline |
| + padding. Written rather than measured, so a change to the column |
| + 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. */ |
| +@media (max-width: 799.98px) { |
| + .ds-view { --ds-bleed: var(--rst-sp-4); margin-inline: calc(-1 * var(--ds-bleed)); } |
| + .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 { border-inline-width: 0; border-radius: 0; } |
| +} |
diff --git a/internal/gallery/view.go b/internal/gallery/view.go| index 025b10b..e3f1b84 100644 |
| --- a/internal/gallery/view.go |
| +++ b/internal/gallery/view.go |
| @@ -1,6 +1,8 @@ |
| package gallery |
| import ( |
| + "errors" |
| + "fmt" |
| "html/template" |
| "strings" |
| @@ -8,21 +10,26 @@ import ( |
| "amadan.net/rastrillo/fiesta/internal/codeview" |
| ) |
| -// The preview widget, in its first form: one frame and the HTML in a |
| -// disclosure under it, under the class names the full widget will |
| -// wear. The Desktop, Mobile and HTML tabs, the measured heights, the |
| -// wrapper line and the grouped rows are the widget task's; the type, |
| -// the template's name and preview's signature are what it fills. |
| +// The preview widget: Desktop, Mobile and HTML over one frame, as |
| +// Rastrillo's gallery draws it, without its Template tab. The frame |
| +// shows the state's own document at a virtual width and a measured |
| +// height; the HTML tab shows the markup a reader copies, without the |
| +// container the manifest's wrapper puts it in, and names that |
| +// container on the line above. A grouped example (status-pill, badge, |
| +// meter) is one widget whose frame is a row document of every state, |
| +// with one code block per state. |
| // previewView is one widget. Group names the widget's radio group, |
| -// unique on the page. Style carries the frame's virtual heights. Class |
| -// is " ds-view--page" on the examples whose document is a whole page |
| -// and empty on the rest, with its own leading space so the template |
| -// writes class="ds-view{{.Class}}" without a conditional. Wrapper is |
| -// the line naming the container a wrapped sample needs; Source the |
| -// markup the HTML tab shows, formatted and highlighted. NoCopy marks a |
| -// block that offers no Copy button. Rows is a grouped example's code, |
| -// one block per state. |
| +// unique on the page. Style carries the frame's virtual heights |
| +// (--ds-h and --ds-hm). Class is " ds-view--page" on the examples whose |
| +// document is a whole page and empty on the rest, with its own leading |
| +// space so the template writes class="ds-view{{.Class}}" without a |
| +// conditional; a grouped widget adds " ds-view--rows". Wrapper is the |
| +// line naming the container a wrapped sample needs; Source the markup |
| +// the HTML tab shows, formatted and highlighted. NoCopy marks a block |
| +// that offers no Copy button, as Rastrillo does for an Illustration; no |
| +// Fiesta example is one. Rows is a grouped example's code, one block |
| +// per state, and Source is empty then. |
| type previewView struct { |
| Group string |
| Style template.CSS |
| @@ -45,17 +52,168 @@ type rowCode struct { |
| // show, as one block or as a grouped example's rows. |
| func (v previewView) HasHTML() bool { return v.Source != "" || len(v.Rows) > 0 } |
| +// grouped names the examples shown as one widget: a row of every state |
| +// in one frame, and one code block per state. Rastrillo's three. |
| +var grouped = map[string]bool{"status-pill": true, "badge": true, "meter": true} |
| + |
| +// pageFrame reports whether an example's document is a whole page |
| +// rather than a component inside one: the layout documents, the two |
| +// shell idioms and the modal, whose backdrop is fixed to the frame's |
| +// viewport and wants a window-sized one. It picks the widget's width |
| +// class: a page lays out at a virtual 1200px, everything else at |
| +// 900px; view.css carries the argument for the two numbers. The |
| +// sign-in screens are components, as in Rastrillo: a card, not a |
| +// window. Getting this wrong costs a rendering, never a broken page. |
| +func pageFrame(e example.Entry) bool { |
| + return e.Document || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" |
| +} |
| + |
| +// previewClass is pageFrame as the template writes it. |
| +func previewClass(e example.Entry) string { |
| + if pageFrame(e) { |
| + return " ds-view--page" |
| + } |
| + return "" |
| +} |
| + |
| +// wrapperMarkup is the opening markup of the container a manifest |
| +// wrapper puts a body in, as the HTML tab names it. The seeded form's |
| +// method and action are left out: they belong to the reader's form, |
| +// and the seed's action="#" is a placeholder nobody should copy. "" is |
| +// no wrapper; anything else the manifest does not know is an error. |
| +func wrapperMarkup(w string) (string, error) { |
| + switch w { |
| + case "list": |
| + return `<div rst-list>`, nil |
| + case "form": |
| + return `<section rst-box><form rst-form>`, nil |
| + case "box": |
| + return `<section rst-box>`, nil |
| + case "stats": |
| + return `<div rst-stats>`, nil |
| + case "": |
| + return "", nil |
| + } |
| + return "", fmt.Errorf("no such wrapper %q", w) |
| +} |
| + |
| +// unwrap is a seeded body without the outer element(s) its wrapper |
| +// names: what the HTML tab shows and Copy copies. The body has to open |
| +// with each of the wrapper's tags in turn, each carrying the wrapper's |
| +// attribute (so a bare <section> is not a box) and whatever else the |
| +// seed gave it, and to close them in reverse at its end; a body that |
| +// does not is an error, so a seeded document that lacks its wrapper |
| +// fails the render rather than being copied with a wrapper line over |
| +// markup that has none. Whitespace between the wrapper's tags goes |
| +// with them; the inner markup is kept as written. |
| +func unwrap(markup, wrapper string) (string, error) { |
| + open, err := wrapperMarkup(wrapper) |
| + if err != nil { |
| + return "", err |
| + } |
| + if open == "" { |
| + return markup, nil |
| + } |
| + var tags, attrs []string |
| + for _, tag := range strings.SplitAfter(open, ">") { |
| + if tag == "" { |
| + continue |
| + } |
| + fields := strings.Fields(strings.Trim(tag, "<>")) |
| + tags, attrs = append(tags, fields[0]), append(attrs, fields[1]) |
| + } |
| + rest := markup |
| + for i, tag := range tags { |
| + rest = strings.TrimLeft(rest, " \t\r\n") |
| + end := strings.IndexByte(rest, '>') |
| + if !strings.HasPrefix(rest, "<"+tag) || end < 0 { |
| + return "", fmt.Errorf("the body does not open with <%s>; its %s wrapper puts it inside %s", tag, wrapper, open) |
| + } |
| + fields := strings.Fields(rest[1:end]) |
| + if len(fields) == 0 || fields[0] != tag || !carriesAttr(fields[1:], attrs[i]) { |
| + return "", fmt.Errorf("the body opens with %s, not <%s %s>; its %s wrapper puts it inside %s", rest[:end+1], tag, attrs[i], wrapper, open) |
| + } |
| + rest = rest[end+1:] |
| + } |
| + // The outermost wrapper closes last, so its closing tag comes off |
| + // first. |
| + for i := range tags { |
| + rest = strings.TrimRight(rest, " \t\r\n") |
| + close := "</" + tags[i] + ">" |
| + if !strings.HasSuffix(rest, close) { |
| + return "", fmt.Errorf("the body does not close with %s; its %s wrapper puts it inside %s", close, wrapper, open) |
| + } |
| + rest = rest[:len(rest)-len(close)] |
| + } |
| + // A wrapper never closed would have taken an inner element's |
| + // closing tag as its own: the inner markup has to open and close |
| + // each wrapper tag as many times. |
| + for _, tag := range tags { |
| + if strings.Count(rest, "<"+tag+">")+strings.Count(rest, "<"+tag+" ") != strings.Count(rest, "</"+tag+">") { |
| + return "", fmt.Errorf("the body does not close its %s wrapper; it puts the markup inside %s", wrapper, open) |
| + } |
| + } |
| + return rest, nil |
| +} |
| + |
| +// carriesAttr says whether a tag's attribute fields carry name, bare or |
| +// with a value. |
| +func carriesAttr(fields []string, name string) bool { |
| + for _, f := range fields { |
| + if f == name || strings.HasPrefix(f, name+"=") { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// frameTitle is the frame's accessible name: the example and the |
| +// state's label, or the example's name alone where the state has no |
| +// label (the idioms' default states), rather than a name that ends in |
| +// a bare colon. |
| +func (s *site) frameTitle(locale string, e example.Entry, st example.State) string { |
| + label := s.label(locale, e, st) |
| + if label == "" { |
| + return e.Name |
| + } |
| + return s.say(locale, "gallery.frame.title", "example", e.Name, "state", label) |
| +} |
| + |
| +// wrapLine is the sentence above a wrapped sample's code, naming the |
| +// container with its opening markup in the mono face the gallery gives |
| +// every identifier (a span with rst-mono, as the rail's items wear it, |
| +// rather than a <code>, so the state's one <code> on the page is the |
| +// block a reader copies). The sentence is the page's, escaped; the |
| +// markup goes in after, so the words around it can be any language's. |
| +func (s *site) wrapLine(locale, open string) template.HTML { |
| + sentence := template.HTMLEscapeString(s.say(locale, "gallery.wrap")) |
| + code := `<span class="rst-mono">` + template.HTMLEscapeString(open) + "</span>" |
| + return template.HTML(strings.Replace(sentence, "{wrapper}", code, 1)) |
| +} |
| + |
| // preview is one state's widget: the frame at frameURL, titled for the |
| -// example and state, and code highlighted for the HTML tab where there |
| -// is any. |
| +// example and state, at its measured heights, and the code for the |
| +// HTML tab: a component's body without its wrapper, with the line that |
| +// names the wrapper, or a layout's whole document. |
| func (s *site) preview(p page, e example.Entry, st example.State, frameURL, code string) (previewView, error) { |
| v := previewView{ |
| Group: anchorID(e.Name, st.ID), |
| + Style: previewStyle(p.Locale, e.Name+"/"+st.ID), |
| + Class: previewClass(e), |
| Src: frameURL, |
| - Title: s.say(p.Locale, "gallery.frame.title", "example", e.Name, "state", s.label(p.Locale, e, st)), |
| + Title: s.frameTitle(p.Locale, e, st), |
| } |
| - if tall(e) { |
| - v.Class = " ds-view--page" |
| + if !e.Document { |
| + open, err := wrapperMarkup(e.Wrapper) |
| + if err != nil { |
| + return previewView{}, err |
| + } |
| + if code, err = unwrap(code, e.Wrapper); err != nil { |
| + return previewView{}, err |
| + } |
| + if open != "" { |
| + v.Wrapper = s.wrapLine(p.Locale, open) |
| + } |
| } |
| if code != "" { |
| v.Source = codeview.Highlight(codeview.Format(code)) |
| @@ -63,22 +221,68 @@ func (s *site) preview(p page, e example.Entry, st example.State, frameURL, code |
| return v, nil |
| } |
| -// tall says whether an example's frames are drawn as pages rather |
| -// than fitted to their content: a layout, a screen, a shell idiom, and |
| -// the modal, whose overlay covers whatever viewport it gets. |
| -func tall(e example.Entry) bool { |
| - return e.Document || e.Kind == "screen" || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" |
| +// rowsStyle lays a grouped document out as a row that wraps into rows |
| +// on a phone, each state under its label. It is in the document's own |
| +// <style> because a ds- class would need gallery.css inside every |
| +// frame. |
| +const rowsStyle = "body > ul { display: flex; flex-wrap: wrap; gap: 1rem 1.5rem; list-style: none; margin: 0; padding: 0; }\n" + |
| + "body > ul > li > p { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); margin: 0 0 0.35rem; }\n" |
| + |
| +// groupedPreview is one grouped example's widget and the body of the |
| +// row document its frame loads: every state, labelled, in one frame; |
| +// one code block per state in the HTML panel. The row document is |
| +// built like a seeded example, so frame treats it like one. A grouped |
| +// example with a wrapper is refused: the grouped panels carry no |
| +// wrapper line, so its rows would be copied without their container. |
| +func (s *site) groupedPreview(p page, e example.Entry) (view previewView, rowDoc string, err error) { |
| + if e.Wrapper != "" { |
| + return previewView{}, "", fmt.Errorf("%s is grouped and wrapped in %q; the grouped panels have no wrapper line", e.Name, e.Wrapper) |
| + } |
| + if len(e.States) == 0 { |
| + return previewView{}, "", errors.New(e.Name + " is grouped and has no states") |
| + } |
| + view = previewView{ |
| + Group: anchorID(e.Name, "row"), |
| + Style: previewStyle(p.Locale, e.Name+"/row"), |
| + Class: previewClass(e) + " ds-view--rows", |
| + Src: s.href(p.Theme, p.Locale, "frames/"+e.Name+"/row.html"), |
| + Title: e.Name, |
| + } |
| + var body strings.Builder |
| + body.WriteString("<ul>") |
| + for _, st := range e.States { |
| + markup, err := example.Markup(s.src, e.Name, st.ID) |
| + if err != nil { |
| + return previewView{}, "", err |
| + } |
| + label := s.label(p.Locale, e, st) |
| + body.WriteString("<li><p>" + template.HTMLEscapeString(label) + "</p>" + markup + "</li>") |
| + view.Rows = append(view.Rows, rowCode{State: label, Source: codeview.Highlight(codeview.Format(markup))}) |
| + } |
| + body.WriteString("</ul>") |
| + return view, exampleDocument(view.Title, e.Scripts, rowsStyle, body.String()), nil |
| } |
| -// viewTemplate is the widget: the frame on its stage, and the HTML in a |
| -// disclosure under it, which is no script and the browser's own |
| -// control. |
| -const viewTemplate = `{{define "ds-view"}}<div class="ds-view{{.Class}}"{{with .Style}} style="{{.}}"{{end}}> |
| -<div class="ds-view__stage"><iframe class="ds-view__frame" title="{{.Title}}" src="{{.Src}}" loading="lazy"></iframe></div> |
| -{{if .HasHTML}}<details class="ds-view__code"><summary>{{say "gallery.html"}}</summary> |
| +// viewTemplate is the widget. The tabs are radios in labels sharing |
| +// the widget's name, none checked; the frame sits in a box on the |
| +// stage; the HTML panel holds one block, or a grouped example's rows |
| +// each under its label. Nothing in it runs: view.css switches the |
| +// panels on :has(). |
| +const viewTemplate = `{{define "ds-view"}}<div class="ds-view{{.Class}}" style="{{.Style}}"> |
| +<fieldset class="ds-view__tabs"><legend class="rst-sr-only">{{say "gallery.view.legend"}}</legend> |
| +<span class="ds-view__pair"><label class="ds-view__tab ds-view__tab--d"><input type="radio" name="{{.Group}}">{{say "gallery.view.desktop"}}</label> |
| +<label class="ds-view__tab ds-view__tab--m"><input type="radio" name="{{.Group}}">{{say "gallery.view.mobile"}}</label></span> |
| +{{if .HasHTML}}<span class="ds-view__pair"><label class="ds-view__tab ds-view__tab--h"><input type="radio" name="{{.Group}}">{{say "gallery.html"}}</label></span> |
| +{{end}}</fieldset> |
| +<div class="ds-view__stage"><div class="ds-view__box"><iframe class="ds-view__frame" title="{{.Title}}" src="{{.Src}}" loading="lazy"></iframe></div></div> |
| +{{if .Rows}}<div class="ds-view__code ds-view__code--h"> |
| +{{range .Rows}}<p class="ds-state">{{.State}}</p> |
| +<pre class="ds-src rst-mono" tabindex="0"><code>{{.Source}}</code></pre> |
| +{{end}}</div> |
| +{{else if .Source}}<div class="ds-view__code ds-view__code--h"> |
| {{if .Wrapper}}<p class="ds-wrap">{{.Wrapper}}</p> |
| {{end}}<pre class="ds-src rst-mono" tabindex="0"{{if .NoCopy}} data-ds-nocopy{{end}}><code>{{.Source}}</code></pre> |
| -</details> |
| +</div> |
| {{end}}</div>{{end}}` |
| func init() { registerTemplate(viewTemplate) } |
diff --git a/internal/gallery/view.js b/internal/gallery/view.js| index b6dc1b7..aff62b5 100644 |
| --- a/internal/gallery/view.js |
| +++ b/internal/gallery/view.js |
| @@ -1,26 +1,29 @@ |
| /* view.js: the preview widget's half of the gallery's script, served |
| joined after gallery.js as one file, and no part of the design |
| - system. It is the frame-painting half of rastrillo's gallery script, |
| - and nothing else: the scheme itself is fiesta.js's, from the scheme |
| - menu in the header, and this file only carries what fiesta.js |
| - decided into every preview frame. |
| - |
| - Every example on the page is a document of its own inside an |
| - iframe, and an iframe does not inherit the reader's choice: a colour |
| - scheme is not propagated into an embedded document that declares |
| - one, and every preview links a theme that does. The frames are |
| - same-origin, so the fix is the attribute fiesta.js wrote on this |
| - page's <html>, written again on each of them: at the frame's load, |
| - and on every rst-scheme event after. frame.js, first in each frame's |
| - head, does the same for itself before its first paint, so a frame |
| - loaded after the choice starts in it. |
| - |
| - A frame is hidden by view.css until this file marks it painted, |
| - which is why the joined script is a blocking script in <head>: the |
| - marker that turns the hiding rule on has to be there before the |
| - first frame can paint in the wrong scheme. With scripts off the |
| - marker is never set, nothing is hidden, and the frames show as they |
| - come, in the OS's scheme, which is what System means anyway. */ |
| + system. Two jobs: the frames, and the Copy button on every code |
| + block. It is Rastrillo's frame-painting and Copy code; the scheme |
| + itself is fiesta.js's, from the scheme menu in the header, and this |
| + file only carries what fiesta.js decided into every preview frame. |
| + |
| + The frames. Every example on the page is a document of its own |
| + inside an iframe, and an iframe does not inherit the reader's |
| + choice: a colour scheme is not propagated into an embedded document |
| + that declares one, and every preview links a theme that does. The |
| + frames are same-origin, so the fix is the attribute fiesta.js wrote |
| + on this page's <html>, written again on each of them: once each |
| + document is parsed and styled, and on every rst-scheme event after. |
| + frame.js, first in each frame's head, does the same for itself |
| + before its first paint, so a frame loaded after the choice starts in |
| + it; a document a navigation inside the frame loads from elsewhere |
| + has no such script, and is what the hiding below is for. |
| + |
| + A frame is hidden by view.css until this file marks it painted |
| + (data-gallery-painted), which is why the joined script is a blocking |
| + script in <head>: the marker that turns the hiding rule on |
| + (data-gallery-js) has to be there before the first frame can paint |
| + in the wrong scheme. With scripts off the marker is never set, |
| + nothing is hidden, and the frames show as they come, in the OS's |
| + scheme, which is what System means anyway. */ |
| (function () { |
| "use strict"; |
| @@ -49,47 +52,131 @@ |
| else doc.documentElement.removeAttribute("data-theme"); |
| } |
| - // A component's frame is as tall as its document, between a floor |
| - // and a ceiling; a page's frame keeps the stylesheet's height, since |
| - // a page lays itself out to whatever viewport it gets. Measured off |
| - // the body's box rather than scrollHeight, which is never less than |
| - // the frame it is measured in. The page class is the widget's |
| - // (ds-view--page on .ds-view), not the frame's own, so it is read |
| - // off the nearest widget. |
| - function fit(frame) { |
| - var doc = readable(frame); |
| - if (!doc || !doc.body || frame.closest(".ds-view--page")) return; |
| - var body = doc.body, style = doc.defaultView.getComputedStyle(body); |
| - var h = body.getBoundingClientRect().bottom + (parseFloat(style.marginBottom) || 0) + 16; |
| - frame.style.blockSize = Math.min(Math.max(Math.round(h), 160), 720) + "px"; |
| + function frames() { |
| + return document.querySelectorAll(".ds-view__frame"); |
| + } |
| + |
| + function all(s) { |
| + var f = frames(); |
| + for (var i = 0; i < f.length; i++) paint(f[i], s); |
| + } |
| + |
| + // Phase two, once the body exists. This file is in <head> and not |
| + // deferred, so readyState is always "loading" here; the branch is |
| + // for a copy of it moved to the foot of the page. |
| + function ready(fn) { |
| + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| + else fn(); |
| } |
| // Shown even if it could not be painted: a frame in the wrong scheme |
| // beats one that never appears. |
| function shown(frame) { |
| paint(frame, scheme()); |
| - fit(frame); |
| frame.setAttribute("data-gallery-painted", ""); |
| } |
| - function frames() { |
| - return document.querySelectorAll(".ds-view__frame"); |
| + // DOMContentLoaded does not wait for stylesheets, and a frame shown |
| + // then shows its document without them. Each sheet not yet arrived |
| + // is waited for, loaded or failed; one that failed before this |
| + // looked never says so, and the frame's load shows it instead. |
| + function styled(frame, doc) { |
| + var links = doc.querySelectorAll('link[rel~="stylesheet"]'), left = 1; |
| + function one() { |
| + if (--left === 0 && readable(frame) === doc) shown(frame); |
| + } |
| + for (var i = 0; i < links.length; i++) { |
| + if (links[i].sheet) continue; |
| + left++; |
| + links[i].addEventListener("load", one); |
| + links[i].addEventListener("error", one); |
| + } |
| + one(); |
| } |
| - function all(s) { |
| - var f = frames(); |
| - for (var i = 0; i < f.length; i++) paint(f[i], s); |
| + // Every document a frame loads goes through arm(), the first and each |
| + // one a sample's form or link loads in it after, and in this order: |
| + // the hook that hides the frame again as the document goes, then the |
| + // reveal. Shown first, a document that navigated before its own load |
| + // left the mark in place, and the next one painted in the OS's |
| + // scheme, unhidden. Not cleared when the whole gallery page enters |
| + // the back/forward cache (persisted), which unloads nothing and |
| + // restores as it was. |
| + // |
| + // Parsed and styled is soon enough to show it: waiting for load kept |
| + // a parsed preview an empty box while an image was slow, or for as |
| + // long as the network took to give up on one that hung. |
| + function arm(frame, armed) { |
| + var doc; |
| + try { |
| + doc = frame.contentDocument; |
| + } catch (e) { |
| + return false; |
| + } |
| + if (!doc || doc === armed.doc || doc.URL === "about:blank") return false; |
| + armed.doc = doc; |
| + doc.defaultView.addEventListener("pagehide", function (event) { |
| + if (event.persisted) return; |
| + frame.removeAttribute("data-gallery-painted"); |
| + seek(frame, armed); |
| + }); |
| + if (doc.readyState === "loading") { |
| + doc.addEventListener("DOMContentLoaded", function () { |
| + if (readable(frame) === doc) styled(frame, doc); |
| + }); |
| + } else { |
| + styled(frame, doc); |
| + } |
| + return true; |
| + } |
| + |
| + // The parent hears nothing when a navigation inside a frame commits |
| + // its next document, so after pagehide it looks until the frame holds |
| + // one, and arms it. The look ends there, or at the frame's load, |
| + // which arms whatever is there; a document from another origin cannot |
| + // be read, and load shows the frame unpainted. |
| + // |
| + // It also ends when the frame leaves the page, and after SEEK_MS |
| + // whatever happens. Without those, a frame removed mid-look, or sent |
| + // to another origin whose load hangs, kept a 16ms timer running for |
| + // the life of the page, holding the frame and its old document, and |
| + // the second kept the frame hidden for good. Giving up shows the |
| + // frame unpainted, as load would have. |
| + var SEEK_MS = 5000; |
| + |
| + function seek(frame, armed) { |
| + clearTimeout(armed.timer); |
| + var end = Date.now() + SEEK_MS; |
| + (function look() { |
| + armed.timer = 0; |
| + if (!frame.isConnected || arm(frame, armed)) return; |
| + if (Date.now() >= end) shown(frame); |
| + else armed.timer = setTimeout(look, 16); |
| + })(); |
| } |
| - // A frame's document is painted and shown at its load, or at once |
| - // when it was already complete before this ran (a frame served from |
| - // cache, or a page whose frames loaded before DOMContentLoaded). |
| + // A frame's first document reuses the window of the about:blank it |
| + // replaces (same origin), so a listener on that window hears the |
| + // preview's DOMContentLoaded and arms it while it is still parsing; |
| + // the frames are lazy, so nothing looks for them before then. load |
| + // stays the fallback, for an engine that does not reuse the window. |
| function watch(frame) { |
| + var armed = { doc: null, timer: 0 }; |
| + try { |
| + frame.contentWindow.addEventListener("DOMContentLoaded", function () { |
| + arm(frame, armed); |
| + }); |
| + } catch (e) { |
| + /* no window yet, or not readable; load will arm it */ |
| + } |
| frame.addEventListener("load", function () { |
| + clearTimeout(armed.timer); |
| + arm(frame, armed); |
| shown(frame); |
| }); |
| - var doc = readable(frame); |
| - if (doc && doc.readyState === "complete") shown(frame); |
| + // A document already parsed before this ran (a frame served from |
| + // cache) has had its DOMContentLoaded. |
| + arm(frame, armed); |
| } |
| // fiesta.js dispatches rst-scheme from the form after it has written |
| @@ -106,13 +193,75 @@ |
| if (event.persisted) all(scheme()); |
| }); |
| - function ready(fn) { |
| - if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| - else fn(); |
| - } |
| - |
| ready(function () { |
| var f = frames(); |
| for (var i = 0; i < f.length; i++) watch(f[i]); |
| }); |
| + |
| + // ── Copy ──────────────────────────────────────────────────────────── |
| + // |
| + // A button on every source block, drawn only where the clipboard API |
| + // exists. On a plain-HTTP origin (a tailnet address) it does not, and |
| + // a button that cannot work is worse than a <pre> a reader can |
| + // select. The words are the page's, read off the live region it |
| + // renders in its own language; a button's name is built from text |
| + // already on screen, so the server writes nothing per block. |
| + ready(function () { |
| + var region = document.querySelector("[data-ds-copy-status]"); |
| + if (!region || !navigator.clipboard || !navigator.clipboard.writeText) return; |
| + var d = region.dataset, pres = document.querySelectorAll("pre.ds-src:not([data-ds-nocopy])"); |
| + |
| + // The name after the label: the tab the block is under, the |
| + // section (its anchored heading, or the last heading before it), |
| + // and the nearest state label above. On an example page the |
| + // anchored section's heading is the state's own label, so the two |
| + // read the same and the second is dropped; a grouped widget's rows |
| + // have the page's heading and then each row's label. |
| + function name(pre) { |
| + var sec = pre.closest("[data-ds-anchor]"), h = sec && sec.querySelector("h1, h2, h3, h4"); |
| + var all = document.querySelectorAll("main :is(h1, h2, h3, h4)"), sample = pre.closest(".ds-sample"); |
| + if (!h) for (var i = 0; i < all.length && all[i].compareDocumentPosition(pre) & 4; i++) h = all[i]; |
| + for (var s = pre.previousElementSibling; s && !s.matches(".ds-state"); s = s.previousElementSibling); |
| + s = s || sample && sample.querySelector(":scope > .ds-state"); |
| + var panel = pre.closest(".ds-view__code"), tab = panel && panel.parentNode.querySelector(".ds-view__tab--h"); |
| + var parts = [tab, h, s].filter(Boolean).map(function (e) { |
| + return e.textContent.trim(); |
| + }); |
| + return parts.filter(function (text, i) { |
| + return text && text !== parts[i - 1]; |
| + }).join(d.join); |
| + } |
| + |
| + // Emptied and written a beat later, so Copied twice in a row is |
| + // still announced. |
| + function say(text) { |
| + region.textContent = ""; |
| + setTimeout(function () { region.textContent = text; }, 50); |
| + } |
| + |
| + for (var i = 0; i < pres.length; i++) (function (pre) { |
| + var code = pre.querySelector("code") || pre, b = document.createElement("button"); |
| + var label = document.createTextNode(d.copy), suffix = document.createElement("span"), timer; |
| + b.type = "button"; |
| + b.className = "ds-copy"; |
| + suffix.className = "rst-sr-only"; |
| + suffix.textContent = " " + name(pre); |
| + b.append(label, suffix); |
| + pre.before(b); |
| + // Copied is said only once the write resolves. A refusal (a |
| + // permission, lost focus, a policy) or a throw is announced as a |
| + // failure and the block selected, so Ctrl or Cmd+C works at once. |
| + b.addEventListener("click", function () { |
| + new Promise(function (ok) { ok(navigator.clipboard.writeText(code.textContent)); }).then(function () { |
| + say(d.copied); |
| + label.data = d.copied; |
| + clearTimeout(timer); |
| + timer = setTimeout(function () { label.data = d.copy; }, 2000); |
| + }, function () { |
| + say(d.failed); |
| + getSelection().selectAllChildren(code); |
| + }); |
| + }); |
| + })(pres[i]); |
| + }); |
| })(); |
diff --git a/internal/gallery/view_browser_test.go b/internal/gallery/view_browser_test.go| new file mode 100644 |
| index 0000000..4449306 |
| --- /dev/null |
| +++ b/internal/gallery/view_browser_test.go |
| @@ -0,0 +1,778 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "slices" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The preview widget in a real engine: Rastrillo's drives, each on |
| +// this task's own markup. The widget is three radios, a container |
| +// query and a scaled frame, and none of what they do shows in the |
| +// rendered bytes. |
| + |
| +// pageURL is one page of the default theme in locale, at the rig. |
| +func pageURL(rig *harness.Rig, locale, file string) string { |
| + return rig.Origin + href(fiesta.ThemeNames()[0]+"/"+locale+"/"+file) |
| +} |
| + |
| +// requireWidget fails a leg by name when the page carries no widget as |
| +// this task renders it: a box on the stage and a Desktop radio per |
| +// frame. Every drive here asks this first, so a page without the |
| +// widget fails on the spot rather than at a wait's deadline. |
| +func requireWidget(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + var got struct{ Boxes, Tabs, Frames int } |
| + at(t, ctx, `JSON.stringify({Boxes: document.querySelectorAll(".ds-view > .ds-view__stage > .ds-view__box").length, |
| + Tabs: document.querySelectorAll(".ds-view__tabs .ds-view__tab--d input[type=radio]").length, |
| + Frames: document.querySelectorAll(".ds-view__frame").length})`, &got) |
| + if got.Boxes == 0 || got.Tabs != got.Boxes || got.Frames != got.Boxes { |
| + t.Fatalf("%s: %d widget boxes, %d Desktop tabs, %d frames; the preview widget is not on this page as this drive expects", where, got.Boxes, got.Tabs, got.Frames) |
| + } |
| +} |
| + |
| +// pickScheme chooses one scheme from the header's menu: the visible |
| +// copy of the controls (a page carries the bar's and the phone menu's), |
| +// opened, and its button pressed, as a reader would. Through the |
| +// debugger rather than chromedp.Click, which waits on the first copy in |
| +// the document being visible. |
| +func pickScheme(t *testing.T, ctx context.Context, scheme string) { |
| + t.Helper() |
| + var ok bool |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { |
| + const menus = [...document.querySelectorAll("[rst-scheme]")]; |
| + const m = menus.find(m => m.checkVisibility()) || menus[0]; |
| + if (!m) return false; |
| + m.open = true; |
| + const b = m.querySelector('input[value="`+scheme+`"] ~ button'); |
| + if (!b) return false; |
| + b.click(); |
| + return true; |
| + })()`, &ok)) |
| + if !ok { |
| + t.Fatalf("no scheme menu with a %s button on the page", scheme) |
| + } |
| +} |
| + |
| +// openPage sizes the viewport, loads url and requires the widget. |
| +func openPage(t *testing.T, ctx context.Context, where, url string, w, h int64) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| + requireWidget(t, ctx, where) |
| +} |
| + |
| +// framing is one reading of a widget: the frame's layout, what the |
| +// framed document reports, which panel is on screen, and the scheme |
| +// the framed document resolved to. |
| +type framing struct { |
| + Virtual, Painted, Inner, Gutter float64 |
| + Body string |
| + FrameShown, HTMLShown bool |
| + Tabs []string |
| + Scheme string |
| +} |
| + |
| +// TestPreviewWidgetDrivesTheWholeJourney: on the callout page, the |
| +// widget opens on the framed rendering at a component's 900px; Mobile |
| +// is the same document at 390px; HTML replaces the frame with its |
| +// panel and nothing else; every link in every framed document goes |
| +// nowhere; the grip really resizes the document's viewport; Dark |
| +// chosen in the header reaches the framed documents; and with scripts |
| +// off the HTML tab still works. Port of Rastrillo's drive without its |
| +// Template tab. |
| +func TestPreviewWidgetDrivesTheWholeJourney(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() |
| + url := pageURL(rig, "en", "callout.html") |
| + const widget = `document.querySelector("#info .ds-view")` |
| + read := func(sel string) string { |
| + return `(() => { |
| + const v = ` + sel + `; |
| + const f = v.querySelector(".ds-view__frame"), box = v.querySelector(".ds-view__box"), stage = v.querySelector(".ds-view__stage"); |
| + const html = v.querySelector(".ds-view__code--h"); |
| + const d = f.contentDocument; |
| + return JSON.stringify({ |
| + Virtual: parseFloat(getComputedStyle(f).width), |
| + Painted: box.getBoundingClientRect().width, |
| + Inner: d ? d.documentElement.getBoundingClientRect().width : -1, |
| + Gutter: d ? d.defaultView.innerWidth - d.documentElement.getBoundingClientRect().width : 0, |
| + Body: d ? d.body.innerHTML.trim().slice(0, 120) : "", |
| + FrameShown: getComputedStyle(stage).display !== "none", |
| + HTMLShown: html ? getComputedStyle(html).display !== "none" : false, |
| + Tabs: [...v.querySelectorAll(".ds-view__tab")].map(l => l.textContent.trim()), |
| + Scheme: d ? getComputedStyle(d.documentElement).colorScheme + " " + getComputedStyle(d.body).backgroundColor : "" |
| + }); |
| + })()` |
| + } |
| + openPage(t, ctx, "callout at 1280px", url, 1280, 900) |
| + painted(t, ctx, "callout") |
| + |
| + var desktop, mobile, htmlTab, dark, scriptless, resized string |
| + var deadLinks int |
| + step := func(what string, actions ...chromedp.Action) { |
| + t.Helper() |
| + if err := chromedp.Run(ctx, actions...); err != nil { |
| + t.Fatalf("%s: %v", what, err) |
| + } |
| + } |
| + step("reading Desktop", chromedp.Evaluate(read(widget), &desktop)) |
| + // Clicking the label, the way a reader does, which is also the |
| + // proof that the label and its radio are paired. |
| + step("choosing Mobile", chromedp.Click(`#info .ds-view__tab--m`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &mobile)) |
| + step("choosing HTML", chromedp.Click(`#info .ds-view__tab--h`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &htmlTab)) |
| + // Every link in every framed document goes nowhere, or to a page |
| + // of this tree: read off the documents themselves. |
| + step("reading the links", chromedp.Evaluate(`(() => { |
| + let bad = 0; |
| + for (const f of document.querySelectorAll(".ds-view__frame")) { |
| + const d = f.contentDocument; |
| + if (!d) continue; |
| + for (const a of d.querySelectorAll("a[href]")) { |
| + const href = a.getAttribute("href"); |
| + if (!href.startsWith("#") && !href.startsWith(`+strconv.Quote(mountPath+"/")+`)) bad++; |
| + } |
| + } |
| + return bad; |
| + })()`, &deadLinks)) |
| + // The grip: the box is what the reader drags, and the frame takes |
| + // its height back off the box, so the document inside really does |
| + // get taller. |
| + step("dragging the grip", chromedp.Click(`#info .ds-view__tab:first-of-type`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(`(() => { |
| + const box = document.querySelector("#info .ds-view__box"), f = document.querySelector("#info .ds-view__frame"); |
| + const before = f.contentDocument.documentElement.clientHeight; |
| + box.style.height = Math.round(box.getBoundingClientRect().height * 2) + "px"; |
| + return JSON.stringify({before: before, after: f.contentDocument.documentElement.clientHeight, |
| + painted: Math.round(f.getBoundingClientRect().height), box: Math.round(box.getBoundingClientRect().height)}); |
| + })()`, &resized)) |
| + pickScheme(t, ctx, "dark") |
| + step("reading Dark", chromedp.Sleep(400*time.Millisecond), chromedp.Evaluate(read(widget), &dark)) |
| + |
| + // The same with the page's scripts switched off at the engine. |
| + off := noScripts(t, rig.Context()) |
| + offCtx, cancelOff := context.WithTimeout(off, 120*time.Second) |
| + defer cancelOff() |
| + openPage(t, offCtx, "callout at 1280px, scripts off", url, 1280, 900) |
| + requireScriptsOff(t, offCtx, "callout, scripts off") |
| + mustRun(t, offCtx, |
| + chromedp.Click(`#info .ds-view__tab--h`, chromedp.ByQuery), |
| + chromedp.Sleep(400*time.Millisecond), |
| + chromedp.Evaluate(`(() => { |
| + const v = `+widget+`; |
| + return JSON.stringify({FrameShown: getComputedStyle(v.querySelector(".ds-view__stage")).display !== "none", |
| + HTMLShown: getComputedStyle(v.querySelector(".ds-view__code--h")).display !== "none", |
| + Body: "", Virtual: 0, Painted: 0, Inner: 0, Gutter: 0, Scheme: "", Tabs: []}); |
| + })()`, &scriptless), |
| + ) |
| + |
| + var d, m, h, dk, o framing |
| + for _, p := range []struct { |
| + raw string |
| + into *framing |
| + }{{desktop, &d}, {mobile, &m}, {htmlTab, &h}, {dark, &dk}, {scriptless, &o}} { |
| + if err := json.Unmarshal([]byte(p.raw), p.into); err != nil { |
| + t.Fatalf("reading a framing (%q): %v", p.raw, err) |
| + } |
| + } |
| + // A component lays out at 900px whatever the column is; the number |
| + // is written here on purpose, the one assertion that would still |
| + // fail if the stylesheet lost the component class. |
| + const componentWidth = 900 |
| + if d.Virtual != componentWidth { |
| + t.Errorf("the desktop frame lays out at %gpx, want %d", d.Virtual, componentWidth) |
| + } |
| + if d.Inner+d.Gutter != componentWidth { |
| + t.Errorf("the framed document laid out at %gpx with a %gpx scrollbar gutter, %gpx in all, want %d", d.Inner, d.Gutter, d.Inner+d.Gutter, componentWidth) |
| + } |
| + if d.Painted < componentWidth-2 || d.Painted > componentWidth+2 { |
| + t.Errorf("the desktop frame is painted %gpx wide in a 1280px window and its virtual page is %dpx; a component that fits its stage is not scaled", d.Painted, componentWidth) |
| + } |
| + t.Logf("desktop preview: a %gpx virtual page painted %gpx wide in a 1280px window, scale %.3f", d.Virtual, d.Painted, d.Painted/d.Virtual) |
| + if !d.FrameShown || d.HTMLShown { |
| + t.Error("the page does not open on the framed rendering") |
| + } |
| + if want := []string{say("gallery.view.desktop"), say("gallery.view.mobile"), say("gallery.html")}; !slices.Equal(d.Tabs, want) { |
| + t.Errorf("the callout's tabs are %q, want %q", d.Tabs, want) |
| + } |
| + if !strings.Contains(d.Body, "rst-callout") { |
| + t.Errorf("the framed document is not the callout sample: %q", d.Body) |
| + } |
| + if m.Virtual != 390 { |
| + t.Errorf("the mobile frame lays out at %gpx, want 390", m.Virtual) |
| + } |
| + if m.Inner+m.Gutter != 390 { |
| + t.Errorf("the framed document laid out at %gpx with a %gpx gutter on the Mobile tab, %gpx in all, want 390", m.Inner, m.Gutter, m.Inner+m.Gutter) |
| + } |
| + if m.Body != d.Body { |
| + t.Error("Desktop and Mobile are not the same document") |
| + } |
| + if h.FrameShown || !h.HTMLShown { |
| + t.Errorf("the HTML tab shows frame=%v HTML=%v", h.FrameShown, h.HTMLShown) |
| + } |
| + var grip struct{ Before, After, Painted, Box int } |
| + if err := json.Unmarshal([]byte(resized), &grip); err != nil { |
| + t.Fatalf("reading the resize (%q): %v", resized, err) |
| + } |
| + if grip.After <= grip.Before { |
| + t.Errorf("the box was dragged to %dpx and the framed document's viewport stayed %dpx; the grip moves the box and not the document", grip.Box, grip.After) |
| + } |
| + if grip.Painted < grip.Box-4 { |
| + t.Errorf("after the drag the box is %dpx and the rendering in it is %dpx", grip.Box, grip.Painted) |
| + } |
| + if deadLinks != 0 { |
| + t.Errorf("%d links inside the framed documents still point at a route this tree does not serve", deadLinks) |
| + } |
| + if !strings.HasPrefix(dk.Scheme, "dark ") { |
| + t.Errorf("after choosing Dark the framed document is %q; the previews are not following the gallery", dk.Scheme) |
| + } |
| + if dk.Scheme == d.Scheme { |
| + t.Errorf("the framed document is %q both before and after Dark was chosen; the reading proves nothing", d.Scheme) |
| + } |
| + if o.FrameShown || !o.HTMLShown { |
| + t.Errorf("with scripts disabled the HTML tab shows frame=%v HTML=%v; the tabs need JavaScript", o.FrameShown, o.HTMLShown) |
| + } |
| +} |
| + |
| +// ── The widget on a phone ──────────────────────────────────────────── |
| + |
| +// minShownSample is the least of a sample a reader must be able to see |
| +// in a box, in CSS pixels: about one line of the smallest sample. |
| +const minShownSample = 32.0 |
| + |
| +// kMin mirrors --ds-kmin in view.css: 12.5px × 0.72 = 9.0px is where |
| +// rendered text stops being read. |
| +const kMin = 0.72 |
| + |
| +// previewBox is one widget's geometry as the engine has it. Tabs are |
| +// identified by position, 0 Desktop, 1 Mobile, 2 HTML. |
| +type previewBox struct { |
| + ID string |
| + Hidden bool |
| + Box float64 |
| + Width string |
| + Desk string |
| + Scale string |
| + Lit []int |
| + Checked []int |
| + View float64 |
| + Stage float64 |
| + Doc float64 |
| + Inner float64 |
| + PanX float64 |
| + OverX string |
| + BoxL, BoxR float64 |
| + DocW float64 |
| +} |
| + |
| +const readBoxes = `(() => { |
| + const out = []; |
| + document.querySelectorAll(".ds-view").forEach(v => { |
| + const stage = v.querySelector(".ds-view__stage"), box = v.querySelector(".ds-view__box"), frame = v.querySelector(".ds-view__frame"); |
| + let d = null; try { d = frame.contentDocument; } catch (e) {} |
| + const tabs = [...v.querySelectorAll(".ds-view__tab")], lit = [], checked = []; |
| + tabs.forEach((l, n) => { |
| + if (getComputedStyle(l).fontWeight === "600") lit.push(n); |
| + const i = l.querySelector("input"); |
| + if (i && i.checked) checked.push(n); |
| + }); |
| + const section = v.closest("article, section"); |
| + out.push({ |
| + ID: section && section.id ? section.id : "widget-" + out.length, |
| + Hidden: getComputedStyle(stage).display === "none", |
| + Box: Math.round(box.getBoundingClientRect().height * 10) / 10, |
| + Width: frame.offsetWidth + "px", |
| + Desk: getComputedStyle(box).getPropertyValue("--ds-wd").trim(), |
| + Scale: getComputedStyle(frame).transform, |
| + Lit: lit, Checked: checked, |
| + View: Math.round(v.getBoundingClientRect().width * 100) / 100, |
| + Stage: Math.round(stage.getBoundingClientRect().width * 100) / 100, |
| + Doc: d && d.body ? Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight) : -1, |
| + Inner: Math.round(box.clientHeight * 10) / 10, |
| + PanX: Math.round((box.scrollWidth - box.clientWidth) * 10) / 10, |
| + OverX: getComputedStyle(box).overflowX, |
| + BoxL: box.getBoundingClientRect().left, BoxR: box.getBoundingClientRect().right, DocW: document.documentElement.clientWidth, |
| + }); |
| + }); |
| + return JSON.stringify(out); |
| +})()` |
| + |
| +var ( |
| + clickedMobile = galleryrig.ClickEvery("m") |
| + clickedDesktop = galleryrig.ClickEvery("d") |
| +) |
| + |
| +// clickAll clicks every radio of one kind and refuses to go on unless |
| +// the clicks moved something. |
| +func clickAll(t *testing.T, ctx context.Context, where, script, what string) { |
| + t.Helper() |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(script, &raw), chromedp.Sleep(400*time.Millisecond)) |
| + var got struct{ Clicked, Moved int } |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the %s click (%q): %v", where, what, raw, err) |
| + } |
| + if got.Clicked == 0 || got.Moved != got.Clicked { |
| + t.Fatalf("%s: %d %s radios clicked and %d moved; the readings after this click are readings of the state before it", where, got.Clicked, what, got.Moved) |
| + } |
| +} |
| + |
| +func scrolls(overflow string) bool { return overflow == "auto" || overflow == "scroll" } |
| + |
| +// noSidewaysPage: a preview that pans has to pan inside its own box. |
| +func noSidewaysPage(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + var over float64 |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.scrollWidth - document.documentElement.clientWidth`, &over)) |
| + if over > 1 { |
| + t.Errorf("%s: the page itself scrolls %.1fpx sideways", where, over) |
| + } |
| +} |
| + |
| +func boxes(t *testing.T, ctx context.Context, where string) []previewBox { |
| + t.Helper() |
| + var got []previewBox |
| + at(t, ctx, readBoxes, &got) |
| + if len(got) == 0 { |
| + t.Fatalf("%s: the page has no preview widget on it at all", where) |
| + } |
| + return got |
| +} |
| + |
| +var tabNames = [...]string{"Desktop", "Mobile", "HTML"} |
| + |
| +func tabName(n int) string { |
| + if n < 0 || n >= len(tabNames) { |
| + return fmt.Sprintf("tab %d", n) |
| + } |
| + return tabNames[n] |
| +} |
| + |
| +// mobileWidth is the Mobile rendering's width for one widget: the |
| +// stage's, up to 390px. |
| +func mobileWidth(r previewBox) string { |
| + return fmt.Sprintf("%dpx", int(math.Round(math.Min(390, r.View)))) |
| +} |
| + |
| +// showsItsSample: what a reader can see of a sample is its own height |
| +// times the scale the frame is drawn at, clipped to the box; and the |
| +// scale never drops under the floor; and a box wider than its column |
| +// is a scroller. |
| +func showsItsSample(t *testing.T, where string, rows []previewBox) { |
| + t.Helper() |
| + say := loudly(t, where, len(rows)) |
| + worst, worstID := math.Inf(1), "" |
| + for _, r := range rows { |
| + if r.Hidden { |
| + continue |
| + } |
| + if r.Doc < 0 { |
| + t.Fatalf("%s: %s has no reachable document in its frame", where, r.ID) |
| + } |
| + if r.Doc == 0 { |
| + t.Fatalf("%s: %s frames a document with no height at all", where, r.ID) |
| + } |
| + k := scaleOf(t, where, r) |
| + shown := math.Min(r.Doc*k, r.Inner) |
| + if shown < worst { |
| + worst, worstID = shown, r.ID |
| + } |
| + if shown < minShownSample { |
| + say("%s: %s shows %.1fpx of its sample: the document is %.0f virtual px tall, the frame is scaled to %.3f, and the box can show %.1fpx", where, r.ID, shown, r.Doc, k, r.Inner) |
| + } |
| + if k < kMin-0.005 { |
| + say("%s: %s is scaled to %.3f, under the %.2f floor", where, r.ID, k, kMin) |
| + } |
| + if r.PanX > 1 && !scrolls(r.OverX) { |
| + say("%s: %s has %.0fpx of sample past the edge of its box and overflow-x is %q; that part is cropped", where, r.ID, r.PanX, r.OverX) |
| + } |
| + } |
| + t.Logf("%s: %d widgets, least of a sample shown %.1fpx (%s)", where, len(rows), worst, worstID) |
| +} |
| + |
| +// agree: the lit tab and the rendering on screen are chosen by the |
| +// same rule, so they never drift. |
| +func agree(t *testing.T, where string, rows []previewBox) { |
| + t.Helper() |
| + say := loudly(t, where, len(rows)) |
| + for _, r := range rows { |
| + if len(r.Lit) != 1 { |
| + say("%s: %s has %d tabs lit at once (%v)", where, r.ID, len(r.Lit), r.Lit) |
| + continue |
| + } |
| + if len(r.Checked) > 1 { |
| + say("%s: %s has %d radios checked (%v); they are not one group", where, r.ID, len(r.Checked), r.Checked) |
| + } |
| + if r.Hidden { |
| + if r.Lit[0] < 2 { |
| + say("%s: %s shows the source with %s lit", where, r.ID, tabName(r.Lit[0])) |
| + } |
| + continue |
| + } |
| + var w string |
| + switch r.Lit[0] { |
| + case 0: |
| + w = r.Desk |
| + case 1: |
| + w = mobileWidth(r) |
| + default: |
| + say("%s: %s lights %s and still shows a frame", where, r.ID, tabName(r.Lit[0])) |
| + continue |
| + } |
| + if w == "" { |
| + say("%s: %s computes no --ds-wd at all", where, r.ID) |
| + continue |
| + } |
| + if r.Width != w { |
| + say("%s: %s lights %s and renders at --ds-w: %s (want %s); the lit tab and the rendering disagree", where, r.ID, tabName(r.Lit[0]), r.Width, w) |
| + } |
| + if r.Scale == "none" || r.Scale == "" { |
| + say("%s: %s has no transform on its frame (%q); --ds-k did not resolve", where, r.ID, r.Scale) |
| + } |
| + } |
| +} |
| + |
| +// opensOn asserts what the widget shows a reader who has clicked |
| +// nothing: the rendering, and the tab lit over it. |
| +func opensOn(t *testing.T, where string, rows []previewBox, tab int, width string) { |
| + t.Helper() |
| + say := loudly(t, where, len(rows)) |
| + for _, r := range rows { |
| + want := width |
| + if want == "" { |
| + want = mobileWidth(r) |
| + } |
| + if len(r.Checked) != 0 { |
| + say("%s: %s opens with %v already checked", where, r.ID, r.Checked) |
| + } |
| + if r.Width != want { |
| + say("%s: %s opens with its frame laid out %s wide, want %s; the opening view does not follow the reader's width", where, r.ID, r.Width, want) |
| + } |
| + if len(r.Lit) != 1 || r.Lit[0] != tab { |
| + say("%s: %s opens with %v lit, want %s", where, r.ID, r.Lit, tabName(tab)) |
| + } |
| + } |
| +} |
| + |
| +// loudly caps a per-widget assertion at four named failures and then |
| +// counts the rest. |
| +func loudly(t *testing.T, where string, of int) func(string, ...any) { |
| + t.Helper() |
| + var n int |
| + t.Cleanup(func() { |
| + if n > 4 { |
| + t.Errorf("%s: %d assertions failed over %d widgets (4 named above)", where, n, of) |
| + } |
| + }) |
| + return func(format string, args ...any) { |
| + n++ |
| + if n <= 4 { |
| + t.Errorf(format, args...) |
| + } |
| + } |
| +} |
| + |
| +// scaleOf reads the x scale out of a computed transform. |
| +func scaleOf(t *testing.T, where string, r previewBox) float64 { |
| + t.Helper() |
| + var a, b, c, d, e, f float64 |
| + if n, err := fmt.Sscanf(r.Scale, "matrix(%g, %g, %g, %g, %g, %g)", &a, &b, &c, &d, &e, &f); err != nil || n != 6 { |
| + t.Errorf("%s: %s has transform %q, which is not a matrix; the frame is not being scaled at all", where, r.ID, r.Scale) |
| + return 0 |
| + } |
| + return a |
| +} |
| + |
| +// calibrate proves the @supports branch that computes --ds-k with |
| +// tan(atan2(…)) is live in this engine, by finding a frame the engine |
| +// scaled: a page-class widget in a stage narrower than 1200px. |
| +func calibrate(t *testing.T, ctx context.Context, rig *harness.Rig, file string) { |
| + t.Helper() |
| + where := file + " at 1280px, calibrating" |
| + openPage(t, ctx, where, pageURL(rig, "en", file), 1280, 900) |
| + eagerly(t, ctx, where) |
| + for _, r := range boxes(t, ctx, where) { |
| + if r.Hidden || dsw(t, where, r.Desk) <= r.Stage { |
| + continue |
| + } |
| + k := scaleOf(t, where, r) |
| + if k <= 0 || k >= 1 { |
| + t.Fatalf("%s: %s is a %s page in a %.0fpx stage and is scaled by %v; it has to be scaled down, so the @supports guard is not admitting this engine", where, r.ID, r.Desk, r.Stage, k) |
| + } |
| + t.Logf("%s: %s is a %s page in a %.0fpx stage, scaled %.3f; the trig branch is live", where, r.ID, r.Desk, r.Stage, k) |
| + return |
| + } |
| + t.Fatalf("%s: no widget on this page is wider than its stage, so nothing here can prove the engine scales at all", where) |
| +} |
| + |
| +// TestThePreviewWidgetIsUsableOnAPhone: at 390px and 320px, in English |
| +// and in Russian, every widget opens on the Mobile rendering at the |
| +// screen's width with the Mobile tab lit, shows its sample, never |
| +// scales under the floor; an explicit Desktop pans inside a box that |
| +// spans the screen; and the whole of it holds with scripts off. The |
| +// controls come first: a wide viewport whose answer is known, clicks |
| +// checked for having landed, and an engine proved to scale. |
| +func TestThePreviewWidgetIsUsableOnAPhone(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 400*time.Second) |
| + defer cancel() |
| + calibrate(t, ctx, rig, "layout-column.html") |
| + |
| + for _, locale := range []string{"en", "ru"} { |
| + for _, file := range []string{"field.html", "layout-column.html"} { |
| + kind := locale + " " + file |
| + url := pageURL(rig, locale, file) |
| + openPage(t, ctx, kind+" at 1280px", url, 1280, 900) |
| + eagerly(t, ctx, kind+" at 1280px") |
| + wide := boxes(t, ctx, kind+" at 1280px") |
| + for _, r := range wide { |
| + if r.Width != r.Desk || len(r.Lit) != 1 || r.Lit[0] != 0 { |
| + t.Fatalf("%s at 1280px: %s renders at --ds-w: %s with %v lit over a %.0fpx stage, want its own desktop width (%s) under a lit Desktop (threshold %.0fpx of stage)", kind, r.ID, r.Width, r.Lit, r.Stage, r.Desk, dsw(t, kind, r.Desk)*kMin) |
| + } |
| + } |
| + agree(t, kind+" at 1280px", wide) |
| + showsItsSample(t, kind+" at 1280px", wide) |
| + |
| + openPage(t, ctx, kind+" at 390px", url, 390, 844) |
| + eagerly(t, ctx, kind+" at 390px") |
| + requireDrawnScrollbar(t, ctx, kind+" at 390px") |
| + phone := boxes(t, ctx, kind+" at 390px") |
| + opensOn(t, kind+" at 390px, opened", phone, 1, "") |
| + agree(t, kind+" at 390px, opened", phone) |
| + showsItsSample(t, kind+" at 390px, opened", phone) |
| + noSidewaysPage(t, ctx, kind+" at 390px, opened") |
| + |
| + mustRun(t, ctx, chromedp.EmulateViewport(320, 844), chromedp.Sleep(400*time.Millisecond)) |
| + narrow := boxes(t, ctx, kind+" at 320px") |
| + opensOn(t, kind+" at 320px, opened", narrow, 1, "") |
| + agree(t, kind+" at 320px, opened", narrow) |
| + showsItsSample(t, kind+" at 320px, opened", narrow) |
| + noSidewaysPage(t, ctx, kind+" at 320px, opened") |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Sleep(400*time.Millisecond)) |
| + |
| + clickAll(t, ctx, kind+" at 390px", clickedDesktop, "Desktop") |
| + chosen := boxes(t, ctx, kind+" at 390px, Desktop chosen") |
| + for _, r := range chosen { |
| + if len(r.Checked) != 1 || r.Checked[0] != 0 { |
| + t.Fatalf("%s at 390px: %s has %v checked after every Desktop radio was clicked", kind, r.ID, r.Checked) |
| + } |
| + if r.Width != r.Desk { |
| + t.Errorf("%s at 390px: %s was told Desktop and renders at --ds-w: %s rather than its class's %s", kind, r.ID, r.Width, r.Desk) |
| + } |
| + } |
| + agree(t, kind+" at 390px, Desktop chosen", chosen) |
| + showsItsSample(t, kind+" at 390px, Desktop chosen", chosen) |
| + panned := 0 |
| + for _, r := range chosen { |
| + if r.PanX > 1 && scrolls(r.OverX) { |
| + panned++ |
| + } |
| + } |
| + if panned != len(chosen) { |
| + t.Errorf("%s at 390px with Desktop chosen: %d of %d boxes both overflow and can be scrolled; --ds-k is clamped at %.2f, so the rest of the page needs somewhere to go", kind, panned, len(chosen), kMin) |
| + } |
| + for _, r := range chosen { |
| + if math.Abs(r.BoxL) > 1 || math.Abs(r.BoxR-r.DocW) > 1 { |
| + t.Errorf("%s at 390px with Desktop chosen: %s's box runs %.1f…%.1f in a %.0fpx document; it should span the screen", kind, r.ID, r.BoxL, r.BoxR, r.DocW) |
| + } |
| + } |
| + noSidewaysPage(t, ctx, kind+" at 390px, Desktop chosen") |
| + |
| + clickAll(t, ctx, kind+" at 390px", clickedMobile, "Mobile") |
| + back := boxes(t, ctx, kind+" at 390px, Mobile chosen") |
| + agree(t, kind+" at 390px, Mobile chosen", back) |
| + showsItsSample(t, kind+" at 390px, Mobile chosen", back) |
| + } |
| + } |
| + |
| + // The whole of the above with script execution switched off. |
| + off := noScripts(t, rig.Context()) |
| + offCtx, cancelOff := context.WithTimeout(off, 120*time.Second) |
| + defer cancelOff() |
| + url := pageURL(rig, "en", "field.html") |
| + openPage(t, offCtx, "field at 390px, scripts off", url, 390, 844) |
| + eagerly(t, offCtx, "field at 390px, scripts off") |
| + requireScriptsOff(t, offCtx, "field at 390px, scripts off") |
| + o := boxes(t, offCtx, "field at 390px, scripts off") |
| + opensOn(t, "field at 390px, scripts off", o, 1, "") |
| + agree(t, "field at 390px, scripts off", o) |
| + showsItsSample(t, "field at 390px, scripts off", o) |
| + mustRun(t, offCtx, chromedp.Click(`.ds-view__tab:first-of-type`, chromedp.ByQuery), chromedp.Sleep(400*time.Millisecond)) |
| + offChosen := boxes(t, offCtx, "field at 390px, scripts off, Desktop chosen") |
| + if len(offChosen[0].Checked) != 1 || offChosen[0].Checked[0] != 0 { |
| + t.Fatalf("with scripts off, clicking the Desktop label left %v checked; the tabs need JavaScript", offChosen[0].Checked) |
| + } |
| + if offChosen[0].Width != offChosen[0].Desk || len(offChosen[0].Lit) != 1 || offChosen[0].Lit[0] != 0 { |
| + t.Errorf("with scripts off, choosing Desktop left the widget rendering at --ds-w: %s rather than its class's %s, with %v lit", offChosen[0].Width, offChosen[0].Desk, offChosen[0].Lit) |
| + } |
| + agree(t, "field at 390px, scripts off, Desktop chosen", offChosen) |
| + showsItsSample(t, "field at 390px, scripts off, the first widget on Desktop", offChosen[:1]) |
| +} |
| + |
| +// ── The default is a function of the stage, not of the window ──────── |
| + |
| +// stageReading is the widget's opening state at one window width. |
| +type stageReading struct { |
| + File string |
| + VW int |
| + View float64 |
| + Stage float64 |
| + DSW, Desk float64 |
| + Lit int |
| + K, Box float64 |
| + Rail bool |
| +} |
| + |
| +// dsw parses the "900px" a box computes --ds-w to. |
| +func dsw(t *testing.T, where, raw string) float64 { |
| + t.Helper() |
| + v, err := strconv.ParseFloat(strings.TrimSuffix(raw, "px"), 64) |
| + if err != nil { |
| + t.Fatalf("%s: --ds-w reads %q, which is not a length", where, raw) |
| + } |
| + return v |
| +} |
| + |
| +// TestThePreviewDefaultIsMonotoneInStageWidth: order every window |
| +// width by the stage it produces, and the opening view never goes from |
| +// Desktop back to Mobile as the stage gets wider; the threshold is each |
| +// widget's own --ds-wd × --ds-kmin; the query container and the stage |
| +// are the same width; and --ds-k is the stage over the virtual width, |
| +// clamped. Both width classes are swept. The control that makes the |
| +// monotone assertion able to fail, a window that grows while its stage |
| +// shrinks, is the rail arriving at 800px: it is required once the page |
| +// has a rail, and logged as absent until the shell lands. |
| +func TestThePreviewDefaultIsMonotoneInStageWidth(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() |
| + classes := map[float64]string{} |
| + for _, file := range []string{"field.html", "layout-column.html"} { |
| + monotoneSweep(t, ctx, rig, file, classes) |
| + } |
| + if len(classes) < 2 { |
| + t.Errorf("the sweep saw %d width class(es) (%v); it is written to cover both", len(classes), classes) |
| + } |
| +} |
| + |
| +func monotoneSweep(t *testing.T, ctx context.Context, rig *harness.Rig, file string, classes map[float64]string) { |
| + t.Helper() |
| + openPage(t, ctx, file+" at 1280px", pageURL(rig, "en", file), 1280, 900) |
| + mustRun(t, ctx, chromedp.Sleep(600*time.Millisecond)) |
| + widths := []int{280, 320, 360, 390, 600, 700, 760, 799, 800, 900, 1000, 1100, 1184, 1186, 1200, 1280, 1500} |
| + readings := make([]stageReading, 0, len(widths)) |
| + for _, vw := range widths { |
| + where := fmt.Sprintf("%s at %dpx", file, vw) |
| + mustRun(t, ctx, chromedp.EmulateViewport(int64(vw), 900), chromedp.Sleep(300*time.Millisecond)) |
| + rows := boxes(t, ctx, where) |
| + agree(t, where, rows) |
| + r := rows[0] |
| + if len(r.Lit) != 1 { |
| + t.Fatalf("%s: %v tabs lit, so there is no opening view to record", where, r.Lit) |
| + } |
| + if len(r.Checked) != 0 { |
| + t.Fatalf("%s: %v is checked; this drive reads the opening view and something has chosen for it", where, r.Checked) |
| + } |
| + var rail bool |
| + mustRun(t, ctx, chromedp.Evaluate(`!!document.querySelector("[rst-shell-nav], .ds-nav")`, &rail)) |
| + desk := dsw(t, where, r.Desk) |
| + classes[desk] = file |
| + readings = append(readings, stageReading{File: file, VW: vw, View: r.View, Stage: r.Stage, DSW: dsw(t, where, r.Width), Desk: desk, Lit: r.Lit[0], K: scaleOf(t, where, r), Box: r.Box, Rail: rail}) |
| + } |
| + for _, r := range readings { |
| + t.Logf("%-18s vw=%-5d view=%-7.1f stage=%-7.1f --ds-wd=%-5.0f --ds-w=%-5.0f k=%.3f box=%.1f opens on %s", r.File, r.VW, r.View, r.Stage, r.Desk, r.DSW, r.K, r.Box, tabName(r.Lit)) |
| + } |
| + var grew, shrank stageReading |
| + for _, r := range readings { |
| + switch r.VW { |
| + case 700: |
| + grew = r |
| + case 800: |
| + shrank = r |
| + } |
| + } |
| + switch { |
| + case grew.VW == 0 || shrank.VW == 0: |
| + t.Fatalf("the sweep is missing the 700px/800px pair") |
| + case shrank.Rail && shrank.Stage >= grew.Stage: |
| + t.Fatalf("the control is gone: with a rail on the page the stage is %.1fpx in a 700px window and %.1fpx in an 800px one, so no window grows while its stage shrinks", grew.Stage, shrank.Stage) |
| + case !shrank.Rail: |
| + t.Logf("no rail on this page yet: the stage is %.1fpx at 700px and %.1fpx at 800px, so the monotone assertion below has no window that grows while its stage shrinks to catch; the shell's rail supplies it", grew.Stage, shrank.Stage) |
| + default: |
| + t.Logf("control: the stage shrinks from %.1fpx to %.1fpx as the window grows from 700px to 800px; the rail arrives", grew.Stage, shrank.Stage) |
| + } |
| + byStage := append([]stageReading(nil), readings...) |
| + sort.Slice(byStage, func(i, j int) bool { return byStage[i].Stage < byStage[j].Stage }) |
| + for i, wide := range byStage { |
| + for _, narrow := range byStage[:i] { |
| + if narrow.Lit == 0 && wide.Lit == 1 { |
| + t.Errorf("a %.1fpx stage (a %dpx window) opens on Desktop and a wider %.1fpx stage (a %dpx window) opens on Mobile", narrow.Stage, narrow.VW, wide.Stage, wide.VW) |
| + } |
| + } |
| + } |
| + for _, r := range readings { |
| + threshold := r.Desk * kMin |
| + want := 1 |
| + if r.Stage >= threshold { |
| + want = 0 |
| + } |
| + if r.Lit != want { |
| + t.Errorf("%s: a %.1fpx stage (a %dpx window) on a %.0fpx virtual page opens on %s; at or over %.0fpx of stage the desktop rendering is the legible one and under it the 390px one is", r.File, r.Stage, r.VW, r.Desk, tabName(r.Lit), threshold) |
| + } |
| + if math.Abs(r.View-r.Stage) > 0.5 { |
| + t.Errorf("at %dpx the query container .ds-view is %.2fpx wide and the stage inside it is %.2fpx; the two have to be the same ruler", r.VW, r.View, r.Stage) |
| + } |
| + if want := math.Min(1, math.Max(kMin, r.Stage/r.DSW)); math.Abs(r.K-want) > 0.01 { |
| + t.Errorf("at %dpx the frame is scaled by %.4f and the stage is %.1fpx of a %.0fpx virtual page, which clamps to %.4f", r.VW, r.K, r.Stage, r.DSW, want) |
| + } |
| + } |
| +} |
| + |
| +// TestTheFrameMirrorsRightToLeft: right to left, the shell's grid puts |
| +// the rail on the right and the bar over main on the left, with no rule |
| +// of the gallery's: the grid lines are numbered, not sided. Port of |
| +// Rastrillo's drive, on an example page of day/ar at 1280px. |
| +func TestTheFrameMirrorsRightToLeft(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() |
| + where := "day/ar form-layout at 1280px" |
| + openPage(t, ctx, where, pageURL(rig, "ar", "form-layout.html"), 1280, 900) |
| + var r struct { |
| + RailLeft, MainRight, BarBottom, MainTop, BarLeft, BarRight, MainLeft, BrandLeft, ControlsRight float64 |
| + } |
| + at(t, ctx, `(() => { |
| + const b = s => document.querySelector(s).getBoundingClientRect(); |
| + const rail = b("[rst-shell-rail]"), bar = b(".ds-top"), main = b("main"); |
| + const brand = b(".ds-top__brand"), controls = b(".ds-top__controls"); |
| + return JSON.stringify({RailLeft: rail.left, MainRight: main.right, BarBottom: bar.bottom, MainTop: main.top, BarLeft: bar.left, BarRight: bar.right, MainLeft: main.left, BrandLeft: brand.left, ControlsRight: controls.right}); |
| + })()`, &r) |
| + if r.RailLeft < r.MainRight-0.5 { |
| + t.Errorf("%s: the rail starts at %.1fpx, left of main's right edge %.1fpx; it belongs on the right", where, r.RailLeft, r.MainRight) |
| + } |
| + if r.BarBottom > r.MainTop+0.5 || r.BarLeft < r.MainLeft-0.5 || r.BarRight > r.MainRight+0.5 { |
| + t.Errorf("%s: the bar [%v…%v, bottom %v] is not above main [%v…%v, top %v]", where, r.BarLeft, r.BarRight, r.BarBottom, r.MainLeft, r.MainRight, r.MainTop) |
| + } |
| + // The brand is first in source order and justify-content: |
| + // space-between puts the first child at the inline start, which in |
| + // Arabic is the bar's right side, not its left: a rule keyed to |
| + // "left" rather than to the writing direction would swap these two. |
| + if r.BrandLeft < r.ControlsRight-0.5 { |
| + t.Errorf("%s: the brand's left edge is at %.1fpx, not right of the controls' right edge %.1fpx; the brand belongs at the inline start and the controls at the end", where, r.BrandLeft, r.ControlsRight) |
| + } |
| +} |
diff --git a/internal/gallery/view_test.go b/internal/gallery/view_test.go| new file mode 100644 |
| index 0000000..61e22f9 |
| --- /dev/null |
| +++ b/internal/gallery/view_test.go |
| @@ -0,0 +1,629 @@ |
| +package gallery |
| + |
| +import ( |
| + "html" |
| + "html/template" |
| + "io/fs" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| + "amadan.net/rastrillo/fiesta/internal/codeview" |
| +) |
| + |
| +// The preview widget as the render writes it: Desktop, Mobile and HTML |
| +// behind three radios, one widget per state or one per grouped |
| +// example, the HTML tab without the wrapper and with the line naming |
| +// it, the row documents, the Copy blocks, the frame titles and the |
| +// shells' open buttons. The browser drives in view_browser_test.go and |
| +// its siblings take over where the engine has to answer. |
| + |
| +// examplePage is one example's page in one directory, failed loudly. |
| +func examplePage(t *testing.T, files map[string][]byte, theme, locale, name string) string { |
| + t.Helper() |
| + page := string(files[theme+"/"+locale+"/"+name+".html"]) |
| + if page == "" { |
| + t.Fatalf("no %s/%s/%s.html in the rendered tree", theme, locale, name) |
| + } |
| + return page |
| +} |
| + |
| +// widgetOpen is a widget's opening tag, matched exactly: `<div |
| +// class="ds-view` alone also matches the stage and the box inside |
| +// every widget. |
| +var widgetOpen = regexp.MustCompile(`<div class="ds-view(?: ds-view--[a-z]+)*" style="`) |
| + |
| +// widgetsOf cuts a page into its preview widgets: everything from one |
| +// widget's opening tag up to the next one, or to the end. |
| +func widgetsOf(page string) []string { |
| + parts := widgetOpen.Split(page, -1) |
| + if len(parts) < 2 { |
| + return nil |
| + } |
| + return parts[1:] |
| +} |
| + |
| +// tabClass reads a widget's tabs in order, by their modifier letter. |
| +var tabClass = regexp.MustCompile(`class="ds-view__tab ds-view__tab--([a-z])"`) |
| + |
| +var radioInput = regexp.MustCompile(`<input type="radio" name="([^"]*)"( checked)?>`) |
| + |
| +// panelOf is a widget's HTML panel, or "" when it has none. |
| +func panelOf(widget string) string { |
| + return regexp.MustCompile(`(?s)<div class="ds-view__code ds-view__code--h">.*?</div>`).FindString(widget) |
| +} |
| + |
| +// sourceBlock finds every source block on a page: the code a reader |
| +// copies, highlighted. |
| +var sourceBlock = regexp.MustCompile(`(?s)<pre class="ds-src[^"]*"[^>]*><code>(.*?)</code></pre>`) |
| + |
| +var anyElement = regexp.MustCompile(`<[^>]*>`) |
| + |
| +// sourceTexts is every source block in a fragment as a reader sees and |
| +// copies it: the highlighting removed and the entities decoded. |
| +func sourceTexts(fragment string) []string { |
| + var out []string |
| + for _, m := range sourceBlock.FindAllStringSubmatch(fragment, -1) { |
| + out = append(out, html.UnescapeString(anyElement.ReplaceAllString(m[1], ""))) |
| + } |
| + return out |
| +} |
| + |
| +// TestEveryExampleIsFramedDesktopMobileAndHTML: on every example page |
| +// of day/en, one widget per state, or one per grouped example; three |
| +// radios in each, one name per widget and the name unique on the page, |
| +// none checked; the HTML panel on every widget, one block per state; |
| +// ds-view--page on the page-frame examples and on no other; and the |
| +// rules that make the tabs switch anything, in view.css. |
| +func TestEveryExampleIsFramedDesktopMobileAndHTML(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + theme := fiesta.ThemeNames()[0] |
| + total := 0 |
| + var pageClass []string |
| + for _, e := range s.m.Examples { |
| + page := examplePage(t, files, theme, "en", e.Name) |
| + widgets := widgetsOf(page) |
| + want := len(e.States) |
| + if grouped[e.Name] { |
| + want = 1 |
| + } |
| + if len(widgets) != want { |
| + t.Errorf("%s: %d preview widgets, want %d", e.Name, len(widgets), want) |
| + } |
| + if strings.Contains(page, "ds-view--page") { |
| + pageClass = append(pageClass, e.Name) |
| + } |
| + if n := strings.Count(page, "ds-view--page"); n != 0 && n != len(widgets) { |
| + t.Errorf("%s: %d of %d widgets take the page class; a page's widgets are all one class", e.Name, n, len(widgets)) |
| + } |
| + total += len(widgets) |
| + groups := map[string]bool{} |
| + for i, w := range widgets { |
| + radios := radioInput.FindAllStringSubmatch(w, -1) |
| + if len(radios) != 3 { |
| + t.Errorf("%s widget %d has %d tabs, want 3: Desktop, Mobile and HTML", e.Name, i, len(radios)) |
| + continue |
| + } |
| + for _, r := range radios { |
| + if r[1] != radios[0][1] { |
| + t.Errorf("%s widget %d: tabs in one widget carry two names (%q, %q), so they are not one group", e.Name, i, radios[0][1], r[1]) |
| + } |
| + if r[2] != "" { |
| + t.Errorf("%s widget %d: a tab starts checked; none may, or the stylesheet cannot tell a reader's choice from the markup's default", e.Name, i) |
| + } |
| + } |
| + if groups[radios[0][1]] { |
| + t.Errorf("%s widget %d: the radio name %q is already used by another widget on this page", e.Name, i, radios[0][1]) |
| + } |
| + groups[radios[0][1]] = true |
| + var order string |
| + for _, m := range tabClass.FindAllStringSubmatch(w, -1) { |
| + order += m[1] |
| + } |
| + if order != "dmh" { |
| + t.Errorf("%s widget %d: tabs %q, want Desktop, Mobile, HTML", e.Name, i, order) |
| + } |
| + if n := strings.Count(w, `<iframe class="ds-view__frame"`); n != 1 { |
| + t.Errorf("%s widget %d frames %d documents, want 1", e.Name, i, n) |
| + } |
| + if !strings.Contains(w, `<div class="ds-view__stage"><div class="ds-view__box"><iframe`) { |
| + t.Errorf("%s widget %d: the frame is not in a box on the stage", e.Name, i) |
| + } |
| + panel := panelOf(w) |
| + if panel == "" { |
| + t.Errorf("%s widget %d has no HTML panel", e.Name, i) |
| + continue |
| + } |
| + blocks, wantBlocks := strings.Count(panel, `<pre class="ds-src`), 1 |
| + if grouped[e.Name] { |
| + wantBlocks = len(e.States) |
| + for _, st := range e.States { |
| + if !strings.Contains(panel, `<p class="ds-state">`+template.HTMLEscapeString(st.Label)+`</p>`) { |
| + t.Errorf("%s: the HTML panel does not label the %s row", e.Name, st.ID) |
| + } |
| + } |
| + } |
| + if blocks != wantBlocks { |
| + t.Errorf("%s widget %d: %d source blocks in the HTML panel, want %d", e.Name, i, blocks, wantBlocks) |
| + } |
| + } |
| + } |
| + // The page class: every layout document, the two shell idioms and |
| + // the modal, and nothing else. The sign-in screens are components, |
| + // as in Rastrillo: a card, not a window. |
| + var wantPage []string |
| + for _, e := range s.m.Examples { |
| + if e.Document || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" { |
| + wantPage = append(wantPage, e.Name) |
| + } |
| + } |
| + if len(wantPage) != 10 { |
| + t.Errorf("%d examples should take the page class (%v), the manifest was written with 10", len(wantPage), wantPage) |
| + } |
| + if strings.Join(pageClass, " ") != strings.Join(wantPage, " ") { |
| + t.Errorf("the page class is on %v, want %v", pageClass, wantPage) |
| + } |
| + if total < 100 { |
| + t.Errorf("the whole gallery frames %d widgets on day/en; it has well over a hundred states to frame", total) |
| + } |
| + // The mechanism, in the widget's half of the stylesheet. Without |
| + // these the tabs are three radios that change nothing. |
| + css := string(viewCSS) |
| + for _, rule := range []string{ |
| + `.ds-view:has(.ds-view__tab--m input:checked) .ds-view__box`, |
| + `.ds-view:has(.ds-view__tab--h input:checked) .ds-view__stage { display: none; }`, |
| + `.ds-view:has(.ds-view__tab--h input:checked) .ds-view__code--h { display: block; }`, |
| + `.ds-view__box { --ds-k: clamp(var(--ds-kmin), tan(atan2(100cqw, var(--ds-w))), 1); }`, |
| + `container-name: ds-view; container-type: inline-size;`, |
| + `@container ds-view (min-width: 40.5rem) { .ds-view:not(.ds-view--page):not(:has(input:checked)) .ds-view__tab--d {`, |
| + `.ds-view:not(.ds-view--page):not(:has(input:checked)) .ds-view__tab--m {`, |
| + `.ds-view:not(.ds-view--page):not(:has(.ds-view__tab--d input:checked)) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); }`, |
| + `@container ds-view (min-width: 54rem) { .ds-view--page:not(:has(input:checked)) .ds-view__tab--d {`, |
| + `.ds-view--page:not(:has(input:checked)) .ds-view__tab--m {`, |
| + `.ds-view--page:not(:has(.ds-view__tab--d input:checked)) .ds-view__box { --ds-h: var(--ds-hm); --ds-w: var(--ds-wm); }`, |
| + `.ds-view { --ds-wm: min(390px, 100cqw); }`, |
| + `[dir="rtl"] .ds-view__frame { transform-origin: top right; }`, |
| + `.ds-view { --ds-wd: 900px; --ds-w: var(--ds-wd);`, |
| + `.ds-view--page { --ds-wd: 1200px; }`, |
| + `overflow: hidden; overflow-x: auto; overscroll-behavior-x: contain;`, |
| + `scrollbar-width: thin;`, |
| + `min-block-size: calc(var(--ds-h) * var(--ds-kmin));`, |
| + `@supports not (container-type: inline-size) { .ds-view:not(:has(input:checked)) .ds-view__tab--d {`, |
| + `:root[data-gallery-js] .ds-view__frame:not([data-gallery-painted]) { visibility: hidden; }`, |
| + } { |
| + if !strings.Contains(css, rule) { |
| + t.Errorf("view.css carries no rule %q; the tabs would switch nothing", rule) |
| + } |
| + } |
| + if strings.Contains(css, "ds-view__tab--t") { |
| + t.Error("view.css still carries the Template tab's rules, which Fiesta has no tab for") |
| + } |
| + // And nothing scripted, anywhere in the tree: an inline handler |
| + // would be a widget that stops working with scripts off. |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") { |
| + continue |
| + } |
| + for _, on := range []string{" onclick=", " onchange=", " oninput=", " onload=", " onsubmit="} { |
| + if strings.Contains(string(body), on) { |
| + t.Errorf("%s carries an inline%shandler", name, on) |
| + } |
| + } |
| + } |
| + t.Logf("%d preview widgets across %d example pages", total, len(s.m.Examples)) |
| +} |
| + |
| +// wrapperOpenings are the first tags the four wrappers put around a |
| +// body. A source block opening with one is a block a reader would |
| +// copy twice-wrapped. |
| +var wrapperOpenings = []string{"<section rst-box>", "<div rst-list>", "<div rst-stats>"} |
| + |
| +// TestTheHTMLTabNeverCarriesTheWrapper: the 22 wrapped examples show |
| +// their body without its outer wrapper element(s) in the HTML tab, |
| +// while the frame document still carries it; the seeded |
| +// action="#" leaves with the wrapper; and the line above the block |
| +// names the container with its opening markup. |
| +func TestTheHTMLTabNeverCarriesTheWrapper(t *testing.T) { |
| + files := render(t, "") |
| + theme := fiesta.ThemeNames()[0] |
| + wrapped, states := 0, 0 |
| + for _, e := range manifest(t).Examples { |
| + page := examplePage(t, files, theme, "en", e.Name) |
| + if e.Wrapper == "" { |
| + if strings.Contains(page, `class="ds-wrap"`) { |
| + t.Errorf("%s has no wrapper and its page carries a wrapper line", e.Name) |
| + } |
| + continue |
| + } |
| + wrapped++ |
| + open, err := wrapperMarkup(e.Wrapper) |
| + if err != nil || open == "" { |
| + t.Fatalf("%s: wrapperMarkup(%q) = %q, %v", e.Name, e.Wrapper, open, err) |
| + } |
| + line := `<p class="ds-wrap">Put this inside <span class="rst-mono">` + template.HTMLEscapeString(open) + `</span>.</p>` |
| + if n := strings.Count(page, line); n != len(e.States) { |
| + t.Errorf("%s: %d widgets say %q, want one per state (%d)", e.Name, n, line, len(e.States)) |
| + } |
| + for _, st := range e.States { |
| + states++ |
| + tab := strings.TrimSpace(htmlTab(t, []byte(page), e.Name, st.ID)) |
| + for _, w := range wrapperOpenings { |
| + if strings.HasPrefix(tab, w) { |
| + t.Errorf("%s/%s: the HTML tab opens with the wrapper %s", e.Name, st.ID, w) |
| + } |
| + } |
| + if strings.Contains(tab, `action="#"`) { |
| + t.Errorf("%s/%s: the HTML tab carries the placeholder action=\"#\"", e.Name, st.ID) |
| + } |
| + // The control: the frame body still opens with it. |
| + frame := string(files[theme+"/en/frames/"+e.Name+"/"+st.ID+".html"]) |
| + body := frame[strings.Index(frame, "<body"):] |
| + body = strings.TrimSpace(body[strings.Index(body, ">")+1:]) |
| + if !strings.HasPrefix(body, strings.Split(open, ">")[0]) { |
| + t.Errorf("%s/%s: the frame body does not open with %s:\n%.80s", e.Name, st.ID, open, body) |
| + } |
| + } |
| + } |
| + if wrapped != 22 { |
| + t.Errorf("%d wrapped examples in the manifest, the spec counts 22", wrapped) |
| + } |
| + if states == 0 { |
| + t.Fatal("no wrapped state was checked") |
| + } |
| +} |
| + |
| +// TestUnwrapStripsTheWrapperAndRefusesABodyWithoutIt: unwrap takes the |
| +// outer wrapper element(s) off a body, the seeded form attributes with |
| +// them, keeps an inner form that is the component's own, leaves an |
| +// unwrapped body alone, and is an error for a body that does not open |
| +// with its wrapper or a wrapper the manifest does not have. |
| +func TestUnwrapStripsTheWrapperAndRefusesABodyWithoutIt(t *testing.T) { |
| + for _, c := range []struct{ wrapper, in, want string }{ |
| + {"form", `<section rst-box><form rst-form method="post" action="#"><div rst-field>\n<input></div></form></section>`, `<div rst-field>\n<input></div>`}, |
| + {"form", "<section rst-box>\n<form rst-form>\n<p>a</p>\n</form>\n</section>\n", "\n<p>a</p>\n"}, |
| + {"box", `<section rst-box><form rst-form-actions method="post" action="/x"><button>Go</button></form></section>`, `<form rst-form-actions method="post" action="/x"><button>Go</button></form>`}, |
| + {"list", `<div rst-list><div rst-row>r</div></div>`, `<div rst-row>r</div>`}, |
| + {"stats", `<div rst-stats><div rst-stat>1</div></div>`, `<div rst-stat>1</div>`}, |
| + {"", `<span rst-status>Draft</span>`, `<span rst-status>Draft</span>`}, |
| + } { |
| + got, err := unwrap(c.in, c.wrapper) |
| + if err != nil || got != c.want { |
| + t.Errorf("unwrap(%q, %q) = %q, %v; want %q", c.in, c.wrapper, got, err, c.want) |
| + } |
| + } |
| + for _, c := range []struct{ wrapper, in, why string }{ |
| + {"form", `<div rst-field><input></div>`, "a body with no wrapper"}, |
| + {"form", `<section rst-box><div rst-field><input></div></section>`, "a box without the form"}, |
| + {"box", `<section><p>a</p></section>`, "a section without rst-box"}, |
| + {"list", `<div rst-list><div rst-row>r</div>`, "a wrapper never closed"}, |
| + {"card", `<div rst-card>a</div>`, "a wrapper the manifest does not have"}, |
| + } { |
| + if got, err := unwrap(c.in, c.wrapper); err == nil { |
| + t.Errorf("unwrap(%q, %q) = %q with no error; %s should fail", c.in, c.wrapper, got, c.why) |
| + } |
| + } |
| + for w, want := range map[string]string{"list": "<div rst-list>", "form": "<section rst-box><form rst-form>", "box": "<section rst-box>", "stats": "<div rst-stats>", "": ""} { |
| + if got, err := wrapperMarkup(w); err != nil || got != want { |
| + t.Errorf("wrapperMarkup(%q) = %q, %v; want %q", w, got, err, want) |
| + } |
| + } |
| + if _, err := wrapperMarkup("card"); err == nil { |
| + t.Error("wrapperMarkup accepted a wrapper the manifest does not have") |
| + } |
| +} |
| + |
| +// TestABodyWithoutItsWrapperFailsTheRender: a seeded document whose |
| +// body has lost its wrapper fails the render naming the state, rather |
| +// than being shown with a wrapper line over markup that has none. |
| +func TestABodyWithoutItsWrapperFailsTheRender(t *testing.T) { |
| + const file = "examples/field-text/bare.html" |
| + raw, err := fs.ReadFile(fiesta.FS(), file) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stripped := strings.Replace(string(raw), "<section rst-box>", "", 1) |
| + stripped = strings.Replace(stripped, "</section>", "", 1) |
| + if stripped == string(raw) { |
| + t.Fatal("the control did not change the document") |
| + } |
| + _, err = Render(overlay{fiesta.FS(), map[string][]byte{file: []byte(stripped)}}, Options{}) |
| + if err == nil { |
| + t.Fatal("a body without its wrapper rendered") |
| + } |
| + if !strings.Contains(err.Error(), "field-text/bare") { |
| + t.Errorf("the error does not name the state: %v", err) |
| + } |
| +} |
| + |
| +// TestAGroupedPartialNeedsNoWrapper: the grouped examples are the three |
| +// Rastrillo groups, each in the manifest and none wrapped: the grouped |
| +// panels carry no wrapper line, so a wrapped one would be copied |
| +// without its container. |
| +func TestAGroupedPartialNeedsNoWrapper(t *testing.T) { |
| + if len(grouped) == 0 { |
| + t.Fatal("no example is grouped, so this checked nothing") |
| + } |
| + m := manifest(t) |
| + for _, name := range []string{"status-pill", "badge", "meter"} { |
| + if !grouped[name] { |
| + t.Errorf("%s is not grouped", name) |
| + } |
| + } |
| + for name := range grouped { |
| + e, ok := m.Entry(name) |
| + if !ok { |
| + t.Errorf("%s is grouped and not in the manifest", name) |
| + continue |
| + } |
| + if w, err := wrapperMarkup(e.Wrapper); err != nil || w != "" { |
| + t.Errorf("%s is grouped and wrapped in %s; the grouped panels would drop the line saying so", name, w) |
| + } |
| + if len(e.States) < 2 { |
| + t.Errorf("%s is grouped with %d state; a group is a row of several", name, len(e.States)) |
| + } |
| + } |
| +} |
| + |
| +// TestAGroupedNoteNamesItsStateApart: under a grouped widget, every |
| +// state has a block of its own naming the state in an element of its |
| +// own, with the note, where there is one, as a paragraph after it and |
| +// nothing written between the two: a joiner would be copy no review |
| +// saw, and an ASCII colon is wrong punctuation in ja, zh-Hans and yue. |
| +func TestAGroupedNoteNamesItsStateApart(t *testing.T) { |
| + files := render(t, "") |
| + p := loadedProse(t) |
| + m := manifest(t) |
| + theme := fiesta.ThemeNames()[0] |
| + notes, blocks := 0, 0 |
| + for _, locale := range Locales { |
| + for name := range grouped { |
| + e, ok := m.Entry(name) |
| + if !ok { |
| + t.Fatalf("no %s example", name) |
| + } |
| + page := examplePage(t, files, theme, locale, name) |
| + for _, st := range e.States { |
| + blocks++ |
| + open := `<div class="ds-group-note" id="` + st.ID + `" data-ds-anchor data-gallery-state="` + name + "/" + st.ID + `"><b class="ds-note__state">` + template.HTMLEscapeString(st.Label) + "</b>\n" |
| + if !strings.Contains(page, open) { |
| + t.Errorf("%s/%s %s: no block names the %s state apart", theme, locale, name, st.ID) |
| + continue |
| + } |
| + note := p.Examples[name].States[st.ID] |
| + if note == "" { |
| + if strings.Contains(page, open+`<p class="ds-note">`) { |
| + t.Errorf("%s/%s %s/%s has a note and the prose has none", theme, locale, name, st.ID) |
| + } |
| + continue |
| + } |
| + notes++ |
| + if !strings.Contains(page, open+`<p class="ds-note">`+template.HTMLEscapeString(note)+"</p>\n</div>") { |
| + t.Errorf("%s/%s %s/%s: the note does not follow its state on a line of its own", theme, locale, name, st.ID) |
| + } |
| + if joined := template.HTMLEscapeString(st.Label + ": " + note); strings.Contains(page, joined) { |
| + t.Errorf("%s/%s %s/%s: the note is joined to its state as %q", theme, locale, name, st.ID, joined) |
| + } |
| + } |
| + } |
| + } |
| + if notes == 0 || blocks == 0 { |
| + t.Fatalf("%d grouped notes and %d state blocks checked; the walk read nothing", notes, blocks) |
| + } |
| +} |
| + |
| +// TestEveryPreviewIsAFileOneFrameNames: every frame document in the |
| +// tree is the src of exactly one frame on one page, except the |
| +// per-state documents of a grouped example, which no frame names (the |
| +// accessibility gate scans them one state at a time) and which are |
| +// counted here. The root index is day/en/index.html again and is not |
| +// walked twice. |
| +func TestEveryPreviewIsAFileOneFrameNames(t *testing.T) { |
| + files := render(t, "") |
| + named := map[string]int{} |
| + frames := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") || isFrame(name) || name == "index.html" { |
| + continue |
| + } |
| + attrsOf(t, body, func(tag string, attrs map[string]string) { |
| + if tag == "iframe" && attrs["class"] == "ds-view__frame" { |
| + frames++ |
| + named[strings.TrimPrefix(attrs["src"], "/")]++ |
| + } |
| + }) |
| + } |
| + if len(grouped) == 0 { |
| + t.Fatal("no example is grouped, so the exception below is never exercised") |
| + } |
| + previews, unframed := 0, 0 |
| + for name := range files { |
| + if !isFrame(name) { |
| + continue |
| + } |
| + previews++ |
| + parts := strings.Split(name, "/") |
| + example, state := parts[len(parts)-2], strings.TrimSuffix(parts[len(parts)-1], ".html") |
| + want := 1 |
| + if grouped[example] && state != "row" { |
| + want, unframed = 0, unframed+1 |
| + } |
| + if n := named[name]; n != want { |
| + t.Errorf("%s is the src of %d frames, want %d", name, n, want) |
| + } |
| + } |
| + if frames == 0 || previews == 0 || unframed == 0 { |
| + t.Fatalf("%d frames, %d frame documents, %d of them a grouped state's; this gate is checking nothing", frames, previews, unframed) |
| + } |
| + t.Logf("%d frames name %d documents; %d grouped state documents are scanned and not framed", frames, previews-unframed, unframed) |
| +} |
| + |
| +// TestTheTextOnScreenIsTheTextCopied: every HTML panel's blocks, with |
| +// the highlighting removed, are exactly the source they stand for, in |
| +// order: the formatted body without its wrapper for a component, the |
| +// whole formatted document for a layout, and one block per state for |
| +// a grouped example. In English and in Arabic. |
| +func TestTheTextOnScreenIsTheTextCopied(t *testing.T) { |
| + files := render(t, "") |
| + m := manifest(t) |
| + theme := fiesta.ThemeNames()[0] |
| + checked := 0 |
| + for _, locale := range []string{"en", "ar"} { |
| + for _, e := range m.Examples { |
| + var want []string |
| + for _, st := range e.States { |
| + var src string |
| + if e.Document { |
| + doc, err := example.Document(fiesta.FS(), e.Name, st.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + src = doc |
| + } else { |
| + markup, err := example.Markup(fiesta.FS(), e.Name, st.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if src, err = unwrap(markup, e.Wrapper); err != nil { |
| + t.Fatalf("%s/%s: %v", e.Name, st.ID, err) |
| + } |
| + } |
| + want = append(want, codeview.Format(src)) |
| + } |
| + // The control, independent of unwrap: a wrapped example's |
| + // expected block does not open with its wrapper. |
| + for i, w := range want { |
| + if e.Wrapper == "" { |
| + continue |
| + } |
| + for _, open := range wrapperOpenings { |
| + if strings.HasPrefix(strings.TrimSpace(w), open) { |
| + t.Errorf("%s/%s block %d: the expected source still opens with %s; unwrap left the wrapper in", locale, e.Name, i, open) |
| + } |
| + } |
| + } |
| + got := sourceTexts(examplePage(t, files, theme, locale, e.Name)) |
| + if len(got) != len(want) { |
| + t.Errorf("%s/%s: %d source blocks on the page, want %d", locale, e.Name, len(got), len(want)) |
| + continue |
| + } |
| + for i := range want { |
| + checked++ |
| + if got[i] != want[i] { |
| + t.Errorf("%s/%s block %d: on screen\n%s\nwant\n%s", locale, e.Name, i, got[i], want[i]) |
| + } |
| + } |
| + } |
| + } |
| + if checked == 0 { |
| + t.Fatal("no block was compared") |
| + } |
| +} |
| + |
| +// TestEveryCodeBlockOffersTheCopyButton: no block in the tree refuses |
| +// the Copy button (no Fiesta example is an illustration), and on day/en |
| +// every block is a copyable block inside an HTML panel, one per state. |
| +func TestEveryCodeBlockOffersTheCopyButton(t *testing.T) { |
| + files := render(t, "") |
| + m := manifest(t) |
| + theme := fiesta.ThemeNames()[0] |
| + for name, body := range files { |
| + if strings.HasSuffix(name, ".html") && strings.Contains(string(body), "data-ds-nocopy") { |
| + t.Errorf("%s carries a block that refuses the Copy button", name) |
| + } |
| + } |
| + const block = `<pre class="ds-src rst-mono" tabindex="0"><code>` |
| + blocks, inPanels, states := 0, 0, 0 |
| + for _, e := range m.Examples { |
| + page := examplePage(t, files, theme, "en", e.Name) |
| + states += len(e.States) |
| + blocks += strings.Count(page, block) |
| + for _, w := range widgetsOf(page) { |
| + inPanels += strings.Count(panelOf(w), block) |
| + } |
| + } |
| + if blocks == 0 || blocks != states || inPanels != blocks { |
| + t.Errorf("day/en has %d copyable blocks, %d of them in HTML panels, over %d states; want one per state, every one in a panel", blocks, inPanels, states) |
| + } |
| +} |
| + |
| +// TestEveryFrameTitleIsUniqueOnThePage: a frame's title is an |
| +// accessible name, unique on its page, and never the empty label's |
| +// bare "name: ": a state with no label takes the example's name. |
| +func TestEveryFrameTitleIsUniqueOnThePage(t *testing.T) { |
| + files := render(t, "") |
| + titleAttr := regexp.MustCompile(`<iframe class="ds-view__frame"[^>]*\stitle="([^"]*)"`) |
| + frames := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") || isFrame(name) { |
| + continue |
| + } |
| + seen := map[string]int{} |
| + for _, m := range titleAttr.FindAllStringSubmatch(string(body), -1) { |
| + frames++ |
| + title := html.UnescapeString(m[1]) |
| + seen[title]++ |
| + if title == "" || strings.HasSuffix(title, ": ") || strings.HasSuffix(title, ":") { |
| + t.Errorf("%s has a frame titled %q", name, title) |
| + } |
| + } |
| + for title, n := range seen { |
| + if n > 1 { |
| + t.Errorf("%s has %d frames titled %q", name, n, title) |
| + } |
| + } |
| + } |
| + if frames == 0 { |
| + t.Fatal("no frame title was read") |
| + } |
| +} |
| + |
| +// TestEveryShellHasABlurb: the seven layout pages have a blurb, and the |
| +// five shells (the -page pair members excluded) end with the button |
| +// that opens the shell's own frame document in a new tab. |
| +func TestEveryShellHasABlurb(t *testing.T) { |
| + files := render(t, "") |
| + p := loadedProse(t) |
| + s := newTestSite(t, fiesta.FS(), "") |
| + theme := fiesta.ThemeNames()[0] |
| + layouts := 0 |
| + var shells []string |
| + for _, e := range s.m.Examples { |
| + page := examplePage(t, files, theme, "en", e.Name) |
| + if !e.Document { |
| + if strings.Contains(page, say("gallery.shell.open", "shell", "")) { |
| + t.Errorf("%s is not a layout and carries an open-shell button", e.Name) |
| + } |
| + continue |
| + } |
| + layouts++ |
| + if p.Examples[e.Name].Blurb == "" { |
| + t.Errorf("the %s layout has no blurb", e.Name) |
| + } |
| + shell := strings.TrimPrefix(e.Name, "layout-") |
| + button := `<p><a rst-btn data-gallery-nav href="` + s.href(theme, "en", "frames/"+e.Name+"/"+e.Default+".html") + `" target="_blank" rel="noopener">` + |
| + template.HTMLEscapeString(say("gallery.shell.open", "shell", shell)) + `<span class="rst-sr-only"> (` + say("gallery.newtab") + `)</span></a></p>` |
| + has := strings.Contains(page, button) |
| + if strings.HasSuffix(e.Name, "-page") { |
| + if has || strings.Contains(page, `<p><a rst-btn data-gallery-nav`) { |
| + t.Errorf("%s is the pair's page and carries an open-shell button", e.Name) |
| + } |
| + continue |
| + } |
| + shells = append(shells, shell) |
| + if !has { |
| + t.Errorf("%s does not end with %s", e.Name, button) |
| + } |
| + if i, j := strings.Index(page, button), strings.Index(page, `<nav class="ds-updown"`); i >= 0 && j >= 0 && i > j { |
| + t.Errorf("%s: the open button is after the previous/next pair, not at the end of the body", e.Name) |
| + } |
| + } |
| + if layouts != 7 { |
| + t.Errorf("%d layout examples, want 7", layouts) |
| + } |
| + if want := "column topbar sidebar console stage"; strings.Join(shells, " ") != want { |
| + t.Errorf("the shells with an open button are %v, want %s", shells, want) |
| + } |
| +} |