chrone/accessibility-audit #123

Merged
longjacksonle merged 8 commits from chrone/accessibility-audit into main 2026-05-16 21:46:27 +02:00

Summary

Phase 25 brings the lets-chat client onto a single consistent accessibility baseline. Closes LC-101 ("Chore(Client): Accessibility audit (keyboard nav, ARIA, focus rings)") plus two adjacent gaps the audit surfaced: live regions for WebSocket-delivered content, and modal semantics for the call-surface dialogs.

The codebase already had two reference-quality widgets — room/notify_dropdown.html and status/picker.html. This phase applies the same pattern to the rest of the app rather than inventing new patterns.

What shipped

  • Global :focus-visible rule in server/assets/main.css. One declaration covers every interactive element across the app. Color #2563eb (blue-600) matches the existing focus:ring-blue-500 palette. Per-component Tailwind overrides (notify_dropdown, sidebar) still win on selector specificity for elements that want different focus styling.

  • HTMX afterSwap focus restoration. Single persistent-shell IIFE on htmx:afterSwap. Cascade: autofocus child in target → triggering element if still in document and focusable → do nothing. [data-lc-skip-focus] opt-out attribute on swap targets that aren't controls (mention popover). Cooperation contract documented in the IIFE comment — non-blocking listener, never calls stopPropagation.

  • Live regions, per-container strategy:

    • #lc-conn-status-textrole="status" aria-live="polite", escalates to role="alert" on failed-long state
    • #lc-notify-bussr-only aria-live="polite" with redesigned drain (2000ms hold so SR engines have time to announce; immediate visual side effects unchanged)
    • [data-lc-call-status]aria-live="polite" for call state transitions
    • #lc-staged (composer attachments) → aria-live="polite" aria-atomic="false"
    • .composer-error / #lc-upload-errorrole="alert"
    • Deliberately no live region on #lc-broadcast-count, WS-fragment templates (ws/*.html), or mention popover — would double-announce or announce too aggressively
  • Icon-only button labels added across composer, sidebar, DM header, reaction bar, status picker:

    • DM header: "Start voice call", "Start video call"
    • Sidebar own-avatar trigger: "Set status" (+ OOB variant)
    • Reaction bar +: "Add reaction"
    • Composer attachment +: "Attach file"
    • Composer remove-attachment ×: "Remove attachment"
    • Buttons with visible text (Save/Cancel/Send/Reply/Ban/Mute/etc.) deliberately left unlabeled to avoid double-announce
  • Mobile nav aria-expanded toggling. Button label changed from "Open navigation" (lied about state) to "Toggle navigation" (state-neutral); aria-expanded flips on open/close.

  • Mention autocomplete combobox wiring. Textarea gains role="combobox", aria-autocomplete="list", aria-controls, aria-haspopup="listbox", aria-expanded toggle on open/close, aria-activedescendant updated on arrow navigation. aria-selected moved from inner <button> to the role="option" <li>. Each LI gets stable server-rendered id (lc-mention-option-{N} via Askama loop.index0). Mouse-click delegation broadened from button-only to anywhere in the option row.

  • Call dialogs get full modal semantics. Incoming and active dialogs gain role="dialog" aria-modal="true" aria-labelledby pointing at title elements. Mute / Camera buttons gain aria-pressed toggling. Focus trap helper (window.__lcDialogTrap) installs on open, disposes on close, restores previously-focused element. Re-queries focusable descendants on every Tab press to handle mid-call DOM mutations. All 13 close paths in call.js route through teardown() which calls disposeDialogTrap() exactly once. Migration semantics (Accept transition incoming→active, glare-loss outgoing→incoming) handled by silent-dispose-then-reinstall.

  • Axe-core baseline committed at docs/superpowers/plans/2026-05-16-phase25-accessibility-axe-baseline.json as a regression artifact for future passes.

