rastrillo / fiesta Public

A gallery of Fiesta's examples, with axe and keyboard walks over every state

internal/gallery renders every state of every example in every theme
as a tree of static files: an index by family, a page per example with
each state in a frame of its own document and its markup beside it
through codeview, a tokens page and an icons page, and a right-to-left
twin of all of it under rtl/. Every stylesheet, script, image, frame
and data-gallery-nav link is an absolute URL under the mount, and the
tests hold each to a file the render wrote. A frame document is the
example's own with the gallery's assets linked, every href and action
in its body pointed at #, and frame.js first in its head, which cancels
every submit at the window and copies the parent's data-theme onto its
own root before first paint; gallery.js, the frame-painting half of
rastrillo's gallery script, paints and shows each frame at load and
repaints them all on fiesta.js's rst-scheme event. The header is the
theme tabs, the scheme-menu example's standalone state (its
data-rst-scheme-store goes on every page's html, and its aria-current
is moved from Dark to System, where the page starts), the direction
link and the page links.

Every label the chrome shows is a TODO-COPY:<id> placeholder in
copy.go, pending copy review, and TestTheGalleryCarriesNoPlaceholderCopy
is red until the approved strings replace them: make root fails on
that one test by design until then.

The untagged tests ran red against a stub Render first, each by name:
TestRenderWritesEveryStateInEveryTheme, TestEveryGalleryURLResolves,
TestSampleURLsStayInTheMarkup and the ported
TestHighlightColoursPassAgainstTheCodeBackground.

In the browser, TestEveryExamplePassesAxe scans all 163 states in day,
plain, signal and right-to-left day, light and dark each: 1304 scans,
no violations, 56 seconds; bypass and color-contrast are logged as the
rules axe could not decide. TestTheGalleryHasNoHorizontalScrollAt320
measures 77 pages. TestAFrameSubmitsNothing submits ten forms in ten
frames and sees no request and every document kept; TestTheSchemeMenuRepaintsTheFrames
sees every frame go dark and a frame loaded afterwards start dark.
Each went red under a one-line mutation of frame.js or gallery.js.

The keyboard walks are rastrillo's TestA11yWalksTheKeyboard and
TestA11yWalksTheStageShell, ported with their documents mapped: the
form page's thirty stops become every stop of the form family's 22
state documents opened one after another as top-level pages, an
adapted count because the family holds 22 controls in all, asserted
against the documents' own focusable count; the modal is modal/default
as its own document; the stage is layout-stage/default with signin's
default in its slot. Both went red with the field focus rules set to
outline: none in a scratch tokens.css; removing :focus-visible alone
leaves the browser's own ring, which the walk rightly accepts.

fiesta gallery -out DIR [-mount /path] writes the tree into DIR after
claiming it. BROWSER_PKGS gains internal/gallery@2, the suite step is
50-suite@10, and three weight lines are measured.
Human

Prompt

Task 13, Phase A: Fiesta's own gallery, axe, and the keyboard walks. Build the gallery, the axe scan over every state, the reflow, submit and scheme drives and the two keyboard walks without final copy, and stop for copy review.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Adds internal/gallery (Render, the tree, gallery.css, gallery.js, frame.js, placeholder copy table, untagged and browser tests), the fiesta gallery subcommand, internal/gallery@2 in BROWSER_PKGS, the 50-suite@10 step and weight lines.
Agent notes
make root is red on TestTheGalleryCarriesNoPlaceholderCopy until the copy review lands; the test lists every placeholder id. The brief's :focus-visible control cannot redden the walk (the UA ring stands in); the outline: none variant does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev abfd6e903f20dab3e91cffbef69604d8eb09f13d parent 296284f
12 files changed, +2374 −13
  • .amadan/ci.d/{50-suite@8 => 50-suite@10} +0 −0
  • Makefile +1 −1
  • cmd/fiesta/main.go +62 −12
  • hack/test-weights.txt +5 −0
  • internal/gallery/a11y_browser_test.go +561 −0
  • internal/gallery/copy.go +85 −0
  • internal/gallery/frame.js +38 −0
  • internal/gallery/gallery.css +79 −0
  • internal/gallery/gallery.go +675 −0
  • internal/gallery/gallery.js +115 −0
  • internal/gallery/gallery_test.go +362 −0
  • internal/gallery/keyboard_browser_test.go +391 −0
