diff --git a/.editorconfig b/.editorconfig
index 5c6fe22..4bd6737 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -42,3 +42,10 @@ indent_size = 4
# there is one place to argue with that rather than two.
[*.md]
indent_size = 4
+
+# The go command writes the module file's require block with tabs, so this
+# follows the toolchain for the same reason the Go section above does. go.sum
+# is not here because nothing in it is indented.
+[go.mod]
+indent_style = tab
+indent_size = 4
diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml
index 2b2ed1f..0e790a7 100644
--- a/.github/workflows/harness.yml
+++ b/.github/workflows/harness.yml
@@ -8,6 +8,11 @@
# Nothing here blocks a merge. decisions/headless-and-unelevated.md says the gate
# never depends on this file, and the gate's own report says on every run that
# none of these ran, so a green gate is not read as a green harness.
+#
+# One job elevates and the rest do not. needs-browser runs a single privileged
+# command, with the reason beside it in that job, because Chrome will not start
+# on this image otherwise. The unelevated rule that file states is about the
+# gate, and no leg of the gate reaches anything here.
name: Harness
on:
@@ -69,6 +74,35 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
+ # Chrome puts every renderer in a namespace sandbox, and building that
+ # sandbox needs an unprivileged user namespace. The runner image restricts
+ # those with AppArmor, so the browser does not start without isolation, it
+ # refuses to start at all:
+ #
+ # FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:128]
+ # No usable sandbox!
+ #
+ # The line below lifts that one restriction, on this runner, for the length
+ # of this job, on a machine that is destroyed when the job ends. The
+ # browser keeps its own sandbox. The alternative is a flag that turns that
+ # sandbox off instead, which would follow the check onto every machine it
+ # ever ran on, a contributor's workstation included, so the weakening is
+ # scoped to a throwaway runner rather than travelling with the test.
+ #
+ # It is the only privileged command in this file and it stays inside this
+ # job. A second job needing a browser gets the same step and the same
+ # reason rather than the setting moving somewhere it applies to everything.
+ #
+ # It goes when the image stops needing it, and that is read rather than
+ # guessed: the step prints the setting before it changes it, and the day
+ # that line already reads 0 is the day the step is deleted.
+ #
+ # `sudo -n` never prompts. It fails rather than asking, which is the same
+ # reason gate.yml gives for asking about elevation in a job at all.
+ - name: Allow the unprivileged user namespace Chrome's sandbox is built from
+ run: |
+ sysctl -n kernel.apparmor_restrict_unprivileged_userns
+ sudo -n sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- run: go run . harness needs-browser
needs-jellyfin:
diff --git a/docs/design-system.html b/docs/design-system.html
index 68ec96a..d2aebb8 100644
--- a/docs/design-system.html
+++ b/docs/design-system.html
@@ -299,7 +299,7 @@
Phone · from 35 cm
The tile
Three states, and the third one decides
-
+
The Shaft
2019 · 1 hr 34
Northern Light
2021 · 2 hr 06
Cold Coast
loading …
@@ -332,7 +332,7 @@
The only place with colour in it
Move with ←→, or click. Switch the colour
vision preset above and look at the same focus again.
-
+
diff --git a/go.mod b/go.mod
index d79375d..cef9e21 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,15 @@
module flowfin.dev/hub
go 1.25
+
+require github.com/chromedp/chromedp v0.14.2
+
+require (
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
+ golang.org/x/sys v0.34.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a62ad28
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,21 @@
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
diff --git a/internal/format/format.go b/internal/format/format.go
index 071f7b2..69e156e 100644
--- a/internal/format/format.go
+++ b/internal/format/format.go
@@ -99,11 +99,18 @@ func CheckFile(name string, content []byte) []Finding {
return findings
}
-// tabIndented says whether .editorconfig indents this path with tabs. Go is the
-// only such type in the tree, because gofmt indents with tabs and rewriting it
-// would be a fight with the toolchain's own formatter rather than a style.
+// tabIndented says whether .editorconfig indents this path with tabs.
+//
+// Two cases, and both are the same argument rather than two exceptions. gofmt
+// indents Go with tabs, and the module file's require block is written by the
+// go command with tabs. Both are files a tool rewrites on its own, so a rule
+// that indented them with spaces would be refused again by the next command
+// anybody ran, and the check would be measuring who ran what last.
+//
+// go.sum has no indentation at all and is not here for that reason rather than
+// by omission.
func tabIndented(name string) bool {
- return filepath.Ext(name) == ".go"
+ return filepath.Ext(name) == ".go" || filepath.Base(name) == "go.mod"
}
// TrackedFiles returns the paths git tracks under root, so an untracked scratch
diff --git a/internal/format/format_test.go b/internal/format/format_test.go
index 0221b81..e09ddd5 100644
--- a/internal/format/format_test.go
+++ b/internal/format/format_test.go
@@ -115,3 +115,18 @@ func TestTrackedTreeIsFormatted(t *testing.T) {
t.Logf("%d tracked file(s) checked", len(paths))
}
}
+
+func TestCheckFileAllowsATabIndentInTheModuleFile(t *testing.T) {
+ // The go command writes the require block with tabs and rewrites the file on
+ // its own, so a rule refusing that would be undone by the next `go get`
+ // anybody ran and the check would be measuring who ran what last.
+ mod := "module m\n\ngo 1.25\n\nrequire (\n\tx v1.0.0\n)\n"
+ if findings := CheckFile("go.mod", []byte(mod)); len(findings) != 0 {
+ t.Fatalf("refused %v, and the go command indents the require block with tabs", findings)
+ }
+ // The name is matched whole rather than as a substring, so a file that
+ // merely carries it is still held to the rule.
+ if findings := CheckFile("docs/go.mod.md", []byte("\tindented prose\n")); len(findings) != 1 {
+ t.Fatalf("refused %v over a file whose name only contains go.mod, want exactly one finding", findings)
+ }
+}
diff --git a/internal/keyboard/browser_test.go b/internal/keyboard/browser_test.go
new file mode 100644
index 0000000..82ca2e7
--- /dev/null
+++ b/internal/keyboard/browser_test.go
@@ -0,0 +1,223 @@
+//go:build needs_browser
+
+// The half of internal/keyboard that needs a browser: what a real render with
+// the page's own script running actually puts in the tab order, and what the
+// keys do once focus is there.
+//
+// It is here rather than in the gate because deciding it needs a browser
+// installed, which decisions/headless-and-unelevated.md keeps out of the merge
+// gate. The expectation it is measured against is derived in the untagged file
+// beside this one and runs in the gate, so a page that grew a control nobody can
+// reach reds here rather than passing quietly in both places.
+package keyboard
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+ "github.com/chromedp/chromedp/kb"
+)
+
+// tabCap bounds the walk through the tab order.
+//
+// A page whose focus handling traps the keyboard would otherwise run until the
+// job timed out, and a timeout says "the runner" where this should say "the
+// page". It is several times the largest control count the pages hold, so it
+// does not quietly become the thing that ends the walk on a page that grew.
+const tabCap = 60
+
+func browser(t *testing.T) (context.Context, func()) {
+ t.Helper()
+ alloc, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
+ append(chromedp.DefaultExecAllocatorOptions[:],
+ chromedp.Flag("headless", true),
+ chromedp.Flag("disable-extensions", true),
+ )...)
+ ctx, cancelCtx := chromedp.NewContext(alloc)
+ ctx, cancelTimeout := context.WithTimeout(ctx, 120*time.Second)
+ return ctx, func() { cancelTimeout(); cancelCtx(); cancelAlloc() }
+}
+
+// pageURL is the file address of a served page in this checkout.
+//
+// The pages are opened off the disk rather than off a server, so this check
+// needs a browser and nothing else. A render that fetched anything would make it
+// depend on somebody else's service while claiming to depend only on a browser,
+// and site-fetches-nothing-outside is the leg that holds the pages to that.
+func pageURL(t *testing.T, page string) string {
+ t.Helper()
+ abs, err := filepath.Abs(filepath.Join("..", "..", filepath.FromSlash(page)))
+ if err != nil {
+ t.Fatalf("resolving %s: %v", page, err)
+ }
+ return (&url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(abs)}).String()
+}
+
+// focused reads the element that has focus, as the Control the untagged reader
+// would have produced for it.
+//
+// The label is taken the same way: an explicit one where there is one, the text
+// otherwise, whitespace collapsed. The tag name carries no space and a label
+// may, so the first space is the separator and nothing else has to be escaped.
+const focused = `(function(){
+ var e = document.activeElement;
+ if (!e || e === document.body || e === document.documentElement) { return "" }
+ var label = e.getAttribute("aria-label");
+ if (label === null) { label = e.textContent }
+ return e.tagName.toLowerCase() + " " + label.split(/\s+/).filter(Boolean).join(" ");
+})()`
+
+func parseFocused(s string) (Control, bool) {
+ tag, label, ok := strings.Cut(s, " ")
+ if !ok || tag == "" {
+ return Control{}, false
+ }
+ return Control{Tag: tag, Label: label}, true
+}
+
+// walk tabs from the top of the document and collects what focus lands on, in
+// order, stopping when it comes back round to the first control or when the cap
+// is reached.
+func walk(ctx context.Context, t *testing.T, page string) []Control {
+ t.Helper()
+ if err := chromedp.Run(ctx,
+ chromedp.Navigate(pageURL(t, page)),
+ chromedp.WaitReady("body"),
+ // Focus starts at the document rather than wherever the previous
+ // navigation left it, so this is the walk a reader who has just opened
+ // the page makes.
+ chromedp.Evaluate(`document.activeElement && document.activeElement.blur && document.activeElement.blur()`, nil),
+ ); err != nil {
+ t.Fatalf("%s: opening: %v", page, err)
+ }
+
+ var out []Control
+ for i := 0; i < tabCap; i++ {
+ var got string
+ if err := chromedp.Run(ctx,
+ chromedp.KeyEvent(kb.Tab),
+ chromedp.Evaluate(focused, &got),
+ ); err != nil {
+ t.Fatalf("%s: tab %d: %v", page, i+1, err)
+ }
+ c, ok := parseFocused(got)
+ if !ok {
+ // Focus has left the page's own controls, which is the browser's
+ // own interface. The walk is over.
+ break
+ }
+ if len(out) > 0 && c == out[0] {
+ break
+ }
+ out = append(out, c)
+ }
+ return out
+}
+
+func TestEveryControlOnEveryServedPageIsReachableByTabAlone(t *testing.T) {
+ ctx, done := browser(t)
+ defer done()
+
+ for _, page := range Pages {
+ declared, err := Interactive(read(t, page))
+ if err != nil {
+ t.Fatalf("%s: %v", page, err)
+ }
+ if len(declared) == 0 {
+ t.Fatalf("%s declares no control, so a green here would say nothing", page)
+ }
+
+ reached := walk(ctx, t, page)
+ t.Logf("%s: %d control(s) declared, %d reached by Tab", page, len(declared), len(reached))
+ if missing := Missing(declared, reached); len(missing) > 0 {
+ t.Errorf("%s: the keyboard never reaches %s", page, Format(missing))
+ }
+ if extra := Extra(declared, reached); len(extra) > 0 {
+ t.Errorf("%s: focus lands on %s, which the markup does not declare as a control, so this comparison no longer describes the page", page, Format(extra))
+ }
+ }
+}
+
+func TestEveryBrightnessAndPresetButtonIsOperableFromTheKeyboard(t *testing.T) {
+ // Reachable and operable are different claims. A button that takes focus and
+ // does nothing on Enter is the failure a pointer never shows.
+ ctx, done := browser(t)
+ defer done()
+
+ page := "docs/design-system.html"
+ var count int
+ if err := chromedp.Run(ctx,
+ chromedp.Navigate(pageURL(t, page)),
+ chromedp.WaitReady("body"),
+ chromedp.Evaluate(`document.querySelectorAll(".mode").length`, &count),
+ ); err != nil {
+ t.Fatalf("opening %s: %v", page, err)
+ }
+ if count == 0 {
+ t.Fatal("the page carries no brightness or preset button, so this proves nothing")
+ }
+ t.Logf("%s: %d brightness and preset button(s)", page, count)
+
+ for i := 0; i < count; i++ {
+ sel := fmt.Sprintf(`document.querySelectorAll(".mode")[%d]`, i)
+ var before, after, label string
+ if err := chromedp.Run(ctx,
+ chromedp.Evaluate(sel+`.textContent.trim()`, &label),
+ chromedp.Evaluate(sel+`.focus()`, nil),
+ chromedp.Evaluate(sel+`.getAttribute("aria-pressed")`, &before),
+ chromedp.KeyEvent(kb.Enter),
+ chromedp.Evaluate(sel+`.getAttribute("aria-pressed")`, &after),
+ ); err != nil {
+ t.Fatalf("operating button %d: %v", i, err)
+ }
+ if after != "true" {
+ t.Errorf("%q read %s before Enter and %s after, so pressing it from the keyboard did not select it",
+ label, before, after)
+ }
+ }
+}
+
+func TestTheExampleRowMovesUnderTheArrowKeys(t *testing.T) {
+ // The behaviour with no pointer equivalent at all, and the reason this half
+ // cannot be decided by a Go test reading the file: the handling is in the
+ // page's own script and only exists once that script has run.
+ ctx, done := browser(t)
+ defer done()
+
+ page := "docs/design-system.html"
+ at := `[].slice.call(document.querySelectorAll(".fcell")).findIndex(function(c){return c.getAttribute("aria-selected")==="true"})`
+
+ var cells, start, right, back int
+ if err := chromedp.Run(ctx,
+ chromedp.Navigate(pageURL(t, page)),
+ chromedp.WaitReady("body"),
+ chromedp.Evaluate(`document.querySelectorAll(".fcell").length`, &cells),
+ chromedp.Evaluate(at, &start),
+ chromedp.Evaluate(`document.querySelectorAll(".fcell")[0].focus()`, nil),
+ chromedp.KeyEvent(kb.ArrowRight),
+ chromedp.Evaluate(at, &right),
+ chromedp.KeyEvent(kb.ArrowLeft),
+ chromedp.Evaluate(at, &back),
+ ); err != nil {
+ t.Fatalf("operating the row: %v", err)
+ }
+ if cells < 2 {
+ t.Fatalf("the row holds %d cell(s), so an arrow key has nowhere to go and this proves nothing", cells)
+ }
+ t.Logf("%s: %d cell(s) in the row", page, cells)
+ if start != 0 {
+ t.Fatalf("the row opens with cell %d selected, want the first", start)
+ }
+ if right != 1 {
+ t.Errorf("ArrowRight moved the selection to %d, want 1", right)
+ }
+ if back != 0 {
+ t.Errorf("ArrowLeft moved the selection back to %d, want 0", back)
+ }
+}
diff --git a/internal/keyboard/keyboard.go b/internal/keyboard/keyboard.go
new file mode 100644
index 0000000..d3ba2f4
--- /dev/null
+++ b/internal/keyboard/keyboard.go
@@ -0,0 +1,300 @@
+// Package keyboard says what a keyboard has to be able to reach on the served
+// pages, and keeps that answer apart from the browser that measures it.
+//
+// The design system promises that everything is operable from the keyboard
+// alone. The page is where a reader first meets that promise, so it is the worst
+// place for it to be untrue, and it is also the only place in this tree where
+// deciding it needs a page rendered and its script executed. That half is the
+// file beside this one, behind the needs_browser tag; this half is the
+// expectation, and it runs in the gate like anything else.
+//
+// The expectation is DERIVED FROM THE MARKUP rather than written down. A list of
+// controls maintained beside a page stops matching it, and the control that gets
+// added without keyboard reach is exactly the one nobody remembers to add to the
+// list. So a page growing a button grows this expectation in the same edit, and
+// the browser has to find that button reachable or the check reds.
+//
+// What it does not decide. Whether the focus ring is drawn, whether the label a
+// screen reader announces is the text this reader takes, and whether the control
+// does anything useful once operated are all outside it. The first needs painted
+// pixels, the second needs an accessibility tree, and the third is what the
+// browser-side assertions are for.
+package keyboard
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// Pages are the served pages this package judges, relative to the repository
+// root.
+//
+// Both, and not only the one carrying the script. A landing page with one link
+// is the easier of the two to get wrong, because there is nothing on it that
+// looks like it needs checking.
+var Pages = []string{"docs/index.html", "docs/design-system.html"}
+
+// Control is one thing on a page that a keyboard has to reach.
+type Control struct {
+ // Tag is the element's tag name, lower case.
+ Tag string
+ // Label is the text a reader sees on it, with whitespace collapsed. It is
+ // the key the rendered page is compared on, because it is the one property
+ // a person and a browser agree about without an accessibility tree.
+ Label string
+}
+
+func (c Control) String() string {
+ if c.Label == "" {
+ return c.Tag + " with no text on it"
+ }
+ return c.Tag + " " + quoted(c.Label)
+}
+
+func quoted(s string) string { return "\"" + s + "\"" }
+
+// focusable are the tag names a browser puts in the tab order without being
+// asked. Anything else needs a tabindex, and carrying one is what puts it in the
+// list below.
+//
+// The set is the natively focusable form controls plus the anchor, which is
+// focusable only when it carries an href. A list that included the anchor
+// unconditionally would expect focus on every in-page target name and find none.
+var focusable = map[string]bool{
+ "button": true, "a": true, "input": true, "select": true, "textarea": true,
+}
+
+// void are the tags that carry no closing tag, so their label cannot come from
+// text between one.
+var void = map[string]bool{"input": true}
+
+// Interactive lists the controls a page declares, in document order.
+//
+// A duplicate label is kept rather than deduplicated. Two controls reading the
+// same are two things a keyboard has to reach, and collapsing them here would
+// let one of them fall out of the tab order with nothing to say so.
+func Interactive(html string) ([]Control, error) {
+ var out []Control
+ for i := 0; i < len(html); i++ {
+ if html[i] != '<' {
+ continue
+ }
+ name, attrs, end, ok := openTag(html, i)
+ if !ok {
+ continue
+ }
+
+ // A disabled control is out of the tab order by the specification, and
+ // correctly so, so expecting focus on it would refuse a page that is
+ // right. It is read before the tabindex, because a disabled control
+ // carrying a positive one is still not focusable.
+ if hasWord(attrs, "disabled") {
+ i = end
+ continue
+ }
+
+ tabindex, hasTabindex := attr(attrs, "tabindex")
+ switch {
+ case hasTabindex && tabindex == "-1":
+ // Deliberately out of the tab order. Reachable by script and by a
+ // pointer, and this check is about the keyboard. A roving tabindex,
+ // which is the pattern a listbox is built with, is made of exactly
+ // these.
+ i = end
+ continue
+ case hasTabindex:
+ case !focusable[name]:
+ i = end
+ continue
+ case name == "a":
+ if _, has := attr(attrs, "href"); !has {
+ i = end
+ continue
+ }
+ }
+
+ label, next, err := labelOf(html, name, attrs, end)
+ if err != nil {
+ return nil, fmt.Errorf("the element <%s> at byte %d: %w", name, i, err)
+ }
+ out = append(out, Control{Tag: name, Label: label})
+ i = next
+ }
+ return out, nil
+}
+
+// openTag reads the opening tag starting at html[i], returning its lower-case
+// name, its attribute text and the index of the closing angle bracket.
+func openTag(html string, i int) (name, attrs string, end int, ok bool) {
+ if i+1 >= len(html) {
+ return "", "", 0, false
+ }
+ if !isNameStart(html[i+1]) {
+ return "", "", 0, false
+ }
+ j := i + 1
+ for j < len(html) && isNameByte(html[j]) {
+ j++
+ }
+ shut := strings.IndexByte(html[j:], '>')
+ if shut < 0 {
+ return "", "", 0, false
+ }
+ end = j + shut
+ return strings.ToLower(html[i+1 : j]), html[j:end], end, true
+}
+
+func isNameStart(b byte) bool {
+ return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
+}
+
+func isNameByte(b byte) bool {
+ return isNameStart(b) || b >= '0' && b <= '9' || b == '-'
+}
+
+// attr reads one attribute out of an opening tag's attribute text. It reads the
+// double-quoted form, which is the one .editorconfig and every page in this tree
+// use, and treats any other spelling as absent rather than guessing at it.
+func attr(attrs, name string) (string, bool) {
+ want := name + "=\""
+ for i := 0; ; {
+ at := strings.Index(attrs[i:], want)
+ if at < 0 {
+ return "", false
+ }
+ at += i
+ // The match has to start a word, so href does not match data-href.
+ if at > 0 && (isNameByte(attrs[at-1]) || attrs[at-1] == '-') {
+ i = at + len(want)
+ continue
+ }
+ rest := attrs[at+len(want):]
+ shut := strings.IndexByte(rest, '"')
+ if shut < 0 {
+ return "", false
+ }
+ return rest[:shut], true
+ }
+}
+
+// hasWord reports whether an opening tag's attribute text carries a bare
+// attribute of that name, in the boolean form HTML writes one.
+//
+// It matches the whole word rather than a substring, so aria-disabled is not
+// read as disabled. The two mean different things: one takes the control out of
+// the tab order and the other only says so to a screen reader, and a control
+// carrying the second is still something a keyboard reaches.
+func hasWord(attrs, name string) bool {
+ for _, f := range strings.Fields(attrs) {
+ if f == name || strings.HasPrefix(f, name+"=") {
+ return true
+ }
+ }
+ return false
+}
+
+// labelOf reads the text a reader sees on a control, and returns the index to
+// carry on scanning from.
+func labelOf(html, name, attrs string, end int) (label string, next int, err error) {
+ if aria, ok := attr(attrs, "aria-label"); ok {
+ return collapse(aria), end, nil
+ }
+ if void[name] {
+ if v, ok := attr(attrs, "value"); ok {
+ return collapse(v), end, nil
+ }
+ return "", end, nil
+ }
+ closing := "" + name
+ at := indexFold(html[end:], closing)
+ if at < 0 {
+ return "", 0, fmt.Errorf("is never closed, so the text on it cannot be read")
+ }
+ return collapse(stripTags(html[end+1 : end+at])), end + at, nil
+}
+
+// indexFold finds needle in s, case-insensitively on the ASCII letters, which is
+// what an HTML tag name is.
+func indexFold(s, needle string) int {
+ return strings.Index(strings.ToLower(s), strings.ToLower(needle))
+}
+
+// stripTags removes markup, leaving the text between it.
+func stripTags(s string) string {
+ var b strings.Builder
+ depth := 0
+ for i := 0; i < len(s); i++ {
+ switch s[i] {
+ case '<':
+ depth++
+ case '>':
+ if depth > 0 {
+ depth--
+ }
+ default:
+ if depth == 0 {
+ b.WriteByte(s[i])
+ }
+ }
+ }
+ return b.String()
+}
+
+// collapse turns every run of whitespace into one space and trims the ends, so
+// a label that wraps in the source is the label a browser reports.
+func collapse(s string) string { return strings.Join(strings.Fields(s), " ") }
+
+// Missing is what a rendered page failed to put in the tab order: every control
+// the markup declares that the browser never gave focus to.
+//
+// It compares MULTISETS rather than sets, so a page declaring two controls that
+// read the same and reaching one of them is a finding rather than a match.
+func Missing(declared, reached []Control) []Control {
+ have := map[Control]int{}
+ for _, c := range reached {
+ have[c]++
+ }
+ var out []Control
+ for _, c := range declared {
+ if have[c] > 0 {
+ have[c]--
+ continue
+ }
+ out = append(out, c)
+ }
+ return out
+}
+
+// Extra is the other direction: focus landing somewhere the markup does not
+// declare a control.
+//
+// It is reported rather than ignored because it is how this check goes quietly
+// blind. A page that put a div in the tab order with a tabindex the scan above
+// does not read would show up here, and treating that as nothing to say would
+// leave the comparison passing on a page it no longer describes.
+func Extra(declared, reached []Control) []Control {
+ want := map[Control]int{}
+ for _, c := range declared {
+ want[c]++
+ }
+ var out []Control
+ for _, c := range reached {
+ if want[c] > 0 {
+ want[c]--
+ continue
+ }
+ out = append(out, c)
+ }
+ return out
+}
+
+// Format renders a list of controls for a failure message, in a stable order.
+func Format(cs []Control) string {
+ out := make([]string, 0, len(cs))
+ for _, c := range cs {
+ out = append(out, c.String())
+ }
+ sort.Strings(out)
+ return strings.Join(out, ", ")
+}
diff --git a/internal/keyboard/keyboard_test.go b/internal/keyboard/keyboard_test.go
new file mode 100644
index 0000000..23eb33a
--- /dev/null
+++ b/internal/keyboard/keyboard_test.go
@@ -0,0 +1,175 @@
+package keyboard
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func read(t *testing.T, page string) string {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(page)))
+ if err != nil {
+ t.Fatalf("opening %s: %v", page, err)
+ }
+ return string(b)
+}
+
+func TestTheServedPagesDeclareTheControlsAKeyboardHasToReach(t *testing.T) {
+ // The expectation the browser side is measured against, read here so that a
+ // scan which quietly stopped finding anything is a failure in the gate
+ // rather than an empty comparison the harness reports as clean.
+ want := map[string]int{"docs/index.html": 1, "docs/design-system.html": 14}
+ for _, page := range Pages {
+ got, err := Interactive(read(t, page))
+ if err != nil {
+ t.Fatalf("%s: %v", page, err)
+ }
+ if len(got) != want[page] {
+ t.Errorf("%s declares %d control(s), want %d: %s", page, len(got), want[page], Format(got))
+ }
+ for _, c := range got {
+ if c.Label == "" {
+ t.Errorf("%s declares a control with no text on it, which the browser side cannot match: %s", page, c)
+ }
+ }
+ }
+}
+
+func TestAControlTakenOutOfTheTabOrderIsNotExpectedInIt(t *testing.T) {
+ // tabindex="-1" is a deliberate statement that the keyboard does not go
+ // there. Expecting it anyway would make this check red on a page that is
+ // right, which is the fastest way to have it switched off.
+ got, err := Interactive(``)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Label != "Shown" {
+ t.Fatalf("read %s, want the shown button alone", Format(got))
+ }
+}
+
+func TestSomethingPutInTheTabOrderByHandIsExpectedInIt(t *testing.T) {
+ // The other direction, and it is the one that matters for a design system:
+ // a div made focusable is a control, and a check reading only the native
+ // tags would walk past exactly the element most likely to be operable by
+ // pointer alone.
+ got, err := Interactive(`
Custom
`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Tag != "div" || got[0].Label != "Custom" {
+ t.Fatalf("read %s, want the div", Format(got))
+ }
+}
+
+func TestAnAnchorWithNoDestinationIsNotAControl(t *testing.T) {
+ // An anchor without an href is not in the tab order, so expecting focus on
+ // every in-page target name would fail on every page that has one.
+ got, err := Interactive(`Go`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Label != "Go" {
+ t.Fatalf("read %s, want the linked anchor alone", Format(got))
+ }
+}
+
+func TestAnAttributeNameIsMatchedWholeAndNotAsASuffix(t *testing.T) {
+ // The one-character mistake in the reader rather than in the page: a
+ // substring match reads data-href as href, and the anchor above then counts
+ // as a control that is not in the tab order.
+ got, err := Interactive(`Not a link`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("read %s, want nothing", Format(got))
+ }
+}
+
+func TestTheLabelIsTheTextAReaderSeesAndNotTheMarkupAroundIt(t *testing.T) {
+ got, err := Interactive("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Label != "The Shaft" {
+ t.Fatalf("read %s, want the collapsed text", Format(got))
+ }
+}
+
+func TestAnExplicitLabelWins(t *testing.T) {
+ got, err := Interactive(``)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Label != "Close the panel" {
+ t.Fatalf("read %s, want the explicit label", Format(got))
+ }
+}
+
+func TestAControlThatIsNeverClosedIsAnErrorRatherThanASkip(t *testing.T) {
+ // Unreadable is not a pass. A control whose text this reader could not find
+ // and silently dropped would be a control the browser side never looks for.
+ if _, err := Interactive(`