Out of scope (deferred, named explicitly)

  • WCAG 2.1 AA compliance certification — legal artifact, not engineering work
  • Color contrast remediation — touches the theme; separate phase. Axe surfaced exactly the two nodes predicted: version footer (text-slate-400 on white, 2.56:1) and (optional) hint next to email field on /register. Both already on the deferred list before axe ran; zero unexpected findings.
  • prefers-reduced-motion honor — reconnect spinner, mention chip animations, auto-scroll all play regardless of user preference. Separate phase.
  • Screen reader walkthrough (VoiceOver / NVDA) — deferred follow-up with explicit time budget. Claiming a comprehensive SR audit without budgeting hours of NVDA/VoiceOver use is dishonest.
  • Voice-message seekbar keyboard alternative — canvas-based player has no role="slider", no <input type="range"> fallback, no arrow-key handling. Structurally hard; half-fixing is worse than not touching.
  • Uploaded-image alt text — requires composer UI change at upload time (user supplies alt text on upload). Separate feature, not an accessibility annotation pass.
  • Touch-target sizes / mobile-specific accessibility — separate concern
  • Voice control (Dragon, macOS Voice Control) / cognitive accessibility / i18n — distinct audits
  • Voice-message Play/Pause label toggling — the play button's aria-label stays "Play voice message" even when in pause state. Real bug, surfaced during Task 3. Deferred to a "toggle-state ARIA" mini-phase along with the next item.
  • Reaction chip aria-pressed — current visual-only state (bg-blue-200 vs bg-slate-200) doesn't reach SR. Same toggle-state ARIA mini-phase candidate.
  • Authenticated-surface axe scan — axe was run against /login and /register; the four requested URLs deduplicated to two because session-less requests redirect. Authenticated scanning requires a Puppeteer login script; deferred follow-up. Phase 25's annotation work targets the surfaces this scan would cover (settings, rooms, DMs, admin), so the next axe pass against authenticated surfaces should show low violation counts.

Verification

  • ./dev/cargo check -p lets-chat-server: clean in both standalone and saas modes
  • just check (clippy + fmt): clean
  • just test and just test-saas: no regression (phase doesn't touch test surface)
  • just verify: passes
  • Manual keyboard-only walkthrough of register → login → enter room → send message → mention via autocomplete → react → notify dropdown → settings → admin user row. Every step reachable, every interactive element shows the global focus outline (or per-component override on notify_dropdown / sidebar), every icon button announces its purpose.
  • Manual SR check with VoiceOver on the live-region surfaces: connection banner state transitions, mention bus burst-of-3 announcements, staged-upload status, composer error alerts, call status pill state. Combobox arrow navigation announces each option via aria-activedescendant.
  • Manual call-dialog check: incoming call → Accept → active call → Tab cycles Mute/Camera/Hang up only → Hang up → focus restored to triggering element. Decline path, getUserMedia failure path, remote-hangup path, Accept transition all preserve trap semantics.
  • Axe baseline scan: 1 unique violation (color-contrast, 2 nodes), both already in the deferred list, 0 unexpected findings.

Implementation notes

  • Reference widgets room/notify_dropdown.html and status/picker.html are the in-repo canonical examples for the disclosure/dropdown pattern.
  • Phase plan at docs/superpowers/plans/2026-05-16-phase25-accessibility.md documents the live-region strategy table, the call-dialog close-path enumeration (13 paths), and the implementation details for each task.
  • The axe baseline at docs/superpowers/plans/2026-05-16-phase25-accessibility-axe-baseline.json is a regression mechanism, not a CI artifact. After accessibility-affecting changes in future phases, rerun axe and diff. Adding new violations is a regression.
  • Phase 24's listener-cleanup discipline applies to the call-dialog focus trap. Every addEventListener has a paired removeEventListener on every lifetime-end path; for the call dialog the lifecycle hook is open/close (not htmx:beforeCleanupElement) because the dialog shells live in the persistent layout shell.
  • The HTMX afterSwap handler's cooperation contract is documented at the IIFE: non-blocking, never stopPropagation. Other handlers (composer's combobox state updates, future additions) run after it in registration order.

Closes LC-101.

