chrone/accessibility-audit #123
Loading…
Reference in a new issue
No description provided.
Delete branch "chrone/accessibility-audit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.htmlandstatus/picker.html. This phase applies the same pattern to the rest of the app rather than inventing new patterns.What shipped
Global
:focus-visiblerule inserver/assets/main.css. One declaration covers every interactive element across the app. Color#2563eb(blue-600) matches the existingfocus:ring-blue-500palette. 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 callsstopPropagation.Live regions, per-container strategy:
#lc-conn-status-text→role="status" aria-live="polite", escalates torole="alert"onfailed-longstate#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"#lc-broadcast-count, WS-fragment templates (ws/*.html), or mention popover — would double-announce or announce too aggressivelyIcon-only button labels added across composer, sidebar, DM header, reaction bar, status picker:
+: "Add reaction"+: "Attach file"×: "Remove attachment"Mobile nav
aria-expandedtoggling. Button label changed from "Open navigation" (lied about state) to "Toggle navigation" (state-neutral);aria-expandedflips on open/close.Mention autocomplete combobox wiring. Textarea gains
role="combobox",aria-autocomplete="list",aria-controls,aria-haspopup="listbox",aria-expandedtoggle on open/close,aria-activedescendantupdated on arrow navigation.aria-selectedmoved from inner<button>to therole="option"<li>. Each LI gets stable server-rendered id (lc-mention-option-{N}via Askamaloop.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-labelledbypointing at title elements. Mute / Camera buttons gainaria-pressedtoggling. 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 incall.jsroute throughteardown()which callsdisposeDialogTrap()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.jsonas a regression artifact for future passes.Out of scope (deferred, named explicitly)
text-slate-400on 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-motionhonor — reconnect spinner, mention chip animations, auto-scroll all play regardless of user preference. Separate phase.role="slider", no<input type="range">fallback, no arrow-key handling. Structurally hard; half-fixing is worse than not touching.aria-labelstays "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.aria-pressed— current visual-only state (bg-blue-200vsbg-slate-200) doesn't reach SR. Same toggle-state ARIA mini-phase candidate./loginand/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 modesjust check(clippy + fmt): cleanjust testandjust test-saas: no regression (phase doesn't touch test surface)just verify: passesaria-activedescendant.Implementation notes
room/notify_dropdown.htmlandstatus/picker.htmlare the in-repo canonical examples for the disclosure/dropdown pattern.docs/superpowers/plans/2026-05-16-phase25-accessibility.mddocuments the live-region strategy table, the call-dialog close-path enumeration (13 paths), and the implementation details for each task.docs/superpowers/plans/2026-05-16-phase25-accessibility-axe-baseline.jsonis a regression mechanism, not a CI artifact. After accessibility-affecting changes in future phases, rerun axe and diff. Adding new violations is a regression.addEventListenerhas a pairedremoveEventListeneron every lifetime-end path; for the call dialog the lifecycle hook is open/close (nothtmx:beforeCleanupElement) because the dialog shells live in the persistent layout shell.stopPropagation. Other handlers (composer's combobox state updates, future additions) run after it in registration order.Closes LC-101.
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.