From ab17a5990f0aa284fa050c55c5d06842719e223b Mon Sep 17 00:00:00 2001
From: Nils Lehnen <30603423+iderex@users.noreply.github.com>
Date: Fri, 21 Aug 2026 14:41:31 +0200
Subject: [PATCH 1/2] Check that a keyboard reaches and operates every control
on the served pages (#37)
The design system promises that everything is operable from the keyboard alone
and nothing read the pages to see whether it is. Deciding it needs the page
rendered and its own script executed, so this is the first check under
needs_browser and it brings the first dependency this tree has carried.
What it found on its first run is why it exists. A real browser puts two
scrollable regions in the tab order, the example row and the tile shelf, and
the markup declared neither as a control. Both are now declared with an
explicit tabindex and a label, so they are tab stops at every viewport width
rather than only at the widths where their content happens to overflow, and a
keyboard can scroll what a pointer could already scroll.
The expectation is derived from the markup rather than written down beside it,
so a page that grows a control grows the expectation in the same edit and the
browser has to find that control reachable. That reader runs in the gate; only
the measurement needs a browser. The comparison refuses in both directions,
because a check that only looked for missing controls would go quietly blind
the day focus landed somewhere it does not understand.
The driver is chromedp v0.14.2, pinned with its hashes in the first go.sum this
tree has had. The newest release requires Go 1.26 and would have raised this
module's language floor along with it, which is a change every contributor and
every runner feels and is not this change's topic.
internal/format now allows a tab indent in go.mod. The go command writes the
require block with tabs and rewrites the file on its own, so refusing that
would be undone by the next go get anybody ran.
Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
---
.editorconfig | 7 +
docs/design-system.html | 4 +-
go.mod | 12 ++
go.sum | 21 ++
internal/format/format.go | 15 +-
internal/format/format_test.go | 15 ++
internal/keyboard/browser_test.go | 223 +++++++++++++++++++++
internal/keyboard/keyboard.go | 300 +++++++++++++++++++++++++++++
internal/keyboard/keyboard_test.go | 175 +++++++++++++++++
9 files changed, 766 insertions(+), 6 deletions(-)
create mode 100644 go.sum
create mode 100644 internal/keyboard/browser_test.go
create mode 100644 internal/keyboard/keyboard.go
create mode 100644 internal/keyboard/keyboard_test.go
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/docs/design-system.html b/docs/design-system.html
index f450bd3..04c2e9b 100644
--- a/docs/design-system.html
+++ b/docs/design-system.html
@@ -291,7 +291,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 …
@@ -324,7 +324,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(`