## Summary Phase 25 brings the lets-chat client onto a single consistent accessibility baseline. Closes LC-101 ("Chore(Client): Accessibility audit (keyboard nav, ARIA, focus rings)") plus two adjacent gaps the audit surfaced: live regions for WebSocket-delivered content, and modal semantics for the call-surface dialogs. The codebase already had two reference-quality widgets — `room/notify_dropdown.html` and `status/picker.html`. This phase applies the same pattern to the rest of the app rather than inventing new patterns. ## What shipped - **Global `:focus-visible` rule** in `server/assets/main.css`. One declaration covers every interactive element across the app. Color `#2563eb` (blue-600) matches the existing `focus:ring-blue-500` palette. Per-component Tailwind overrides (notify_dropdown, sidebar) still win on selector specificity for elements that want different focus styling. - **HTMX afterSwap focus restoration.** Single persistent-shell IIFE on `htmx:afterSwap`. Cascade: autofocus child in target → triggering element if still in document and focusable → do nothing. `[data-lc-skip-focus]` opt-out attribute on swap targets that aren't controls (mention popover). Cooperation contract documented in the IIFE comment — non-blocking listener, never calls `stopPropagation`. - **Live regions, per-container strategy:** - `#lc-conn-status-text` → `role="status" aria-live="polite"`, escalates to `role="alert"` on `failed-long` state - `#lc-notify-bus` → `sr-only aria-live="polite"` with redesigned drain (2000ms hold so SR engines have time to announce; immediate visual side effects unchanged) - `[data-lc-call-status]` → `aria-live="polite"` for call state transitions - `#lc-staged` (composer attachments) → `aria-live="polite" aria-atomic="false"` - `.composer-error` / `#lc-upload-error` → `role="alert"` - Deliberately **no** live region on `#lc-broadcast-count`, WS-fragment templates (`ws/*.html`), or mention popover — would double-announce or announce too aggressively - **Icon-only button labels** added across composer, sidebar, DM header, reaction bar, status picker: - DM header: "Start voice call", "Start video call" - Sidebar own-avatar trigger: "Set status" (+ OOB variant) - Reaction bar `+`: "Add reaction" - Composer attachment `+`: "Attach file" - Composer remove-attachment `×`: "Remove attachment" - Buttons with visible text (Save/Cancel/Send/Reply/Ban/Mute/etc.) deliberately left unlabeled to avoid double-announce - **Mobile nav `aria-expanded`** toggling. Button label changed from "Open navigation" (lied about state) to "Toggle navigation" (state-neutral); `aria-expanded` flips on open/close. - **Mention autocomplete combobox** wiring. Textarea gains `role="combobox"`, `aria-autocomplete="list"`, `aria-controls`, `aria-haspopup="listbox"`, `aria-expanded` toggle on open/close, `aria-activedescendant` updated on arrow navigation. `aria-selected` moved from inner `<button>` to the `role="option"` `<li>`. Each LI gets stable server-rendered id (`lc-mention-option-{N}` via Askama `loop.index0`). Mouse-click delegation broadened from button-only to anywhere in the option row. - **Call dialogs** get full modal semantics. Incoming and active dialogs gain `role="dialog" aria-modal="true" aria-labelledby` pointing at title elements. Mute / Camera buttons gain `aria-pressed` toggling. Focus trap helper (`window.__lcDialogTrap`) installs on open, disposes on close, restores previously-focused element. Re-queries focusable descendants on every Tab press to handle mid-call DOM mutations. All 13 close paths in `call.js` route through `teardown()` which calls `disposeDialogTrap()` exactly once. Migration semantics (Accept transition incoming→active, glare-loss outgoing→incoming) handled by silent-dispose-then-reinstall. - **Axe-core baseline** committed at `docs/superpowers/plans/2026-05-16-phase25-accessibility-axe-baseline.json` as a regression artifact for future passes. ## Out of scope (deferred, named explicitly) - **WCAG 2.1 AA compliance certification** — legal artifact, not engineering work - **Color contrast remediation** — touches the theme; separate phase. Axe surfaced exactly the two nodes predicted: version footer (`text-slate-400` on white, 2.56:1) and `(optional)` hint next to email field on `/register`. Both already on the deferred list before axe ran; zero unexpected findings. - **`prefers-reduced-motion` honor** — reconnect spinner, mention chip animations, auto-scroll all play regardless of user preference. Separate phase. - **Screen reader walkthrough** (VoiceOver / NVDA) — deferred follow-up with explicit time budget. Claiming a comprehensive SR audit without budgeting hours of NVDA/VoiceOver use is dishonest. - **Voice-message seekbar keyboard alternative** — canvas-based player has no `role="slider"`, no `<input type="range">` fallback, no arrow-key handling. Structurally hard; half-fixing is worse than not touching. - **Uploaded-image alt text** — requires composer UI change at upload time (user supplies alt text on upload). Separate feature, not an accessibility annotation pass. - **Touch-target sizes / mobile-specific accessibility** — separate concern - **Voice control (Dragon, macOS Voice Control) / cognitive accessibility / i18n** — distinct audits - **Voice-message Play/Pause label toggling** — the play button's `aria-label` stays "Play voice message" even when in pause state. Real bug, surfaced during Task 3. Deferred to a "toggle-state ARIA" mini-phase along with the next item. - **Reaction chip `aria-pressed`** — current visual-only state (`bg-blue-200` vs `bg-slate-200`) doesn't reach SR. Same toggle-state ARIA mini-phase candidate. - **Authenticated-surface axe scan** — axe was run against `/login` and `/register`; the four requested URLs deduplicated to two because session-less requests redirect. Authenticated scanning requires a Puppeteer login script; deferred follow-up. Phase 25's annotation work targets the surfaces this scan would cover (settings, rooms, DMs, admin), so the next axe pass against authenticated surfaces should show low violation counts. ## Verification - `./dev/cargo check -p lets-chat-server`: clean in both standalone and saas modes - `just check` (clippy + fmt): clean - `just test` and `just test-saas`: no regression (phase doesn't touch test surface) - `just verify`: passes - **Manual keyboard-only walkthrough** of register → login → enter room → send message → mention via autocomplete → react → notify dropdown → settings → admin user row. Every step reachable, every interactive element shows the global focus outline (or per-component override on notify_dropdown / sidebar), every icon button announces its purpose. - **Manual SR check** with VoiceOver on the live-region surfaces: connection banner state transitions, mention bus burst-of-3 announcements, staged-upload status, composer error alerts, call status pill state. Combobox arrow navigation announces each option via `aria-activedescendant`. - **Manual call-dialog check**: incoming call → Accept → active call → Tab cycles Mute/Camera/Hang up only → Hang up → focus restored to triggering element. Decline path, getUserMedia failure path, remote-hangup path, Accept transition all preserve trap semantics. - **Axe baseline scan**: 1 unique violation (color-contrast, 2 nodes), both already in the deferred list, 0 unexpected findings. ## Implementation notes - Reference widgets `room/notify_dropdown.html` and `status/picker.html` are the in-repo canonical examples for the disclosure/dropdown pattern. - Phase plan at `docs/superpowers/plans/2026-05-16-phase25-accessibility.md` documents the live-region strategy table, the call-dialog close-path enumeration (13 paths), and the implementation details for each task. - The axe baseline at `docs/superpowers/plans/2026-05-16-phase25-accessibility-axe-baseline.json` is a regression mechanism, not a CI artifact. After accessibility-affecting changes in future phases, rerun axe and diff. Adding new violations is a regression. - Phase 24's listener-cleanup discipline applies to the call-dialog focus trap. Every `addEventListener` has a paired `removeEventListener` on every lifetime-end path; for the call dialog the lifecycle hook is open/close (not `htmx:beforeCleanupElement`) because the dialog shells live in the persistent layout shell. - The HTMX afterSwap handler's cooperation contract is documented at the IIFE: non-blocking, never `stopPropagation`. Other handlers (composer's combobox state updates, future additions) run after it in registration order. Closes LC-101.
Add the phase 25 plan document covering keyboard nav, ARIA, focus rings, live regions, and call dialog improvements. Also land the first mechanical task from that plan: a global `:focus-visible` rule in `main.css` that pins a 2px blue-600 outline to every interactive element on keyboard focus without affecting mouse users.
Wire up accessible live regions across the notification bus, connection
status banner, call status overlay, composer error/upload slots, and the
staged-files indicator.

