Skip to content

feat(ux): accessibility pass — live regions, skip link, landmarks, ARIA#618

Merged
SoundMindsAI merged 3 commits into
mainfrom
feat_ux_accessibility
Jul 11, 2026
Merged

feat(ux): accessibility pass — live regions, skip link, landmarks, ARIA#618
SoundMindsAI merged 3 commits into
mainfrom
feat_ux_accessibility

Conversation

@SoundMindsAI

Copy link
Copy Markdown
Owner

Third UX-audit fix PR. Scope: the accessibility findings (on top of the already-solid Radix baseline).

Changes

  • Chat streaming is now announced (was silent to screen readers) — the MessageStream container is role="log" aria-live="polite", so streaming tokens / tool-call cards / results are spoken as they arrive. The "Streaming…" indicator is also a live region.
  • Skip-to-content link — a visually-hidden-until-focused link as the first focusable element, so keyboard/switch users skip the 8-item nav; #main-content wraps all page content.
  • Chat pages get a <main> landmark (they were the only 2 routes using a bare <div>), so "jump to main content" works everywhere.
  • Long-running reseed progress is a live region — the scenario checklist + live step are wrapped in aria-live="polite", so the multi-minute reseed is announced as it advances instead of only at dialog open.
  • aria-sort moved to the real columnheader — it was on an inert <span>; now on the <TableHead> (the only element where it's honored), via a new ariaSortForColumn helper. The sr-only "Sorted …" text is retained.
  • Search-space builder tab widget completed — tabs now carry id + aria-controls, panels are role="tabpanel" + aria-labelledby, roving tabIndex, and Left/Right arrow-key navigation per the ARIA tablist pattern.
  • Form-error association — the query-set edit dialogs now set aria-invalid + aria-describedby linking the input to its error text (errors were announced but not programmatically associated).
  • Reduced-motion — the reseed spinner respects prefers-reduced-motion (motion-reduce:animate-none), matching the new Skeleton.

Deferred (tracked)

Comprehensive aria-describedby/aria-invalid association across the large study-creation wizard's many fields is a focused follow-up — errors there are already announced via role="alert"; this PR wires the association pattern on the smaller dialogs first.

Testing

New a11y test file (chat live region, tab-widget ARIA + arrow-key switch, ariaSortForColumn). Existing sort-header tests still green (they assert data-active-dir + sr-only text, both preserved). Full suite green (1319); tsc + eslint clean.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces comprehensive accessibility (a11y) improvements across the UI, including a skip-to-content link, semantic main elements, ARIA tablist patterns for responsive layouts, proper aria-sort handling in data tables, and enhanced form validation attributes. Feedback on these changes highlights that the roving tabindex implementation in the tab layout should programmatically shift focus to the active tab button, with corresponding test coverage added. Additionally, the reviewer warns that applying aria-live directly to a token-by-token message stream may cause screen reader stuttering, recommending instead a visually hidden live region that announces only key milestones and completed messages.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +32 to +37
const onTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab(activeTab === 'builder' ? 'json' : 'builder');
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In a roving tabindex tablist pattern, when the active tab changes via keyboard navigation (ArrowLeft/ArrowRight), focus must be programmatically moved to the newly activated tab button. Currently, only the state is updated, which leaves the focus on the old button (now with tabIndex={-1}), breaking keyboard navigation flow and screen reader announcements.

  const onTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
      e.preventDefault();
      setActiveTab(activeTab === 'builder' ? 'json' : 'builder');
      const target = e.currentTarget;
      const parent = target.parentElement;
      if (parent) {
        const buttons = Array.from(parent.querySelectorAll('[role="tab"]')) as HTMLButtonElement[];
        const nextButton = buttons.find((btn) => btn !== target);
        nextButton?.focus();
      }
    }
  };

Comment on lines +37 to +44
it('ArrowRight moves the active tab (roving selection)', () => {
renderTabs();
const builderTab = screen.getByTestId('cs-builder-tab-builder');
expect(builderTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(builderTab, { key: 'ArrowRight' });
expect(screen.getByTestId('cs-builder-tab-json')).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('cs-builder-tab-builder')).toHaveAttribute('aria-selected', 'false');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test to assert that focus is correctly moved to the newly selected tab when navigating with arrow keys.

  it('ArrowRight moves the active tab (roving selection)', () => {
    renderTabs();
    const builderTab = screen.getByTestId('cs-builder-tab-builder');
    const jsonTab = screen.getByTestId('cs-builder-tab-json');
    builderTab.focus();
    expect(builderTab).toHaveFocus();
    expect(builderTab).toHaveAttribute('aria-selected', 'true');
    fireEvent.keyDown(builderTab, { key: 'ArrowRight' });
    expect(jsonTab).toHaveAttribute('aria-selected', 'true');
    expect(builderTab).toHaveAttribute('aria-selected', 'false');
    expect(jsonTab).toHaveFocus();
  });

Comment on lines +41 to +47
<div
className="flex flex-col gap-3"
data-testid="message-stream"
role="log"
aria-live="polite"
aria-relevant="additions text"
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Making the entire MessageStream container a live region (role="log" aria-live="polite") when messages are updated token-by-token can result in a poor screen reader experience. Screen readers may attempt to announce every single token/word fragment as it arrives, leading to constant interruptions and stuttering.

Recommendation:
Instead of making the active stream container live, consider using a visually hidden live region (sr-only with aria-live="polite") that only announces:

  1. When the assistant starts generating (e.g., "Assistant is typing...").
  2. The final completed message once streaming finishes.
  3. Any tool calls or major state transitions (like "Running tool...") rather than raw token updates.

@SoundMindsAI

Copy link
Copy Markdown
Owner Author

Review adjudication

Gemini — 1 finding (with a paired test suggestion), accepted + fixed in 70bfa5d0:

Finding Verdict Action
responsive-layout.tsx — roving tabindex: ArrowLeft/Right updated state but didn't move focus, stranding it on the now-tabIndex=-1 button Accept (real a11y bug in the fix itself) Added refs to both tab buttons; focus now follows selection. Test updated to assert .toHaveFocus() + tabindex=0 on the newly active tab.

Also fixed the earlier prettier miss (edit-query-popover.tsx). The first run's docker buildx (api) red was a flake — clean on re-run. Only remaining red is the pre-existing backend (contract+integration) UBI test (#610).

SoundMindsAI and others added 3 commits July 11, 2026 09:11
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
…eview)

Roving tabindex requires focus to follow the active tab on ArrowLeft/Right —
updating state alone stranded focus on the now-tabIndex=-1 button, breaking
keyboard flow + SR announcements. Add refs to both tab buttons and focus the
newly-selected one; test asserts .toHaveFocus() + tabindex=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
@SoundMindsAI
SoundMindsAI force-pushed the feat_ux_accessibility branch from 70bfa5d to c8b7f3f Compare July 11, 2026 13:11
@SoundMindsAI
SoundMindsAI merged commit 6e2b5c5 into main Jul 11, 2026
17 of 20 checks passed
@SoundMindsAI
SoundMindsAI deleted the feat_ux_accessibility branch July 11, 2026 13:21
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