Skip to content

Check that a keyboard reaches and operates every control on the served pages - #123

Merged
iderex merged 3 commits into
mainfrom
keyboard-reaches-every-control
Aug 22, 2026
Merged

Check that a keyboard reaches and operates every control on the served pages#123
iderex merged 3 commits into
mainfrom
keyboard-reaches-every-control

Conversation

@iderex

@iderex iderex commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Part of #37, the keyboard half. This is ready to merge now. It was held back for one reason, that reason was answered on the issue, and the answer is built and shown running below.

What it checks

Every control on both served pages is reachable by Tab alone, and the ones with behaviour are operable once focus is there: Enter selects a brightness or preset button, ArrowRight and ArrowLeft move the example row.

The expectation is derived from the markup rather than written down beside it. A list of controls maintained next to 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. internal/keyboard reads the pages and says what should be in the tab order; the file under needs_browser renders them and says what is. That reader runs in the gate, so a scan which quietly stopped finding anything is a red gate rather than an empty comparison the harness reports as clean.

The comparison refuses in both directions. A check that only looked for missing controls would go blind the day focus landed somewhere it does not understand, and nothing would say so.

What it found on its first run

Two scrollable regions that a real browser puts in the tab order and the markup declared as nothing:

docs/design-system.html: 12 control(s) declared, 13 reached by Tab
docs/design-system.html: focus lands on div "The Shaft2019 - 1 hr 34 Northern Light2021 - 2 hr 06 [...]",
  which the markup does not declare as a control, so this comparison no longer describes the page

The example row and the tile shelf. Chrome makes an overflowing scroller keyboard-focusable so that a keyboard can scroll what a pointer can, and neither carried a tabindex or a label. Both now do, which also makes them tab stops at every viewport width rather than only at the widths where their content happens to overflow. The row already handled the arrow keys; now Tab reaches it without clicking a cell first.

The guard, refusing something, in both directions

Undeclared control in the tab order, by putting the shelf back as it stood:

go test -tags needs_browser ./internal/keyboard -run TestEveryControlOnEveryServedPageIsReachableByTabAlone -count=1

--- FAIL: TestEveryControlOnEveryServedPageIsReachableByTabAlone (0.90s)
    browser_test.go:137: docs/index.html: 1 control(s) declared, 1 reached by Tab
    browser_test.go:137: docs/design-system.html: 13 control(s) declared, 14 reached by Tab
    browser_test.go:142: docs/design-system.html: focus lands on div "The Shaft2019 - 1 hr 34 [...]"

Declared control missing from it, by hiding one at the stylesheet, which is how it actually happens:

--- FAIL: TestEveryControlOnEveryServedPageIsReachableByTabAlone (1.36s)
    browser_test.go:137: docs/design-system.html: 14 control(s) declared, 13 reached by Tab
    browser_test.go:139: docs/design-system.html: the keyboard never reaches button "Tritanopia"

Both run 2026-08-21 on this branch, with the planted change reverted afterwards. The untagged reader carries its own near-misses: an attribute name matched as a suffix would read data-href as href, aria-disabled is not disabled, and two controls reading the same are two controls rather than one.

The dependency, which is the first this tree has carried

github.com/chromedp/chromedp v0.14.2, with its hashes in the first go.sum here. Seven modules arrive with it.

Not the newest release: v0.15 and v0.16 declare go 1.26 and would raise this module's own language floor along with it. That is a change every contributor and every runner feels and it is not this change's topic.

internal/format now allows a tab indent in go.mod, and .editorconfig says the same. 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 and the check would be measuring who ran what last. go.sum has no indentation and is not covered by that, which the code says where the rule is.

The means

Go, its own test runner, and one browser driver in it. The driver is what #37 settled, and the alternative named there was a hand-written protocol client, which would mean maintaining here what somebody else maintains elsewhere. Nothing is added to the gate: TestTheGateDependsOnNoRequirement still holds, and the gate's own report still says the harness did not run.

What the browser could not do before, and what changed

The tagged tests passed on a workstation and the job on the runner never reached an assertion:

gh run view 32483123175 --repo Flowfin/hub --log-failed

browser_test.go:136: docs/index.html: opening: chrome failed to start:
  FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:128] No usable sandbox!
  If you are running on Ubuntu 23.10+ or another Linux distro that has disabled
  unprivileged user namespaces with AppArmor [...]

