Repository navigation
fix(dashboard): AA contrast for the default palette, skip link, nav landmark, section tab title (A11Y-6/9/10/11) - #16
Merged
Conversation
… (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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
developso 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.tsandSidebarCollapsedIconNav.a11y.test.tsxare all present indevelop— 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
#B04759with 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#E8899Afor 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#E85D75on its button) and 4 token fallbacks.New
themeContrast.test.tspins 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-selectedbefore. The track is now a labelled tablist witharia-selectedand 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 isaria-hidden, andagentSelector.state.*exists in both locales.A11Y-10 — skip link and nav landmark
0 skip links and 0
<nav>underlayouts/before. Both sidebar render paths (desktop rail and mobile drawer — separate returns, easy to half-fix) are wrapped in a labelled<nav>withdisplay:contentsso layout is untouched. A skip link is the first focusable element, targeting the<main>that antd'sContentalready renders, which gainstabIndex={-1}so focus actually moves rather than just scrolling.A11Y-11 — the tab title reflects the section
document.titlewas 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 byresolveSelectedKey(), reusing the sidebar's ownnav.*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/comboboxroles — not in this PR.Test plan
cd dashboard && npx tsc -b→ cleannpx eslinton changed files → 0 errorsthemeContrast.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:
#E85D75for rose<nav>wrapper{skipLink}from the DOMpageTitle.test.tscaught a realnav.adminSecuritytypo during development)Notes
pageTitle.test.tscross-checks the title map against the sidebar'slabelKeyvalues in source and against both locales, so a new nav item cannot silently lose its title.<nav>wrapper causes line churn inSidebar.tsx(Prettier re-indents the wrapped block) — the diff is the wrapper plus indentation, no logic changes.AuthGuard,DocumentPreviewCore.docxSanitize,SkillDrawer,skillMarkdown,useChat.history) are unchanged and not caused by this branch.