Skip to content

fix(dashboard): AA contrast for the default palette, skip link, nav landmark, section tab title (A11Y-6/9/10/11) - #16

Merged
ra-co88 merged 2 commits into
developfrom
feat/a11y-6-9-10-11
Oct 19, 2026
Merged

ra-co88 merged 2 commits into
developfrom
feat/a11y-6-9-10-11

Conversation

@ra-co88

@ra-co88 ra-co88 commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Follow-up to #15, which was merged while this work was still in flight. #15 captured the first three commits (the in-flight latch + A11Y-1/4/5); it does not contain A11Y-6, A11Y-9, A11Y-10 or A11Y-11. This PR carries exactly those, rebased onto current develop so the diff shows only the new work — no re-showing of what already landed.

Verified after the merge by content, not by commit hash (the merge was a squash, so hashes differ): AppDialog.tsx, MessageList.a11y.test.tsx, asyncLatch.ts and SidebarCollapsedIconNav.a11y.test.tsx are all present in develop — so none of #15's work is duplicated here, and none of it was lost.

20 files, +846/−199.

A11Y-6 — the default palette failed WCAG AA

The contrast gate already existed, but deliberately exempted rose — and rose is the default palette. Measured across all 16 primary tokens: rose was the only one failing, at 3.36:1 light / 2.37:1 dark against the 4.5:1 floor.

Now #B04759 with darker hover (#973D4D) and active (#7F3340), which also brings rose in line with the convention every other palette already followed (same value in both modes; hover and active go darker). Dark mode keeps a separate bright #E8899A for text/link roles — button background and on-dark text need opposite values, which is why one shared hex could not work. 30 tint sites, plus the boot spinner, the offline page (which had white-on-rose #E85D75 on its button) and 4 token fallbacks.

New themeContrast.test.ts pins all 8 palettes and asserts the default palette is covered, so the exemption cannot silently return.

A11Y-9 — the agent picker is a real tablist

0 role="tablist"/role="tab"/aria-selected before. The track is now a labelled tablist with aria-selected and roving tabindex (Arrows/Home/End move and select), so Tab is one stop instead of 19.

Carries the first half of the colour-only-state fix: the state dot was a bare coloured <span> with no accessible name, so for running-vs-failed the colour was the only signal — the exact red/green pair a deuteranope cannot separate. Each tab is now named <agent> · <state>, the dot is aria-hidden, and agentSelector.state.* exists in both locales.

A11Y-10 — skip link and nav landmark

0 skip links and 0 <nav> under layouts/ before. Both sidebar render paths (desktop rail and mobile drawer — separate returns, easy to half-fix) are wrapped in a labelled <nav> with display:contents so layout is untouched. A skip link is the first focusable element, targeting the <main> that antd's Content already renders, which gains tabIndex={-1} so focus actually moves rather than just scrolling.

A11Y-11 — the tab title reflects the section

document.title was set once from the app name and never updated: every tab, bookmark and history entry read identically. MainLayout now derives it from the section already resolved by resolveSelectedKey(), reusing the sidebar's own nav.* labels, so the tab and the visible nav entry cannot disagree. Unknown keys and missing translations degrade to the bare app name rather than printing a raw key.

Still live in this row: slash/mention menu listbox/combobox roles — not in this PR.

Test plan

  • cd dashboard && npx tsc -b → clean
  • npx eslint on changed files → 0 errors
  • 307 passed across 49 files (styles, layouts, AgentSelector, utils)
  • New: themeContrast.test.ts (10), pageTitle.test.ts (5), skipLink.a11y.test.ts (7), AgentSelector.a11y.test.tsx (7)

Mutation-verified — each guard fails when its behaviour is removed:

Mutation Result
restore #E85D75 for rose contrast gate fails with the 3.36 figure
break roving tabindex exactly the roving-focus case fails
drop the state from the tab name the naming case fails
remove the mobile <nav> wrapper landmark count case fails
remove {skipLink} from the DOM source-order case fails
drop a map entry / typo a key sync case fails (pageTitle.test.ts caught a real nav.adminSecurity typo during development)

Notes

  • pageTitle.test.ts cross-checks the title map against the sidebar's labelKey values in source and against both locales, so a new nav item cannot silently lose its title.
  • The <nav> wrapper causes line churn in Sidebar.tsx (Prettier re-indents the wrapped block) — the diff is the wrapper plus indentation, no logic changes.
  • Pre-existing unrelated vitest failures (AuthGuard, DocumentPreviewCore.docxSanitize, SkillDrawer, skillMarkdown, useChat.history) are unchanged and not caused by this branch.

… (A11Y-6)

The rose palette was exempt from the contrast gate — it was pinned to the
"historic Elegant Rose" values (#E85D75 light, #F08B9A dark) instead. That
exemption meant the DEFAULT palette shipped white text on a rose primary button
at 3.36:1 in light mode and 2.37:1 in dark, both under the 4.5:1 AA floor.

Rose is now #B04759 with darker hover (#973D4D) and active (#7F3340) states, so
all three solid states clear AA in both modes while matching the hover/active
direction every other palette already uses. Dark mode keeps a bright accent
(#E8899A) for its text/link roles, which is a separate token from the button
background — the two roles needing opposite values is why one shared hex could
not work.

The tint layer follows: 30 rgba(232,93,117,a) sites and the boot spinner,
offline page and token fallbacks now carry the new value.

New themeContrast.test.ts makes the exemption impossible to reintroduce: it
asserts every palette including rose, pins the default palette as covered, and
records why the decorative swatch fills carry no contrast requirement (the
aria-hidden chips inside a labelled 42px button are not distinguished by
fill-vs-surface alone — a fill assertion would fail emerald and amber for a
rule the design does not rely on).

Removing the exemption from themePalettes.test.ts and switching expertColor's
hard-coded rose hex to PALETTE_SWATCH.rose.
…(A11Y-10)

Two gaps, both verified live before the fix.

The sidebar exposed no navigation landmark. It rendered as a plain <div>, so a
screen reader user could neither list nor jump to the nav region. Both render
paths (desktop rail and mobile overlay drawer) are now wrapped in a labelled
<nav aria-label> — both, because they are separate returns and missing one
leaves that layout without a landmark, which is invisible in review. The wrapper
uses display:contents so the existing flex layout is untouched.

There was no skip link. Reaching page content meant tabbing through the whole
sidebar — roughly 19 items plus group headers — on every navigation. Machine
added as the first focusable element in the DOM, targeting the <main> that
antd's Content already renders; main gains tabIndex={-1} so the click handler
can move focus as well as the scroll position (otherwise the next Tab resumes
from the link, not the content), and it stays out of the tab order.

Both strings are translated in en and zh rather than falling back to English.

skipLink.a11y.test.ts asserts the landmark count in BOTH paths, that the link
precedes the sidebar in source order, and that the two locale strings are
present and not copies of each other.
@ra-co88
ra-co88 merged commit 3b5eac3 into develop Oct 19, 2026
@ra-co88
ra-co88 deleted the feat/a11y-6-9-10-11 branch October 19, 2026 20:40
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.

2 participants