Chrome builds a namespace sandbox for every renderer, that sandbox needs an unprivileged user namespace, and the runner image restricts those with AppArmor, so the browser did not start with weaker isolation, it refused to start at all.

#37 settled which of the two ways out is taken: the needs-browser job lifts that one runner setting, and the browser keeps its own sandbox. The alternative was a flag turning the browser's own isolation off, which would have followed the check onto every machine it ever ran on, a contributor's workstation included. A runner is destroyed when the job ends; a flag in a test file is not.

So the step is one privileged command, inside that job only, with the reason for it in the file beside it. It prints the setting before it changes it, so the day that line already reads 0 is the day the step is deleted rather than a matter of opinion. sudo -n fails rather than prompting.

The same job, on the runner, at the head of this branch

gh run view 32588242093 --repo Flowfin/hub --log

1
kernel.apparmor_restrict_unprivileged_userns = 0
harness: needs-browser: 1 file(s): internal/keyboard/browser_test.go
harness: needs-browser: go test -tags needs_browser ./... -count=1 -v
    browser_test.go:137: docs/index.html: 1 control(s) declared, 1 reached by Tab
    browser_test.go:137: docs/design-system.html: 14 control(s) declared, 14 reached by Tab
--- PASS: TestEveryControlOnEveryServedPageIsReachableByTabAlone (26.47s)
--- PASS: TestEveryBrightnessAndPresetButtonIsOperableFromTheKeyboard (0.79s)
--- PASS: TestTheServedPagesDeclareTheControlsAKeyboardHasToReach (0.00s)
ok  	flowfin.dev/hub/internal/keyboard	27.911s

Run 2026-08-22 against ea9c046, which is the head of this branch. The leading 1 is the setting as the image ships it, printed before the change, so the condition for deleting the step is read on every run rather than remembered.

The gate over the tree this merge produces

The branch was four commits behind, so I merged main into it and ran the gate over the result rather than over the older tree:

go run . gate

gate examined 14 of 14 legs.
  build, test, format, editorconfig, tests-reach-nothing, no-hardcoded-names,
  site-fetches-nothing-outside, site-links-resolve, coverage,
  site-declares-its-language, install-address-is-answered,
  colour-stands-off-every-surface, page-matches-the-token-file, pr-hygiene
  all passed

needs-browser   1 file(s) carry it: internal/keyboard/browser_test.go

That last line is the one worth reading: the requirement was carried by nothing before this change, and go run . harness needs-browser refuses a requirement no file carries rather than exiting zero over nothing.

Reading

This has had no second reader, and nothing below stands in for one. What is here instead is evidence: the check is shown failing in both directions with the planted change named, the dependency's version carries the reason it is not the newest, and the job that could not start a browser is quoted first failing and then passing, at the head being merged.

…d 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>
@iderex iderex added the enhancement New feature or request label Aug 21, 2026
@iderex iderex self-assigned this Aug 21, 2026
@iderex iderex added this to the 5. The published site milestone Aug 21, 2026
iderex added 2 commits August 22, 2026 09:49
The needs-browser job never reached an assertion. Chrome builds a namespace
sandbox for every renderer, that sandbox needs an unprivileged user namespace,
and the runner image restricts those with AppArmor, so the browser refused to
start at all:

    FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:128]
    No usable sandbox!

The step lifts that one restriction, on the runner, for the length of the 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.

What this prevents is a harness that goes red about the runner in a place a
reader takes for a verdict about the pages, and a keyboard check that is only
ever proven on a workstation.

The step prints the setting before it changes it, so the day that line already
reads 0 is the day the step is deleted rather than a matter of opinion. `sudo -n`
fails rather than prompting, which is the reason gate.yml already gives for
asking about elevation inside a job.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
…ree the merge produces

The branch was four commits behind and its gate listed the contrast leg under the
name it carried before main renamed it, so a reading taken here did not match the
one the checks report. This prevents a pasted gate listing that disagrees with the
checks for a reason that is not about the change.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex
iderex merged commit c71e442 into main Aug 22, 2026
39 checks passed
@iderex
iderex deleted the keyboard-reaches-every-control branch August 22, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant