Check that a keyboard reaches and operates every control on the served pages - #123
Merged
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/keyboardreads the pages and says what should be in the tab order; the file underneeds_browserrenders 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:
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:
Declared control missing from it, by hiding one at the stylesheet, which is how it actually happens:
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-hrefashref,aria-disabledis notdisabled, 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 firstgo.sumhere. Seven modules arrive with it.Not the newest release: v0.15 and v0.16 declare
go 1.26and 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/formatnow allows a tab indent ingo.mod, and.editorconfigsays 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 nextgo getanybody ran and the check would be measuring who ran what last.go.sumhas 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:
TestTheGateDependsOnNoRequirementstill 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:
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-browserjob 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 -nfails rather than prompting.The same job, on the runner, at the head of this branch
Run 2026-08-22 against
ea9c046, which is the head of this branch. The leading1is 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:
That last line is the one worth reading: the requirement was carried by nothing before this change, and
go run . harness needs-browserrefuses 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.