- Convert `lc-notify-bus` from `hidden` to `sr-only` with `aria-live="polite"` and `aria-relevant="additions"` so mention notifications are announced without disrupting the visual layout
- Defer the bus drain by 2000ms after the last mutation so the SR has time to pick up each added child before `replaceChildren()` clears them; populate `node.textContent` with a human-readable summary for `mentioned` events only
- Add `aria-live="polite"` to `lc-conn-status-text` (role=status) and escalate to `role="alert"` only when the connection reaches the `failed-long` state, matching the visual red escalation
- Add `aria-live="polite"` to the call status overlay so SR users hear call progress without visual focus
- Mark upload errors and composer send errors as `role="alert"` (assertive) since they block the user action
- Add `aria-live="polite" aria-atomic="false"` to `lc-staged` so file staging changes are announced politely
- Add explicit `aria-label="Attach file"` to the `+` icon button so its purpose is unambiguous to SR users
Add aria-label to icon-only buttons (voice call, video call, add reaction,
set status, remove attachment) so assistive technology announces their
purpose rather than reading the visible "x" or remaining silent.

Harden the `announceForScreenReader` function to degrade gracefully when
data attributes are missing. Previously a missing author fell back to the
string "Someone" while a missing room label was silently omitted, producing
inconsistent output. The new six-way branch covers every combination of
author/room presence for both DM and mention events and always emits a
coherent phrase ("New direct message", "Mention in #general", "New mention",
etc.) instead of exposing raw undefined values.
Adds a persistent script to layout.html that restores keyboard focus after HTMX swaps using a three-step cascade: prefer an [autofocus] descendant in the swap target, fall back to the triggering element if it is still live and focusable, then do nothing so OOB-only swaps and WebSocket-driven updates leave focus undisturbed.

