fix(a11y): readable link text, 24px clear target, h2 widget titles, skip link focus #175

Merged
KofTwentyTwo merged 2 commits from feature/home-a11y-contrast into main 2026-10-16 15:19:18 +00:00
KofTwentyTwo commented 2026-10-16 14:24:37 +00:00 (Migrated from github.com)

Summary

Wave 0 item W0-1 (NEXT-A11Y). The kof22 app homes fail axe serious checks (wcag2a/aa/21aa/22aa) on 18 pages. This PR fixes the Next UI side:

  • Link text contrast. Accent-colored text (the table widget header link row, cell links, widget links, error retry link) used text-primary, which fails 4.5:1 for light brand colors (kof22 accent #059669 is 3.8:1 on white). New --color-link token (text-link utility). applyBrandingTheme derives, per theme, the closest shade of the accent that reaches 4.5:1 on the card and muted surfaces (darkened in light, lightened in dark); without branding the stylesheet derives it from --color-primary.
  • Accent-filled surfaces. --color-primary-foreground is white when that reaches 4.5:1 on the accent, else black (active sidebar item, selected tabs such as Jobs, primary buttons). The brand fill is unchanged.
  • Target size. Widget dropdown icon buttons (Clear range, previous/next) are at least 24 x 24 px (min-h-6 min-w-6; was 22 px). WCAG 2.2 target size.
  • Skip link / main. <main> is focusable (tabIndex={-1}); the skip link moves focus to the first zone of the page (the first widget of an app home, else main) via skipToFirstZone(). The href="#main-content" stays as the no-JS fallback.
  • Heading hierarchy. Widget titles on app homes render as h2 under the page h1 (WidgetHeadingLevelContext; widgets nested in another widget stay h3). Same classes, no visual change.

Rubric item moved

A10/L10 0 -> 1 on the pages whose serious axe violations are the link row, link text, accent fills or the Clear range target: Website, Pages, Navigation, Analytics, Data Analysis, EDI, Audit, Slack, Jobs, SQL Workbench*, Users*, Sessions** (see caveats). The list was checked against AUDIT.md: the 18 pages with A10 = 0 in "Scores by rubric item" are Website, Pages, Navigation, Analytics, Data Analysis, EDI, Shipments, Audit, Sessions, Message Bus, Search Indexes, SQL Workbench, Alerts, Jobs, Email, Slack, Webhooks, Users (the list in the task is correct).

Not cleared by this PR (qbit HTML with inline colours or opacity, to be fixed in the qbits; I did not change them):

  • Alerts, Email, Webhooks: badge/status spans in widget HTML (<span style="color:#2e9e5b">Set up</span>, opacity:.65/.75 labels). Contrast comes from inline styles in the qbit widget HTML (alertsHomeOpenBySeverity, emailHome, webhooksHome).
  • Shipments (easypost): p > a links inside qbit HTML (also a target-size failure on those inline links).
  • Message Bus (esb) and Search Indexes (quickSearchAdmin): a bare .underline link; it is in qbit HTML, not a Next class.
  • SQL Workbench: link row is fixed; the empty-state HTML in noRowsFoundHTML (.py-4.text-center.text-muted-foreground > div) comes from qbit HTML.
  • Users: link row fixed; widget-footer-sessionsByUser footerHTML is qbit HTML.
  • Navigation: link row fixed; the item-cell badge (td[data-label="Item"] > span ...) is not yet traced (cell badge, likely qbit HTML).
  • Sessions (authentikAdmin): the error retry link (text-primary underline in the widget error state) is fixed; the remaining nodes need a re-measure.

So after the kof22 pin, A10 should be re-measured with the harness; pages marked above with qbit HTML will need the qbit follow-ups.

Audit row fixed

AUDIT.md A10: "Measured: axe serious color-contrast ... target-size ..." on the pages above. "Almost all are one recurring element, the small pointer-coarse:inline-flex link row above table widgets (color-contrast), plus badge spans (alerts, email, webhooks), links on Shipments, and the 24 px target-size failure on the analytics 'Clear range' control." Also the heading-order gap (widget titles h3 under h1) and the skip link that did not move focus.

Tests

  • Unit (vitest): skip-link.test.ts (first widget, main fallback, no main), AppHome.headings.test.tsx (titles are h2 under the h1, no h3), WidgetBlock.test.tsx (h2 with level 2, nested h3), WidgetDropdownMenu.test.tsx (clear button min-h-6 min-w-6), apply-branding.test.ts (derived link colors reach 4.5:1 on light and dark surfaces for #059669, #6366f1, #34d399, #facc15, #0062ff, white, black; on-accent foreground).
  • E2E (Playwright, mocked API): new tests/e2e/dashboard-a11y.spec.ts: axe (wcag2a/aa/21aa/22aa, color-contrast and target-size enabled) on a table-widget home with the kof22 accent, light and dark, at 390 and 1440 px; Clear range >= 24 x 24; h2 titles; skip link focuses the first widget; main has tabindex -1. The repo has jest-axe/@axe-core/playwright; the existing checkA11y helper still disables color-contrast, left unchanged.
  • Ran: pnpm lint (0 errors), pnpm typecheck, pnpm test (3 pre-existing/flaky failures only: two Windows path-separator tests in tests/standalone / tests/acceptance/support, which fail identically on main; WidgetRenderer.test.tsx "falls back to the payload type" failed once under full-suite load and passes alone), pnpm build, pnpm exec playwright test for the new spec plus dashboard-widths and navigation (29 passed against the production build), pnpm perf:budget (report-only; after numbers: total JS 1509.9 KB gzip, /app/search first-load 371.9 KB, CSS 25.3 KB; the change adds a few hundred bytes of source, a baseline run on main was not captured, budget is report-only).

Axe results (mock home with 2 table widgets, accent #059669; serious/critical node counts)

before (main) after
390 light / dark 2 rules (link row contrast x2, Clear range target-size x2) / dark 1 0 / 0
768 light / dark 2 / 1 0 / 0
1024 to 3200 light / dark 2 / 2 (adds active sidebar item contrast) 0 / 0

Details: axe.txt in the screenshots folder.

Screenshots

Attached locally (not committed): R:\...\scratchpad\dashboard-standard\pr-shots\next-a11y\{before,after}\{390,768,1024,1440,1920,3200}.png plus -dark variants and axe.txt per side. The page is the mocked-API app home (/app/a11yLab: two table widgets with a link row and a Range dropdown).

Release

A Next UI release is needed: the next RC after 1.0.0-RC.35 (1.0.0-RC.36). kof22 (Website-Backend) must bump its pinned qqq-frontend-next.version to that RC to pick up the change.

🤖 Generated with Claude Code

## Summary Wave 0 item W0-1 (NEXT-A11Y). The kof22 app homes fail axe `serious` checks (wcag2a/aa/21aa/22aa) on 18 pages. This PR fixes the Next UI side: - **Link text contrast.** Accent-colored text (the table widget header link row, cell links, widget links, error retry link) used `text-primary`, which fails 4.5:1 for light brand colors (kof22 accent `#059669` is 3.8:1 on white). New `--color-link` token (`text-link` utility). `applyBrandingTheme` derives, per theme, the closest shade of the accent that reaches 4.5:1 on the card and muted surfaces (darkened in light, lightened in dark); without branding the stylesheet derives it from `--color-primary`. - **Accent-filled surfaces.** `--color-primary-foreground` is white when that reaches 4.5:1 on the accent, else black (active sidebar item, selected tabs such as Jobs, primary buttons). The brand fill is unchanged. - **Target size.** Widget dropdown icon buttons (Clear range, previous/next) are at least 24 x 24 px (`min-h-6 min-w-6`; was 22 px). WCAG 2.2 target size. - **Skip link / main.** `<main>` is focusable (`tabIndex={-1}`); the skip link moves focus to the first zone of the page (the first widget of an app home, else `main`) via `skipToFirstZone()`. The `href="#main-content"` stays as the no-JS fallback. - **Heading hierarchy.** Widget titles on app homes render as `h2` under the page `h1` (`WidgetHeadingLevelContext`; widgets nested in another widget stay `h3`). Same classes, no visual change. ## Rubric item moved A10/L10 0 -> 1 on the pages whose serious axe violations are the link row, link text, accent fills or the Clear range target: **Website, Pages, Navigation, Analytics, Data Analysis, EDI, Audit, Slack, Jobs, SQL Workbench\*, Users\*, Sessions\*\*** (see caveats). The list was checked against AUDIT.md: the 18 pages with A10 = 0 in "Scores by rubric item" are Website, Pages, Navigation, Analytics, Data Analysis, EDI, Shipments, Audit, Sessions, Message Bus, Search Indexes, SQL Workbench, Alerts, Jobs, Email, Slack, Webhooks, Users (the list in the task is correct). Not cleared by this PR (qbit HTML with inline colours or opacity, to be fixed in the qbits; I did not change them): - **Alerts**, **Email**, **Webhooks**: badge/status spans in widget HTML (`<span style="color:#2e9e5b">Set up</span>`, `opacity:.65/.75` labels). Contrast comes from inline styles in the qbit widget HTML (alertsHomeOpenBySeverity, emailHome, webhooksHome). - **Shipments** (easypost): `p > a` links inside qbit HTML (also a target-size failure on those inline links). - **Message Bus** (esb) and **Search Indexes** (quickSearchAdmin): a bare `.underline` link; it is in qbit HTML, not a Next class. - **SQL Workbench**: link row is fixed; the empty-state HTML in `noRowsFoundHTML` (`.py-4.text-center.text-muted-foreground > div`) comes from qbit HTML. - **Users**: link row fixed; `widget-footer-sessionsByUser` footerHTML is qbit HTML. - **Navigation**: link row fixed; the item-cell badge (`td[data-label="Item"] > span ...`) is not yet traced (cell badge, likely qbit HTML). - **Sessions** (authentikAdmin): the error retry link (`text-primary underline` in the widget error state) is fixed; the remaining nodes need a re-measure. So after the kof22 pin, A10 should be re-measured with the harness; pages marked above with qbit HTML will need the qbit follow-ups. ## Audit row fixed AUDIT.md A10: "Measured: axe serious color-contrast ... target-size ..." on the pages above. "Almost all are one recurring element, the small `pointer-coarse:inline-flex` link row above table widgets (color-contrast), plus badge spans (alerts, email, webhooks), links on Shipments, and the 24 px target-size failure on the analytics 'Clear range' control." Also the heading-order gap (widget titles h3 under h1) and the skip link that did not move focus. ## Tests - Unit (vitest): `skip-link.test.ts` (first widget, main fallback, no main), `AppHome.headings.test.tsx` (titles are h2 under the h1, no h3), `WidgetBlock.test.tsx` (h2 with level 2, nested h3), `WidgetDropdownMenu.test.tsx` (clear button `min-h-6 min-w-6`), `apply-branding.test.ts` (derived link colors reach 4.5:1 on light and dark surfaces for #059669, #6366f1, #34d399, #facc15, #0062ff, white, black; on-accent foreground). - E2E (Playwright, mocked API): new `tests/e2e/dashboard-a11y.spec.ts`: axe (wcag2a/aa/21aa/22aa, color-contrast and target-size enabled) on a table-widget home with the kof22 accent, light and dark, at 390 and 1440 px; Clear range >= 24 x 24; h2 titles; skip link focuses the first widget; `main` has tabindex -1. The repo has jest-axe/@axe-core/playwright; the existing `checkA11y` helper still disables color-contrast, left unchanged. - Ran: `pnpm lint` (0 errors), `pnpm typecheck`, `pnpm test` (3 pre-existing/flaky failures only: two Windows path-separator tests in `tests/standalone` / `tests/acceptance/support`, which fail identically on `main`; `WidgetRenderer.test.tsx` "falls back to the payload type" failed once under full-suite load and passes alone), `pnpm build`, `pnpm exec playwright test` for the new spec plus dashboard-widths and navigation (29 passed against the production build), `pnpm perf:budget` (report-only; after numbers: total JS 1509.9 KB gzip, /app/search first-load 371.9 KB, CSS 25.3 KB; the change adds a few hundred bytes of source, a baseline run on main was not captured, budget is report-only). ## Axe results (mock home with 2 table widgets, accent #059669; serious/critical node counts) | | before (main) | after | |---|---|---| | 390 light / dark | 2 rules (link row contrast x2, Clear range target-size x2) / dark 1 | 0 / 0 | | 768 light / dark | 2 / 1 | 0 / 0 | | 1024 to 3200 light / dark | 2 / 2 (adds active sidebar item contrast) | 0 / 0 | Details: `axe.txt` in the screenshots folder. ## Screenshots Attached locally (not committed): `R:\...\scratchpad\dashboard-standard\pr-shots\next-a11y\{before,after}\{390,768,1024,1440,1920,3200}.png` plus `-dark` variants and `axe.txt` per side. The page is the mocked-API app home (`/app/a11yLab`: two table widgets with a link row and a Range dropdown). ## Release A Next UI release is needed: the next RC after 1.0.0-RC.35 (1.0.0-RC.36). kof22 (Website-Backend) must bump its pinned `qqq-frontend-next.version` to that RC to pick up the change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.