Skip to content

fix(a11y): give operable controls a 3:1 border (WCAG 1.4.11) - #72

Merged
nikolas-sapa merged 1 commit into
mainfrom
fix/a11y-control-borders
Oct 13, 2026
Merged

nikolas-sapa merged 1 commit into
mainfrom
fix/a11y-control-borders

Conversation

@nikolas-sapa

Copy link
Copy Markdown
Owner

The report

"Text on pictures 1 and 2 will most likely fail accessibility standards / checks (not enough contrast). If I need ui library in production it should pass a11y standards"

Their specific claim was wrong and the surrounding point was right.

Text contrast passes everywhere

Measured with WCAG relative-luminance maths on every shipped pair:

pair dark light
muted text on background 6.12:1 AA 8.45:1 AAA
muted text on surface 5.54:1 AA 8.10:1 AAA
body copy on background 16.91:1 AAA 17.93:1 AAA

What actually failed is non-text

--border is 1.46:1 on the page in dark and 1.19:1 in light, against WCAG 1.4.11's 3.0 minimum — and it is the only thing identifying a control, because --surface against --background is about 1.1:1, so the fill distinguishes nothing either.

A search input, a sort select, every category chip and the save button on every card had boundaries nobody with low vision could locate.

Fixed with a second token, not a louder --border

1.4.11 exempts decorative edges, and most uses here are decorative: 1px rules, card outlines, the preview dot grid, and 1318 uses inside registry/ components. Raising --border would have passed the criterion by restyling the entire published product.

--border-control is #646464 dark / #919191 light — the lowest-contrast greys clearing 3.0:1 against both --background and --surface (3.35/3.03 and 3.15/3.02), so the visual jump is as small as the criterion allows. registry/** is untouched.

After

1150 bordered controls across both themes on the homepage, 0 failing. Also 0 on /install, /submit, /account, /review, /categories and a component page.

The check lied twice before it worked

This is the part worth recording.

Version 1 hand-picked four selectors and reported PASS on two elements whose border-width was 0px — there was nothing to measure.

Version 2 swept everything and reported "0 of 575 failing" while the category chips sat at 1.46:1. It parsed colours with /\d+/g, so a backdrop of oklab(0.144787 0.00000661612 0.00000289828 / 0.85) became [0, 144787, 0], produced nonsense luminance, and scored as passing. I believed that green and nearly shipped on it.

The version used here resolves every colour through a canvas (which normalises any CSS colour function), composites translucent backdrops over what shows through them, and aborts rather than scoring a value it cannot parse.

Mutation-proven: reverting the save button's single line takes it from 0 failures to 1084; restoring it returns 0. That 1084 is one component rendered once per card — which is also why the raw count looked alarming and was in fact two components.

Three detection gaps, named so the next sweep doesn't repeat them: a 12-line look-back for the owning element missed catalog-controls.tsx entirely; a lowercase <a pattern missed every <Link>; and a \b after a chip label never matched, because the text is Heroes75 with no space.

Verification

  • npx tsc --noEmit — exit 0
  • npm run test:source-invariants — 0 problems
  • Border sweep: 1150 controls / 0 failures, 6 additional routes clean, mutation-proven able to fail
  • Looked at in both themes at 1440×900: controls read as controls, the page does not read as louder

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 13, 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 13, 2026 12:43pm UTC

A prospective user said: "Text on pictures 1 and 2 will most likely fail
accessibility standards / checks (not enough contrast). If I need ui
library in production it should pass a11y standards."

Their specific claim was wrong and the surrounding point was right.

TEXT contrast passes everywhere, measured with WCAG relative-luminance
maths on every shipped pair: muted text 6.12:1 on background and 5.54:1
on surface in dark, 8.45:1 / 8.10:1 in light; body copy 16.91:1 /
17.93:1. AA throughout, AAA mostly.

What failed is non-text. `--border` is 1.46:1 on the page in dark and
1.19:1 in light, against 1.4.11's 3.0 minimum — and it is the ONLY thing
identifying a control, because `--surface` against `--background` is
about 1.1:1, so the fill distinguishes nothing either. A search input, a
sort select, a category chip and the save button on every card were
boundaries nobody with low vision could locate.

Fixed with a SECOND token, not a louder `--border`. 1.4.11 exempts
decorative edges, and most uses here are decorative: 1px rules, card
outlines, the preview dot grid, and 1318 uses inside registry/
components. Raising `--border` would have passed the criterion by
restyling the entire published product. `--border-control` is #646464 in
dark and #919191 in light — the lowest-contrast greys clearing 3.0:1
against BOTH `--background` and `--surface` (3.35/3.03 and 3.15/3.02), so
the visual jump is as small as the criterion allows. registry/** is
untouched.

Measured after: 1150 bordered controls across both themes on the
homepage, 0 failing, and 0 failing on /install, /submit, /account,
/review, /categories and a component page.

THE CHECK LIED TWICE BEFORE IT WORKED, which is the part worth recording.

Version 1 hand-picked four selectors and reported PASS on two elements
whose border-width was 0px — there was nothing to measure. Version 2
swept everything and reported "0 of 575 failing" while the category chips
sat at 1.46:1: it parsed colours with /\d+/g, so a backdrop of
`oklab(0.144787 0.00000661612 0.00000289828 / 0.85)` became [0, 144787,
0], produced nonsense luminance, and scored as passing. I believed that
green and nearly shipped on it.

The version used here resolves every colour through a canvas, which
normalises any CSS colour function, composites translucent backdrops over
what shows through them, and ABORTS rather than scoring a value it cannot
parse. Mutation-proven: reverting the save button's single line takes it
from 0 failures to 1084, restoring it returns 0. The 1084 is one
component rendered once per card, which is also why the raw failure count
looked alarming and was in fact two components.

Three detection gaps are worth naming so the next sweep does not repeat
them: a 12-line look-back for the owning element missed
`catalog-controls.tsx` entirely, a lowercase `<a` pattern missed every
`<Link>`, and a `\b` after a chip label never matched because the text is
`Heroes75` with no space.

Looked at in both themes at 1440x900: controls read as controls, the page
does not read as louder.

Signed-off-by: Nik <84yk8btb9f@privaterelay.appleid.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nikolas-sapa
nikolas-sapa force-pushed the fix/a11y-control-borders branch from 3349bee to 4bf6652 Compare October 13, 2026 12:42
@nikolas-sapa
nikolas-sapa merged commit 1e4b8b1 into main Oct 13, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
Preview — 4bf6652d Deployed Oct 13, 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