Includes a `data-lc-skip-focus` opt-out attribute (used by the mention-popover slot) and documents carve-outs for the composer send path (hx-swap="none") and WebSocket OOB swaps (no meaningful trigger element).
Add modal accessibility to the incoming-call and active-call overlays:

- Mark both dialog containers with `role="dialog"`, `aria-modal="true"`, and `aria-labelledby` pointing to a visible/sr-only title element; set `aria-hidden="true"` initially so the a11y tree matches the visual state.
- Add `window.__lcDialogTrap` in layout.html: a Tab/Shift+Tab focus trap that re-queries focusable descendants on every keypress so mid-call DOM mutations (mute/camera label changes, status pill) do not break wrap-around behavior.
- Wire `installDialogTrap` / `disposeDialogTrap` in call.js at every dialog open path (incoming, outgoing accept, callee accept) and funnel disposal through `teardown()` so the keydown listener is always removed exactly once.
- Sync `aria-hidden` in `show()`/`hide()` helpers to keep the a11y tree consistent with the CSS hidden class.
- Add `aria-pressed` to Mute and Camera toggle buttons; initial state in HTML is `false` and call.js keeps it in sync on every toggle and on reset.
Move aria-selected from the inner button to the role="option" li elements to match the WAI-ARIA listbox pattern. Wire up the textarea as a proper combobox with aria-expanded, aria-activedescendant, aria-controls, and aria-haspopup so screen readers track the highlighted option as the user navigates the list. Add data-lc-skip-focus to the popover slot so the global afterSwap focus handler does not steal focus from the textarea while the listbox refreshes.
feat(a11y): record axe baseline JSON and document Task 8 findings
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 48s
656280978f
longjacksonle deleted branch chrone/accessibility-audit 2026-05-16 21:46:27 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!123
No description provided.