diff --git a/.amadan/ci.d/50-suite@8 b/.amadan/ci.d/50-suite@10
similarity index 100%
rename from .amadan/ci.d/50-suite@8
rename to .amadan/ci.d/50-suite@10
diff --git a/Makefile b/Makefile
index 54d2804..ecd896e 100644
--- a/Makefile
+++ b/Makefile
@@ -54,7 +54,7 @@ ci: gofmt no-rastrillo size suite
# check/drives@2 would become browser.check.drives~1of2 and ~2of2.
# The tasks that add a browser package append here and rename
# 50-suite@N to match.
-BROWSER_PKGS := internal/harness check@3
+BROWSER_PKGS := internal/harness check@3 internal/gallery@2
go-roles = $(foreach p,$(2),$(if $(findstring @,$(p)),$(call go-slices,$(1),$(subst /,.,$(word 1,$(subst @, ,$(p)))),$(word 2,$(subst @, ,$(p)))),$(1).$(subst /,.,$(p))))
go-slices = $(foreach k,$(shell seq 1 $(3)),$(1).$(2)~$(k)of$(3))
BROWSER_ROLES := $(call go-roles,browser,$(BROWSER_PKGS))
diff --git a/cmd/fiesta/main.go b/cmd/fiesta/main.go
index f47cd11..e86ebc7 100644
--- a/cmd/fiesta/main.go
+++ b/cmd/fiesta/main.go
@@ -1,18 +1,26 @@
-// Command fiesta is the design system's command line. Its one
-// subcommand, build, writes the release:
+// Command fiesta is the design system's command line. Two subcommands.
//
// fiesta build -out DIR
//
-// puts the files under DIR/<version>/, where <version> is
+// writes the release under DIR/<version>/, where <version> is
// fiesta.Version: the one-file themes, the separate stylesheets, the
// stripped scripts, the strings, the icons, the licences and
// SHA256SUMS, exactly what internal/build.Dist returns. A site mounts
// that directory and a page links one file from it.
//
-// It owns the version directory. It empties it before it writes, so
-// what is there afterwards is the release and only the release, and
-// it will only take a directory that is absent, empty, or marked
-// with its own stamp (.fiesta); anything else it refuses, changing
+// fiesta gallery -out DIR [-mount /path]
+//
+// writes the gallery into DIR itself: every example state in every
+// theme, with its markup, the tokens and the icons, exactly what
+// internal/gallery.Render returns, with every URL in it an absolute
+// path under -mount (the site root when not given). It is a
+// development and test artefact, for looking at; nothing publishes
+// it.
+//
+// Each owns the directory it writes. It empties it before it writes,
+// so what is there afterwards is the output and only the output, and
+// it will only take a directory that is absent, empty, or marked with
+// its own stamp (.fiesta); anything else it refuses, changing
// nothing. See claim.
package main
@@ -27,9 +35,10 @@ import (
"amadan.net/rastrillo/fiesta"
"amadan.net/rastrillo/fiesta/internal/build"
+ "amadan.net/rastrillo/fiesta/internal/gallery"
)
-const usage = "usage: fiesta build -out DIR"
+const usage = "usage: fiesta build -out DIR | fiesta gallery -out DIR [-mount /path]"
func main() {
if err := run(os.Args[1:], os.Stderr); err != nil {
@@ -47,6 +56,8 @@ func run(args []string, stderr io.Writer) error {
switch args[0] {
case "build":
return buildCmd(args[1:], stderr)
+ case "gallery":
+ return galleryCmd(args[1:], stderr)
default:
return fmt.Errorf("unknown command %q\n%s", args[0], usage)
}
@@ -75,6 +86,30 @@ func buildCmd(args []string, stderr io.Writer) error {
return write(*out)
}
+// galleryCmd is the gallery subcommand's flags, then write.
+func galleryCmd(args []string, stderr io.Writer) error {
+ flags := flag.NewFlagSet("gallery", flag.ContinueOnError)
+ flags.SetOutput(io.Discard)
+ out := flags.String("out", "", "directory to write the gallery into")
+ mount := flags.String("mount", "", "URL path the gallery will be served from (default the site root)")
+ if err := flags.Parse(args); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ flags.SetOutput(stderr)
+ fmt.Fprintln(stderr, usage)
+ flags.PrintDefaults()
+ return nil
+ }
+ return err
+ }
+ if flags.NArg() > 0 {
+ return fmt.Errorf("unexpected argument %q\n%s", flags.Arg(0), usage)
+ }
+ if *out == "" {
+ return errors.New("-out is required\n" + usage)
+ }
+ return writeGallery(*out, *mount)
+}
+
// write builds the release and puts it under out/<version>/, a
// directory it claims first. Separate from the flags so a test can
// call it with a directory of its own.
@@ -83,15 +118,30 @@ func write(out string) error {
if err != nil {
return err
}
- root, err := filepath.Abs(filepath.Join(out, fiesta.Version))
+ return writeTree(filepath.Join(out, fiesta.Version), dist)
+}
+
+// writeGallery renders the gallery for mount and puts it in out, a
+// directory it claims first.
+func writeGallery(out, mount string) error {
+ files, err := gallery.Render(fiesta.FS(), gallery.Options{Mount: mount})
+ if err != nil {
+ return err
+ }
+ return writeTree(out, files)
+}
+
+// writeTree claims root and writes files under it, in name order.
+func writeTree(root string, files map[string][]byte) error {
+ root, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("resolving -out: %w", err)
}
if err := claim(root); err != nil {
return err
}
- names := make([]string, 0, len(dist))
- for name := range dist {
+ names := make([]string, 0, len(files))
+ for name := range files {
names = append(names, name)
}
sort.Strings(names)
@@ -100,7 +150,7 @@ func write(out string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("mkdir for %s: %w", name, err)
}
- if err := os.WriteFile(dest, dist[name], 0o644); err != nil {
+ if err := os.WriteFile(dest, files[name], 0o644); err != nil {
return fmt.Errorf("writing %s: %w", name, err)
}
}
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index 886a4c6..59142a1 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -66,3 +66,8 @@ browser check TestTheCalendarDocksAndItsDaysAreTaps 2
browser check TestTheCardFitsTheSmallestPhone 2
browser check TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas 2
browser check TestTheTrackColourFollowsTheKnob 2
+# Measured 2026-10-27 on docs-t1, one run of internal/gallery with the
+# headless shell.
+browser internal/gallery TestEveryExamplePassesAxe 56
+browser internal/gallery TestTheGalleryHasNoHorizontalScrollAt320 6
+browser internal/gallery TestAFrameSubmitsNothing 2
diff --git a/internal/gallery/a11y_browser_test.go b/internal/gallery/a11y_browser_test.go
new file mode 100644
index 0000000..658e8d4
--- /dev/null
+++ b/internal/gallery/a11y_browser_test.go
@@ -0,0 +1,561 @@
+//go:build browser
+
+// The accessibility gate.
+//
+// "And everything is WCAG 2.2 AA, right?" This file is the half of
+// that answer a machine can give. It serves what Render produces in a
+// real Chromium, the exact bytes fiesta gallery would write, injects
+// the vendored axe-core, and runs it over every example state in every
+// theme and both schemes with the WCAG 2.2 AA tag set. Any violation
+// fails the build, printing rule id, impact and the offending
+// selector, so a regression names itself.
+//
+// Run it with:
+//
+// make browser.internal.gallery~1of2 browser.internal.gallery~2of2
+//
+// Three honest limits, written down here rather than implied:
+//
+// - Automated scanning catches roughly half of the WCAG success
+// criteria, the machine-checkable half. The rest (meaningful
+// alternative text, sensible reading order, whether a label says
+// something true) is a person's job. A green run here is a floor,
+// not a certificate.
+// - axe checks what is on the screen. Every state is scanned as its
+// own document, which is where its markup lives; the gallery page
+// around it is scanned by the reflow drive and the keyboard walks
+// and not by axe, because its labels are still under review.
+// - Two criteria axe cannot see at all get their own drives:
+// 1.4.10 Reflow (a 320px viewport that does not scroll sideways)
+// and 2.4.7/2.1.2 (keyboard_browser_test.go's walks, which prove
+// focus is visible at every stop and can always move on).
+package gallery
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "path"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/example"
+ "amadan.net/rastrillo/fiesta/internal/axe"
+ "amadan.net/rastrillo/fiesta/internal/harness"
+)
+
+// mountPath is where the drives serve the tree: under a directory
+// rather than at the root, so every absolute URL the render writes is
+// exercised against a mount a developer's machine would not need.
+const mountPath = "/gallery"
+
+// tree serves the rendered tree under mountPath, with extra files
+// beside it, and reports every path it was asked for and did not have
+// when the test ends: a frame whose stylesheet 404s is a frame the
+// drive measured unstyled. requests records every request, for the
+// drive that proves a frame requests nothing.
+type tree struct {
+ files map[string][]byte
+ mu sync.Mutex
+ missing []string
+ requests []string
+}
+
+func newTree(t *testing.T, extra map[string][]byte) *tree {
+ t.Helper()
+ files := render(t, mountPath)
+ for name, body := range extra {
+ files[name] = body
+ }
+ tr := &tree{files: files}
+ t.Cleanup(func() {
+ tr.mu.Lock()
+ defer tr.mu.Unlock()
+ if len(tr.missing) > 0 {
+ t.Errorf("the tree was asked for %d file(s) it does not have: %v", len(tr.missing), tr.missing)
+ }
+ })
+ return tr
+}
+
+var contentTypes = map[string]string{
+ ".html": "text/html; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+}
+
+func (tr *tree) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ tr.mu.Lock()
+ tr.requests = append(tr.requests, r.Method+" "+r.URL.RequestURI())
+ tr.mu.Unlock()
+ if !strings.HasPrefix(r.URL.Path, mountPath+"/") || r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+ name := strings.TrimPrefix(r.URL.Path, mountPath+"/")
+ body, ok := tr.files[name]
+ if !ok {
+ tr.mu.Lock()
+ tr.missing = append(tr.missing, r.URL.Path)
+ tr.mu.Unlock()
+ http.NotFound(w, r)
+ return
+ }
+ if ct, ok := contentTypes[path.Ext(name)]; ok {
+ w.Header().Set("Content-Type", ct)
+ }
+ w.Write(body)
+}
+
+// seen returns the requests so far.
+func (tr *tree) seen() []string {
+ tr.mu.Lock()
+ defer tr.mu.Unlock()
+ return append([]string(nil), tr.requests...)
+}
+
+// href is the URL of one file of the tree at the mount.
+func href(rel string) string { return mountPath + "/" + rel }
+
+// frameFile is the path of one state's frame document within the tree.
+func frameFile(theme string, rtl bool, name, state string) string {
+ dir := theme + "/"
+ if rtl {
+ dir += rtlDir
+ }
+ return dir + "frames/" + name + "/" + state + ".html"
+}
+
+func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) {
+ t.Helper()
+ if err := chromedp.Run(ctx, actions...); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// at evaluates a JS expression returning a JSON string into v.
+func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) {
+ t.Helper()
+ var raw string
+ if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil {
+ t.Fatalf("evaluating: %v\n%s", err, js)
+ }
+ if err := json.Unmarshal([]byte(raw), v); err != nil {
+ t.Fatalf("decoding %q: %v", raw, err)
+ }
+}
+
+func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }
+
+// settleJS takes every frame off lazy loading and reports how many
+// there are and how many gallery.js has painted.
+const settleJS = `(() => {
+ const f = [...document.querySelectorAll(".gallery-frame")];
+ f.forEach(x => { if (x.loading !== "eager") x.loading = "eager"; });
+ return JSON.stringify({total: f.length, painted: f.filter(x => x.hasAttribute("data-gallery-painted")).length});
+})()`
+
+// eagerly loads every frame on the page and waits until gallery.js
+// has painted them all, so a reading that follows is of documents
+// that are there.
+func eagerly(t *testing.T, ctx context.Context, where string) int {
+ t.Helper()
+ deadline := time.Now().Add(60 * time.Second)
+ for {
+ var got struct{ Total, Painted int }
+ at(t, ctx, settleJS, &got)
+ if got.Total == 0 {
+ t.Fatalf("%s: no frames on the page", where)
+ }
+ if got.Painted == got.Total {
+ t.Logf("%s: %d frames loaded and painted", where, got.Total)
+ return got.Total
+ }
+ if !time.Now().Before(deadline) {
+ t.Fatalf("%s: %d of %d frames painted after 60s", where, got.Painted, got.Total)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+// report prints one scan's violations, one block each, and returns how
+// many there were.
+func report(t *testing.T, where string, vs []axe.Violation) int {
+ t.Helper()
+ for _, v := range vs {
+ nodes := v.Nodes
+ if len(nodes) > 5 {
+ nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5))
+ }
+ t.Errorf("%s: %s (%s): %s\n %s", where, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n "))
+ }
+ return len(vs)
+}
+
+// TestEveryExamplePassesAxe is the gate: every frame document, scanned
+// in day, plain and signal with data-theme light and dark, and in
+// right-to-left day, with the WCAG 2.2 AA tag set. Expected: no
+// violations. The rules axe could not decide are logged per leg, never
+// silently dropped: they are the seam between what this scan holds and
+// what a person reviews.
+func TestEveryExamplePassesAxe(t *testing.T) {
+ rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 18*time.Minute)
+ defer cancel()
+ m := manifest(t)
+
+ legs := []struct {
+ theme string
+ rtl bool
+ }{{"day", false}, {"plain", false}, {"signal", false}, {"day", true}}
+ for _, theme := range fiesta.ThemeNames() {
+ found := false
+ for _, l := range legs {
+ found = found || l.theme == theme
+ }
+ if !found {
+ t.Fatalf("theme %s is scanned by no leg", theme)
+ }
+ }
+ scans, failed := 0, 0
+ for _, leg := range legs {
+ name := leg.theme
+ if leg.rtl {
+ name += " rtl"
+ }
+ incomplete := map[string]int{}
+ start := time.Now()
+ for _, e := range m.Examples {
+ for _, s := range e.States {
+ where := name + " " + e.Name + "/" + s.ID
+ mustRun(t, ctx,
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+href(frameFile(leg.theme, leg.rtl, e.Name, s.ID))),
+ chromedp.WaitReady("body"),
+ )
+ for _, scheme := range []string{"light", "dark"} {
+ mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), nil))
+ res, err := axe.Scan(ctx, "", axe.DefaultTags())
+ if err != nil {
+ t.Fatalf("%s %s: %v", where, scheme, err)
+ }
+ scans++
+ for _, id := range res.Incomplete {
+ incomplete[id]++
+ }
+ failed += report(t, where+" "+scheme, res.Violations)
+ }
+ }
+ }
+ var ids []string
+ for id, n := range incomplete {
+ ids = append(ids, fmt.Sprintf("%s x%d", id, n))
+ }
+ sort.Strings(ids)
+ t.Logf("%s: scanned in %s; needs review, axe could not decide: %v", name, time.Since(start).Round(time.Second), ids)
+ }
+ if scans == 0 {
+ t.Fatal("no document was scanned")
+ }
+ t.Logf("%d scans, %d violations", scans, failed)
+ rig.Screen("body", "the last frame document")
+}
+
+// measureJS reports whether the document scrolls sideways, and the
+// elements that reach past either edge. Both edges, because an LTR
+// page spills past the right and an RTL page past the left. The edge
+// is the root's own laid-out box, not clientWidth, which over a stable
+// gutter differs by the scrollbar's width wherever none is drawn.
+const measureJS = `(() => {
+ const de = document.documentElement;
+ const w = Math.round(de.getBoundingClientRect().width);
+ const over = [];
+ document.querySelectorAll("body *").forEach(el => {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0) return;
+ if (r.right > w + 1 || r.left < -1) {
+ const cls = (el.className || "").toString().trim().split(/\s+/)[0];
+ over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]");
+ }
+ });
+ return JSON.stringify({doc: de.scrollWidth, client: w, body: document.body.scrollWidth, over: over.slice(0, 8)});
+})()`
+
+// TestTheGalleryHasNoHorizontalScrollAt320 is WCAG 2.2 AA 1.4.10
+// Reflow on the gallery's own pages: content at a 320 CSS-pixel
+// viewport must not need horizontal scrolling. 320x640 is the
+// criterion's own number, 1280 CSS px at 400% zoom, so a desktop
+// browser rather than mobile emulation. A frame is a scroll container
+// by design and may scroll inside its box; the page may not.
+func TestTheGalleryHasNoHorizontalScrollAt320(t *testing.T) {
+ rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
+ defer cancel()
+ m := manifest(t)
+ theme := fiesta.ThemeNames()[0]
+
+ pages := []string{theme + "/index.html", theme + "/tokens.html", theme + "/icons.html", theme + "/" + rtlDir + "index.html"}
+ for _, e := range m.Examples {
+ pages = append(pages, theme+"/"+e.Name+".html")
+ }
+ pages = append(pages, theme+"/"+rtlDir+"field.html")
+ for _, p := range pages {
+ mustRun(t, ctx,
+ chromedp.ActionFunc(func(c context.Context) error {
+ return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c)
+ }),
+ chromedp.Navigate(rig.Origin+href(p)),
+ chromedp.WaitReady("body"),
+ )
+ var got struct {
+ Doc, Client, Body int
+ Over []string
+ }
+ at(t, ctx, measureJS, &got)
+ if got.Doc > got.Client || got.Body > got.Client {
+ t.Errorf("%s scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s",
+ p, got.Doc, got.Body, got.Client, strings.Join(got.Over, "\n "))
+ }
+ }
+ t.Logf("%d pages measured", len(pages))
+ rig.Screen("main", "the last page")
+}
+
+// submitJS submits every form in every frame on the page, by its
+// submit button where it has one, and reports how many submit events
+// the frames saw and whether every frame kept its window: a mark set
+// on a window before the submits is still there after only if no
+// navigation replaced it.
+const submitJS = `(async () => {
+ const frames = [...document.querySelectorAll(".gallery-frame")];
+ let events = 0, forms = 0;
+ for (const f of frames) {
+ const w = f.contentWindow, d = f.contentDocument;
+ w.__mark = 1;
+ w.addEventListener("submit", () => { events++; }, true);
+ for (const form of d.querySelectorAll("form")) {
+ forms++;
+ const b = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])');
+ if (b) b.click(); else form.requestSubmit();
+ }
+ }
+ await new Promise(r => setTimeout(r, 300));
+ const kept = frames.filter(f => f.contentWindow.__mark === 1 && f.contentDocument.readyState === "complete").length;
+ return JSON.stringify({frames: frames.length, forms, events, kept});
+})()`
+
+// TestAFrameSubmitsNothing: submitting each form in a few frames makes
+// no request beyond the frame's own assets, and the frame keeps its
+// document. Three example pages whose states hold forms of different
+// kinds: a confirmation, a select inside a field, and a search bar.
+func TestAFrameSubmitsNothing(t *testing.T) {
+ var tr *tree
+ rig := harness.New(t, func(string) http.Handler { tr = newTree(t, nil); return tr })
+ ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute)
+ defer cancel()
+ theme := fiesta.ThemeNames()[0]
+
+ total := 0
+ for _, name := range []string{"confirm-form", "field-select", "list-bar-search"} {
+ mustRun(t, ctx,
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+href(theme+"/"+name+".html")),
+ chromedp.WaitReady("body"),
+ )
+ eagerly(t, ctx, name)
+ before := len(tr.seen())
+ var got struct{ Frames, Forms, Events, Kept int }
+ at(t, ctx, submitJS, &got, awaitPromise)
+ if got.Forms == 0 || got.Events == 0 {
+ t.Errorf("%s: %d forms submitted and %d submit events seen; the drive submitted nothing", name, got.Forms, got.Events)
+ }
+ if got.Kept != got.Frames {
+ t.Errorf("%s: %d of %d frames kept their document through the submits", name, got.Kept, got.Frames)
+ }
+ after := tr.seen()
+ if len(after) > before {
+ t.Errorf("%s: the submits made %d request(s):\n %s", name, len(after)-before, strings.Join(after[before:], "\n "))
+ }
+ total += got.Events
+ t.Logf("%s: %d forms in %d frames, %d submit events, no request", name, got.Forms, got.Frames, got.Events)
+ }
+ if total == 0 {
+ t.Fatal("no submit event was seen on any page")
+ }
+ rig.Screen("main", "the last page")
+}
+
+// schemeJS reports the parent's scheme and, for every frame, its
+// scheme attribute and the colour painted behind its content: the body
+// where it paints one, else the root.
+const schemeJS = `(() => {
+ const paint = d => {
+ const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor;
+ return b === "rgba(0, 0, 0, 0)" ? r : b;
+ };
+ const frames = [...document.querySelectorAll(".gallery-frame")].map(f => ({
+ theme: f.contentDocument.documentElement.getAttribute("data-theme") || "",
+ bg: paint(f.contentDocument),
+ }));
+ let stored = "";
+ try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {}
+ return JSON.stringify({theme: document.documentElement.getAttribute("data-theme") || "", stored, frames});
+})()`
+
+// luminance is the relative luminance of an rgb() string, for telling
+// a dark canvas from a light one.
+func luminance(t *testing.T, rgb string) float64 {
+ t.Helper()
+ var r, g, b float64
+ if _, err := fmt.Sscanf(rgb, "rgb(%g, %g, %g)", &r, &g, &b); err != nil {
+ t.Fatalf("%q is not an rgb() colour: %v", rgb, err)
+ }
+ lin := func(c float64) float64 {
+ c /= 255
+ if c <= 0.03928 {
+ return c / 12.92
+ }
+ return ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055)
+ }
+ return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b)
+}
+
+// chooseScheme picks one scheme from the header's menu, as a reader
+// would: open the menu, press the button.
+func chooseScheme(t *testing.T, ctx context.Context, scheme string) {
+ t.Helper()
+ btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme)
+ mustRun(t, ctx,
+ chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery),
+ chromedp.WaitVisible(btn, chromedp.ByQuery),
+ chromedp.Click(btn, chromedp.ByQuery),
+ )
+}
+
+// TestTheSchemeMenuRepaintsTheFrames: choosing Dark in the parent
+// gives every loaded frame data-theme="dark" and a dark computed
+// background, and a frame loaded afterwards starts dark. The later
+// frame is added outside gallery.js's reach, so only frame.js can have
+// painted it.
+func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) {
+ rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute)
+ defer cancel()
+ theme := fiesta.ThemeNames()[0]
+
+ mustRun(t, ctx,
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+href(theme+"/field.html")),
+ chromedp.WaitReady("body"),
+ )
+ n := eagerly(t, ctx, "field")
+
+ type reading struct {
+ Theme, Stored string
+ Frames []struct{ Theme, Bg string }
+ }
+ var before reading
+ at(t, ctx, schemeJS, &before)
+ if before.Theme != "" || before.Stored != "" {
+ t.Fatalf("before any choice the page is on %q with %q stored; the drive would be testing a remembered choice", before.Theme, before.Stored)
+ }
+ for i, f := range before.Frames {
+ if f.Theme != "" {
+ t.Errorf("frame %d starts on %q, want the OS's scheme", i, f.Theme)
+ }
+ if l := luminance(t, f.Bg); l < 0.5 {
+ t.Errorf("frame %d starts dark (%s); the reading after Dark would prove nothing", i, f.Bg)
+ }
+ }
+
+ chooseScheme(t, ctx, "dark")
+ deadline := time.Now().Add(10 * time.Second)
+ var after reading
+ for {
+ at(t, ctx, schemeJS, &after)
+ dark := 0
+ for _, f := range after.Frames {
+ if f.Theme == "dark" {
+ dark++
+ }
+ }
+ if dark == len(after.Frames) || !time.Now().Before(deadline) {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ if after.Theme != "dark" {
+ t.Errorf("after choosing Dark the page is on %q", after.Theme)
+ }
+ if after.Stored != "dark" {
+ t.Errorf("after choosing Dark the page stored %q; the header's menu is the standalone state, which remembers", after.Stored)
+ }
+ if len(after.Frames) != n {
+ t.Fatalf("%d frames read, %d loaded", len(after.Frames), n)
+ }
+ for i, f := range after.Frames {
+ if f.Theme != "dark" {
+ t.Errorf("frame %d is on %q after Dark was chosen", i, f.Theme)
+ }
+ if l := luminance(t, f.Bg); l > 0.2 {
+ t.Errorf("frame %d paints %s after Dark was chosen, which is not dark", i, f.Bg)
+ }
+ }
+
+ // A frame loaded afterwards, outside gallery.js's class, so the
+ // attribute it starts with is frame.js's doing alone.
+ later := fmt.Sprintf(`(async () => {
+ const f = document.createElement("iframe");
+ f.src = %q;
+ const loaded = new Promise(r => f.addEventListener("load", r));
+ document.querySelector("main").append(f);
+ await loaded;
+ const d = f.contentDocument;
+ const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor;
+ return JSON.stringify({theme: d.documentElement.getAttribute("data-theme") || "", bg: b === "rgba(0, 0, 0, 0)" ? r : b});
+ })()`, href(frameFile(theme, false, "badge", mustDefault(t, "badge"))))
+ var fresh struct{ Theme, Bg string }
+ at(t, ctx, later, &fresh, awaitPromise)
+ if fresh.Theme != "dark" {
+ t.Errorf("a frame loaded after the choice starts on %q, want dark", fresh.Theme)
+ }
+ if l := luminance(t, fresh.Bg); l > 0.2 {
+ t.Errorf("a frame loaded after the choice paints %s, which is not dark", fresh.Bg)
+ }
+ rig.Screen("main", "the field page in Dark")
+}
+
+// mustDefault is the manifest's default state for name.
+func mustDefault(t *testing.T, name string) string {
+ t.Helper()
+ state, ok := manifest(t).Default(name)
+ if !ok || state == "" {
+ t.Fatalf("the manifest has no default state for %s", name)
+ }
+ return state
+}
+
+// statesOf lists one family's states in manifest order, as
+// name/state pairs.
+func statesOf(m *example.Manifest, family string) [][2]string {
+ var out [][2]string
+ for _, e := range m.Examples {
+ if e.Family != family {
+ continue
+ }
+ for _, s := range e.States {
+ out = append(out, [2]string{e.Name, s.ID})
+ }
+ }
+ return out
+}
diff --git a/internal/gallery/copy.go b/internal/gallery/copy.go
new file mode 100644
index 0000000..b6e216c
--- /dev/null
+++ b/internal/gallery/copy.go
@@ -0,0 +1,85 @@
+package gallery
+
+import "strings"
+
+// The gallery's own words: every label the chrome shows, by id. The
+// examples' labels and markup are the manifest's and are not here.
+//
+// A value that still reads TODO-COPY:<id> has not been through copy
+// review; TestTheGalleryCarriesNoPlaceholderCopy fails while any
+// remains, so a placeholder cannot reach a page by accident. The ids
+// are the ones the review is filed under.
+var text = map[string]string{
+ // The <title> of every page: the page first, because a tab strip
+ // truncates from the end, then the site, then the theme.
+ "gallery.title": "TODO-COPY:gallery.title",
+ // The brand link at the start of the header, back to the index.
+ "gallery.brand": "TODO-COPY:gallery.brand",
+ // The skip link, first in every page.
+ "gallery.skip": "TODO-COPY:gallery.skip",
+ // The theme switcher's accessible name.
+ "gallery.theme.label": "TODO-COPY:gallery.theme.label",
+ // The link to the page's right-to-left twin, and back.
+ "gallery.direction.rtl": "TODO-COPY:gallery.direction.rtl",
+ "gallery.direction.ltr": "TODO-COPY:gallery.direction.ltr",
+ // The page navigation: its accessible name and its three links.
+ "gallery.nav.label": "TODO-COPY:gallery.nav.label",
+ "gallery.nav.examples": "TODO-COPY:gallery.nav.examples",
+ "gallery.nav.tokens": "TODO-COPY:gallery.nav.tokens",
+ "gallery.nav.icons": "TODO-COPY:gallery.nav.icons",
+ // The index: its heading, its lead, and a heading per family.
+ "gallery.index.heading": "TODO-COPY:gallery.index.heading",
+ "gallery.index.lead": "TODO-COPY:gallery.index.lead",
+ "gallery.family.list-screen": "TODO-COPY:gallery.family.list-screen",
+ "gallery.family.display": "TODO-COPY:gallery.family.display",
+ "gallery.family.form": "TODO-COPY:gallery.family.form",
+ "gallery.family.choices-and-actions": "TODO-COPY:gallery.family.choices-and-actions",
+ "gallery.family.date-and-time": "TODO-COPY:gallery.family.date-and-time",
+ "gallery.family.route": "TODO-COPY:gallery.family.route",
+ "gallery.family.primitives": "TODO-COPY:gallery.family.primitives",
+ "gallery.family.shells": "TODO-COPY:gallery.family.shells",
+ "gallery.family.formats": "TODO-COPY:gallery.family.formats",
+ "gallery.family.screens": "TODO-COPY:gallery.family.screens",
+ // An example's page: the sentence under its heading, the HTML tab,
+ // the frame's accessible name, and the note naming the routes a
+ // sample stands for.
+ "gallery.example.lead": "TODO-COPY:gallery.example.lead",
+ "gallery.html": "TODO-COPY:gallery.html",
+ "gallery.frame.title": "TODO-COPY:gallery.frame.title",
+ "gallery.routes": "TODO-COPY:gallery.routes",
+ // The tokens page: heading, lead, the note on the palette, and a
+ // heading per group of tokens.Group.Key.
+ "gallery.tokens.heading": "TODO-COPY:gallery.tokens.heading",
+ "gallery.tokens.lead": "TODO-COPY:gallery.tokens.lead",
+ "gallery.tokens.note": "TODO-COPY:gallery.tokens.note",
+ "gallery.tokens.group.surfaces-and-lines": "TODO-COPY:gallery.tokens.group.surfaces-and-lines",
+ "gallery.tokens.group.text": "TODO-COPY:gallery.tokens.group.text",
+ "gallery.tokens.group.accent": "TODO-COPY:gallery.tokens.group.accent",
+ "gallery.tokens.group.status-tones": "TODO-COPY:gallery.tokens.group.status-tones",
+ "gallery.tokens.group.depth": "TODO-COPY:gallery.tokens.group.depth",
+ "gallery.tokens.group.controls": "TODO-COPY:gallery.tokens.group.controls",
+ "gallery.tokens.group.type-choices": "TODO-COPY:gallery.tokens.group.type-choices",
+ "gallery.tokens.group.other": "TODO-COPY:gallery.tokens.group.other",
+ "gallery.tokens.group.radius": "TODO-COPY:gallery.tokens.group.radius",
+ "gallery.tokens.group.type-family": "TODO-COPY:gallery.tokens.group.type-family",
+ "gallery.tokens.group.type-scale": "TODO-COPY:gallery.tokens.group.type-scale",
+ "gallery.tokens.group.spacing": "TODO-COPY:gallery.tokens.group.spacing",
+ // The icons page: heading and lead.
+ "gallery.icons.heading": "TODO-COPY:gallery.icons.heading",
+ "gallery.icons.lead": "TODO-COPY:gallery.icons.lead",
+}
+
+// say returns the text filed under id, with each {name} replaced by
+// the value following it in args. An id nothing is filed under is a
+// programming error, not a page to render, so it panics: every id
+// written in a template is one of the keys above.
+func say(id string, args ...string) string {
+ s, ok := text[id]
+ if !ok {
+ panic("gallery: no text for " + id)
+ }
+ for i := 0; i+1 < len(args); i += 2 {
+ s = strings.ReplaceAll(s, "{"+args[i]+"}", args[i+1])
+ }
+ return s
+}
diff --git a/internal/gallery/frame.js b/internal/gallery/frame.js
new file mode 100644
index 0000000..b0cb109
--- /dev/null
+++ b/internal/gallery/frame.js
@@ -0,0 +1,38 @@
+/* frame.js: the first script in every frame document the gallery
+ renders, and no part of the design system. Two jobs, both done
+ before the document's own scripts run.
+
+ The scheme. A frame does not inherit its parent's colour scheme,
+ and the gallery's reader may have chosen Light or Dark before this
+ frame loaded. The parent is same-origin, so its <html> is readable:
+ whatever data-theme it carries is copied here before the first
+ paint, and gallery.js writes it again on every change after. Opened
+ on its own, outside a frame, there is no parent and nothing is
+ copied.
+
+ Submits. Every href in a frame's body is "#", so a click goes
+ nowhere; a form would still request its action, "#" included, which
+ is the page again with its fields in the query. Cancelling every
+ submit at the window, in the capture phase, stops the request
+ before any other listener runs, and runs them all the same:
+ fiesta.js's scheme switch sees its submit in its own capture
+ listener on the document and applies the scheme as usual. */
+(function () {
+ "use strict";
+
+ var host = null;
+ try {
+ host = window.frameElement;
+ } catch (e) {
+ /* framed by another origin, whose page is not the gallery's */
+ }
+ if (host) {
+ var parent = host.ownerDocument.documentElement, s = parent.getAttribute("data-theme");
+ if (s === "light" || s === "dark") document.documentElement.setAttribute("data-theme", s);
+ else document.documentElement.removeAttribute("data-theme");
+ }
+
+ addEventListener("submit", function (event) {
+ event.preventDefault();
+ }, true);
+})();
diff --git a/internal/gallery/gallery.css b/internal/gallery/gallery.css
new file mode 100644
index 0000000..75d0611
--- /dev/null
+++ b/internal/gallery/gallery.css
@@ -0,0 +1,79 @@
+/* gallery.css: the gallery's own chrome, and nothing an application
+ receives. The header, the index's lists, a state's frame and its
+ source, the token grid and the icon grid. Everything is drawn from
+ the design system's own tokens, so the chrome follows the theme and
+ the scheme like the examples inside it do.
+
+ The highlight colours at the foot are the five custom elements
+ codeview.Highlight writes; TestHighlightColoursPassAgainstTheCodeBackground
+ reads them off this file by their exact lines. */
+
+/* The header: the brand, the theme tabs, the scheme menu, the link to
+ the other direction and the page links, wrapping as they need to at
+ any width. The seg-tabs' own margin is for a page body, not a bar. */
+.gallery-top { align-items: center; border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-3) var(--rst-sp-4); padding: var(--rst-sp-3) var(--rst-sp-4); }
+.gallery-top [rst-seg-tabs] { margin-block-end: 0; }
+.gallery-brand { color: var(--rst-text); font-weight: 650; text-decoration: none; }
+.gallery-pages { display: flex; flex-wrap: wrap; gap: var(--rst-sp-3); }
+.gallery-pages a[aria-current] { font-weight: 650; }
+/* Every link in the bar is a target like any control: the floor in
+ force, centred on its line. */
+.gallery-brand, .gallery-twin, .gallery-pages a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); }
+
+/* A heading is one word on the index, the tokens and the icons pages,
+ and a word has to be able to break at 320px: the page may not scroll
+ sideways for anything. */
+main[rst-page] h1, main[rst-page] h2 { overflow-wrap: anywhere; }
+.gallery-lead { color: var(--rst-text-muted); margin: 0 0 var(--rst-sp-4); max-width: 62ch; }
+.gallery-note { border-inline-start: 2px solid var(--rst-line-strong); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-4); max-width: 62ch; padding-inline-start: var(--rst-sp-3); }
+.gallery-family h2 { font-size: var(--rst-fs-base); margin: var(--rst-sp-5) 0 var(--rst-sp-2); }
+.gallery-list { display: grid; gap: var(--rst-sp-2); grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); list-style: none; margin: 0; padding: 0; }
+.gallery-list a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); }
+
+/* One state: its label, its frame, its source. */
+.gallery-state { margin-block: var(--rst-sp-5); }
+.gallery-label { font-size: var(--rst-fs-base); margin: 0 0 var(--rst-sp-2); }
+.gallery-view { 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. */
+.gallery-frame { block-size: 20rem; border: 0; color-scheme: normal; display: block; inline-size: 100%; }
+.gallery-frame--page { block-size: 40rem; resize: vertical; }
+:root[data-gallery-js] .gallery-frame:not([data-gallery-painted]) { visibility: hidden; }
+
+/* The HTML tab is a disclosure: no script, and the browser's own
+ control. Its summary is a target like the links above. */
+.gallery-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. */
+.gallery-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; }
+.gallery-src code { overflow-wrap: anywhere; white-space: pre-wrap; }
+.gallery-routes { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: var(--rst-sp-3) 0 0; max-width: 62ch; }
+
+/* The token grid, and the icon grid that reuses it: one chip per
+ token, its name and value beside it. */
+.gallery-toks { display: grid; gap: var(--rst-sp-3); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; }
+.gallery-tok { align-items: center; display: flex; gap: var(--rst-sp-3); min-inline-size: 0; }
+.gallery-tok__text { min-inline-size: 0; }
+.gallery-tok__name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gallery-tok__value { color: var(--rst-text-muted); display: block; overflow-wrap: anywhere; }
+.gallery-chip { background: var(--rst-surface); block-size: 2.25rem; border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); flex: none; inline-size: 2.25rem; }
+.gallery-chip--fill { background: var(--rst-accent); border-color: var(--rst-accent); }
+.gallery-chip--icon { align-items: center; color: var(--rst-text); display: flex; justify-content: center; }
+.gallery-bar { background: var(--rst-accent); block-size: 1.25rem; border-radius: 2px; flex: none; }
+.gallery-type { display: block; flex: none; inline-size: 3.25rem; line-height: 1.15; text-align: center; }
+
+/* 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. */
+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); }
diff --git a/internal/gallery/gallery.go b/internal/gallery/gallery.go
new file mode 100644
index 0000000..0e8f704
--- /dev/null
+++ b/internal/gallery/gallery.go
@@ -0,0 +1,675 @@
+// Package gallery renders Fiesta's own gallery: every state of every
+// example, in every theme, each in a frame of its own, with its markup
+// beside it; a tokens page and an icons page per theme; and a
+// right-to-left twin of all of it. It is a development and test
+// artefact: the accessibility drives scan what it renders, and
+// fiesta gallery writes it out for looking at.
+//
+// Render returns the whole tree in memory, keyed by path relative to
+// the tree root, and it is deterministic: the same tree from the same
+// source every time, so two renders can be compared.
+//
+// index.html the first theme's index again
+// <theme>/index.html the examples, by family
+// <theme>/<name>.html one page per example: its
+// states, each a frame, its HTML
+// and the routes it stands for
+// <theme>/frames/<name>/<state>.html one document per state, the
+// example's own with the
+// gallery's assets and dead links
+// <theme>/tokens.html the theme's palette and the
+// structure every theme shares
+// <theme>/icons.html every icon
+// <theme>/rtl/... the same, lang="ar" dir="rtl"
+// assets/css/... assets/js/... the source files, as they are
+// assets/images/... the examples' sample pictures
+// assets/gallery.css gallery.js frame.js
+//
+// Two kinds of URL. The gallery owns every stylesheet, script, image
+// and frame URL and every link marked data-gallery-nav: each is an
+// absolute path under the mount, and the tests check every one names a
+// file the render wrote. A sample's own links and forms are the
+// example's: the HTML tab shows them as written, and the frame
+// documents rewrite each href and action to "#", with frame.js
+// cancelling every submit, so a click or a submit inside a frame
+// requests nothing.
+package gallery
+
+import (
+ "bytes"
+ _ "embed"
+ "errors"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "path"
+ "regexp"
+ "sort"
+ "strings"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/example"
+ "amadan.net/rastrillo/fiesta/internal/codeview"
+ "amadan.net/rastrillo/fiesta/internal/tokens"
+)
+
+// Options is what a caller decides about the tree.
+type Options struct {
+ // Mount is the URL path the tree is served from; "" means "/".
+ // Every URL in the output is an absolute path under it, so a tree
+ // rendered for one mount cannot be served at another.
+ Mount string
+}
+
+// The gallery's own furniture, embedded beside this file so each is
+// edited as what it is. None is part of the design system: no page
+// but the gallery's own links them.
+//
+//go:embed gallery.css
+var galleryCSS []byte
+
+//go:embed gallery.js
+var galleryJS []byte
+
+//go:embed frame.js
+var frameJS []byte
+
+// rtlDir is where a theme's right-to-left twin lives under the theme.
+const rtlDir = "rtl/"
+
+// cleanMount normalises the mount to "" for the root or a rooted,
+// cleaned path with no slash at the end, so URLs are mount + "/" + rel
+// in either case.
+func cleanMount(mount string) string {
+ if mount == "" {
+ return ""
+ }
+ if !strings.HasPrefix(mount, "/") {
+ mount = "/" + mount
+ }
+ clean := path.Clean(mount)
+ if clean == "/" {
+ return ""
+ }
+ return clean
+}
+
+// site is one render in progress: the source, the manifest, the mount
+// and the output.
+type site struct {
+ src fs.FS
+ m *example.Manifest
+ mount string
+ out map[string][]byte
+ // scheme is the header's scheme menu: the scheme-menu example's
+ // standalone state, whose <html> attributes every gallery page
+ // takes as well, so fiesta.js stores the choice where that state
+ // says to.
+ scheme template.HTML
+ htmlAttrs template.HTMLAttr
+ // schemeScripts are the scripts that state's head asks for, by
+ // file name.
+ schemeScripts []string
+ families []family
+ icons []icon
+}
+
+// family is one run of examples on the index.
+type family struct {
+ Key string
+ Examples []example.Entry
+}
+
+type icon struct {
+ Slug string
+ Markup template.HTML
+}
+
+// Render builds the whole tree in memory: path relative to the tree
+// root to file content. src is fiesta.FS() or any tree laid out the
+// same way.
+func Render(src fs.FS, opts Options) (map[string][]byte, error) {
+ m, err := example.Load(src)
+ if err != nil {
+ return nil, fmt.Errorf("gallery: %w", err)
+ }
+ s := &site{src: src, m: m, mount: cleanMount(opts.Mount), out: map[string][]byte{}}
+ if err := s.assets(); err != nil {
+ return nil, fmt.Errorf("gallery: %w", err)
+ }
+ if err := s.schemeMenu(); err != nil {
+ return nil, fmt.Errorf("gallery: %w", err)
+ }
+ s.families = groupFamilies(m.Examples)
+ if err := s.readIcons(); err != nil {
+ return nil, fmt.Errorf("gallery: %w", err)
+ }
+ for _, theme := range fiesta.ThemeNames() {
+ for _, rtl := range []bool{false, true} {
+ if err := s.theme(theme, rtl); err != nil {
+ return nil, fmt.Errorf("gallery: %s: %w", theme, err)
+ }
+ }
+ }
+ // The tree root is the first theme's index again, copied rather
+ // than aliased so callers get one slice per path.
+ root, ok := s.out[fiesta.ThemeNames()[0]+"/index.html"]
+ if !ok {
+ return nil, errors.New("gallery: no index to serve as the tree root")
+ }
+ if err := s.put("index.html", append([]byte(nil), root...)); err != nil {
+ return nil, err
+ }
+ return s.out, nil
+}
+
+// put adds one file to the tree and refuses a second writer: a name
+// one page took from another would replace a document with no test
+// noticing.
+func (s *site) put(name string, body []byte) error {
+ if _, dup := s.out[name]; dup {
+ return fmt.Errorf("gallery: %s is produced twice", name)
+ }
+ s.out[name] = body
+ return nil
+}
+
+// url is the absolute URL of one file of the tree.
+func (s *site) url(rel string) string { return s.mount + "/" + rel }
+
+// assets copies the source files the frames and pages link, as they
+// are: css/ and js/ under assets/, the examples' pictures under
+// assets/images/, and the gallery's own three files beside them.
+func (s *site) assets() error {
+ for _, dir := range []string{"css", "js"} {
+ err := fs.WalkDir(s.src, dir, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ b, err := fs.ReadFile(s.src, p)
+ if err != nil {
+ return err
+ }
+ return s.put("assets/"+p, b)
+ })
+ if err != nil {
+ return err
+ }
+ }
+ for _, theme := range fiesta.ThemeNames() {
+ if _, ok := s.out["assets/css/themes/"+theme+".css"]; !ok {
+ return fmt.Errorf("no css/themes/%s.css in the source", theme)
+ }
+ }
+ pictures, err := fs.ReadDir(s.src, "examples/assets")
+ if err != nil {
+ return err
+ }
+ for _, e := range pictures {
+ if e.IsDir() {
+ continue
+ }
+ b, err := fs.ReadFile(s.src, "examples/assets/"+e.Name())
+ if err != nil {
+ return err
+ }
+ if err := s.put("assets/images/"+e.Name(), b); err != nil {
+ return err
+ }
+ }
+ for name, body := range map[string][]byte{"gallery.css": galleryCSS, "gallery.js": galleryJS, "frame.js": frameJS} {
+ if err := s.put("assets/"+name, body); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+var (
+ htmlTag = regexp.MustCompile(`<html\b[^>]*>`)
+ schemeStore = regexp.MustCompile(`\sdata-rst-scheme-store="[^"]*"`)
+ scriptSrc = regexp.MustCompile(`<script[^>]*\ssrc="\.\./\.\./js/([^"]+)"`)
+ ariaCurrent = regexp.MustCompile(`\saria-current="true"`)
+ formTag = regexp.MustCompile(`(?s)<form\b.*?</form>`)
+)
+
+// schemeMenu reads the header's scheme menu from the scheme-menu
+// example's standalone state: its markup, the scripts its head asks
+// for, and the attribute on its <html> that tells fiesta.js to
+// remember the choice, which every gallery page carries too.
+//
+// The state ships with one scheme marked current; the gallery starts
+// on System, with no scheme stored, so the mark is moved to the System
+// button. Nothing else in the markup changes.
+func (s *site) schemeMenu() error {
+ const name = "scheme-menu"
+ state, ok := s.m.Default(name)
+ if !ok {
+ return fmt.Errorf("the manifest has no %s example", name)
+ }
+ doc, err := example.Document(s.src, name, state)
+ if err != nil {
+ return err
+ }
+ tag := htmlTag.FindString(doc)
+ store := schemeStore.FindString(tag)
+ if store == "" {
+ return fmt.Errorf("%s/%s carries no data-rst-scheme-store on its <html>", name, state)
+ }
+ s.htmlAttrs = template.HTMLAttr(strings.TrimSpace(store))
+ for _, m := range scriptSrc.FindAllStringSubmatch(doc, -1) {
+ s.schemeScripts = append(s.schemeScripts, m[1])
+ }
+ markup, err := example.Markup(s.src, name, state)
+ if err != nil {
+ return err
+ }
+ if !ariaCurrent.MatchString(markup) {
+ return fmt.Errorf("%s/%s marks no scheme current", name, state)
+ }
+ markup = ariaCurrent.ReplaceAllString(markup, "")
+ marked := false
+ markup = formTag.ReplaceAllStringFunc(markup, func(form string) string {
+ if marked || !strings.Contains(form, `value="system"`) {
+ return form
+ }
+ marked = true
+ return strings.Replace(form, `<button type="submit"`, `<button type="submit" aria-current="true"`, 1)
+ })
+ if !marked {
+ return fmt.Errorf("%s/%s has no form for the system scheme", name, state)
+ }
+ s.scheme = template.HTML(markup)
+ return nil
+}
+
+// groupFamilies lists the families in the order the manifest first
+// names them, each with its examples in manifest order.
+func groupFamilies(entries []example.Entry) []family {
+ var out []family
+ at := map[string]int{}
+ for _, e := range entries {
+ i, ok := at[e.Family]
+ if !ok {
+ i = len(out)
+ at[e.Family] = i
+ out = append(out, family{Key: e.Family})
+ }
+ out[i].Examples = append(out[i].Examples, e)
+ }
+ return out
+}
+
+// readIcons lists every icon in the source, by slug.
+func (s *site) readIcons() error {
+ entries, err := fs.ReadDir(s.src, "icons")
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".svg") {
+ continue
+ }
+ b, err := fs.ReadFile(s.src, "icons/"+e.Name())
+ if err != nil {
+ return err
+ }
+ s.icons = append(s.icons, icon{Slug: strings.TrimSuffix(e.Name(), ".svg"), Markup: template.HTML(b)})
+ }
+ sort.Slice(s.icons, func(i, j int) bool { return s.icons[i].Slug < s.icons[j].Slug })
+ if len(s.icons) == 0 {
+ return errors.New("no icons in the source")
+ }
+ return nil
+}
+
+// page is what every gallery page's template is executed with.
+type page struct {
+ s *site
+ Theme string
+ RTL bool
+ // File is the page's name within its theme directory, and Lang
+ // and Dir what its <html> says.
+ File string
+ Lang string
+ Dir string
+ // Name is the page's own name for its <title> and the brand's
+ // aria-current; Body its content.
+ Name string
+ Body template.HTML
+ // Current is the navigation entry this page is: "examples",
+ // "tokens" or "icons".
+ Current string
+}
+
+// Title is the document's <title>.
+func (p page) Title() string { return say("gallery.title", "page", p.Name, "theme", p.Theme) }
+
+// Scheme is the header's scheme menu.
+func (p page) Scheme() template.HTML { return p.s.scheme }
+
+// HTMLAttrs are the attributes every gallery page's <html> carries
+// beyond lang and dir.
+func (p page) HTMLAttrs() template.HTMLAttr { return p.s.htmlAttrs }
+
+// Asset is the URL of one of the gallery's assets.
+func (p page) Asset(name string) string { return p.s.url("assets/" + name) }
+
+// Scripts are the scripts every gallery page loads, for the scheme
+// menu.
+func (p page) Scripts() []string { return p.s.schemeScripts }
+
+// dir is the theme directory this page's twin in the given direction
+// sits in.
+func (p page) dir(theme string, rtl bool) string {
+ if rtl {
+ return theme + "/" + rtlDir
+ }
+ return theme + "/"
+}
+
+// Href is the URL of file in this page's theme and direction.
+func (p page) Href(file string) string { return p.s.url(p.dir(p.Theme, p.RTL) + file) }
+
+// link is one entry of a switcher.
+type link struct {
+ Href string
+ Label string
+ Current bool
+}
+
+// Themes are the theme links: this page in every theme.
+func (p page) Themes() []link {
+ var out []link
+ for _, theme := range fiesta.ThemeNames() {
+ out = append(out, link{Href: p.s.url(p.dir(theme, p.RTL) + p.File), Label: theme, Current: theme == p.Theme})
+ }
+ return out
+}
+
+// Twin is the link to this page in the other direction.
+func (p page) Twin() link {
+ if p.RTL {
+ return link{Href: p.s.url(p.dir(p.Theme, false) + p.File), Label: say("gallery.direction.ltr")}
+ }
+ return link{Href: p.s.url(p.dir(p.Theme, true) + p.File), Label: say("gallery.direction.rtl")}
+}
+
+// Nav is the page navigation: the index, the tokens and the icons.
+func (p page) Nav() []link {
+ return []link{
+ {Href: p.Href("index.html"), Label: say("gallery.nav.examples"), Current: p.Current == "examples"},
+ {Href: p.Href("tokens.html"), Label: say("gallery.nav.tokens"), Current: p.Current == "tokens"},
+ {Href: p.Href("icons.html"), Label: say("gallery.nav.icons"), Current: p.Current == "icons"},
+ }
+}
+
+var funcs = template.FuncMap{"say": say}
+
+// pageTemplate is the frame every gallery page is drawn in: the head,
+// the header with the theme links, the scheme menu, the direction link
+// and the page navigation, then the page's own body.
+//
+// gallery.js blocks in <head>, as rastrillo's does, so that a frame is
+// hidden before it can paint in the wrong scheme; the scheme menu's own
+// scripts are deferred, as the example that ships them has them.
+var pageTemplate = template.Must(template.New("page").Funcs(funcs).Parse(`<!doctype html>
+<html lang="{{.Lang}}" dir="{{.Dir}}" {{.HTMLAttrs}}>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<title>{{.Title}}</title>
+<link rel="stylesheet" href="{{.Asset "css/tokens.css"}}">
+<link rel="stylesheet" href="{{.Asset (print "css/themes/" .Theme ".css")}}">
+<link rel="stylesheet" href="{{.Asset "gallery.css"}}">
+<script src="{{.Asset "gallery.js"}}"></script>
+{{range .Scripts}}<script defer src="{{$.Asset (print "js/" .)}}"></script>
+{{end}}</head>
+<body>
+<a rst-skip href="#main">{{say "gallery.skip"}}</a>
+<header class="gallery-top">
+<a class="gallery-brand" data-gallery-nav href="{{.Href "index.html"}}">{{say "gallery.brand"}}</a>
+<nav rst-seg-tabs aria-label="{{say "gallery.theme.label"}}">{{range .Themes}}<a data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav>
+{{.Scheme}}
+{{with .Twin}}<a class="gallery-twin" data-gallery-nav href="{{.Href}}">{{.Label}}</a>{{end}}
+<nav class="gallery-pages" aria-label="{{say "gallery.nav.label"}}">{{range .Nav}}<a data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav>
+</header>
+<main rst-page id="main">
+{{.Body}}
+</main>
+</body>
+</html>
+`))
+
+// indexTemplate is the index's body: the examples, by family.
+var indexTemplate = template.Must(template.New("index").Funcs(funcs).Parse(`<h1>{{say "gallery.index.heading"}}</h1>
+<p class="gallery-lead">{{say "gallery.index.lead" "theme" .Theme}}</p>
+{{range .Families}}<section class="gallery-family">
+<h2 id="{{.Key}}">{{say (print "gallery.family." .Key)}}</h2>
+<ul class="gallery-list">{{range .Examples}}
+<li><a class="rst-mono" data-gallery-nav href="{{$.Href (print .Name ".html")}}">{{.Name}}</a></li>{{end}}
+</ul>
+</section>
+{{end}}`))
+
+// state is one state on an example's page.
+type state struct {
+ ID string
+ Label string
+ Frame string
+ Title string
+ Page bool
+ Source template.HTML
+ Routes string
+}
+
+// exampleTemplate is one example's page: a heading, then every state
+// as a frame of its own document, its HTML, and the routes it stands
+// for.
+var exampleTemplate = template.Must(template.New("example").Funcs(funcs).Parse(`<h1 class="rst-mono">{{.Entry.Name}}</h1>
+<p class="gallery-lead">{{say "gallery.example.lead"}}</p>
+{{range .States}}<section class="gallery-state" id="{{.ID}}" data-gallery-state="{{$.Entry.Name}}/{{.ID}}">
+<h2 class="gallery-label">{{.Label}}</h2>
+<div class="gallery-view"><iframe class="gallery-frame{{if .Page}} gallery-frame--page{{end}}" title="{{.Title}}" src="{{.Frame}}" loading="lazy"></iframe></div>
+<details class="gallery-code"><summary>{{say "gallery.html"}}</summary>
+<pre class="gallery-src rst-mono" tabindex="0"><code>{{.Source}}</code></pre>
+</details>
+{{if .Routes}}<p class="gallery-routes">{{.Routes}}</p>
+{{end}}</section>
+{{end}}`))
+
+// tokensTemplate is the tokens page: the theme's palette, then the
+// structure every theme shares.
+var tokensTemplate = template.Must(template.New("tokens").Funcs(funcs).Parse(`<h1>{{say "gallery.tokens.heading"}}</h1>
+<p class="gallery-lead">{{say "gallery.tokens.lead" "theme" .Theme}}</p>
+<p class="gallery-note">{{say "gallery.tokens.note"}}</p>
+{{range .Groups}}<h2 id="tokens-{{.Key}}">{{say (print "gallery.tokens.group." .Key)}}</h2>
+<ul class="gallery-toks">{{$kind := .Kind}}{{range .Rows}}<li class="gallery-tok">{{if eq $kind "type"}}<span class="gallery-type" style="{{.Preview}}">Ag</span>{{else if eq $kind "space"}}<span class="gallery-bar" style="{{.Preview}}"></span>{{else if eq $kind "radius"}}<span class="gallery-chip gallery-chip--fill" style="{{.Preview}}"></span>{{else if .Preview}}<span class="gallery-chip" style="{{.Preview}}"></span>{{end}}<span class="gallery-tok__text rst-mono"><span class="gallery-tok__name">{{.Name}}</span><span class="gallery-tok__value">{{.Value}}</span></span></li>{{end}}</ul>
+{{end}}`))
+
+// iconsTemplate is the icons page: one chip per icon, its slug beside
+// it.
+var iconsTemplate = template.Must(template.New("icons").Funcs(funcs).Parse(`<h1>{{say "gallery.icons.heading"}}</h1>
+<p class="gallery-lead">{{say "gallery.icons.lead" "total" .Total}}</p>
+<ul class="gallery-toks gallery-icons">{{range .Icons}}
+<li class="gallery-tok" id="icon-{{.Slug}}"><span class="gallery-chip gallery-chip--icon">{{.Markup}}</span><span class="gallery-tok__text rst-mono"><span class="gallery-tok__name">{{.Slug}}</span><span class="gallery-tok__value">icons/{{.Slug}}.svg</span></span></li>{{end}}
+</ul>`))
+
+// theme renders one theme in one direction: its index, tokens and
+// icons pages, and a page and a set of frames per example.
+func (s *site) theme(theme string, rtl bool) error {
+ p := page{s: s, Theme: theme, RTL: rtl, Lang: "en", Dir: "ltr"}
+ if rtl {
+ p.Lang, p.Dir = "ar", "rtl"
+ }
+ dir := p.dir(theme, rtl)
+
+ var b bytes.Buffer
+ if err := indexTemplate.Execute(&b, struct {
+ page
+ Families []family
+ }{p, s.families}); err != nil {
+ return err
+ }
+ if err := s.write(p, "index.html", say("gallery.nav.examples"), "examples", b.String()); err != nil {
+ return err
+ }
+
+ groups, err := tokens.Palette(theme)
+ if err != nil {
+ return err
+ }
+ structure, err := tokens.Structural()
+ if err != nil {
+ return err
+ }
+ b.Reset()
+ if err := tokensTemplate.Execute(&b, struct {
+ page
+ Groups []tokens.Group
+ }{p, append(groups, structure...)}); err != nil {
+ return err
+ }
+ if err := s.write(p, "tokens.html", say("gallery.nav.tokens"), "tokens", b.String()); err != nil {
+ return err
+ }
+
+ b.Reset()
+ if err := iconsTemplate.Execute(&b, struct {
+ page
+ Icons []icon
+ Total string
+ }{p, s.icons, fmt.Sprint(len(s.icons))}); err != nil {
+ return err
+ }
+ if err := s.write(p, "icons.html", say("gallery.nav.icons"), "icons", b.String()); err != nil {
+ return err
+ }
+
+ for _, e := range s.m.Examples {
+ var states []state
+ for _, st := range e.States {
+ doc, err := example.Document(s.src, e.Name, st.ID)
+ if err != nil {
+ return err
+ }
+ frame, err := s.frame(doc, theme, rtl)
+ if err != nil {
+ return fmt.Errorf("%s/%s: %w", e.Name, st.ID, err)
+ }
+ rel := dir + "frames/" + e.Name + "/" + st.ID + ".html"
+ if err := s.put(rel, []byte(frame)); err != nil {
+ return err
+ }
+ src := doc
+ if !e.Document {
+ if src, err = example.Markup(s.src, e.Name, st.ID); err != nil {
+ return err
+ }
+ }
+ routes := ""
+ if len(st.SampleURLs) > 0 {
+ routes = say("gallery.routes", "routes", strings.Join(st.SampleURLs, ", "))
+ }
+ states = append(states, state{
+ ID: st.ID,
+ Label: st.Label,
+ Frame: s.url(rel),
+ Title: say("gallery.frame.title", "example", e.Name, "state", st.Label),
+ Page: tall(e),
+ Source: codeview.Highlight(codeview.Format(src)),
+ Routes: routes,
+ })
+ }
+ b.Reset()
+ if err := exampleTemplate.Execute(&b, struct {
+ page
+ Entry example.Entry
+ States []state
+ }{p, e, states}); err != nil {
+ return err
+ }
+ if err := s.write(p, e.Name+".html", e.Name, "examples", b.String()); err != nil {
+ return err
+ }
+ }
+ return 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"
+}
+
+// write executes the page template around body and puts the result at
+// file within p's theme directory.
+func (s *site) write(p page, file, name, current, body string) error {
+ p.File, p.Name, p.Current, p.Body = file, name, current, template.HTML(body)
+ var b bytes.Buffer
+ if err := pageTemplate.Execute(&b, p); err != nil {
+ return err
+ }
+ return s.put(p.dir(p.Theme, p.RTL)+file, b.Bytes())
+}
+
+var (
+ headTag = regexp.MustCompile(`<head\b[^>]*>`)
+ firstLink = regexp.MustCompile(`<(link|script)\b`)
+ bodyTag = regexp.MustCompile(`<body\b[^>]*>`)
+ assetLink = regexp.MustCompile(`(href|src)="\.\./\.\./(css|js)/([^"]+)"`)
+ picture = regexp.MustCompile(`src="\.\./assets/([^"]+)"`)
+ sampleRef = regexp.MustCompile(`\b(href|action)="([^"]*)"`)
+ ltrHTML = `<html lang="en" dir="ltr"`
+)
+
+// frame turns one example document into a frame document: the
+// gallery's assets in place of the relative links, the page's theme in
+// place of the example's, frame.js first in the head, every href and
+// action in the body pointed at "#", and the right-to-left attributes
+// when the twin is being rendered. The example's title and everything
+// else in it are kept.
+func (s *site) frame(doc, theme string, rtl bool) (string, error) {
+ head := headTag.FindStringIndex(doc)
+ body := bodyTag.FindStringIndex(doc)
+ if head == nil || body == nil || body[0] < head[1] {
+ return "", errors.New("no <head> and <body> to rewrite")
+ }
+ // frame.js goes before the first stylesheet or script of the head,
+ // after the charset and viewport metas, which stay first.
+ at := firstLink.FindStringIndex(doc[head[1]:body[0]])
+ if at == nil {
+ return "", errors.New("no stylesheet in the head to put frame.js before")
+ }
+ at[0] += head[1]
+ top := doc[:at[0]] + `<script src="` + s.url("assets/frame.js") + `"></script>` + "\n" + doc[at[0]:body[1]]
+ top = assetLink.ReplaceAllStringFunc(top, func(m string) string {
+ sub := assetLink.FindStringSubmatch(m)
+ file := sub[2] + "/" + sub[3]
+ if strings.HasPrefix(file, "css/themes/") {
+ file = "css/themes/" + theme + ".css"
+ }
+ return sub[1] + `="` + s.url("assets/"+file) + `"`
+ })
+ top = picture.ReplaceAllString(top, `src="`+s.url("assets/images/")+`$1"`)
+ if rtl {
+ if !strings.Contains(top, ltrHTML) {
+ return "", fmt.Errorf("no %s to turn around", ltrHTML)
+ }
+ top = strings.Replace(top, ltrHTML, `<html lang="ar" dir="rtl"`, 1)
+ }
+ rest := doc[body[1]:]
+ rest = picture.ReplaceAllString(rest, `src="`+s.url("assets/images/")+`$1"`)
+ rest = sampleRef.ReplaceAllStringFunc(rest, func(m string) string {
+ sub := sampleRef.FindStringSubmatch(m)
+ if strings.HasPrefix(sub[2], "#") {
+ return m
+ }
+ return sub[1] + `="#"`
+ })
+ return top + rest, nil
+}
diff --git a/internal/gallery/gallery.js b/internal/gallery/gallery.js
new file mode 100644
index 0000000..8db462f
--- /dev/null
+++ b/internal/gallery/gallery.js
@@ -0,0 +1,115 @@
+/* gallery.js: the gallery page's own script, 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 gallery.css until this file marks it painted,
+ which is why this 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. */
+(function () {
+ "use strict";
+
+ var root = document.documentElement;
+ root.setAttribute("data-gallery-js", "on");
+
+ // The scheme in force on this page: fiesta.js keeps it on <html>,
+ // and no attribute is System.
+ function scheme() {
+ return root.getAttribute("data-theme");
+ }
+
+ function readable(frame) {
+ try {
+ var doc = frame.contentDocument;
+ return doc && doc.documentElement && doc.URL !== "about:blank" ? doc : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function paint(frame, s) {
+ var doc = readable(frame);
+ if (!doc) return;
+ if (s === "light" || s === "dark") doc.documentElement.setAttribute("data-theme", s);
+ 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.
+ function fit(frame) {
+ var doc = readable(frame);
+ if (!doc || !doc.body || frame.classList.contains("gallery-frame--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";
+ }
+
+ // 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(".gallery-frame");
+ }
+
+ function all(s) {
+ var f = frames();
+ for (var i = 0; i < f.length; i++) paint(f[i], s);
+ }
+
+ // 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).
+ function watch(frame) {
+ frame.addEventListener("load", function () {
+ shown(frame);
+ });
+ var doc = readable(frame);
+ if (doc && doc.readyState === "complete") shown(frame);
+ }
+
+ // fiesta.js dispatches rst-scheme from the form after it has written
+ // the attribute, so the detail and the root agree; the detail is
+ // read all the same, so this file never depends on the order.
+ document.addEventListener("rst-scheme", function (event) {
+ var s = event.detail && event.detail.scheme;
+ all(s === "light" || s === "dark" ? s : null);
+ });
+
+ // A page back from the back/forward cache is reactivated, not
+ // re-run, and fiesta.js may have restored a stored choice since.
+ addEventListener("pageshow", function (event) {
+ 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]);
+ });
+})();
diff --git a/internal/gallery/gallery_test.go b/internal/gallery/gallery_test.go
new file mode 100644
index 0000000..3faf64e
--- /dev/null
+++ b/internal/gallery/gallery_test.go
@@ -0,0 +1,362 @@
+package gallery
+
+import (
+ "html"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ xhtml "golang.org/x/net/html"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/check"
+ "amadan.net/rastrillo/fiesta/example"
+)
+
+// render is one tree at one mount, failed loudly.
+func render(t *testing.T, mount string) map[string][]byte {
+ t.Helper()
+ files, err := Render(fiesta.FS(), Options{Mount: mount})
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ return files
+}
+
+func manifest(t *testing.T) *example.Manifest {
+ t.Helper()
+ m, err := example.Load(fiesta.FS())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m
+}
+
+// directions are the two trees every theme is rendered as: the
+// left-to-right one at the theme's root, and its right-to-left twin
+// under rtl/.
+var directions = []string{"", "rtl/"}
+
+// attrsOf walks doc and calls fn for every element with its lower-case
+// tag name and attributes, so a test reads URLs the way a browser
+// would rather than with a regexp over the bytes.
+func attrsOf(t *testing.T, doc []byte, fn func(tag string, attrs map[string]string)) {
+ t.Helper()
+ z := xhtml.NewTokenizer(strings.NewReader(string(doc)))
+ for {
+ switch z.Next() {
+ case xhtml.ErrorToken:
+ return
+ case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
+ name, more := z.TagName()
+ attrs := map[string]string{}
+ for more {
+ var k, v []byte
+ k, v, more = z.TagAttr()
+ attrs[string(k)] = string(v)
+ }
+ fn(string(name), attrs)
+ }
+ }
+}
+
+// galleryURLs are the URLs the gallery owns on one page: every
+// stylesheet, script, image and frame, and every link marked
+// data-gallery-nav. A sample's own links are not among them.
+func galleryURLs(t *testing.T, doc []byte) []string {
+ t.Helper()
+ var out []string
+ attrsOf(t, doc, func(tag string, attrs map[string]string) {
+ switch tag {
+ case "link":
+ out = append(out, attrs["href"])
+ case "script", "img", "iframe":
+ if v, ok := attrs["src"]; ok {
+ out = append(out, v)
+ }
+ case "a":
+ if _, ok := attrs["data-gallery-nav"]; ok {
+ out = append(out, attrs["href"])
+ }
+ }
+ })
+ return out
+}
+
+// TestRenderWritesEveryStateInEveryTheme: the tree holds one frame
+// document per manifest state, per theme, in both directions, and no
+// other frame; and the gallery page, the index, the tokens and the
+// icons pages beside them.
+func TestRenderWritesEveryStateInEveryTheme(t *testing.T) {
+ files := render(t, "")
+ m := manifest(t)
+ states := 0
+ for _, theme := range fiesta.ThemeNames() {
+ for _, dir := range directions {
+ for _, page := range []string{"index.html", "tokens.html", "icons.html"} {
+ if _, ok := files[theme+"/"+dir+page]; !ok {
+ t.Errorf("no %s/%s%s", theme, dir, page)
+ }
+ }
+ for _, e := range m.Examples {
+ if _, ok := files[theme+"/"+dir+e.Name+".html"]; !ok {
+ t.Errorf("no gallery page %s/%s%s.html", theme, dir, e.Name)
+ }
+ for _, s := range e.States {
+ if theme == fiesta.ThemeNames()[0] && dir == "" {
+ states++
+ }
+ if _, ok := files[theme+"/"+dir+"frames/"+e.Name+"/"+s.ID+".html"]; !ok {
+ t.Errorf("no frame %s/%sframes/%s/%s.html", theme, dir, e.Name, s.ID)
+ }
+ }
+ }
+ }
+ }
+ if states == 0 {
+ t.Fatal("the manifest lists no states; the loop above checked nothing")
+ }
+ frames := 0
+ for name := range files {
+ if strings.Contains(name, "/frames/") {
+ frames++
+ }
+ }
+ if want := states * len(fiesta.ThemeNames()) * len(directions); frames != want {
+ t.Errorf("the tree holds %d frame documents, want %d (%d states x %d themes x %d directions)", frames, want, states, len(fiesta.ThemeNames()), len(directions))
+ }
+ if _, ok := files["index.html"]; !ok {
+ t.Error("no index.html at the tree root")
+ }
+}
+
+// TestEveryGalleryURLResolves: at a mount other than the root, every
+// URL the gallery owns is an absolute path under that mount naming a
+// file the render wrote. A relative URL, or one written for the root,
+// would be a page that works on a developer's machine and nowhere else.
+func TestEveryGalleryURLResolves(t *testing.T) {
+ const mount = "/x"
+ files := render(t, mount)
+ checked, frames := 0, 0
+ for name, body := range files {
+ if !strings.HasSuffix(name, ".html") {
+ continue
+ }
+ for _, u := range galleryURLs(t, body) {
+ checked++
+ if !strings.HasPrefix(u, mount+"/") {
+ t.Errorf("%s links %q, which is not under the mount %s/", name, u, mount)
+ continue
+ }
+ path, _, _ := strings.Cut(strings.TrimPrefix(u, mount+"/"), "#")
+ if _, ok := files[path]; !ok {
+ t.Errorf("%s links %q, and the render wrote no %s", name, u, path)
+ }
+ if strings.Contains(path, "/frames/") {
+ frames++
+ }
+ }
+ }
+ if checked == 0 || frames == 0 {
+ t.Fatalf("checked %d URLs and %d of them frames; the walk above read no gallery page", checked, frames)
+ }
+}
+
+// highlighted is a highlight element, which a reader does not see and
+// the copied text does not hold.
+var highlighted = regexp.MustCompile(`</?ds-[tavxs]>`)
+
+// htmlTab returns the text a reader sees in one state's HTML tab on its
+// gallery page: the highlighted source with its elements removed and
+// its entities decoded.
+func htmlTab(t *testing.T, page []byte, name, state string) string {
+ t.Helper()
+ marker := `data-gallery-state="` + name + "/" + state + `"`
+ i := strings.Index(string(page), marker)
+ if i < 0 {
+ t.Fatalf("%s/%s: the gallery page has no %s", name, state, marker)
+ }
+ rest := string(page)[i:]
+ open := strings.Index(rest, "<code>")
+ shut := strings.Index(rest, "</code>")
+ if open < 0 || shut < open {
+ t.Fatalf("%s/%s: no <code> block after its marker", name, state)
+ }
+ return html.UnescapeString(highlighted.ReplaceAllString(rest[open+len("<code>"):shut], ""))
+}
+
+// TestSampleURLsStayInTheMarkup: the routes a sample stands for are
+// shown as written in its HTML tab, where a reader copies them from,
+// and none of them is the href or action of anything in a frame
+// document, where a click or a submit would otherwise request it.
+func TestSampleURLsStayInTheMarkup(t *testing.T) {
+ const mount = ""
+ files := render(t, mount)
+ m := manifest(t)
+ theme := fiesta.ThemeNames()[0]
+ routes := map[string]bool{}
+ tabs := 0
+ for _, e := range m.Examples {
+ page, ok := files[theme+"/"+e.Name+".html"]
+ if !ok {
+ t.Fatalf("no gallery page for %s", e.Name)
+ }
+ for _, s := range e.States {
+ if len(s.SampleURLs) == 0 {
+ continue
+ }
+ tabs++
+ tab := htmlTab(t, page, e.Name, s.ID)
+ for _, u := range s.SampleURLs {
+ routes[u] = true
+ if !strings.Contains(tab, u) {
+ t.Errorf("%s/%s: the HTML tab does not show %q", e.Name, s.ID, u)
+ }
+ }
+ }
+ }
+ if tabs == 0 {
+ t.Fatal("no state declares a sample URL; the check above read nothing")
+ }
+
+ frames, seen := 0, 0
+ for name, body := range files {
+ if !strings.Contains(name, "/frames/") {
+ continue
+ }
+ frames++
+ inBody := false
+ attrsOf(t, body, func(tag string, attrs map[string]string) {
+ if tag == "body" {
+ inBody = true
+ }
+ if !inBody {
+ return
+ }
+ for _, attr := range []string{"href", "action"} {
+ v, ok := attrs[attr]
+ if !ok {
+ continue
+ }
+ seen++
+ if routes[v] {
+ t.Errorf("%s: <%s %s=%q> would request a sample route", name, tag, attr, v)
+ }
+ if !strings.HasPrefix(v, "#") && !strings.HasPrefix(v, mount+"/") {
+ t.Errorf("%s: <%s %s=%q> is neither a fragment nor the gallery's own", name, tag, attr, v)
+ }
+ }
+ })
+ }
+ if frames == 0 || seen == 0 {
+ t.Fatalf("%d frames with %d hrefs and actions between them; the check above read nothing", frames, seen)
+ }
+}
+
+// placeholder is how a label that has not been through copy review is
+// written while the code is built around it. None may reach a page.
+const placeholder = "TODO-COPY:"
+
+// TestTheGalleryCarriesNoPlaceholderCopy fails while any label is still
+// a placeholder: the render, and the stylesheet and scripts it embeds,
+// hold no TODO-COPY marker.
+func TestTheGalleryCarriesNoPlaceholderCopy(t *testing.T) {
+ files := render(t, "")
+ var names []string
+ for name := range files {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ left := map[string]bool{}
+ for _, name := range names {
+ for _, m := range regexp.MustCompile(placeholder+`[a-z0-9.-]+`).FindAll(files[name], -1) {
+ left[string(m)] = true
+ }
+ }
+ if len(left) > 0 {
+ var ids []string
+ for id := range left {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ t.Errorf("%d placeholders are still in the render, waiting for copy review:\n %s", len(ids), strings.Join(ids, "\n "))
+ }
+}
+
+var highlightRule = regexp.MustCompile(`(?m)^(ds-[tavxs]) \{ color: var\((--rst-[a-z0-9-]+)\); \}`)
+
+// halves returns the light and dark halves of a whole-value
+// light-dark(<light>, <dark>), split at the top-level comma, or the
+// value twice when it is not one.
+func halves(v string) (light, dark string) {
+ const prefix = "light-dark("
+ if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") {
+ return v, v
+ }
+ inner, depth := v[len(prefix):len(v)-1], 0
+ for i := 0; i < len(inner); i++ {
+ switch inner[i] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ case ',':
+ if depth == 0 {
+ return strings.TrimSpace(inner[:i]), strings.TrimSpace(inner[i+1:])
+ }
+ }
+ }
+ return v, v
+}
+
+var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`)
+
+// Every highlight colour reaches 4.5:1 against the code block's
+// --rst-surface, in every theme and both schemes, resolved from the
+// themes' own light-dark() pairs. The axe scan checks the rendered
+// cascade; this checks every pair, including the ones a page with no
+// such token on it would never show axe.
+func TestHighlightColoursPassAgainstTheCodeBackground(t *testing.T) {
+ pairs := map[string]string{}
+ for _, m := range highlightRule.FindAllStringSubmatch(string(galleryCSS), -1) {
+ pairs[m[1]] = m[2]
+ }
+ if len(pairs) != 5 {
+ t.Fatalf("gallery.css colours %d highlight elements, want 5: %v", len(pairs), pairs)
+ }
+ for _, theme := range fiesta.ThemeNames() {
+ raw, _ := fiesta.ThemeCSS(theme)
+ css := string(raw)
+ i := strings.Index(css, ":root {")
+ if i < 0 {
+ t.Fatalf("%s: no :root block", theme)
+ }
+ body := css[i:]
+ if j := strings.Index(body, "}"); j >= 0 {
+ body = body[:j]
+ }
+ values := map[string]string{}
+ for _, m := range declPattern.FindAllStringSubmatch(body, -1) {
+ values[m[1]] = strings.TrimSpace(m[2])
+ }
+ surfaceLight, surfaceDark := halves(values["--rst-surface"])
+ for scheme, bg := range map[string]string{"light": surfaceLight, "dark": surfaceDark} {
+ for el, token := range pairs {
+ light, dark := halves(values[token])
+ fg := light
+ if scheme == "dark" {
+ fg = dark
+ }
+ r, err := check.ContrastRatio(fg, bg)
+ if err != nil {
+ t.Errorf("%s %s %s (%s on %s): %v", theme, scheme, el, fg, bg, err)
+ continue
+ }
+ if r < 4.5 {
+ t.Errorf("%s %s: %s is %s (%s) on %s, %.2f:1, under 4.5:1", theme, scheme, el, token, fg, bg, r)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/gallery/keyboard_browser_test.go b/internal/gallery/keyboard_browser_test.go
new file mode 100644
index 0000000..936f061
--- /dev/null
+++ b/internal/gallery/keyboard_browser_test.go
@@ -0,0 +1,391 @@
+//go:build browser
+
+package gallery
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+ "github.com/chromedp/chromedp/kb"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/example"
+ "amadan.net/rastrillo/fiesta/internal/harness"
+)
+
+// focusRingExempt is the keyboard walk's exemption table: the tab
+// stops where "nothing visible changed" is the browser's doing and not
+// the page's, keyed by the focused element's tag name, with the reason.
+// The gate says out loud what it is not checking.
+//
+// Empty, and that is the point of keeping the table: rastrillo's walk
+// exempted the iframe, which Chromium makes a tab stop of its own and
+// paints nothing on, because it walked the gallery page with its
+// frames. These walks open each state's document on its own, so
+// there is no frame to stop on; an element of any kind with no ring
+// is a failure, and adding a kind here is a ruling somebody has to
+// write down.
+var focusRingExempt = map[string]string{
+ // (none)
+}
+
+// focusablesJS is the selector the keyboard tests agree on: what a
+// browser puts in the tab order on these documents. No [tabindex] hunt
+// beyond the explicit ones, because the examples write none.
+const focusablesJS = `'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), summary, iframe, [tabindex]:not([tabindex="-1"])'`
+
+// walkJS reports where focus actually is, and whether it shows.
+//
+// Two things it has to get right that a first attempt does not.
+//
+// Focus inside a frame: once focus enters one, the top document's
+// activeElement is the frame and stays the frame for every element
+// inside it, which read naively looks like focus refusing to move,
+// which is exactly what a keyboard trap looks like. So the probe
+// descends: through the frame, into the document, to the element a
+// person is actually on. These walks open documents with no frames in
+// them, and the descent is kept so the probe reads the same whatever
+// it is pointed at.
+//
+// Whether the ring shows: a focus indicator is a style that only exists
+// while the element is focused, so it can only be measured against the
+// same element unfocused. Blur it, measure, focus it again. The refocus
+// is checked to have restored the same style: :focus-visible is a
+// heuristic about how focus arrived, and the walk arrived by Tab, so it
+// holds; if it ever stopped holding, the measurement would be lying and
+// the test says so rather than passing quietly.
+const walkJS = `(() => {
+ let el = document.activeElement, doc = document, depth = 0;
+ while (el && el.tagName === "IFRAME" && depth++ < 4) {
+ let d = null;
+ try { d = el.contentDocument; } catch (e) { d = null; }
+ const inner = d && d.activeElement;
+ if (!inner || inner === d.body || inner === d.documentElement) break;
+ doc = d; el = inner;
+ }
+ if (!el || el === document.body || el === document.documentElement) return JSON.stringify({tag: "body"});
+ const uaFrame = el.tagName === "IFRAME";
+ // Is there a ring, really. A first version of this compared raw
+ // computed values and was fooled twice, both times passing a page
+ // with no visible focus at all:
+ //
+ // - outline-offset counts for nothing on its own. Chromium reports
+ // an offset on an element whose outline is "none", and the value
+ // changes with focus, so "outline: none" showed a ring.
+ // - a ring you cannot see is not a ring. "outline-color:
+ // transparent" is a different colour string from the unfocused
+ // one, so a page where every indicator was invisible scored 29
+ // of 30.
+ //
+ // So every property is normalised to present-and-visible or absent
+ // before anything is compared. Absent means: outline with style
+ // none, zero width, or a fully transparent colour; box-shadow of
+ // none, or one drawn entirely in transparent colours; a border with
+ // no width or no visible colour; a transparent background or text
+ // colour. Offset rides along inside the outline, where it is a real
+ // difference, and contributes nothing when there is no outline.
+ const clear = v => {
+ v = (v || "").trim();
+ if (v === "transparent") return true;
+ const m = /^rgba?\(([^)]*)\)$/.exec(v);
+ if (!m) return false;
+ const parts = m[1].split(/[\s,\/]+/).filter(Boolean);
+ return parts.length > 3 && parseFloat(parts[3]) === 0;
+ };
+ const anyVisible = v => (String(v).match(/rgba?\([^)]*\)/g) || []).some(c => !clear(c));
+ const widest = v => Math.max(...String(v).split(/\s+/).map(parseFloat).map(n => isNaN(n) ? 0 : n), 0);
+ const one = e => {
+ if (!e) return "";
+ const s = (e.ownerDocument.defaultView || window).getComputedStyle(e);
+ const bits = [];
+ if (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0 && !clear(s.outlineColor)) {
+ bits.push("outline:" + s.outlineStyle + " " + s.outlineWidth + " " + s.outlineColor + " " + s.outlineOffset);
+ }
+ if (s.boxShadow && s.boxShadow !== "none" && anyVisible(s.boxShadow)) {
+ bits.push("shadow:" + s.boxShadow);
+ }
+ if (widest(s.borderWidth) > 0 && anyVisible(s.borderColor)) {
+ bits.push("border:" + s.borderWidth + " " + s.borderColor);
+ }
+ if (!clear(s.backgroundColor)) bits.push("bg:" + s.backgroundColor);
+ if (!clear(s.color)) bits.push("fg:" + s.color);
+ if (s.textDecorationLine && s.textDecorationLine !== "none") bits.push("dec:" + s.textDecorationLine);
+ return bits.join(" ");
+ };
+ // The element and two ancestors, because a ring is not always painted
+ // on the thing that has focus: a wrapper with :focus-within or :has()
+ // is a normal way to do it, and tokens.css does it, on a switch's
+ // track and a choice card's label.
+ const snap = e => [one(e), one(e.parentElement), one(e.parentElement && e.parentElement.parentElement)].join(" || ");
+ const focused = snap(el);
+ el.blur();
+ const blurred = snap(el);
+ el.focus();
+ const cls = (el.className && el.className.toString) ? el.className.toString().trim().split(/\s+/)[0] : "";
+ const label = el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (cls ? "." + cls : "") +
+ (doc !== document ? " (in " + JSON.stringify(doc.title) + ")" : "");
+ const same = window.__prev === el;
+ window.__prev = el;
+ return JSON.stringify({
+ tag: label,
+ kind: el.tagName.toLowerCase(),
+ focused: focused,
+ blurred: blurred,
+ ring: focused !== blurred,
+ restored: snap(el) === focused,
+ same: same,
+ uaFrame: uaFrame,
+ stillOn: doc.activeElement === el,
+ });
+})()`
+
+// stop is one reading of walkJS.
+type stop struct {
+ Tag, Kind, Focused, Blurred string
+ Ring, Restored, Same, StillOn, UAFrame bool
+}
+
+// tab presses Tab and reads where focus landed.
+func tab(t *testing.T, ctx context.Context, where string) stop {
+ t.Helper()
+ var raw string
+ if err := chromedp.Run(ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(walkJS, &raw)); err != nil {
+ t.Fatalf("%s: %v", where, err)
+ }
+ var s stop
+ if err := json.Unmarshal([]byte(raw), &s); err != nil {
+ t.Fatalf("%s: decoding: %v", where, err)
+ }
+ return s
+}
+
+// TestA11yWalksTheKeyboard is 2.4.7 Focus Visible and 2.1.2 No Keyboard
+// Trap, neither of which axe can see: a focus indicator is a computed
+// style that only exists while an element is focused, and a trap is a
+// property of a sequence rather than of a node.
+//
+// The walk: thirty Tabs with real key events. Real, because
+// :focus-visible is a heuristic about how the focus arrived, and a
+// script calling .focus() out of nowhere does not always earn the ring.
+// Every stop must show something (2.4.7) and must be a different
+// element from the last (2.1.2, locally). Then the modal is walked all
+// the way round, which is the trap question answered globally: a
+// reader can always Tab their way back out.
+//
+// WHERE the stops are, and how many: rastrillo's walk Tabbed past its
+// gallery's rail and bar and spent thirty stops on the form page's
+// content, which was the form family's frames and the widget chrome
+// around each: the view tabs, the source blocks, the frames themselves
+// and the fields inside them. Here the walk is over the form family's
+// state documents themselves, opened one after another as top-level
+// pages in manifest order, each walked from its first stop until focus
+// leaves it, so every stop is a control the examples ship and none is
+// the gallery's own. That family holds fewer than thirty: 22 documents
+// (field 7, field-text 5, field-url 3, field-money 3, field-textarea 4)
+// with one control each, 22 stops, measured on 2026-10-27. So the
+// count is adapted: the walk covers every stop in the family, asserts
+// the number equals the documents' own count of focusable elements,
+// and holds it at formStops or more, so a family that lost a control,
+// or a Tab that skipped one, fails by number.
+func TestA11yWalksTheKeyboard(t *testing.T) {
+ rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
+ defer cancel()
+ m := manifest(t)
+ theme := fiesta.ThemeNames()[0]
+
+ docs := statesOf(m, "form")
+ if len(docs) == 0 {
+ t.Fatal("the manifest has no form family")
+ }
+ // formStops is the family's count of stops when this was written:
+ // 7 + 5 + 3 + 3 + 4 documents, one control each.
+ const formStops = 22
+ stops, rings, focusables := 0, 0, 0
+ for _, d := range docs {
+ where := d[0] + "/" + d[1]
+ var count int
+ mustRun(t, ctx,
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+href(frameFile(theme, false, d[0], d[1]))),
+ chromedp.WaitReady("body"),
+ chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count),
+ )
+ focusables += count
+ walked := 0
+ // Bounded by the document's own count plus the browser's chrome
+ // stop, so a document that cycles never walks forever.
+ for walked <= count {
+ s := tab(t, ctx, where)
+ if s.Tag == "body" {
+ // Focus left the document: the browser's own chrome,
+ // between the last stop and the first. On to the next
+ // document.
+ break
+ }
+ stops++
+ walked++
+ if s.Same {
+ t.Errorf("stop %d (%s): focus did not move off %s: keyboard trap (WCAG 2.1.2)", stops, where, s.Tag)
+ }
+ if !s.UAFrame && (!s.StillOn || !s.Restored) {
+ t.Errorf("stop %d (%s): %s: measuring the focus ring did not leave focus as it found it (stillOn=%v restored=%v); the reading below cannot be trusted",
+ stops, where, s.Tag, s.StillOn, s.Restored)
+ }
+ if !s.Ring {
+ if reason, ok := focusRingExempt[s.Kind]; ok {
+ t.Logf("EXEMPT stop %d (%s): %s: %s", stops, where, s.Tag, reason)
+ continue
+ }
+ t.Errorf("stop %d (%s): %s has no visible focus indicator (WCAG 2.4.7)\n focused: %s\n blurred: %s",
+ stops, where, s.Tag, s.Focused, s.Blurred)
+ continue
+ }
+ rings++
+ }
+ if walked != count {
+ t.Errorf("%s: Tab reached %d stops of the %d focusable elements in the document", where, walked, count)
+ }
+ }
+ if stops != focusables {
+ t.Errorf("the form family's %d documents hold %d focusable elements and the walk made %d stops", len(docs), focusables, stops)
+ }
+ if stops < formStops {
+ t.Errorf("the form family's %d documents hold only %d stops between them, under the %d this walk was written for", len(docs), stops, formStops)
+ }
+ t.Logf("form: %d of %d stops showed a focus indicator, over %d documents", rings, stops, len(docs))
+
+ // The full circuit, on the document small enough to walk all of.
+ var modalCount int
+ modal := frameFile(theme, false, "modal", mustDefault(t, "modal"))
+ mustRun(t, ctx,
+ chromedp.Navigate(rig.Origin+href(modal)),
+ chromedp.WaitReady("body"),
+ chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &modalCount),
+ )
+ if modalCount == 0 {
+ t.Fatal("the modal has nothing focusable; Close is a link, so this is a rendering failure")
+ }
+ const firstOf = `(() => { const e = document.activeElement; return e && e !== document.body ? (e.tagName.toLowerCase() + (e.id ? "#" + e.id : "") + "|" + (e.textContent || "").trim().slice(0, 40)) : "body"; })()`
+ var first string
+ mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(firstOf, &first))
+ if first == "body" {
+ t.Fatalf("modal: the first Tab landed nowhere; the backdrop is inert and the panel has %d focusables", modalCount)
+ }
+ // One extra tab past the count is the browser's own chrome, which
+ // shows up as body; one more comes back to the first element. Both
+ // are ways out, and either proves there is no trap.
+ escaped := false
+ for i := 0; i < modalCount+3 && !escaped; i++ {
+ var here string
+ mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(firstOf, &here))
+ if here == "body" || here == first {
+ escaped = true
+ }
+ }
+ if !escaped {
+ t.Errorf("modal: %d tabs never returned to %q: focus is trapped (WCAG 2.1.2)", modalCount+3, first)
+ }
+ rig.Screen("body", "the modal")
+}
+
+// stageDocument is the page every visitor meets first: the stage
+// layout's default state with the signin example's default state in
+// its slot, through example.Slot, so the walk is over the document the
+// rulings name whatever the shipped layout happens to carry. It is
+// served beside the tree as a frame document of its own.
+func stageDocument(t *testing.T, m *example.Manifest) (name string, doc []byte) {
+ t.Helper()
+ layout, err := example.Document(fiesta.FS(), "layout-stage", mustDefault(t, "layout-stage"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ signin, err := example.Markup(fiesta.FS(), "signin", mustDefault(t, "signin"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ slotted, err := example.Slot(layout, signin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if slotted == layout {
+ t.Logf("the stage layout's default state already carries the signin default in its slot")
+ }
+ theme := fiesta.ThemeNames()[0]
+ framed, err := (&site{mount: mountPath}).frame(slotted, theme, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return theme + "/frames/layout-stage/with-signin.html", []byte(framed)
+}
+
+// TestA11yWalksTheStageShell is the keyboard walk on the page every
+// visitor meets first: the stage shell with the sign-in card in it,
+// walked all the way round from where autofocus puts the reader. Every
+// stop must show a focus indicator (2.4.7), none may hold focus (2.1.2),
+// and the circuit must reach every focusable element on the page: a
+// card control that Tab never reaches is a control a keyboard user does
+// not have, and the form family's walk above never meets this card.
+func TestA11yWalksTheStageShell(t *testing.T) {
+ m := manifest(t)
+ name, doc := stageDocument(t, m)
+ rig := harness.New(t, func(string) http.Handler { return newTree(t, map[string][]byte{name: doc}) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
+ defer cancel()
+
+ var count int
+ var start string
+ mustRun(t, ctx,
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+href(name)),
+ chromedp.WaitReady("body"),
+ chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count),
+ // Autofocus lands at a rendering update, not at load.
+ chromedp.Evaluate(`(async () => {
+ for (let i = 0; i < 40 && document.activeElement === document.body; i++) await new Promise(r => setTimeout(r, 50));
+ return document.activeElement.id;
+ })()`, &start, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),
+ )
+ if start != "rst-signin-email" {
+ t.Fatalf("the stage shell's card starts focus on %q, want the email field", start)
+ }
+ visited := map[int]string{}
+ back := false
+ for i := 0; i < count+3 && !back; i++ {
+ // at is the stop's place among the focusables: walkJS's label is
+ // tag, id and class, and the skip link and the foot's link are
+ // both a bare <a>, so a set of labels would count them as one.
+ s := tab(t, ctx, "stage shell")
+ var at int
+ mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll(`+focusablesJS+`)].indexOf(document.activeElement)`, &at))
+ if s.Tag == "body" {
+ // The browser's own chrome, between the last stop and the
+ // first: a way out, not a stop to measure.
+ continue
+ }
+ if s.Same {
+ t.Errorf("stage shell: tab %d: focus did not move off %s: keyboard trap (WCAG 2.1.2)", i+1, s.Tag)
+ }
+ if !s.Ring {
+ t.Errorf("stage shell: tab %d: %s has no visible focus indicator (WCAG 2.4.7)\n focused: %s\n blurred: %s", i+1, s.Tag, s.Focused, s.Blurred)
+ }
+ if at < 0 {
+ t.Errorf("stage shell: tab %d: focus is on %s, which the focusable selector does not count; the coverage check below would be comparing different things", i+1, s.Tag)
+ }
+ visited[at] = s.Tag
+ back = s.Tag == "input#"+start
+ }
+ if !back {
+ t.Errorf("stage shell: %d tabs never came back round to the email field: focus is trapped (WCAG 2.1.2)", count+3)
+ }
+ if len(visited) != count {
+ t.Errorf("stage shell: the walk reached %d of the page's %d focusable elements: %v", len(visited), count, visited)
+ }
+ t.Logf("stage shell: %d stops walked round from %s", len(visited), start)
+ rig.Screen("body", "the stage shell")
+}