Skip to content

fix(a11y): palette ARIA was invalid, component source was keyboard-unreachable - #76

Merged
nikolas-sapa merged 1 commit into
mainfrom
fix/a11y-audit
Oct 14, 2026
Merged

nikolas-sapa merged 1 commit into
mainfrom
fix/a11y-audit

Conversation

@nikolas-sapa

Copy link
Copy Markdown
Owner

Site-wide audit with axe injected into every frame (a top-frame-only run audits the chrome and none of the product), plus manual checks for what axe structurally cannot see.

75 → 67 violation nodes across 40 page/theme/viewport combinations. The interactive states axe never reaches by default went 9 nodes → 0.

The two that actually block someone

1. The command palette's listbox was invalid ARIA three ways.

ul[role=listbox] > li > button[role=option] gave the listbox a listitem child (aria-required-children, critical), gave each option a plain <li> parent (aria-required-parent, critical, ×4), and left four bare listitems (serious).

So aria-activedescendant pointed at an option the listbox doesn't own: no "option 2 of 8", no reliable active-option announcement — on the only search reachable from all 542 component pages. The role="option" button is now a direct child. Pixel-identical in both themes.

2. Every component's source was unreachable by keyboard.

A <pre> with 976×4465 of content in a 686×538 box, zero focusable descendants, no tabindex. A keyboard-only visitor could not scroll the source of any component.

axe missed this entirely — it only flagged the smaller props-table wrapper, and only at 390px. Found by measuring overflow directly. Same class fixed in the props table, /theming's token table + 2 code blocks, /docs' error document, and /writing's code blocks and tables (160/285/293px of real overflow measured).

3. That fix then exposed a pre-existing hazard. site-shell's ArrowLeft/ArrowRight prev/next shortcut preventDefaults unless focus is in an input, slider or contenteditable. Measured: focus in the source box, one ArrowRight navigated to a different component instead of scrolling. So the newly-focusable boxes still couldn't scroll horizontally and a reader lost their place. The guard now also bails for any ancestor that can still scroll horizontally in the pressed direction, and hands the shortcut back at the edge.

4. The handle-rename input had no label, no htmlFor partner, no aria-label, not even a placeholder — announced as bare "edit text".

Not claimed

Two color-contrast deltas appeared in the before/after and are not claimed as fixes — they're run-to-run sampling variance on accent links.

Verified independently

Not taken on the auditing agent's word:

  • With the palette open and filtered, axe reports no violations for aria-required-children, aria-required-parent, listitem or aria-valid-attr-value
  • Of three scrollable boxes on a component page, zero unreachable by keyboard

Also: directree badge

Unlike the five opaque pills already in the footer, directree's has no background of its own — bare text and a logo — so one variant always loses: their lightmode art is #0d1b1f (invisible on #0a0a0a), darkmode is #faf5f1 (invisible on white). Both are published, so both ship and CSS picks — the same dark: swap the theme and cursor toggles use, correct on the first painted frame with no JS. Their verifier finds its asset either way since both are in the markup.

The first version of that had an a11y bug of its own. The name was on one image, which is right in one theme and backwards in the other: CSS chooses which <img> paints but cannot move alt, so in light mode the visible badge carried alt="" aria-hidden while the hidden one was the thing announced. Caught in verification, not review. The name is on the anchor now — theme-independent, announces exactly once.

Verified both themes: one variant visible and loaded at 200×33, both images aria-hidden, link named, 0px horizontal overflow.

Verification

  • npx tsc --noEmit — exit 0
  • npm run test:source-invariants — 0 problems
  • npm run build — exit 0, compiled 22.1s

Node was upgraded to v26.11.1 by another process mid-change. engines allows >=22.18, and all three gates were re-run on the new major rather than assumed to still hold.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ns-ui Ready Ready Preview Oct 14, 2026 3:30am UTC

…adge

Audit of the whole site with axe injected into every frame (a top-frame
run audits the chrome and none of the product) plus manual checks for
what axe structurally cannot see. 75 -> 67 violation nodes across 40
page/theme/viewport combinations, and the interactive states axe never
reaches by default went 9 nodes -> 0.

The two that actually block someone:

The command palette's listbox was invalid ARIA three ways:
`ul[role=listbox] > li > button[role=option]` gave the listbox a
`listitem` child (aria-required-children, CRITICAL), gave each option a
plain `<li>` parent (aria-required-parent, CRITICAL, x4), and left four
bare `listitem`s (serious). So `aria-activedescendant` pointed at an
option the listbox does not own: no "option 2 of 8", no reliable
active-option announcement, on the only search reachable from all 542
component pages. The `role="option"` button is now a direct child.
Pixel-identical in both themes.

Every component's source was unreachable by keyboard. A `<pre>` with
976x4465 of content in a 686x538 box, zero focusable descendants, no
`tabindex` -- a keyboard-only visitor could not scroll the source of any
component. axe MISSED this one entirely; it only flagged the smaller
props-table wrapper, and only at 390px. Found by measuring overflow
directly. Same class fixed in the props table, /theming's token table
and two code blocks, /docs' error document, and /writing's code blocks
and tables (160/285/293px of real overflow measured on one post).

That fix then exposed a pre-existing hazard: site-shell's
ArrowLeft/ArrowRight prev/next-component shortcut preventDefaults unless
focus is in an input, slider or contenteditable. Measured -- focus in the
source box, one ArrowRight navigated to a different component instead of
scrolling. So the newly-focusable boxes still could not scroll
horizontally and a reader lost their place. The guard now also bails for
any ancestor that can still scroll horizontally in the pressed
direction, and hands the shortcut back at the edge.

Also: the handle-rename input at account-handle.tsx had no label, no
htmlFor partner, no aria-label, not even a placeholder -- announced as
bare "edit text". Named now.

Two `color-contrast` deltas that appeared in the before/after are NOT
claimed as fixes; they are run-to-run sampling variance on accent links.

Verified independently rather than on the auditing agent's word: with the
palette open and filtered, axe reports NO violations for
aria-required-children, aria-required-parent, listitem or
aria-valid-attr-value; and of three scrollable boxes on a component
page, zero are unreachable by keyboard.

