Skip to content

fix(ui): web accessibility fixes and axe gate in Storybook tests - #543

Merged
bntvllnt merged 29 commits into
mainfrom
fix/ui-a11y
Oct 1, 2026
Merged

bntvllnt merged 29 commits into
mainfrom
fix/ui-a11y

Conversation

@bntvllnt

@bntvllnt bntvllnt commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes the real web accessibility defects found by the #535 audit (axe on every story and registry embed in both themes, keyboard/APG sampling, reduced-motion sampling) and makes Storybook enforce it: the test-runner now runs axe-core (WCAG 2.0/2.1/2.2 A + AA) on every story in the light and dark theme and fails on any violation.

Part of #535

Builds on #537 (atomic layout) and #538 (token contrast), both merged; main (through #541/#542) is merged in, so this is no longer stacked.

Release note for registry installs. Registry items (shadcn shims) now import moveRovingFocus, useReturnFocus, focusCalendarDay, CHECKLIST_PROGRESS_EVENT and parseChecklistStorageValue from @vllnt/ui. The registry pins ^0.3.0, which does not export them, so shadcn add of tabs, animated-tabs, list-box, rating, theme-switcher, view-switcher, workspace-switcher, time-picker, command, completion-dialog, share-dialog, date-picker, date-range-picker and progress-tracker needs a @vllnt/ui release that contains these exports. registry:integrity now fails if a shim imports a name the package does not export.

What changed

