Skip to content

fix(dashboard): announce transcript, mark active nav, real modal behaviour (A11Y-1/4/5 + latch) - #15

Merged
ra-co88 merged 3 commits into
developfrom
refactor/async-inflight-latch
Oct 19, 2026
Merged

ra-co88 merged 3 commits into
developfrom
refactor/async-inflight-latch

Conversation

@ra-co88

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

Copy link
Copy Markdown
Owner

Fork remediation slice. Four commits, 20 files.

Posture note (2026-10-19): this fork is now developed for its own use, not
for upstream acceptance — measured upstream engagement was 15 PRs / 0 merged /
8 never commented on / oldest 21 days idle. See AGENTS.project.md §01. These
commits are still clean and tested because that protects the fork on rebase,
not because a reviewer is expected.

Commits

75b0c5aa extract the in-flight latch into a tested helper (FE-5/FE-12)
ae098e12 A11Y-1 + A11Y-4 — transcript live region, sidebar aria-current
cf2178e1 A11Y-5 — AppDialog with real modal keyboard behaviour
f9ffd84d A11Y-6 — default rose palette to WCAG AA

FE-5/FE-12 — the latch

The double-submit guards were hand-rolled inline refs, exercisable only by
mounting a 1400-line drawer whose mount effects never settle in jsdom. Extracted
runExclusive(ref, fn) and routed both handlers through it. confirmListRename
keeps its validation outside the latch so a rejected name cannot leave the
guard set.

A11Y-1 — the transcript is a live region

It was not, so a screen reader user got no announcement when a reply arrived.
Both render paths now carry role="log", aria-live="polite",
aria-relevant="additions", with aria-busy bound to isStreaming so
announcements are not emitted per streamed token.

A11Y-4 — aria-current on the sidebar

Zero aria-current under layouts/ before the fix. Both nav surfaces now set
it. Also found: the collapsed rail's buttons had no accessible name at all —
the label lived only in an antd Tooltip, which is not in the accessibility tree,
so every icon button announced as "button". Added aria-label.

A11Y-5 — AppDialog

The three hand-rolled overlays already declared role="dialog"/"alertdialog"
with aria-modal="true", but implemented none of it — zero Escape/keydown
handlers in any of them. Focus stayed on the trigger behind the overlay, Tab
walked into the page the attribute had declared inert, Escape did nothing, and
closing did not restore focus.

AppDialog provides focus-in, Tab/Shift+Tab wrap, Escape and focus restore;
layout and styling stay with the caller, so appearance is unchanged.
ServiceRestartOverlay is dismissable only in confirm/timeout — Escape must
not close it out from under a user mid-restart.

A11Y-6 — the default palette failed 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/active, which also brings rose in line with the
convention every other palette already follows (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, offline page and
token fallbacks follow.

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

Test plan

  • cd dashboard && npx tsc -b → clean
  • npx eslint on changed files → 0 errors
  • make all green on each commit via the pre-commit hook
  • Affected frontend area: 271 passed across 41 files
  • New: asyncLatch.test.ts (5), MessageList.a11y.test.tsx (3),
    SidebarCollapsedIconNav.a11y.test.tsx (3), AppDialog.test.tsx (6),
    themeContrast.test.ts (10)

Every new guard is mutation-verified — the suite fails when the behaviour is
removed:

Mutation Result
delete the guard in runExclusive 2 double-submit cases fail
remove aria-current from the collapsed rail nav test fails
remove either role="log" site corresponding MessageList case fails
disable the Tab trap / remove focus restore the matching AppDialog case fails
restore #E85D75 for rose themeContrast fails with the 3.36 figure

Notes / known limitations

  • MessageList's virtualised branch is asserted by source-attribute count, not
    by rendering
    — mounting Virtuoso with a real message list does not settle in
    jsdom (it hangs rather than failing). The plain path is asserted by rendering.
    Weaker than a render assertion; flagged in the test comment.
  • A palette-contrast assertion on the decorative swatch fills was deliberately
    not added.
    It would fail emerald (2.54:1) and amber (2.15:1) for a rule the
    design does not rely on — the chips are aria-hidden fills inside a labelled
    42px button. The test documents the reasoning and the measured spread instead.
  • New finding, not fixed here — split out: agent state dots are colour-only
    (bare <span>, no aria-hidden, no text alternative, at three sites in
    AgentSelector.tsx) and still use the saturated red/green pair. Needs
    lightness/shape as well as hue, plus an accessible name.
  • Pre-existing, unrelated vitest failures (AuthGuard,
    DocumentPreviewCore.docxSanitize, SkillDrawer, skillMarkdown,
    useChat.history) are load-order dependent and not caused by this branch.
    Neither CI nor make all runs vitest.

The double-submit guards in EditAgentDrawer were hand-rolled inline refs,
so they could only be exercised by mounting the 1400-line drawer - whose
mount effects never settle in jsdom, making a component-level regression
test impractical.

Extract runExclusive(ref, fn) into asyncLatch.ts and route both save
handlers through it. confirmListRename keeps its validation outside the
latch, so a rejected name can no longer leave the guard set.

Behaviour is unchanged; the guard is now covered by asyncLatch.test.ts,
which fails if the ref check is removed.
…1Y-1, A11Y-4)

A11Y-1: the chat transcript was not a live region, so a screen reader user got
no announcement when a reply arrived and had to re-navigate the log manually.
Both render paths (plain div and the virtualised Virtuoso list) now carry
role="log", aria-live="polite" and aria-relevant="additions", with aria-busy
bound to isStreaming so announcements are not fired per streamed token.

A11Y-4: the active sidebar entry was conveyed by colour alone. Both the
expanded row and the collapsed icon rail now set aria-current="page". The
collapsed rail also gains aria-label: its text lives in an antd Tooltip, which
is not part of the accessibility tree, so each button announced as "button".
…(A11Y-5)

The three hand-rolled overlays (PwaInstallPrompt's IosGuide and
DesktopInstallGuide, ServiceRestartOverlay) already declared
role="dialog"/"alertdialog" with aria-modal="true", but none implemented it:
focus stayed on the trigger behind the overlay, Tab walked into the page the
attribute had declared inert, Escape did nothing, and closing did not restore
focus. The markup promised modality that the keyboard never got.

Add AppDialog: focus moves to the first control on open, Tab and Shift+Tab
wrap inside, Escape dismisses when dismissable, and focus returns to the
trigger on close. Layout and styling stay with the caller.

ServiceRestartOverlay is dismissable only in its confirm and timeout phases —
Escape must not close it out from under a user mid-restart or on the success
screen. AppDialog.test.tsx covers all six behaviours and fails if the Tab trap
or the focus restore is removed.
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