WCAG 2.2 AA conformance record
what is claimed
Section titled “what is claimed”netdash toolkit targets WCAG 2.2 Level AA. of the 55 Level A and Level AA success criteria in WCAG 2.2, being 31 at Level A and 24 at Level AA once the obsolete 4.1.1 parsing is removed, this evaluation records 46 as supported, 0 as partially supported, and 9 as not applicable, with 0 recorded as wholly unsupported. that is every criterion an automated check can reach. it is not a conformance claim: see the limitation at the end, which is a human one and has not been discharged.
the previous revision made it 41 supported and 6 partially supported, and reduced those six partials to four distinct defects: three live clocks with no pause control, tool validation messages not tied to the field that produced them, six arbitrary pixel type utilities in the shell, and one title tooltip. all four are closed. 2.2.2, 3.3.1, 3.3.3 and 1.4.4 move to supported; 1.4.13 returns to not applicable, this time with a floor-of-zero assertion behind the claim rather than an unchecked sentence.
the last partial, 1.4.11, is now closed. it was held open deliberately: three light-theme state highlights sat below 3:1 because --accent and --sidebar-accent are a colour decision across every menu and every sidebar row, and that was the owner’s call to make rather than one to take unilaterally. the owner made it. see gap 5.
the honest summary is that the automated evidence is now complete, and the claim is still a screen reader pass away from being true on the evidence that matters most.
this document follows the shape of an accessibility conformance report. it is written to be read by someone who intends to check it, so every verdict names the evidence behind it and every limitation is stated next to the claim it limits rather than collected into a disclaimer at the end.
what was evaluated: the web build at commit-time HEAD, comprising the dashboard, the 48 tool routes under /tools/[slug], /projects, /about, and the unauthenticated /auth/action route used for password reset and email verification links.
what was not evaluated: the Electron desktop build. it renders the same react tree from the same static export, so the component-level findings here carry over, but its window chrome, native menu bar, and focus behaviour on window activation are supplied by Electron rather than by this codebase and were not assessed. no claim is made for the desktop build.
baseline: the evaluation assumes a current browser with javascript enabled. the app is a static export with no server-rendered fallback, so it does not function without javascript, which is a product decision rather than a conformance one, since WCAG does not require it.
how it was evaluated
Section titled “how it was evaluated”three methods, in descending order of how much they prove.
automated, executed on every run. the suites below gate every change:
| suite | what it covers | scale |
|---|---|---|
tests/components/wcag.test.tsx |
axe-core, tags wcag2a wcag2aa wcag21a wcag21aa wcag22aa, on the tool components |
48 cases |
tests/components/wcag-shared-surfaces.test.tsx |
the same axe run plus behavioural assertions on the surfaces the tool suite never renders | 14 cases |
tests/components/wcag-label-in-name.test.tsx |
2.5.3, which axe ships only as an untagged experimental rule | 48 cases |
tests/components/wcag-live-regions.test.tsx |
4.1.3 and 3.3.1, including two cases that drive a real error rather than reading the resting tree | 124 cases |
tests/components/wcag-error-association.test.tsx |
3.3.1 and 3.3.3 driven: a real invalid value in a real tool, then the reference resolved to text | 25 cases |
tests/components/wcag-pause-stop-hide.test.tsx |
2.2.2, asserted by counting the interval rather than by finding a button | 3 cases |
tests/components/wcag-primitives.test.tsx |
keyboard operation, focus return, live regions and target size in the shared primitives | 11 cases |
tests/unit/contrast.test.ts |
1.4.3 and 1.4.11 from the design tokens | 45 cases |
tests/unit/wcag-state-contrast.test.ts |
1.4.11 for state indicators and 1.4.3 for --destructive as a foreground, both measured |
13 cases |
tests/unit/wcag-target-size.test.ts |
2.5.8, resolved through the app’s own tailwind-merge |
7 cases |
tests/unit/wcag-focus-indicator.test.ts |
2.4.7, per class expression rather than per file | 3 cases |
tests/unit/wcag-keyboard-operability.test.ts |
2.1.1, 2.4.1 and 2.4.3 authoring failures | 6 cases |
tests/unit/wcag-reflow-and-spacing.test.ts |
1.4.4, 1.4.10 and 1.4.12 authoring failures | 8 cases |
tests/unit/wcag-authentication.test.ts |
1.3.5 and 3.3.8 | 9 cases |
tests/unit/wcag-page-titled.test.ts |
2.4.2 on the one route that cannot export metadata | 4 cases |
tests/unit/wcag-non-text-content.test.ts |
1.1.1 for images the axe run never reaches | 3 cases |
tests/unit/wcag-error-identification.test.ts |
3.3.1 and 3.3.3 statically: every reported input error names a field, or says why it does not | 6 cases |
tests/unit/wcag-pause-stop-hide.test.ts |
2.2.2 for any interval a future tool adds | 3 cases |
tests/unit/wcag-not-applicable.test.ts |
proves the not-applicable claims are still true | 8 cases |
static analysis of source and stylesheet. several criteria have no runtime signature in a test environment without layout. those were assessed by resolving tailwind classes through tailwind-merge, the same library cn() uses at runtime, so a caller’s h-4 overriding a base h-8 resolves in the analysis exactly as it does in the browser, and by reading app/globals.css directly.
code reading. the shared primitives in components/ui/, the application shell, and the authentication flows were read in full.
the suites were tested against deliberately broken inputs
Section titled “the suites were tested against deliberately broken inputs”a passing suite is evidence only if it can fail. every accessibility suite above was run against a mutation designed to defeat it: a partial-alpha focus ring, a 16 pixel pointer target, a role="button" with no key handler, a bare / shortcut, a <video> element, a password field with no autocomplete token, a table with no scroll ancestor, an image with no alt attribute, an error message with its aria-describedby removed. thirty-one such mutations were applied one at a time and reverted. the six that did not turn a suite red are recorded below as holes, and five of the six have since been closed, each re-tested against the same mutation.
that exercise is the reason for several of the changes in this revision. the holes were not in the rules; they were in what the rules were pointed at and in how the patterns were written.
what this evaluation could not do, and what that costs
Section titled “what this evaluation could not do, and what that costs”no browser was used. the test environment is happy-dom, which has no layout engine, no paint, and no viewport. this evaluation therefore could not observe a single rendered pixel. the practical consequences, criterion by criterion:
- 1.4.3 and 1.4.11 contrast are computed from the hex values of the design tokens in
app/globals.css, not sampled from painted pixels. this is sound where a foreground token sits on the surface it was designed for, and it does not cover text composited over a translucent layer, over an image, or over a gradient. the codebase uses none of those, which is why the token-level result is meaningful, but it is a narrower claim than a pixel sample would be. - 1.4.10 reflow was assessed by looking for the authoring decisions that cause it to fail: fixed widths above 320 css pixels, containers that clip instead of scroll, tables with no horizontal scroll ancestor. no page was actually rendered at 320 css pixels. a layout that reflows badly for a reason not on that list would not have been caught.
- 1.4.4 resize text and 1.4.12 text spacing were assessed the same way, by confirming nothing in the stylesheet would win against a user override and that no px font sizes are declared in css or in an inline style. neither was observed at 200% zoom or with the 1.4.12 override stylesheet applied.
- 2.4.11 focus not obscured was resolved by reading the layout tree rather than by tabbing through a rendered page, which is stated in full in the row for that criterion because the conclusion depends entirely on that reading being correct.
- 1.1.1 on the avatar is asserted from the source rather than from a render, because radix mounts the underlying
<img>only once the image itself loads and that never happens in happy-dom. a rendered assertion on it passes whether or not thealtattribute exists, which is why the check lives intests/unit/wcag-non-text-content.test.ts.
no screen reader was used. nothing here reports how NVDA, JAWS, or VoiceOver actually announce this interface. the aria in the tree was checked for correctness, and correct aria is a precondition for a good announcement rather than a guarantee of one. the criteria whose real-world outcome cannot be known without a screen reader are 1.3.1, 1.3.2, 2.4.6, 3.3.1, 4.1.2, and 4.1.3, and each is marked accordingly. a test plan for that pass is at the end of this document.
no assistive technology other than a screen reader was considered. speech input, switch access, and screen magnification were not exercised. 2.5.3 is the criterion most relevant to speech input and it is tested statically across all 48 tools, but a static match is not the same as a successful voice command.
criterion by criterion
Section titled “criterion by criterion”verdicts use the standard vocabulary: supports means no known failure, partially supports means some functionality does not conform, not applicable means the criterion has nothing to bind to in this product.
level a
Section titled “level a”| criterion | level | verdict | evidence |
|---|---|---|---|
| 1.1.1 non-text content | a | supports | axe rules image-alt, role-img-alt, svg-img-alt, object-alt clean on 48 tools. decorative icons carry aria-hidden, and icon-only controls carry either aria-label or sr-only text. two AvatarImage call sites in the account and share dialogs rendered an <img> with no alt attribute at all and sat outside the axe run; the primitive now defaults it, asserted in tests/unit/wcag-non-text-content.test.ts. |
| 1.2.1 audio-only and video-only | a | not applicable | no time-based media. asserted, not assumed, by tests/unit/wcag-not-applicable.test.ts, which fails if <audio>, <video>, <track>, AudioContext, or MediaRecorder ever appears. |
| 1.2.2 captions (prerecorded) | a | not applicable | as above. |
| 1.2.3 audio description or media alternative | a | not applicable | as above. |
| 1.3.1 info and relationships | a | supports | axe label, form-field-multiple-labels, list, listitem, th-has-data-cells, td-headers-attr, aria-required-parent, aria-required-children clean on 48 tools. three <Label> elements with no htmlFor, and one labelling a scroll region rather than a control, were fixed this cycle. CardTitle renders a <div>, so a card section’s title is not a heading; result sections carry role="region" with that title as their name instead, which is a different mechanism rather than a missing one. how any of it is voiced was not verified, since no screen reader was used. |
| 1.3.2 meaningful sequence | a | supports | reading order equals dom order: no positive tabIndex anywhere, and the only order-reversing utilities are the two dialog footers, asserted by tests/unit/wcag-keyboard-operability.test.ts. not observed rendered. |
| 1.3.3 sensory characteristics | a | supports | no instruction references shape, position, or colour alone. the tool copy identifies controls by their labels. |
| 1.4.1 use of color | a | supports | closed this cycle. components/tools/network-tester/mtu-panel.tsx conveyed a pass or fail verdict by the colour of a figure alone and now states it in a sentence with an icon. the sync indicator on the avatar in components/ui/user-menu.tsx distinguished syncing from synced by glyph and colour with no text equivalent, and now carries sr-only text with the glyphs hidden. |
| 1.4.2 audio control | a | not applicable | nothing plays audio. asserted by tests/unit/wcag-not-applicable.test.ts. |
| 2.1.1 keyboard | a | supports | closed this cycle. the two role="button" elements in components/project-manager.tsx now handle Space as well as Enter. the rule that guards this used to report only the Enter-without-Space case, so an element with no key handler at all passed it; it reports every branch now, re-tested against exactly that input. |
| 2.1.2 no keyboard trap | a | supports | every overlay is a radix modal with a managed focus trap. Escape closes and focus returns to the trigger, asserted behaviourally in tests/components/wcag-primitives.test.tsx. |
| 2.1.4 character key shortcuts | a | supports | closed this cycle. the bare / in components/command-palette.tsx was removed, leaving only the modified Cmd/Ctrl+K, which is out of the criterion’s scope. the scan that guards this matched the receiver by name, e.key, so renaming a handler’s parameter to event hid the same shortcut from it; it is receiver-agnostic now and also matches a destructured key inside a keyboard context. |
| 2.2.1 timing adjustable | a | supports | the content sets no time limit. authentication sessions are managed by firebase and persist rather than expiring on a timer. |
| 2.2.2 pause, stop, hide | a | supports | closed this cycle. the three live clocks, timestamp-converter.tsx and jwt-decoder.tsx at one second and cron-parser.tsx at thirty, each carry a pause and resume toggle with aria-pressed and visible label text that changes with the state. the essential-exception argument is no longer needed and is not used. asserted by counting the interval rather than by finding a button: tests/components/wcag-pause-stop-hide.test.tsx fakes setInterval and requires the count to fall to zero on pause and rise again on resume, so deleting the if (paused) return guard leaves the toggle flipping its own state and nothing else, and turns the suite red. |
| 2.3.1 three flashes or below threshold | a | supports | nothing blinks or flashes. asserted by tests/unit/wcag-not-applicable.test.ts. |
| 2.4.1 bypass blocks | a | supports | a skip link in components/app-shell.tsx targets <main id="main-content" tabIndex={-1}> and becomes visible on focus. all three halves of that, the link, the focusable target, and the visibility, are asserted together in tests/unit/wcag-keyboard-operability.test.ts, because any one of them silently breaks the other two. |
| 2.4.2 page titled | a | supports | closed this cycle. all 48 tool routes plus /, /projects and /about carry unique descriptive titles via generateMetadata. /auth/action is a client component and cannot export metadata, so a sibling layout.tsx supplies one and the page narrows it per ?mode=. both halves, and the distinctness of the four mode titles, are asserted in tests/unit/wcag-page-titled.test.ts. |
| 2.4.3 focus order | a | supports | no positive tabIndex; the only css reordering is flex-col-reverse in the two dialog footers, where both buttons sit inside a focus-trapped modal. asserted statically, not observed rendered. |
| 2.4.4 link purpose (in context) | a | supports | axe link-name clean on 48 tools. links leaving the app name their destination and their behaviour. |
| 2.5.1 pointer gestures | a | not applicable | no multipoint or path-based gesture. asserted by tests/unit/wcag-not-applicable.test.ts. |
| 2.5.2 pointer cancellation | a | supports | no control activates on onMouseDown or onPointerDown; activation is on click or on radix’s own pointer-up handling throughout. |
| 2.5.3 label in name | a | supports | closed this cycle. the components/tools/dns-tools.tsx button that read “Clear cache” while named “Clear DNS cache” was fixed. the suite that proves this ran synchronously, so on the 19 tools whose panels are lazy it inspected the tab strip: measured, it examined 175 of the 274 named controls in the app and none of reference-hub’s 55. it waits for the tree now, and reports no mismatch across all 274. |
| 2.5.4 motion actuation | a | not applicable | no device-motion or orientation listener. asserted by tests/unit/wcag-not-applicable.test.ts. |
| 3.1.1 language of page | a | supports | app/layout.tsx sets <html lang="en">. |
| 3.2.1 on focus | a | supports | there are no onFocus handlers in the codebase, so focus cannot trigger a change of context. |
| 3.2.2 on input | a | supports | tools recompute results as input changes, which updates content in place. no input submits a form, navigates, or moves focus. |
| 3.2.6 consistent help | a | supports | the footer, which carries the repository and contact links, is rendered once inside components/app-shell.tsx and therefore appears in the same relative order on every route in the set. the criterion does not require that help be offered, only that it be consistently placed where it is. |
| 3.3.1 error identification | a | supports | closed this cycle. of the 69 conditionally rendered error surfaces across components/tools/ and components/ui/, 60 name the field that produced them with aria-invalid and an aria-describedby that resolves. the other 9 are not input errors, and each carries a written reason in tests/unit/wcag-error-identification.test.ts where an exemption that stops matching a real surface fails the suite. IPInput and PasteParser gained a passthrough so a caller validating further up can add its own message id without displacing the primitive’s own. how any of it is voiced was not verified, since no screen reader was used. |
| 3.3.2 labels or instructions | a | supports | axe label and form-field-multiple-labels clean on 48 tools and on the six shared surfaces; every input resolves to a label, an aria-label, or an aria-labelledby. |
| 3.3.7 redundant entry | a | supports | no tool is a multi-step or paginated flow, so no information is requested twice in a process. the project save and load flow reads previously entered values back rather than asking for them again. |
| 4.1.2 name, role, value | a | supports | axe button-name, link-name, aria-input-field-name, aria-toggle-field-name, aria-valid-attr-value, aria-required-attr clean on 48 tools and on the six shared surfaces. three fixes this cycle: the account settings tabs were labelled only by hidden sm:inline text, which is display:none below 640px and therefore absent from the accessibility tree; ScrollArea accepted a name onto its root while the viewport is the element that takes focus; and two aria-labels sat on roleless elements, where aria prohibits them and the name is discarded. axe’s aria-prohibited-attr rule is untagged, so the tagged run does not see that last class at all. how any of it is voiced was not verified. |
level aa
Section titled “level aa”| criterion | level | verdict | evidence |
|---|---|---|---|
| 1.2.4 captions (live) | aa | not applicable | no time-based media. |
| 1.2.5 audio description | aa | not applicable | no time-based media. |
| 1.3.4 orientation | aa | supports | no orientation lock and no rotate-your-device interstitial; layout is driven by width breakpoints only. |
| 1.3.5 identify input purpose | aa | supports | closed this cycle. all 15 credential fields across components/ui/user-menu.tsx, components/ui/account-settings-dialog.tsx and app/auth/action/page.tsx carry autocomplete tokens, and every token used is checked against the html autofill field-name list. tool inputs are out of scope for this criterion because they collect a subject of analysis, a hostname or a subnet, not information about the user. |
| 1.4.3 contrast (minimum) | aa | supports | assertions check every foreground token against the surface it is designed for, in both themes, at the 4.5:1 floor. that set originally omitted --destructive as a foreground, even though text-destructive draws every validation message and error alert; adding it showed four of six combinations below the floor. --destructive is now per theme and all six pass: light 6.47:1 on background and popover, 5.91:1 on card; dark 6.45:1 on background, 5.29:1 on card and popover. |
| 1.4.4 resize text | aa | supports | closed this cycle, and verified rather than taken on report: measured zero px-pinned type across all 201 .tsx and .ts files under components/ and app/, and no px font-size in the stylesheet. the six text-[10px] and text-[11px] utilities in the shell are gone. the check behind this was a ceiling of 20 while those six sat under it, so it reported green for the whole time they existed; it is a floor of zero now, across three spellings rather than one, and it names the three shell files it must be reading. the rem values in command-palette.tsx scale with the user’s default size and are correctly not reported. not observed at 200% zoom. |
| 1.4.5 images of text | aa | supports | all text is live text. the only images are svg icons and the app icon, none of which carry text as their content. |
| 1.4.10 reflow | aa | supports | every <table> sits inside a horizontally scrollable container, no unconditional min-width exceeds 320 css pixels, and .tool-container scrolls wide children rather than clipping them. all three asserted in tests/unit/wcag-reflow-and-spacing.test.ts. no page was rendered at 320 css pixels. |
| 1.4.11 non-text contrast | aa | supports | boundary and focus tokens clear 3:1 in both themes, and no focus indicator is drawn at partial alpha in any colour. the three light-theme state highlights that sat below the floor now measure 5.48:1, 5.01:1 and 6.53:1: --accent and --sidebar-accent are emerald-700 in the light theme, and the destructive menu item’s focus fill is solid rather than a 10% wash. see gap 5. |
| 1.4.12 text spacing | aa | supports | the stylesheet uses no !important outside the reduced-motion reset, so nothing competes with a user override of line height, letter spacing or word spacing. asserted in tests/unit/wcag-reflow-and-spacing.test.ts. not observed with the override stylesheet applied. |
| 1.4.13 content on hover or focus | aa | not applicable | nothing shows additional content on hover or focus. there is no tooltip component in the dependency tree, and the one title attribute, on the colour converter’s preset swatches, is gone; the hex still reaches assistive technology through the button’s aria-label, which is an accessible name rather than a tooltip. the previous revision recorded this as not applicable on a claim nothing was checking, and it had already stopped being true. it is a floor of zero in tests/unit/wcag-not-applicable.test.ts now, so putting a title back on any element turns that suite red. |
| 2.4.5 multiple ways | aa | supports | three independent routes to any tool: the sidebar navigation, the dashboard grid, and the command palette with search. |
| 2.4.6 headings and labels | aa | supports | axe heading-order and empty-heading clean on 48 tools, and tests/components/render-all-tools.test.tsx asserts every tool exposes a heading. sections inside a tool are named by role="region" rather than by a nested heading, since CardTitle renders a <div>. whether each name is genuinely descriptive is an editorial judgement no test makes. |
| 2.4.7 focus visible | aa | supports | the destructive button and badge variants drew their focus ring at 20% alpha, which composited to roughly 1.2:1; the ring is opaque and measures 4.83:1 light and 3.70:1 dark. TabsContent cleared its outline and drew nothing back, and radix gives the active panel tabIndex={0}, so it is a real tab stop; the rule that should have caught that was asserted per file, and TabsTrigger in the same file supplied an indicator that made the file look compliant. it is asserted per class expression now. one exemption is recorded rather than waived: DialogContent clears its outline and is focused programmatically with tabIndex={-1}, so no keyboard focus moves onto it. |
| 2.4.11 focus not obscured (minimum) | aa | supports | this conclusion rests on reading the layout tree rather than on tabbing through a rendered page, so it is stated with its reasoning. components/header.tsx is sticky top-0 z-50, but it is a flex sibling of <main> inside a parent that is overflow-hidden and never scrolls, and <main> is itself the scroll container. scrolled content therefore moves within a box that begins below the header and is never overlaid by it. the mobile drawer is a focus-trapped modal, so focus cannot be on an element behind it. no scroll-padding-top is required, and adding one would be a no-op here. |
| 2.5.7 dragging movements | aa | supports | the radix slider is the only draggable control, and it is operable by clicking the track and by arrow keys. there is no hand-rolled drag-and-drop, asserted by tests/unit/wcag-not-applicable.test.ts. |
| 2.5.8 target size (minimum) | aa | supports | closed this cycle. Checkbox and the slider thumb grow their pointer target to 24 css pixels with a positioned pseudo-element so the painted design is unchanged, the dialog close button is padded from 16 to 24, and a clickable Badge carries a 24 pixel floor. the four remaining tool-side findings, two copy buttons in conflict-checker, one in reference-hub and the auth page’s cancel button, are all fixed, so tests/unit/wcag-target-size.test.ts is now a floor of zero across the whole tree rather than a ceiling. the resolver only reports a control whose classes pin a dimension under 24px; one sized by its content is left alone rather than guessed at, so the suite under-reports rather than crying wolf. |
| 3.1.2 language of parts | aa | supports | all content is english. protocol names and command syntax are not natural-language passages and do not require a lang change. |
| 3.2.3 consistent navigation | aa | supports | the header, sidebar, and footer are rendered once in components/app-shell.tsx and are structurally identical on every route in the set. |
| 3.2.4 consistent identification | aa | supports | repeated functions come from shared primitives, so a copy button, a save action, and a result card are the same component with the same accessible name wherever they appear. |
| 3.3.3 error suggestion | aa | supports | closed with 3.3.1, which it rode on: a suggestion the user never hears is not a suggestion, and the association is now in place. where a message exists it describes the expected format rather than only reporting failure, for example the prefix-length message in components/ui/ip-input.tsx, the format list in mac-formatter.tsx and the parse failure in components/ui/paste-parser.tsx. tests/components/wcag-error-association.test.tsx reads the resolved description back and matches it against what the message has to say, so an association that resolves to an empty or unrelated element fails. |
| 3.3.4 error prevention (legal, financial, data) | aa | supports | project and account deletion are confirmed through an AlertDialog. revoking a collaborator’s access in components/ui/share-project-dialog.tsx is not confirmed, which the criterion permits because the action is reversible by re-sharing at the same permission. that is a thinner argument than a confirmation step would be, and adding one is recommended below for consistency rather than for conformance. |
| 3.3.8 accessible authentication (minimum) | aa | supports | closed this cycle. no cognitive function test exists anywhere in the auth flow: there is no captcha, no puzzle, and no code to transcribe, since password reset is link-based and handleCodeInApp is not enabled. paste is never blocked, asserted across the whole codebase. every password field on all three auth surfaces carries an autocomplete token, matched on the whole attribute list rather than on type="password" so that a future visibility toggle, which writes the type as an expression, cannot drop a field out of the check. |
| 4.1.3 status messages | aa | supports | closed this cycle. every tool the registry marks as doing network i/o is asserted to render a live region, and none uses aria-live="assertive". IPInput uses role="status" rather than role="alert" because it validates on every keystroke. the Alert primitive carried role="alert" for its destructive variant and no role at all for the default one, so every “saved”, “email sent” and “shared” confirmation mounted silent while its error twin spoke; the default variant is role="status" now. a static alert is excluded from satisfying a tool’s live-region requirement, so that change cannot weaken the gate it sits behind. how any of it is announced was not verified. |
known gaps and remediation status
Section titled “known gaps and remediation status”no gap is open. gaps 2, 3, 4 and 6 were closed in the previous cycle, gap 1 shortly before it, and gap 5 in this one; all six are kept, with what they were and what holds them shut, because a gap that disappears from the record is indistinguishable from one that was never found. gap 7 is neither open nor closed: it is a limitation of a check, recorded so the check is not read as saying more than it does. ordered by how much they affect a real user, not by how easy they were to fix.
gap 5. closed. three light-theme state highlights were below 3:1. all in app/globals.css, all affecting 1.4.11 for the boundary between a focused or active row and its neighbours. --accent and --sidebar-accent were emerald-500 in both themes, which reads well on a dark surface and washes out on a white one:
| indicator | tokens | light before | light after | dark |
|---|---|---|---|---|
| menu and listbox focused row | --accent on --popover |
2.54:1 | 5.48:1 | 5.77:1 |
| sidebar active item | --sidebar-accent on --sidebar |
2.32:1 | 5.01:1 | 5.77:1 |
| destructive menu item focus fill | --destructive on --popover |
1.17:1 | 6.53:1 | 5.94:1 |
the first two are emerald-700 in the light theme now, matching --primary, with --accent-foreground and --sidebar-accent-foreground moving to white so the row’s own text still clears 4.5:1 on the new fill. the dark theme keeps emerald-500, which already measured 5.77:1: one hex cannot serve both surfaces, which is the same conclusion gap 1 reached about --destructive.
the third was a 10% wash of --destructive behind the focused destructive menu item, which is why it measured 1.17:1. a 10% wash cannot reach 3:1 against a white popover at any hue, so it is a solid fill now, the same shape every other focused row uses, with --destructive-foreground on top.
status: closed. tests/unit/wcag-state-contrast.test.ts asserted these were under 3:1 while the gap was open, so that the numbers were checked rather than remembered, and it failed the moment the tokens moved. those two assertions are >= 3 now. proven by putting emerald-500 back: the light menu highlight reports 2.54:1 and the gate goes red.
gap 1. closed. error text was drawn in a red that was too light on four of six surfaces. --destructive was #dc2626 in both themes, and text-destructive draws every validation message and every error alert in the app. the surface under each is known statically rather than guessed, because the primitive that renders it declares its own background: components/ui/alert.tsx sets bg-card on both variants, components/ui/dropdown-menu.tsx sets bg-popover, and the three password-mismatch messages sit on the bg-background of DialogContent. all of it is text-sm or text-xs, so the floor is 4.5:1 rather than 3:1. no single red clears that floor on both light and dark surfaces, so --destructive is per theme now, #b91c1c light and #f87171 dark, with --destructive-foreground following it. measured:
| theme | surface | before | after |
|---|---|---|---|
| light | --background |
4.83:1 | 6.47:1 |
| light | --popover |
4.83:1 | 6.47:1 |
| light | --card |
4.41:1 | 5.91:1 |
| dark | --background |
3.70:1 | 6.45:1 |
| dark | --card |
3.03:1 | 5.29:1 |
| dark | --popover |
3.03:1 | 5.29:1 |
the alert description was additionally drawn at 90% alpha, which took the light-theme card case from 4.41:1 to 4.00:1 and the dark-theme card case from 3.03:1 to 2.64:1. that alpha is removed, because a translucent foreground makes contrast depend on whatever is behind it, which is the same reasoning already applied to focus rings. status: closed. all six are pinned in tests/unit/wcag-state-contrast.test.ts, which fails if any of them falls back under 4.5:1.
gap 2. closed. three live clocks could not be paused. timestamp-converter.tsx, jwt-decoder.tsx and cron-parser.tsx each now render a pause and resume toggle carrying aria-pressed and visible label text, and each interval returns early while paused. affects 2.2.2. the essential-exception argument for a “current timestamp” display was never established and is not used. status: closed, held by tests/components/wcag-pause-stop-hide.test.tsx, which counts the interval rather than trusting the button, and by tests/unit/wcag-pause-stop-hide.test.ts, which catches a fourth interval added later with no control.
gap 3. closed. tool-level validation messages were not tied to the field that produced them. affects 3.3.1, with 3.3.3 riding on it. the previous revision counted 20 of 55 tool files tying the message to the offending field. counted per surface rather than per file, 69 error surfaces across components/tools/ and components/ui/ are rendered conditionally; 60 of them now name the field with aria-invalid and a resolving aria-describedby, and the remaining 9 are not input errors and are excused individually with a reason. status: closed. the reason each exemption gives is checked: an exemption that no longer matches a real surface fails tests/unit/wcag-error-identification.test.ts, which is the only escape hatch in the gate and the place to look first in review.
three residual items are recorded rather than solved, because faking an association would be worse than naming the limit:
vlan-manager’s subnet overlap is a relation between two committed subnets on two different rows, and neither row is more at fault. the only subnet control is a draft-add field that is cleared once the value is committed, so at the moment the error exists no input holds the offending value. closing it properly needs a per-row affordance.http-headers,redirect-checkerandsecurity-headerscarry the URL field inindex.tsxwhile the failure is raised inside a lazily imported relay panel. thearia-describedbyhalf is correct and cross-file;aria-invalidwould need the error state hoisted across the Suspense boundary, which was judged a worse trade than the missing attribute.acl-generatorrule validation is rule-level rather than field-level inlib/acl.ts, so a rule with an error marks every error-capable field in that row rather than the one at fault. attributing per field would mean matching message text, which is exactly the kind of check this record has had to repair three times.
gap 4. closed. six arbitrary px type utilities did not respond to text-only resize. text-[10px] and text-[11px] in components/dashboard.tsx (3), components/sidebar.tsx (2) and components/header.tsx (1). affects 1.4.4 on the text-resize pathway only. status: closed, and verified rather than taken on report: measured zero across all 201 .tsx and .ts files under components/ and app/. the check that was meant to hold this was a ceiling of 20 offenders, which the six never came close to, so it reported green for the entire time they existed. it is a floor of zero now, over three spellings, and it separately asserts that the three shell files are in the set it reads.
gap 6. closed. one title attribute rendered a tooltip that could not be dismissed, hovered, or focused. components/tools/color-converter.tsx set title={c} on each preset colour swatch. affects 1.4.13. the button already carried an aria-label naming the same hex, so removing the title cost assistive technology nothing and removed the only thing in the app that showed content on hover. status: closed. the ceiling of one in tests/unit/wcag-not-applicable.test.ts is a floor of zero now, and 1.4.13 returns to not applicable with a test behind the claim.
gap 7. role="alert" is assertive by definition, and this record’s “no assertive region” claim is narrower than it reads. the suite forbids aria-live="assertive" and there is none anywhere in the codebase. the destructive Alert primitive uses role="alert", which is the same thing spelled differently, and it is used deliberately: it fires from a submit-time guard or an async catch, never on a keystroke. this is recorded as a limitation of the check rather than a defect, because the criterion does not forbid assertive announcement of an error. status: recorded, no change intended.
the gates that did not hold
Section titled “the gates that did not hold”twelve mutations or measurements defeated a suite that existed to stop them. the first seven are from the previous revision; five more were found this cycle, and every one of them was found the same way, by writing the check and then feeding it the thing it was written to catch before believing its green.
-
the 2.5.3 suite never waited for the tree. it rendered and inspected synchronously, so on the 19 tools whose panels are lazy it saw the tab strip. measured: 175 of the app’s 274 named controls were examined, and none of reference-hub’s 55. closed by settling before the scan. after the fix the suite still reports no mismatch, so this closed a coverage hole rather than uncovering a defect.
-
a
role="button"with no key handler at all passed the 2.1.1 scan, because the rule reported onlyhandlesEnter && !handlesSpace. the worse case, an element operable by pointer alone, was the one it let through. closed by reporting every branch, including a handler passed by reference that names only one key. -
a bare single-character shortcut escaped the 2.1.4 scan if the event parameter was not named
e.event.key === "/"was invisible to it. closed by making the pattern receiver-agnostic, and by matching a destructuredkeywhen it appears inside a keyboard context, which excludes unrelated code such as the regex tester’s flag toggle. -
style={{ fontSize: "10px" }}escaped the 1.4.4 px-type scan, which matched only the tailwind arbitrary-value utility. closed by matching both spellings, and by a third,[font-size:10px], added this cycle. -
a focus indicator was asserted per file rather than per element.
components/ui/tabs.tsxcleared the outline on a real tab stop while a sibling component in the same file supplied an indicator that made the file look compliant. closed by reading eachcn()andcva()call as one unit, which still passesInput, whose indicator sits in a sibling argument of the same call. -
the axe run only ever mounted the 48 tool components. the account settings dialog, the project save and load dialogs, the paste parser, the user menu and the avatar were never rendered by any suite, so an unnamed tab and an
<img>with noaltsat outside every gate even though axe ships rules for both.tests/components/wcag-shared-surfaces.test.tsxnow runs the same tagged axe configuration over those surfaces. it is not full coverage of the app: the shell, the projects page and the auth route are still only read, not rendered. -
the not-applicable suite asserted six of the nine not-applicable claims, and 1.4.13 was not one of them. the criterion was recorded as not applicable because there was “no
titleattribute on any dom element”, and no test held that. one had appeared. the file that suite exists for is exactly the place where an unverified claim rots, and this is what that looks like. closed by adding the check, capped at the known instance, and now a floor of zero. -
the 1.4.4 px-type check was a ceiling of 20 offenders while six existed. a ceiling that the real number never approaches is not a gate; it reports green from the day it is written until the day the codebase doubles. the same shape had already been fixed once for 2.5.8, which the previous revision turned “from a ceiling into a floor of zero”, and it was left in place here. closed by making it a floor of zero, and by asserting that the three shell files it exists for are actually in the file list it reads, because a floor of zero passes just as happily over an empty list.
-
the first draft of the 3.3.1 association scan read the comparison side of a ternary as an id.
aria-describedby={invalidField === "time" ? "download-size-error" : undefined}holds two string literals and only one of them is a reference. it reported thirteen correctly wired fields as broken. a check that cries wolf gets its exemption list padded until it stops meaning anything, which is the slower version of the same failure. closed by dropping comparison operands before reading literals. -
the same scan could not see a reference it had to follow. four spellings defeated it in turn:
aria-describedby="x"with no braces, a hoistedconst describedBy = ... ? "x" : undefined, a composed[ownError, callerError].join(" "), and an id handed to a shared primitive as a prop rather than written as an attribute. each one made a correctly associated field look orphaned. closed by resolving through one level of declaration for the precise check and two for the coverage check, and by matching the prop spellings as well as the attribute. -
it could not see a surface in the else arm of a ternary. the scan recognised
&& (and? (and not: (, sovlsm-planner’s plan-failure alert was invisible to it and its exemption looked stale. that is the shape of every entry on this list: not a wrong rule, a rule pointed slightly to one side of the thing it was written for. closed by matching the else arm, which immediately surfaced one further alert indns-tools.tsxthat nothing had been looking at. -
the shared post-render gate was not used by the suites that most needed it.
settled()exists because a suite that scans a lazy tool before its chunk resolves scans a tab strip. the axe run, the render smoke suite and four of the live-region scans still called baresettle(), which waits but asserts nothing about what it waited for. closed by moving all of them ontosettled(), which fails on a node count under the floor and on a Suspense fallback still being on screen.
two further limits are worth stating rather than fixing:
- the 3.3.1 scan of the resting tree asserts almost nothing. across all 48 tools, zero render a field with
aria-invalid="true"on mount and six render anyaria-describedbyat all, because nobody has typed anything yet. it is a regression guard, not evidence. the evidence for 3.3.1 is the cases that drive a real error, of which there are 17: the four that existed, plus 13 intests/components/wcag-error-association.test.tsx, which seed an invalid value into a real tool and then follow the reference to an element and read it. - the runtime dangling-reference check deliberately skips every id ending in
-error, because an error id legitimately exists only while the error is on screen. that is correct at runtime and it means a typo in an error id is invisible there. the static suite resolves those references against the source instead, where the id exists whether or not it is rendered; a typo fails it. - the exemption table in the 3.3.1 gate is the one unguarded escape hatch. an exemption that stops matching a surface fails, and one whose reason is wrong does not. it is nine entries long, each with a written reason, and it is the first thing to read when reviewing a change to that suite.
- a password field written as
<input type={"password"} />is not matched by the credential scan. the scan now matchespasswordanywhere in the attribute list, which covers the realistic case of a visibility toggle, but a field named and typed entirely through variables would still escape it.
what to conclude from this document, and what not to
Section titled “what to conclude from this document, and what not to”you may conclude that the mechanically detectable failures are absent across all 48 tools and the six shared surfaces, and stay absent, because axe runs on every one of them on every change rather than as a one-off audit; that the criteria axe cannot see have been reasoned about individually rather than waved through; that where a gap exists it is named with a file, a measurement and a fix; and that each suite has been shown to fail on an input built to defeat it, rather than only to pass on the current code.
you may conclude that no criterion in this evaluation is recorded as unsupported or partially supported, and that every gap a machine can find has been closed. 1.4.11, the last partial, closed this cycle when the owner made the colour call it was waiting on. what that entitles you to conclude is bounded entirely by the next paragraph.
you may not conclude that the supported verdicts have been confirmed against real assistive technology. they have not. no screen reader was run, no browser was opened, and no page was rendered at any viewport. the contrast figures are computed from design tokens rather than sampled from pixels, the reflow and text-spacing verdicts are inferences from source rather than observations of layout, and the 2.4.11 verdict is a reading of a layout tree. each of those is a defensible method and none of them is a substitute for looking.
the remaining work that genuinely requires a human is the screen reader pass below; a zoom and reflow pass at 320 css pixels and at 200% covering 1.4.4, 1.4.10 and 1.4.12; a keyboard pass through the mobile drawer and the command palette on a touch device; and the colour judgement in gap 5, which is a design decision rather than a technical one. two smaller items belong on that list and are easy to miss: whether the pause controls read sensibly in place, since where a toggle sits next to a live figure is a layout judgement no test makes, and whether a field that is marked invalid because a sibling field is at fault is more helpful or more confusing to hear, which is a question about announcements rather than about markup.
the screen reader pass this record cannot replace
Section titled “the screen reader pass this record cannot replace”1.3.1, 1.3.2, 2.4.6, 3.3.1, 4.1.2 and 4.1.3 are marked on the correctness of the aria in the tree. that is a precondition for a good announcement, not a guarantee of one. the pass below is what would turn those verdicts from “the markup is right” into “the announcement is right”, and it is written to be executed rather than summarised.
pairings. run all three; they disagree, and the disagreements are the findings.
| reader | browser | platform | why this pairing |
|---|---|---|---|
| NVDA | Firefox | Windows | the most common combination in survey data, and the strictest about live regions |
| JAWS | Chrome | Windows | the most common in enterprise, and the most different in how it handles forms mode |
| VoiceOver | Safari | macOS | the only way to test the desktop build’s host platform |
sample. eight surfaces, chosen because each is the only instance of its pattern:
network-calculator, a tool that recomputes on every keystroke with no submitport-scanner, an async tool with a validated free-text field, and the one whose rejection message is already asserted programmaticallyemail-diagnostics, an async tool whose error is raised on submit rather than on inputreference-hub, six lazy tab panels behind a tab strip, and the largest tree in the apppassword-generator, the only slider- the account settings dialog, three tabs and three forms inside a focus trap
- the share project dialog, the only list of people with per-row controls
/auth/action?mode=resetPassword, the only route outside the shell
per surface, in this order.
- landmark and heading pass. with the reader’s element list, page up the headings and then the landmarks. every tool must announce one
h1and amain. record any section that reads as plain text where it looks like a heading;CardTitlerenders a<div>and this is where that shows up if it matters. covers 1.3.1 and 2.4.6. - tab pass, forwards then backwards. tab from the address bar to the end of the document and back. record the order, anything focusable that announces no name, and anything that takes focus with no visible indicator. the tab panel in
reference-huband the scroll region in the share dialog are the two most likely to surprise. covers 2.4.3, 2.4.7 and 4.1.2. - forms mode pass. on each text field, enter forms or focus mode and read the field. it must announce its label, its type, and, where present, its description. then type an invalid value and confirm that the reader says what is wrong without the user having to hunt for it. the four fields to try are
network-calculator’s address,port-scanner’s custom port list,email-diagnostics’s domain, and the confirm-password field in the account dialog. add two that are new this cycle and are the ones most likely to disappoint:wireless-tools’s band and channel-width selects, where one message is attached to two controls, andmtu-calculator, where a list of messages is attached to four. the question to answer is whether hearing the same sentence on each of several fields helps or just repeats. covers 3.3.1, 3.3.2 and 3.3.3. - status pass. with focus left in the field, trigger the async action and wait without moving. record whether the result is announced, how long after it appears, and whether it repeats. then trigger an error the same way. this is the criterion the aria cannot settle: a region can be present, correctly polite, and still never announce because it was populated in the same tick it was inserted. covers 4.1.3.
- interruption pass. in
network-calculator, type a full address one character at a time at normal speed. the validation message fires on every keystroke. record whether the reader talks over the typing. the region isrole="status"specifically to prevent this, and this step is the only way to know whether that worked. - menu and dialog pass. open the user menu, arrow through it, and confirm each row announces its name and its position. open the account settings dialog, confirm focus enters it, that the title is announced, that Escape closes it and that focus returns to the trigger. covers 2.1.2, 2.4.3 and 4.1.2.
what to record. for each finding: reader, browser, surface, the exact utterance, and what was expected. an utterance that is merely verbose is not a failure and should be recorded separately from one that is wrong or absent. the verdicts in this document should be revised only where the failure is that something was not announced, was announced with the wrong role, or was announced with no name.
a defect that appears in one reader and not the others is still a defect worth recording, but it is not necessarily a conformance failure: the criterion binds the content, not the reader. note which of the three disagreed.
history
Section titled “history”| date | change |
|---|---|
| 2026-08-06 | third evaluation. the four remaining code-closable defects are closed and four verdicts move to supported: 2.2.2, on a pause and resume toggle for each of the three live clocks; 3.3.1 and 3.3.3, on 60 of 69 error surfaces naming the field that produced them with the other 9 excused individually in a checked table; and 1.4.4, on zero px-pinned type measured across the tree rather than reported. 1.4.13 returns to not applicable, with a floor of zero behind the claim this time. one verdict remains partial, 1.4.11, reserved for the owner as a colour decision. five more suites were shown to be defeatable and all five repaired: a 1.4.4 ceiling that never bound, a 3.3.1 scan that read a comparison operand as an id, the same scan blind to four spellings of a reference and to the else arm of a ternary, and the shared post-render gate missing from the axe run and the live-region scans. two new suites and two new static checks hold the four closures shut. |
| 2026-07-30 | second evaluation. nine partial verdicts closed: 1.4.1, 2.1.1, 2.1.4, 2.4.2, 2.5.3, 1.3.5, 2.5.8, 3.3.8 and 4.1.3. two verdicts downgraded: 1.4.3, on a contrast finding the previous check could not see, and 1.4.13, on a title attribute the not-applicable claim never checked for. fixed in components/ui/: a focus indicator on TabsContent, a default alt on AvatarImage, role="status" on the default Alert variant, the alpha removed from destructive description text, aria-label moved off two roleless elements, names moved onto the ScrollArea viewport, aria-label on the account settings tabs, field association on the three password-mismatch messages, a reported error in paste-parser, htmlFor on three labels, and a focus indicator for the clipped file input. six suites were shown to be defeatable and five were repaired; a new suite covers the shared surfaces the axe run never reached. |
| 2026-07-30 | first full evaluation against all 55 Level A and AA criteria. fixed in components/ui/: partial-alpha focus rings on the destructive button and badge variants (2.4.7), pointer targets on Checkbox, the slider thumb and root, the dialog close button and clickable badges (2.5.8), clickable Badge rendered as a real button (2.1.1, 4.1.2), focusable ScrollArea viewports (2.1.1), autocomplete on 13 credential fields (1.3.5, 3.3.8), and IPInput moved from assertive to polite (4.1.3). added nine automated suites covering the criteria axe does not reach. |