Separately, the directree directory badge. Unlike the five opaque pills
already in the footer it has NO background of its own -- bare text and a
logo -- so one variant always loses: their lightmode art is #0d1b1f
(invisible on #0a0a0a) and darkmode is #faf5f1 (invisible on white).
Both are published, so both ship and CSS picks, the same `dark:` swap the
theme and cursor toggles use, correct on the first painted frame with no
JS. Their verifier finds its asset either way since both are in the
markup.

The first version of that had the accessible name on one image, which is
right in one theme and BACKWARDS in the other: CSS chooses which <img>
paints but cannot move `alt`, so in light mode the visible badge carried
alt="" aria-hidden while the hidden one was the thing announced. Caught
in verification, not review. The name is on the anchor now, which is
theme-independent and announces exactly once. Verified in both themes:
one variant visible and loaded at 200x33, both images aria-hidden, link
named, zero horizontal overflow.

Node was upgraded to v26.11.1 by another process mid-change; engines
allow >=22.18 and tsc, source invariants and build were all re-run clean
on it rather than assumed.

Signed-off-by: Nik <84yk8btb9f@privaterelay.appleid.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nikolas-sapa
nikolas-sapa merged commit d0c37f6 into main Oct 14, 2026
4 checks passed
nikolas-sapa added a commit that referenced this pull request Oct 14, 2026
/submit opens a PR as the submitting user, in 9 sequential GitHub calls
with up to 9s of deliberate sleep in the fork poll, with no timeout on
any call and no maxDuration on the route. The branch name is
deterministic (`submit/${slug}`), and createBranch threw on any non-ok
response including 422.

So a function killed mid-flow, after the fork and branch existed, left
the slug unsubmittable FOREVER: every later retry hit 422 "Reference
already exists" and threw. The contributor could not recover without
manually deleting the branch on their own fork. This is the contribution
path for a project with almost no outside contributors, so one person
hitting it probably never came back.

Reproduced on unmodified shipped code, not argued from reading it.
`origin/main:lib/github-submit.ts` was extracted verbatim (sole edit: a
`public status` constructor parameter property rewritten longhand,
because Node's strip-only .ts loader rejects that syntax — diffed to
prove nothing else changed) and the route's exact call sequence replayed
against a fake GitHub at the fetch boundary. Attempt 1 killed at call 6:
branch survived, 0 files written, 0 PRs. Attempts 2, 3 and 4 — each with
a full budget and no kill — then died identically on `Could not create
branch submit/retry-trap-demo (422)` after 5 calls. Zero PRs ever.

THREE traps existed, not the one the audit found:

  ensureBranch      422 "Reference already exists"
  putFile           422 `"sha" wasn't supplied`   (one step later)
  openPullRequest   422 "A pull request already exists"

Each now resumes, but only on POSITIVE confirmation — it re-reads the
ref, the file, or the PR list. A 422 that cannot be confirmed is still a
real failure and still throws, rather than being pattern-matched out of
GitHub's prose.

Kept the deterministic branch name instead of uniquifying per attempt. A
unique branch retries just as cleanly but leaves a dead branch on the
contributor's fork and a duplicate upstream PR for every failed attempt:
permanent maintainer cleanup to spare the contributor one click.
Pre-flight delete is worse — it can destroy work.

Clobber protection: when the branch exists AND an open PR is found, the
PR is adopted and nothing is written, so a resubmit cannot rewrite files
a maintainer is mid-review on.

Closed the window as well as the trap: GITHUB_CALL_TIMEOUT_MS of 8s per
call (the precedent set in #73), a 45s whole-submission AbortSignal
budget intersected into every call, a fork poll that will not sleep past
that budget, and maxDuration = 60 on the route.

Security properties verified rather than assumed. The PR-adoption path
cannot cross users: `ensureFork` returns `owner: data.owner.login` read
from the fork response made with the CALLER's own token, never from env
and never from client input; `findOpenPullRequest` filters
`head=<that login>:<branch>` and `openPullRequest` posts
`head: ${forkOwner}:${branch}` from the same value. UPSTREAM_OWNER /
UPSTREAM_REPO only ever name the target repo, never the head. So user B
submitting a slug user A already submitted resolves to
`B:submit/<slug>` on B's own fork and can never adopt A's PR.

SUBMIT_TOKEN_BINDING_SECRET untouched and still fails closed. This
change does touch the route that owns that secret, so "no hits in the
diff" was not good enough: all 14 lines mentioning the secret,
`github_not_configured`, `resolveGithubToken` or 428 are text-identical
to origin/main with only line numbers shifted by the maxDuration comment
block above them, and `resolveGithubToken` itself is byte-identical
(diff -q clean). The missing-secret branch still returns
`github_not_configured` and no unbound token is ever issued. The new
test references neither the secret nor that error string and adds no
credentials.

scripts/test-submit-retry.ts — 17 checks, no framework, driven against a
fake GitHub at the fetch boundary. Mutation-proven: reverting the
ensureBranch 422 resume (the original bug) takes it to exit 1 with 10
failing checks and the message `Could not create branch
submit/retry-trap-demo (422)`; restoring it returns exit 0. Five further
mutants — no AbortSignal, putFile not resuming, openPullRequest not
adopting, the open-PR pre-check dropped, the fork poll ignoring its
budget — each fail with their own named check.

Verified on this rebase onto #76: tsc exit 0, source invariants 0
problems, submit-retry PASS, build exit 0.

Separately established, not a code change: the 3 tsc errors in
convex/auth.ts seen in this worktree are an install artifact.
`@auth/core` is a `peer: true` lockfile entry at 0.41.3, a peer of
@convex-dev/auth and absent from package.json, and the global ~/.npmrc
sets legacy-peer-deps=true, which makes npm skip auto-installing peers.
It was missing from node_modules in this worktree only, present in all
five siblings. Installing it with --no-save --package-lock=false took
tsc to 0 errors with package.json and package-lock.json untouched. CI
has no legacy-peer-deps=true and never saw these.

Signed-off-by: Nik <84yk8btb9f@privaterelay.appleid.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nikolas-sapa added a commit that referenced this pull request Oct 14, 2026
…ug (#78)

/submit opens a PR as the submitting user, in 9 sequential GitHub calls
with up to 9s of deliberate sleep in the fork poll, with no timeout on
any call and no maxDuration on the route. The branch name is
deterministic (`submit/${slug}`), and createBranch threw on any non-ok
response including 422.

So a function killed mid-flow, after the fork and branch existed, left
the slug unsubmittable FOREVER: every later retry hit 422 "Reference
already exists" and threw. The contributor could not recover without
manually deleting the branch on their own fork. This is the contribution
path for a project with almost no outside contributors, so one person
hitting it probably never came back.

Reproduced on unmodified shipped code, not argued from reading it.
`origin/main:lib/github-submit.ts` was extracted verbatim (sole edit: a
`public status` constructor parameter property rewritten longhand,
because Node's strip-only .ts loader rejects that syntax — diffed to
prove nothing else changed) and the route's exact call sequence replayed
against a fake GitHub at the fetch boundary. Attempt 1 killed at call 6:
branch survived, 0 files written, 0 PRs. Attempts 2, 3 and 4 — each with
a full budget and no kill — then died identically on `Could not create
branch submit/retry-trap-demo (422)` after 5 calls. Zero PRs ever.

THREE traps existed, not the one the audit found:

  ensureBranch      422 "Reference already exists"
  putFile           422 `"sha" wasn't supplied`   (one step later)
  openPullRequest   422 "A pull request already exists"

Each now resumes, but only on POSITIVE confirmation — it re-reads the
ref, the file, or the PR list. A 422 that cannot be confirmed is still a
real failure and still throws, rather than being pattern-matched out of
GitHub's prose.

Kept the deterministic branch name instead of uniquifying per attempt. A
unique branch retries just as cleanly but leaves a dead branch on the
contributor's fork and a duplicate upstream PR for every failed attempt:
permanent maintainer cleanup to spare the contributor one click.
Pre-flight delete is worse — it can destroy work.

Clobber protection: when the branch exists AND an open PR is found, the
PR is adopted and nothing is written, so a resubmit cannot rewrite files
a maintainer is mid-review on.

Closed the window as well as the trap: GITHUB_CALL_TIMEOUT_MS of 8s per
call (the precedent set in #73), a 45s whole-submission AbortSignal
budget intersected into every call, a fork poll that will not sleep past
that budget, and maxDuration = 60 on the route.

Security properties verified rather than assumed. The PR-adoption path
cannot cross users: `ensureFork` returns `owner: data.owner.login` read
from the fork response made with the CALLER's own token, never from env
and never from client input; `findOpenPullRequest` filters
`head=<that login>:<branch>` and `openPullRequest` posts
`head: ${forkOwner}:${branch}` from the same value. UPSTREAM_OWNER /
UPSTREAM_REPO only ever name the target repo, never the head. So user B
submitting a slug user A already submitted resolves to
`B:submit/<slug>` on B's own fork and can never adopt A's PR.

SUBMIT_TOKEN_BINDING_SECRET untouched and still fails closed. This
change does touch the route that owns that secret, so "no hits in the
diff" was not good enough: all 14 lines mentioning the secret,
`github_not_configured`, `resolveGithubToken` or 428 are text-identical
to origin/main with only line numbers shifted by the maxDuration comment
block above them, and `resolveGithubToken` itself is byte-identical
(diff -q clean). The missing-secret branch still returns
`github_not_configured` and no unbound token is ever issued. The new
test references neither the secret nor that error string and adds no
credentials.

scripts/test-submit-retry.ts — 17 checks, no framework, driven against a
fake GitHub at the fetch boundary. Mutation-proven: reverting the
ensureBranch 422 resume (the original bug) takes it to exit 1 with 10
failing checks and the message `Could not create branch
submit/retry-trap-demo (422)`; restoring it returns exit 0. Five further
mutants — no AbortSignal, putFile not resuming, openPullRequest not
adopting, the open-PR pre-check dropped, the fork poll ignoring its
budget — each fail with their own named check.

Verified on this rebase onto #76: tsc exit 0, source invariants 0
problems, submit-retry PASS, build exit 0.

Separately established, not a code change: the 3 tsc errors in
convex/auth.ts seen in this worktree are an install artifact.
`@auth/core` is a `peer: true` lockfile entry at 0.41.3, a peer of
@convex-dev/auth and absent from package.json, and the global ~/.npmrc
sets legacy-peer-deps=true, which makes npm skip auto-installing peers.
It was missing from node_modules in this worktree only, present in all
five siblings. Installing it with --no-save --package-lock=false took
tsc to 0 errors with package.json and package-lock.json untouched. CI
has no legacy-peer-deps=true and never saw these.

Signed-off-by: Nik <84yk8btb9f@privaterelay.appleid.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — f37f6285 Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant