fix: accessibility, motion, and dark-mode QA pass across loading states (MON-217) - #304
Conversation
…ON-217) QA pass across MON-207's loading-state system found one real accessibility gap: in /chat, the Annuler/Reessayer button renders above the textarea in the DOM (messages first, input fixed below), so a keyboard user who just submitted and stays focused in the textarea can never reach it via forward Tab - only Shift+Tab, which is not a discoverable path. Confirmed via Playwright (Tab order landed on the page's disclaimer link, never on Annuler; Shift+Tab x3 reached it). Adds an opt-in `autoFocusAction` prop to AsyncStatus that focuses the cancel/retry button on mount, wired on for both of /chat's typing and error states. Verified live: focus now lands directly on Annuler the instant loading starts, and on Reessayer the instant a request fails. Rest of the epic-wide QA pass (reduced motion, dark-mode contrast, throttled network settling, mobile viewport, screen-reader question-answer cycle on both search and verify) verified clean with no further gaps - see PR description for the full pass writeup.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🧪 dbt data-health check — ✅ passedValidates prod data health. A failure means current prod data is stale/broken, not necessarily that this PR is wrong. |
Walid-peach
left a comment
There was a problem hiding this comment.
Attention Score
100/100 - SAFE TO SKIM
- Base 100
Reason you can skim: no Must/Should Fix findings, tiny diff (29 lines / 3 files, well under the size threshold), no high-risk paths, the new prop is optional/off-by-default so no existing usage changes behavior, new tests directly cover the change, and CI is green. The PR description also documents an extensive live-verification pass beyond what's in this diff.
Summary
This PR closes out the MON-207 epic with a targeted accessibility fix found during the promised cross-cutting QA pass: keyboard users could never reach AsyncStatus's cancel/retry button in /chat via forward-Tab, because it renders above the textarea in DOM order (messages accumulate above a fixed input bar) - only Shift+Tab reached it, which isn't discoverable. The fix adds an opt-in autoFocusAction prop that focuses the button on mount, wired on for both of chat's loading and error states. The PR description's QA writeup (reduced motion, dark-mode screenshots, screen-reader cycle, throttled network, mobile viewport) is a genuinely thorough verification pass for a feature this timing/accessibility-sensitive.
Must Fix
None.
Should Fix
None.
Nice to Have
autoFocusAction's doc comment onAsyncStatusProps(frontend/src/components/ui/AsyncStatus.tsx) explains why it's needed for chat's specific DOM ordering but doesn't flag that any futureAsyncStatusconsumer should think through whether their own DOM ordering has the same problem before blindly copyingautoFocusActionon - worth a one-line addition if this component gains more call sites, not urgent now with a single consumer.- No test covers that
autoFocusActionrefocuses correctly across successive submissions (i.e., that a second typing message in the same conversation also receives focus, not just the first). GivenautoFocusis a mount-time-only DOM behavior and each new typing/error message is a genuinely new React element (verified in the MON-216 PR's own reasoning about append-only message arrays), this should already work, but an explicit test would make that guarantee visible rather than inferred.
Testing / Validation Gaps
None. Three new unit tests cover the prop directly (focuses cancel, focuses retry, no-op when unset), and the PR description documents live verification of the actual fix (focus lands on Annuler/Réessayer immediately, Enter activates without any Tab needed) plus the rest of the epic-wide QA pass.
Documentation / Reviewer Notes
The PR body's QA section is worth reading in full even though the diff is small - it's the actual deliverable for this issue (a verification pass, not new component work) and documents real findings (e.g. the reduced-motion unit-conversion non-issue, the throttled-network false-negative caught and re-verified) rather than just asserting "verified." No further docs needed.
Verdict
Ready to merge
What
The final sub-issue in the MON-207 epic: a cross-cutting QA pass over everything MON-214/215/216 shipped (reduced motion, dark-mode contrast, keyboard access, screen-reader cycle, cached/throttled network, mobile viewport), fixing one real gap it found rather than filing it onward.
Why
MON-214/215/216 each shipped with their own unit tests and manual verification, but nothing had checked the system end to end - across viewports, themes, motion preference, and input modality at once. This issue is that check.
Changes
frontend/src/components/ui/AsyncStatus.tsx: new opt-inautoFocusActionprop that focuses the cancel/retry button on mount.frontend/src/app/chat/page.tsx: wiredautoFocusActionon both the typing-state and error-stateAsyncStatusinstances.frontend/__tests__/components/AsyncStatus.test.tsx: three new tests for the prop (focuses cancel, focuses retry, does nothing when unset).QA pass findings
All checks below were run against the live production API (not mocks) via a scripted Playwright pass, plus the existing unit suites.
Bug found and fixed:
/chat,AsyncStatus's Annuler/Réessayer button renders inside the message list, which sits above the input bar in the DOM (messages accumulate above a fixed-position textarea). A keyboard user who just pressed Enter to submit stays focused in the textarea - and forward-Tab from there skips straight past the newly-appeared cancel button to the page's disclaimer link, never reaching it. Confirmed with a scripted Tab-order walk (landed onassemblee-nationale.fr, notAnnuler) and confirmed the button was only reachable via 3x Shift+Tab - not a discoverable path. Fixed withautoFocusAction: focus now moves to Annuler/Réessayer the instant it mounts, verified live (focus lands there immediately, Enter activates it, no Tab needed).Verified clean, no gaps:
.dp-skeleton-block's shimmer computedanimation-duration: 1e-06s(0.001ms) underprefers-reduced-motion: reduce- the existing MON-194 global CSS rule catches it automatically, no component-level change needed.AsyncStatus's activity dot already omitsanimate-pulseunder reduced motion (tested since MON-214)./votes,/deputes, and/chatall render correctly in dark mode - verified visually (screenshots), tokens are the existing contrast-vettedvar(--dp-*)set already used elsewhere in the app.role="status"+aria-busy="true"while in flight, then the sr-onlyaria-live="polite"region announces the completed answer/verdict once it lands (e.g."Nouvelle réponse : Positions de vote par groupe parlementaire : ...","Vérification terminée : trompeur")./votes' client-side filter refetch under 900ms CDP-throttled latency settles cleanly (confirmed with a patient poll after an initial false-negative in a too-eager first check - the request completes and the skeleton clears within ~1.5s, matching the timing policy). Cached ISR navigation to/votesshows no loader per MON-215's existing verification./votesskeleton and/chatloading state both render with no horizontal overflow.Not independently re-verified in this pass (already covered by MON-214/215/216's own test suites and manual checks, not re-litigated here): exact 100ms/500ms/1.5s/8s timing-phase boundaries -
frontend/__tests__/lib/loadingPolicy.test.tsalready asserts these deterministically with fake timers, which is a more precise instrument than a live wall-clock screenshot race would be.Testing
npm test- full suite, 232/232 passing.npm run lint/npm run build- clean.Risks / Notes
AsyncStatus's new prop is optional and defaults to off, so no existing usage changes behavior.Breaking Changes
None.