Area Defect (audit) Fix Evidence
Gate No automated a11y check in CI .storybook/test-runner.js injects axe-core (already a devDependency) after each story renders, checks light + dark, fails on violations; page-scaffold rules off; per-story exceptions only via parameters.a11y.config.rules[{ id, enabled: false, reason }] (reason required) Fails on the baseline build (e.g. canvas-snapguides--* aria-prohibited-attr in both themes); full CLI run on a4323f28: 317 suites, 770 of 770 stories pass in light + dark
Names Combobox/MultiSelect trigger had no name; popover dialogs unnamed (Combobox, MultiSelect, DatePicker, DateRangePicker, TimePicker, ColorPicker, Popover); DataTable filter, NumberInput steppers, VideoEmbed play, Slider thumb, GlassProgress/ScrollProgress, geography map svg; English-only names in Pagination, AIArtifact, ModelSelector, DataTable, VideoEmbed aria-label/aria-labelledby/id forwarding, new optional popoverLabel (sensible English default), labels on steppers/filters/progress bars, slider name on the thumb, PopoverContent TSDoc; localizable names: Pagination labels, AIArtifactLabels content, ModelSelector listLabel, DataTableFilter ariaLabel, VideoEmbed playLabel (English defaults unchanged) vitest (combobox, multi-select, date-picker, date-range-picker, time-picker, color-picker, number-input, slider, glass-progress, scroll-progress, video-embed, data-table, pagination, ai-artifact, model-selector)
ARIA structure aria-label on generic elements (AnimatedText, ScrambleText, Typewriter, SpinningText, TextReveal, SnapGuides, SelectionHalo, Curriculum, Map2D image); unfocusable scroll regions (AIArtifact, BottomActivityStrip, ConversationThread, GanttChart, ParallelTimeline, ScrollArea); nested interactive (CanvasView role=button, CommentPin, FileUpload, GeographyQuizMap); ChoroplethMap focusables inside aria-hidden; KeyConcept dl; ChronologicalTimeline li; Globe3D duplicate landmark/ids; PrimarySourceViewer nested aside; 24px targets (RunTimeline, SearchField, Tour); Pagination a > button sr-only text + aria-hidden visuals or real roles (img/group); tabIndex=0 + region names + focus rings; the ScrollArea viewport is a tab stop only while it scrolls and a named region when given aria-label/aria-labelledby; TabsContent is a tab stop only when it holds no focusable content; CanvasView is a labelled focusable region (R15: not a button); FileUpload dropzone is a <label> around the native input; choropleth regions are keyboard buttons (aria-pressed, Enter/Space); valid dl/li; unique ids; section region; 24px hit areas; Pagination is a nav of single links with aria-current; Combobox sets aria-controls itself vitest per component (red on main, green here)
Keyboard (APG) Tabs/AnimatedTabs/ViewSwitcher, ThemeSwitcher/Rating/WorkspaceSwitcher, ListBox, TimePicker, TreeView, ModelSelector, CompletionDialog, Drawer, AnimatedTooltip, Search/Command/Share dialogs, Toast, date pickers, ContextMenu Shared roving-tabindex helper (moveRovingFocus, now exported), Arrow/Home/End(/PageUp/PageDown), aria-controls wiring, single tab stop on the selected option; arrows can leave a focused aria-disabled item; TreeView aria-activedescendant plus Home/End, ArrowRight to the first child, ArrowLeft to the parent and scroll-into-view; ModelSelector search inside the cmdk root; CompletionDialog on the Radix dialog primitive (trap, Escape, focus return; shortcuts scoped to the dialog, disable with "" per WCAG 2.1.4; Enter no longer always confirms; new modal prop); focus returns to the opener (useReturnFocus, now exported); Drawer focuses its content; AnimatedTooltip handles Escape only while open, in the capture phase, and stops propagation so a parent dialog stays open; Toast is a status/alert live region; date pickers open on the selected day; ContextMenuTrigger documents Shift+F10 and the demo trigger is focusable vitest keyboard tests (tabs, animated-tabs, view-switcher, theme-switcher, rating, workspace-switcher, list-box, time-picker, tree-view, model-selector, completion-dialog, drawer, animated-tooltip inside a Dialog, command, search-dialog, share-dialog, toast, roving-focus); Playwright CT: NavigationMenu ArrowDown into open content; keyboard audit (run-kb.mjs) before → after below
Motion Inline animation overrode motion-reduce:animate-none (Marquee, Meteors, Particles, Sparkles, LiquidGlass, ShineBorder, BorderBeam); unguarded infinite pulse/ping/spin; Marquee/TickerTape hover-only pause (WCAG 2.2.2); AnimatedText JS typing/scramble ignored reduced motion Animation names moved to classes; per-component motion-reduce: guards (no global rule, so consumer elements are never touched); the 7 inline loaders (ChainOfThought, PromptInput, Toaster, AgentActivity, AIChatInput, NewsletterSignup, FlowControls) and Spinner are data-motion="essential" and spin at 1.5s under reduced motion; Marquee/TickerTape get a keyboard pause/play button (new optional pauseControl, labels), pause while focus is inside; AnimatedText shows the final text at once under reduced motion vitest per component; Playwright CT reduced-motion.visual.tsx under reducedMotion: reduce: a consumer element keeps its own animation and transition (red with a global * rule), Skeleton stops, Spinner slows; real-browser check: AnimatedText decipher shows the final string at 0 ms and +400 ms
Contrast (palette, not tokens) emerald/rose/green/red/amber/orange/sky text on tints, white on -500 fills, collaborator colours (#5b8def/#10b981/#f59e0b), opacity-dimmed text, CodeBlock syntax theme (and wrong theme detection), FlowDiagram dark nodes, RunTimeline dark fill, GanttChart today caption (destructive/15 → /10), Toast description opacity-90, cmdk data-[disabled] dimming every item darker light-mode shades (+ dark variants), -700 fills under white text, collaborator colours (CommentPin, PresenceStack, LiveCursor) darkened with color-mix(in oklab, …) so any user colour keeps its hue and stays AA, theme tokens when no colour is given, muted token instead of opacity, CodeBlock colours from --vllnt-code-* OKLCH variables (no JS theme detection, no theme chunk), xyflow node colours from card tokens, data-[disabled=true] axe gate (real Chromium, both themes); theme-contrast.test.ts checks every CodeBlock token at 4.5:1 on the background for all 24 preset × mode combinations (12 fail with the previous values); vitest where class-testable
Stories ~20 stub/empty stories, fixture-only a11y issues, next/image stub exported Link, heading-override stories skipped a level Real demos with unchanged IDs/names (Tabs, Select, Accordion, Menubar, RadioGroup, Command, Carousel, Popover, HoverCard, ContextMenu, NavigationMenu, Pagination, Table, Drawer, AlertDialog, Collapsible, Card, …); labelled form fixtures; .storybook/next-image-stub.ts; ContentIntro/TutorialComplete HeadingOverride stories use h1 + h2; registry NavigationMenu demo links have an href storybook:verify green; story IDs unchanged (registry:verify-previews: 313 previews resolve, 770 stories); axe incl. best-practice on the HeadingOverride stories: 0

Non-obvious roles (R15): CanvasView region + aria-roledescription="canvas" (WAI-ARIA landmark, not button, cf. PR #139); SnapGuides img, SelectionHalo/GeographyQuizMap svg group; choropleth regions button + aria-pressed (APG toggle button); TimePicker columns listbox/option with roving tabindex (APG listbox); TreeView aria-activedescendant (APG tree view); Toast status/alert (APG alert, ARIA live regions).

CodeBlock palette across presets. The review asked for per-preset code colours where needed. Presets change the surrounding tokens (background etc.) but not --vllnt-code-*, so one light and one dark set of defaults is checked against every preset's own background. Those defaults pass 4.5:1 in all 24 combinations, so no per-preset overrides were needed; the test fails if a future preset breaks that.

Review follow-ups (independent review of this PR)

# Finding Resolution Commit
1 Shims import lib helpers that @vllnt/ui does not export Exported moveRovingFocus (+ RovingFocusOptions, RovingOrientation), useReturnFocus (+ ReturnFocusHandlers), focusCalendarDay, CHECKLIST_PROGRESS_EVENT, parseChecklistStorageValue. New registry:integrity check: every named @vllnt/ui import in every shim must be a public export (shared collectPublicExports). 14 shims fail before, OK after. Stale comment in inline-component-source.ts fixed 86704802
2 CompletionDialog always modal modal?: boolean (default true) mapped to Radix modal 7edf3274
3 ESLint relaxations were global Rules restored globally; relaxations moved to files: overrides (drawer, the six scroll containers, tabs, canvas-view) 96c7942c
4 Global * reduced-motion net could alter consumer elements Net removed; per-component guards; 7 loaders + Spinner marked data-motion="essential"; CT test with a consumer element 01c262b1
5 Code palette not verified on presets OKLCH tokens, 24 combos in theme-contrast.test.ts, CSS comment corrected 103e76de
6 Marquee/TickerTape label types; Marquee root clipping MarqueeLabels, TickerTapeLabels exported; overflow-hidden restored on the Marquee root 9448e039
7 Extra tab stops ScrollArea viewport only when it scrolls (+ name); TabsContent only without focusable content 2195c36d
8 Drawer autoFocus undocumented CHANGELOG entries (root + package) f87051d7
10 TreeView APG gaps Home/End, Right → first child, Left → parent, active row scrolls into view 2195c36d
11 Collaborator colour darkening color-mix(in oklab, …) in CommentPin, PresenceStack, LiveCursor 2195c36d
12 Hard-coded English names labels/*Label props with English defaults (Pagination, AIArtifactContent, ModelSelector, DataTable filter, VideoEmbed) 2195c36d, a4323f28
13 Roving focus stuck on a focused aria-disabled item Focused item stays in the navigable set 2195c36d
14 AnimatedTooltip Escape Only while open, capture phase, stopPropagation; tested inside a Radix Dialog (first Escape closes the tooltip only) 2195c36d
— Left open: NavigationMenu ArrowDown Radix already moves focus into open content; the registry demo's links had no href, so nothing in it was focusable. Demo fixed; CT test + Storybook real-browser check a4323f28, f87051d7
— Left open: AnimatedText under reduced motion Final text at once for terminal, matrix and decipher (useSyncExternalStore on the media query) a4323f28
— Left open: heading order in two stories Stories use h1 + h2 (component defaults h2 + h3 were already sequential) a4323f28
— CI react-doctor error (combobox role without aria-controls) Combobox sets aria-controls itself, MultiSelect pattern 76db9f1f

Axe before → after

Audit scripts from #535 (run-axe2.mjs: axe 4.11, WCAG A/AA 2.0–2.2 plus best-practice, page-scaffold rules off, both themes). Counts are violating nodes.

Scan Before After
Stories, 770 × 2 themes 509 WCAG + 28 best-practice (base build ae65aab5; 152 of the WCAG nodes were token contrast, fixed by #538) 2 WCAG + 4 best-practice (build at 33dc3bbe); the 4 best-practice nodes (heading order) are now fixed: 0 on re-scan of those stories
Registry embeds, 313 × 2 themes 181 WCAG + 2 best-practice (original #535 audit on 008a9291) 0 (HEAD registry build)
Storybook test-runner gate (WCAG only) n/a (no gate) 0 failures, 770/770 stories, full CLI run on a4323f28

The 2 remaining WCAG nodes in the bulk scan are AnimatedTabs measured mid-layout while the scan switches stories inside one page; the same stories pass with a fresh page load (axe-story.mjs) and in the gate.

Keyboard (run-kb.mjs, embeds + Storybook) before → after:

Component Before After
Tabs, AnimatedTabs, ViewSwitcher ArrowRight did not move focus ok
ThemeSwitcher, Rating, WorkspaceSwitcher arrows did not move focus ok
ListBox ArrowDown did not move focus ok
TreeView active item only in data-active ok (aria-activedescendant, Home/End, Left/Right)
TimePicker ArrowDown did nothing ok (the harness's "focus not returned" is a key-match artefact: selection follows focus changes the trigger text; a direct check confirms focus returns to the trigger)
DatePicker, DateRangePicker arrows did nothing on open ok (focus starts on the selected day)
Drawer focus not moved in, Tab escaped ok
AnimatedTooltip Escape did not close ok (and a parent dialog stays open)
CommandDialog, SearchDialog, ShareDialog focus to <body> after Escape ok
ContextMenu no tab stop ok (Shift+F10 opens it)
CompletionDialog Tab escaped the dialog trap ok; direct check: focus on Done, Tab stays inside
ModelSelector list unreachable direct check: focus in the search box, ArrowDown moves the highlight
NavigationMenu ArrowDown after opening stayed on the trigger (registry demo) ok: demo links now have an href; Storybook story: ArrowDown focuses "Installation"

Reduced motion (run-motion.mjs, embeds with reducedMotion: reduce): embeds still moving 18 → 2, infinite animations 17 → 1 (the remaining infinite one is the Spinner, kept as essential feedback at a slower speed). AnimatedText no longer scrambles under reduced motion.

API (additive only)

api.mjs snapshot of dist/index.d.ts, current main (818a3994) vs this branch: 0 exports removed, 11 added, 0 members removed. 47 exports differ in type text: 43 add optional props or only change destructuring text; 4 (cn, GeoPosition, THEME_PRESETS, ChoroplethColorScale) differ only in generated symbol ids (run noise).

  • Added exports: moveRovingFocus, RovingFocusOptions, RovingOrientation, useReturnFocus, ReturnFocusHandlers, focusCalendarDay, CHECKLIST_PROGRESS_EVENT, parseChecklistStorageValue, MarqueeLabels, TickerTapeLabels, PaginationLabels
  • popoverLabel?: string on Combobox, MultiSelect, DatePicker, DateRangePicker, TimePicker, ColorPicker
  • Combobox: aria-label?, aria-labelledby?, aria-describedby?, id?
  • InlineInput: aria-label?, aria-labelledby?
  • NumberInput: incrementLabel?, decrementLabel?
  • Marquee and TickerTape: pauseControl?: boolean, labels?: { pause?; play? }
  • TabsContent: tabIndex?: number
  • GanttChartLabels: region?: string
  • CompletionDialog: modal?: boolean
  • Pagination: labels?: PaginationLabels; AIArtifactLabels: content?; ModelSelector: listLabel?; DataTableFilter: ariaLabel?; VideoEmbed: playLabel?
  • useEscapeKey options: capture?, stopPropagation?
  • Text-only changes (no new or removed members): AnimatedTabs, CommandSeparator, Drawer (autoFocus defaults to true), ScrollArea, Slider (name props go to the thumb), WorkspaceSwitcher, mdxComponents

Behaviour changes (also in CHANGELOG.md and packages/ui/CHANGELOG.md, [Unreleased])

  • Drawer moves focus into its content on open (autoFocus defaults to true; autoFocus={false} restores vaul's default).
  • CompletionDialog is a Radix dialog: focus trap, Escape, focus return. modal (default true) maps to Radix modal; modal={false} leaves the page behind it interactive. Shortcuts work only while focus is inside the dialog, "" turns one off, and Enter no longer confirms from any focused element.
  • Marquee / TickerTape render a 24px pause/play button by default (pauseControl={false} opts out). Position: it overlays the top-right corner of the Marquee (right-1 top-1) and the right edge, vertically centred, of the TickerTape (right-2 top-1/2); it hides under reduced motion, when nothing moves. The Marquee root clips its content (overflow-hidden) as before.
  • Pagination root is a nav and pages are single links styled as buttons, aria-current="page" on the current one.
  • CodeBlock colours come from --vllnt-code-* CSS variables (OKLCH channels) instead of JS theme detection.
  • CommandSeparator renders an aria-hidden divider.
  • Reduced motion is handled per component; loaders marked data-motion="essential" spin slower; nothing outside the library is affected.
  • TabsContent / ScrollArea add a tab stop only when needed (see ARIA structure).

Exceptions

None: no story disables an axe rule. (The gate supports reasoned exceptions; the list is empty.)

Gates

Run on this branch. UI code is final at 76db9f1f; the full axe CLI run is on a4323f28, after which only the changelogs, the registry NavigationMenu demo and the Combobox aria-controls changed (CI's Storybook Tests job re-runs the gate on HEAD).

  • pnpm -F @vllnt/ui lint: pass (its only report is the local, uncommitted test-runner-jest.config.js that points the runner at a local Chromium); changed files re-linted after the last commit: 0 problems; pnpm -F @vllnt/ui-registry lint: pass
  • pnpm -F @vllnt/ui exec tsc --noEmit --project tsconfig.build.json: pass
  • pnpm build: 6/6 tasks
  • pnpm test:once: ui-core, registry (99 tests) and native pass; ui: 1502 tests in 320 files pass (vitest run --maxWorkers=4). With full parallelism on a heavily loaded machine (load ~40), one timing-sensitive test timed out per run (MultiSelect first render at 5s, TutorialMDX findByRole at 1s); both pass alone and in the 4-worker run
  • pnpm check:atomic, pnpm -F @vllnt/ui size (13 budgets ok), check:dist-imports: pass
  • pnpm -F @vllnt/ui-registry registry:build, then registry:integrity (with the new shim-import check): OK, 313 items; the registry build leaves no diff
  • Storybook build + test-runner with the axe gate: 770/770, 317 suites (full CLI run, --maxWorkers=3 --testTimeout=60000, same timeout as CI)
  • react-doctor as in CI (--diff origin/main --fail-on error): 0 errors (was 1: the Combobox aria-controls)
  • Regression proof: new tests were run red against the code before their fix (the integrity check fails on 14 shims before the exports; the CT reduced-motion test fails with a global * rule; the code-token contrast test fails 12 cases with the previous colours). Not run red: the Pagination "label passed as undefined" test, and the Combobox aria-controls assertion (Radix already set it at runtime; that change is for static analysis)

Lint config (packages/ui/eslint.config.js): global rules unchanged from main; files: overrides only: drawer (no-autofocus ignores the vaul autoFocus prop), the six scroll containers (no-noninteractive-tabindex allows log/region and <section>), tabs (tabpanel), canvas-view (the <section> may handle key events). No eslint-disable, ts-ignore or any.

Remaining (not fixed here)

  • Visual baselines are not refreshed: only 34 are committed; local rendering differs from them, and CI's visual job runs with --update-snapshots.
  • Token contrast items are owned by fix(design): meet WCAG AA contrast in shared colour tokens #538 (merged); after it, no contrast violations remain in either scan.
  • Merge commit dc45faf9 carries a copy of the previous commit's message by mistake; the PR is squash-merged.

https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc

Registry scripts (inline-component-source, generate-component-metadata,
check-registry-integrity, verify-preview-stories) and the component page now
find a @vllnt/ui component folder by name through
apps/registry/lib/component-directory.ts, which searches the atomic level
folders (atoms/molecules/organisms/templates) and still accepts today's flat
layout. The Storybook scripts list component folders through the same kind of
helper in packages/ui/scripts/lib/component-source.ts.

The shim sibling-import rewrite now also matches `../../<level>/<name>/<name>`
at any `../` depth, and the ESLint per-component overrides use name-based
globs that match at any depth. Registry output is unchanged (public/r,
registry/default, registry.json and component-metadata.json diff clean).

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Move every @vllnt/ui component folder into
packages/ui/src/components/{atoms,molecules,organisms,templates}/<name>/
(196 atoms, 70 molecules, 46 organisms, 1 template). The level is the higher
of the import-derived minimum and a conceptual promotion: self-contained
section-level widgets (full charts, maps and scaled timelines, pan/zoom
workspaces and viewers, data grids and trees) are organisms.

Relative imports (lib, .storybook, __tests__, sibling components, vi.mock
paths, the barrel and the top-level visual fixtures) follow the new depth;
ESLint re-sorted the affected import lines. src/components/index.ts keeps
identical export names and order. Committed visual baselines move with their
tests, the tsup exclude follows canvas-foundation-demo, and
conversation-thread.mdx pins its current docs ID with an explicit
<Meta title>. The registry and Storybook folder helpers drop the flat-layout
fallback, with a vitest test that every registry item resolves.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Brings in #536 (relative-import-free shims). The static sibling, dynamic
import() and same-folder shim rewrites are all level-aware: dynamic imports
share the `(?:../)+(?:<level>/)?<name>(?:/<name>)?` sibling path with static
ones, and collectPublicExports keeps following src/index.ts ->
components/index.ts (FlowCanvas included). The #536 edits to flow-diagram/index.ts
land on organisms/flow-diagram/index.ts.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Add scripts/check-atomic-levels.mjs, a dependency-free check for a
component package: every component folder sits in atoms/, molecules/,
organisms/ or templates/; atoms import no component, molecules only atoms,
organisms atoms/molecules/organisms, templates anything but templates; no
cycles, no duplicate names, no barrel imports. Static imports, re-exports,
import() and require() are read with a comment/string-aware lexer; stories,
tests, visual fixtures, MDX and __tests__ are exempt. `--base <dir>` marks a
directory below atoms (for the native package's primitives later).

node --test fixtures cover a clean tree and every violation. `pnpm
check:atomic` (turbo -> packages/ui) runs it, and the CI quality job runs the
fixtures plus the check.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
COMPONENTS.md documents the {level}/{name} folder layout, the import rules
per level, how to choose a level (import-derived minimum plus the organism
promotion heuristic) and how to move a component between levels.
CONTRIBUTING, ARCHITECTURE, the registry README and docs page (en/fr), the
vllnt-ui skill and the agent gate lists (AGENTS.md, R6, PR playbook) point at
the new layout and `pnpm check:atomic`.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
…-a11y

The branch was based on the unsquashed atomic-design branch, whose tree
matches #537. Main's remaining delta (#538, #540) is applied on top.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Every story now runs axe-core (WCAG 2.0/2.1/2.2 A + AA tags) in the light
and dark theme after render and fails on any violation. Page-scaffold rules
(region, landmark-one-main, page-has-heading-one, bypass, document-title,
html-has-lang) are off because a story canvas is a fragment. A story can
only opt a rule out through parameters.a11y.config.rules with a reason.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
- Name every control and popup: Combobox/MultiSelect triggers take
  aria-label/aria-labelledby/id; Combobox, MultiSelect, DatePicker,
  DateRangePicker, TimePicker and ColorPicker name their popover dialog
  (new optional popoverLabel); date pickers open with focus on the
  selected day; progress bars, slider thumb, number-input steppers,
  video play button and data-table filter get names.
- Replace aria-label on generic elements with sr-only text or a real role
  (animated/scramble/typewriter/spinning/text-reveal text, snap guides as
  img, selection halo as group, curriculum, map backdrop image).
- Keyboard-reachable scroll regions (AI artifact content, activity strip,
  conversation log, Gantt chart, parallel timeline, ScrollArea viewport).
- Fix nested interactive content (CanvasView is a focusable region, not a
  button; CommentPin, FileUpload label dropzone, geography quiz map group),
  choropleth regions are keyboard buttons outside aria-hidden, valid
  dl/ol/landmark structure (KeyConcept, ChronologicalTimeline, Globe3D,
  PrimarySourceViewer), 24px targets (RunTimeline, SearchField, Tour),
  Pagination as a nav of single links with aria-current.
- GanttChart today caption on a 10% destructive tint (AA).
- eslint: allow tabIndex on region/log/tabpanel roles and sections, and
  key handlers on the canvas section, since WCAG 2.1.1 requires
  scrollable regions to be focusable.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
- Shared roving-tabindex helper (lib/roving-focus): Tabs, AnimatedTabs and
  ViewSwitcher (Arrow/Home/End, aria-controls/aria-labelledby wiring),
  ThemeSwitcher, Rating and WorkspaceSwitcher radio groups, ListBox and the
  TimePicker columns (one tab stop, focus on the selected option, PageUp/
  PageDown) and TreeView aria-activedescendant on stable item ids.
- ModelSelector: search box inside the cmdk root so arrows and Enter reach
  the list.
- CompletionDialog on the Radix dialog primitive (focus trap, Escape,
  focus return); shortcuts act inside the dialog only, Enter no longer
  always confirms, empty shortcut strings turn them off (WCAG 2.1.4).
- Focus returns to the opener for CommandDialog, SearchDialog and
  ShareDialog (lib/use-return-focus); Drawer moves focus into its content
  (autoFocus defaults to true); AnimatedTooltip closes on Escape;
  Toast announces as a status/alert live region; CommandSeparator no longer
  puts role=separator inside the listbox; cmdk disabled styling matches
  data-disabled=true only.
- ContextMenuTrigger documents the Shift+F10 keyboard path; real demos
  for the Command, ContextMenu and Drawer stories.
- eslint: no-autofocus ignores component props (vaul autoFocus).

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
- styles.css: global reduced-motion safety net that collapses animations
  and transitions to their end state; data-motion="essential" opts out.
- Move animation names out of inline styles (Marquee, Meteors, Particles,
  Sparkles, LiquidGlass, ShineBorder, BorderBeam) so
  motion-reduce:animate-none can stop them.
- Guard infinite pulse/ping animations (AlertPulse, HandoffBeacon,
  LiveFeed, ObjectInspector, PresenceSyncIndicator, Skeleton,
  StateBadgeOverlay, SeverityBadge); Spinner keeps turning, slower, as
  essential loading feedback.
- Marquee and TickerTape: keyboard-operable pause / play button (WCAG
  2.2.2, new optional pauseControl + labels props), pause while focus is
  inside, stop under reduced motion. Marquee keeps the fade mask off the
  control. TickerTape change badges use emerald/rose-700 for AA contrast.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
…olours

- Darken light-mode palette text on tints (emerald/rose/green/red-700,
  amber/orange/sky-800) in CandlestickChart, Watchlist, SparklineGrid,
  MarketTreemap, TransactionList, StatCard, OrderBook, Exercise,
  SeverityBadge (+ dark: variants), EraComparison, HistoricTimeline,
  StateBadgeOverlay and Comparison; white text sits on -700 fills
  (FollowMode, InteractiveTimeline, SeverityBadge solid, CommentPin
  unread badge, StepByStep).
- Collaborator colours stay legible whatever the user picks: chips darken
  the colour with color-mix behind white text (LiveCursor,
  SelectionPresence) and ThreadBubble mixes the author colour toward the
  foreground.
- Opacity-dimmed text uses the AA muted token (ObjectHandle,
  PropertySection, HistoricalFigureCard, LiveCursor status); PlaybackGhost
  fades its frame and keeps the label readable.
- CodeBlock colours tokens from theme-aware --vllnt-code-* variables
  (AA in both themes, no JS theme detection, no Prism theme chunk);
  CodePlayground line numbers use the muted token and are aria-hidden.
- FlowDiagram nodes take card / card-foreground colours via the xyflow
  node variables in both colour modes.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Give CodePlayground, HorizontalScrollRow, ShareSection and ProfileSection
stories their required content so the headings and links they render are
not empty (story IDs and names unchanged).

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Switch themes by waiting for the class on <html> instead of a render
event, cap the animation settle at 1s, and give the test-runner a 60s
per-story timeout for the two axe passes.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Switch themes by waiting for the class on <html> instead of a render
event, cap the animation settle at 1s, and give the test-runner a 60s
per-story timeout for the two axe passes.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
The embed previews for Combobox, MultiSelect, Select, Slider, InputOTP,
NumberInput and InlineInput now pass an accessible name, and the
ContextMenu demo trigger is focusable so Shift+F10 opens it.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Mixing in oklch toward black or a neutral token interpolated the hue
toward 0deg (blue chips turned purple). Oklab darkens or lightens the
same colour without rotating it; contrast is unchanged.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
…orts

Registry shims rewrite ../../lib/* to "@vllnt/ui", but moveRovingFocus,
useReturnFocus, focusCalendarDay and the checklist helpers used by
progress-tracker were not public exports, so shadcn installs failed with
TS2305. Export them, share collectPublicExports in lib/public-exports,
and make registry:integrity fail when a shim imports a name the package
does not export (14 failures before, 0 after).

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
The Radix-based CompletionDialog is page-modal by default (focus trap,
scroll lock, rest of the page inert). modal={false} keeps the page
interactive like the earlier container overlay.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
no-autofocus ignoreNonDOM applies to drawer only, the section key
handlers to canvas-view only, tabIndex on log/region/section to the
scroll containers and tabpanel to tabs; the global rules are back to
their previous settings.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
Drop the document-wide reduced-motion rule from the shipped styles.css:
it also froze animations the consuming app owns. Every library
animation now carries its own motion-reduce guard; the seven spinning
loaders are marked data-motion="essential" and only slow down. A
Playwright CT test proves a consumer element keeps its animation under
prefers-reduced-motion (it fails with the old global rule) while
library pulses stop.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
The code palette failed 4.5:1 on 13 of 24 preset/mode backgrounds (e.g.
nord dark comments 4.05). Express the --vllnt-code-* tokens as OKLCH
channels and retune their lightness so each passes on the default theme
and every preset in light and dark; theme-contrast.test.ts now checks
the code tokens against each preset background (12 combos fail with
the previous values). Presets and custom themes can still override
the variables.

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
…scape

- ScrollArea: the viewport is a tab stop only while its content overflows;
  aria-label / aria-labelledby name it as a region.
- TabsContent: tabIndex=0 only when the panel holds no focusable content
  (re-checked with a MutationObserver).
- TreeView: Home / End, ArrowRight to the first child of an open branch,
  ArrowLeft to the parent, and the active row scrolls into view.
- CommentPin, PresenceStack, LiveCursor: darken collaborator colours with
  color-mix(in oklab) and fall back to theme tokens.
- moveRovingFocus: arrows can leave a focused aria-disabled item.
- AnimatedTooltip: Escape listens only while open, in the capture phase,
  and stops propagation so a parent dialog stays open.
- Localizable names: Pagination labels, AIArtifact labels.content,
  ModelSelector listLabel, DataTableFilter ariaLabel, VideoEmbed playLabel
  (English defaults unchanged). Export PaginationLabels.

Part of #535

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
…de stories

- AnimatedText: under prefers-reduced-motion the terminal, matrix and
  decipher variants show the final text at once (no JS typing or scramble),
  as the docs already promised. Tracks the media query with
  useSyncExternalStore.
- ContentIntro / TutorialComplete HeadingOverride stories use h1 + h2 so the
  outline no longer skips a level.
- NavigationMenu: real-browser test that ArrowDown on an open trigger moves
  focus into the content (Radix already does this; the earlier audit probe
  did not recognise the content as a popup).
- Pagination: a label passed as undefined keeps its English default.

Part of #535

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
- CHANGELOG (root and packages/ui): the axe gate, the new optional props and
  exports, the registry release requirement, and the behaviour changes
  (Drawer autoFocus, CompletionDialog modal, Marquee/TickerTape pause button
  and its position, Pagination structure, CodeBlock colour variables,
  CommandSeparator, reduced motion, Tabs/ScrollArea tab stops, TreeView keys).
- Registry NavigationMenu preview: the links had no href, so they were not
  focusable and ArrowDown on an open trigger had nowhere to go.

Part of #535

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
The combobox role requires aria-controls. Radix added it at runtime, but the
trigger now sets it itself and the popover content takes the same id (the
MultiSelect pattern), so static checks (react-doctor in CI) see it too.

Part of #535

Claude-Session: https://claude.ai/code/session_01TY3D3WivybufQN49bAnPjc
@bntvllnt
bntvllnt marked this pull request as ready for review October 1, 2026 18:29
@bntvllnt
bntvllnt merged commit 3c8ee66 into main Oct 1, 2026
13 checks passed
@bntvllnt
bntvllnt deleted the fix/ui-a11y branch October 1, 2026 18:29
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