fix(a11y): frontend batch — 8 WCAG 2.2 AA findings#1230
Conversation
…mation
Wire useTranslations("aiActionConfirmation") and migrate every user-facing
string (action labels, descriptions, and Deny/Allow/Working…/Done/Denied
buttons + status) to t(). Mark all decorative ActionIcon glyphs and the
done/denied status icons aria-hidden="true" so screen readers announce the
real button/status text instead of icon noise. Adds the aiActionConfirmation
namespace to all 14 locale files with genuine translations.
WCAG 2.2 AA 4.1.2 Name, Role, Value
Refs #1202
The trigger button previously exposed its label only via title and a
hidden sm:inline span, leaving narrow viewports with no reliable accessible
name. Add an aria-label via t("triggerAriaLabel", { mode }), mark the icon
and now-redundant visible span aria-hidden, and migrate the mode
labels/descriptions and menu title to the new chainingModePicker namespace
across all 14 locales.
WCAG 2.2 AA 4.1.2 Name, Role, Value
Refs #1201
The boot splash swapped between waiting, error, and connecting states with no live region, so screen-reader users never heard "Couldn't connect" or the spinner. Wrap the state container in role="status" aria-live="polite" aria-atomic="true", give the spinner an accessible label, mark the WifiOff glyph aria-hidden, and migrate the heading/description/waiting/refresh strings to the new bootGate namespace across all 14 locales. WCAG 2.2 AA 4.1.3 Status Messages, 4.1.2 Name, Role, Value Refs #1199
The hand-rolled slide-in panel had no dialog semantics, no accessible name, and let Tab focus escape to the page behind it. Add role="dialog" aria-modal="true" and an aria-label, then implement a minimal focus trap: move focus into the panel on open, cycle Tab/Shift+Tab within it, keep Escape closing, and restore focus to the element that opened the drawer on close. Adds a single panelAriaLabel key to the new globalAiChatDrawer namespace across all 14 locales. WCAG 2.2 AA 2.4.3 Focus Order, 4.1.2 Name, Role, Value Refs #1198
…name
The entity search field exposed only a placeholder, which is not a reliable
accessible name. Add aria-label={t("searchAriaLabel")} and a matching
searchAriaLabel key in the existing homeAssistantPicker namespace across all
14 locales.
WCAG 2.2 AA 1.3.1 Info and Relationships, 4.1.2 Name, Role, Value
Refs #1196
Wire ConfigDisplay through useTranslations("configDisplay") so the
"Configuration" title, the "(click to toggle preview)" hint, and the
On/Off badges are localized across all 14 locales instead of being
hardcoded English.
Each service toggle button now exposes aria-pressed reflecting its
enabled state and a translatable aria-label ("Toggle {name}"), so a
screen reader announces both the control's purpose and its pressed
state. The decorative Vulcan-salute emoji span is marked
aria-hidden="true" so assistive tech no longer reads out "vulcan
salute" in place of the Quotes label.
Extends the existing configDisplay namespace with on/off/
toggleServiceAriaLabel keys in all 14 locale files (genuine
translations, proper nouns preserved).
WCAG 2.2 AA 4.1.2 Name, Role, Value
Closes #1194
AccountSection (and its ChangeUsername / ChangePassword / DisableAuth / EnableLogin sub-components) hardcoded every user-facing string in English, including the form aria-labels "Change username" and "Change password" that give the two settings forms their accessible names. On a non-English UI those forms announced an English name while all surrounding chrome was localized. Routes every card title, description, field label, button label, toast, validation message, and the two form aria-labels through a new accountSection namespace added to all 14 locale files with genuine translations (FiestaBoard / WiFi / API kept as-is). Rich strings (the mono username readout and the bold "not recommended" / "off" emphasis) use t.rich so the markup is preserved per-locale. WCAG 2.2 AA 4.1.2 Name, Role, Value Closes #1193
The FiestaBot chat transcript and the task-progress panel updated silently: streamed assistant tokens and pending→in_progress→done task transitions never reached a screen reader because neither container was a live region. The messages list is now aria-live="polite" with aria-relevant="additions text" and aria-atomic="false", so newly streamed assistant text is announced incrementally without re-reading the whole conversation. The TaskListPanel becomes a role="status" live region so task status changes are announced. Both regions carry a translatable aria-label from a new aiChatPanel namespace (added to all 14 locales). Also updates the ChainingModePicker assertion in ai-chat-panel.test.tsx to query by the trigger's accessible name (aria-label) instead of the title attribute removed in the chaining-mode-picker a11y fix earlier in this batch. WCAG 2.2 AA 4.1.3 Status Messages; 4.1.2 Name, Role, Value Closes #1192
There was a problem hiding this comment.
Small a11y fix needed — one redundant live-region announcement; everything else is a clear improvement.
Finding — boot-gate.tsx:108-112: duplicate announcement in the waiting state (WCAG 1.3.1)
The spinning loader gets role="img" + aria-label={t("waiting")} and the sibling <p> renders the identical text. Both live inside the role="status" aria-atomic="true" wrapper, so a screen reader announces the container as a unit — leading to "Waiting to start… Waiting to start…".
The spinner is purely decorative; the <p> already carries the announcement.
<div
className="h-8 w-8 rounded-full border-[2.5px] border-muted-foreground/25 border-t-muted-foreground animate-spin"
aria-hidden="true"
/>
Everything else looks solid
account-section.tsx— all hardcoded strings migrated;aria-labelon both<form>elements; labels properly associated to inputs viahtmlFor/id. ✓ai-action-confirmation.tsx— allActionIconrenders and theCheckCircle/XCirclestatus icons are nowaria-hidden="true"; label and description strings flow through i18n. ✓ai-chat-panel.tsx— message list promoted toaria-live="polite" aria-relevant="additions text"; task panel getsrole="status" aria-live="polite"— appropriate for streaming chat and progress updates. ✓chaining-mode-picker.tsx—titleattribute (hover-only, not reliably exposed to AT) correctly replaced witharia-labelon the trigger button; icon and visible label-text botharia-hidden="true"to avoid double-reading. ✓config-display.tsx— toggle buttons gainaria-pressed(state) andaria-label="Toggle {name}"(name);VulcanSaluteemoji span gainsaria-hidden="true". ✓global-ai-chat-drawer.tsx— panel promoted torole="dialog" aria-modal="true" aria-label=…; full focus-trap (Tab/Shift-Tab cycle), Escape-to-close, opener-focus restore. A proper modal treatment. ✓home-assistant-entity-picker.tsx—aria-labeladded to the search<input>that previously had only aplaceholder(placeholder is not a label substitute). ✓- i18n — every new
aria-label,formAriaLabel, and status string goes throughnext-intl; all 14 locale files carry matching keys; no hardcoded English left in TSX. ✓
Signed-off-by: Jeffrey D Johnson <jeffredodd@users.noreply.github.com>
There was a problem hiding this comment.
Small a11y fixes needed: one broken accessible name, one double-announcement.
Finding 1: Missing taskProgressAriaLabel key after namespace rename (WCAG 4.1.2)
File: web/src/components/ai-chat-panel.tsx (TaskListPanel)
The PR renames the translation namespace from aiChat to aiChatPanel but only migrates two of the three keys TaskListPanel needs. The progressbar loses its label.
The PR adds messagesAriaLabel and taskStatusAriaLabel to aiChatPanel across all 14 locale files, but NOT taskProgressAriaLabel. The base en.json has aiChat.taskProgressAriaLabel = 'AI task progress'. After this PR, the role=progressbar element calls t('taskProgressAriaLabel') with namespace aiChatPanel, where that key does not exist. next-intl will return the raw key string or throw, leaving the progressbar without an accessible label.
Fix: add taskProgressAriaLabel to the aiChatPanel block in all 14 locale files alongside the two keys already added. The orphaned aiChat namespace (now unused) can be dropped separately.
Finding 2: Double-announcement on the boot-gate loading spinner (WCAG 4.1.3 / aria-atomic)
File: web/src/components/boot-gate.tsx (waiting state)
The spinner div has role=img and aria-label={t('waiting')} with a visible p element containing the same t('waiting') text directly below it, all inside a role=status container with aria-atomic=true. Because the container is atomic, screen readers announce the entire subtree as a unit, so 'Waiting to start...' gets read twice.
Fix: replace role=img and aria-label on the spinner div with aria-hidden=true. The p element already conveys the state.
What looks good:
- config-display.tsx: aria-pressed plus translated aria-label on the service toggle buttons (WCAG 4.1.2). aria-hidden on the VulcanSalute emoji. Both are correct.
- chaining-mode-picker.tsx: replacing title with aria-label on the trigger button is correct; title is keyboard-inaccessible.
- global-ai-chat-drawer.tsx: full focus-management (focus moves in on open, Tab trap, restore to opener on close, Escape closes, role=dialog plus aria-modal plus aria-label) is solid WCAG 2.4.3 and 2.1.2 work.
- ai-chat-panel.tsx: aria-live=polite plus aria-relevant=additions text on the message list is the correct pattern for streaming chat. role=status on the task panel is appropriate.
- boot-gate.tsx: role=status plus aria-live=polite plus aria-atomic=true on the boot container is correct. aria-hidden on the WifiOff icon is right.
- account-section.tsx: swapping hardcoded aria-label and Label text for t() calls is the needed fix across the change-username and change-password forms.
- home-assistant-entity-picker.tsx: adding aria-label={t('searchAriaLabel')} to the search input is correct (placeholder alone is not an accessible label per WCAG 1.3.1).
|
@claude can you fix the lint issues? |
|
Claude finished @jeffredodd's task in 7m 33s —— View job Fixed 3 lint/review issues
All 3 issues were in a single commit: Root cause of the JSON issue: Two separate commits each added an |
…rogressAriaLabel, duplicate import
- boot-gate.tsx: replace role="img" aria-label on the loading spinner with
aria-hidden="true"; the sibling <p> already carries the announcement inside
the role="status" aria-atomic="true" container, so the img role caused a
double-read (WCAG 4.1.3 / aria-atomic finding from review)
- ai-chat-panel.tsx: remove duplicate `import { useTranslations }` (lint error)
- messages/*.json (all 14 locales): merge the two split aiChatPanel namespace
blocks into one and add the missing taskProgressAriaLabel key (was stranded
in the now-removed aiChat namespace). JSON parsers silently kept only the
second block, so messagesAriaLabel / taskStatusAriaLabel / taskProgressAriaLabel
were all falling back to raw key strings at runtime (WCAG 4.1.2 finding)
Co-authored-by: Jeffrey D Johnson <jeffredodd@users.noreply.github.com>
Summary
Resolves eight WCAG 2.2 AA accessibility findings from the automated a11y
audit across the FiestaBoard web UI. Each finding is one focused commit
(
fix(a11y): …) citing its WCAG criterion. Every new user-facing string —including all
aria-labels — is routed through the customuseTranslationswrapper (
@/i18n/translations) and added to all 14 locale files withgenuine, natural translations (proper nouns like FiestaBoard / WiFi / Home
Assistant / AI preserved). The strict 14-locale parity test
(
src/__tests__/i18n-config.test.ts) stays green.Findings addressed
ai-action-confirmation.tsxaiActionConfirmationnamespace;aria-hiddenon decorative action iconschaining-mode-picker.tsxaria-labelon the trigger (replaces title-only name that vanished at narrow widths); decorative icon + hidden visible labelaria-hiddenboot-gate.tsxrole="status"aria-live="polite"aria-atomic="true"; strings moved to abootGatenamespaceglobal-ai-chat-drawer.tsxrole="dialog"aria-modal="true"with an accessible name, a focus trap while open, and focus restored to the trigger on closehome-assistant-entity-picker.tsxaria-label(was placeholder-only);searchAriaLabeladded to the existinghomeAssistantPickernamespaceconfig-display.tsxaria-pressed+ translatablearia-label; "Configuration"/"On"/"Off"/"(click to toggle preview)" localized via the existingconfigDisplaynamespace; decorative Vulcan-salute emojiaria-hiddenaccount-section.tsxaccountSectionnamespace, including the "Change username" / "Change password" formaria-labelsso the forms' accessible names are locale-awareai-chat-panel.tsxaria-live="polite"region (aria-relevant="additions text",aria-atomic="false") so streamed replies are announced;TaskListPanelbecomes arole="status"live region for task progress; labels from a newaiChatPanelnamespaceTest plan
vitest run src/__tests__/i18n-config.test.ts→ 10 passed (identical namespaces, leaf-key paths, key counts; no empty values across de/en/es/fr/it/ja/ko/nl/pl/pt/ru/sv/tr/zh)vitest run→ 1065 passed, 13 skipped, 0 failed (87 files), including theai-chat-panelandconfig-displaycomponent testsprettier --checkclean on every touched.tsxand all 14messages/*.jsonai-chat-panel.test.tsxto assert the ChainingModePicker's accessible name viaaria-label(the title-based query broke when a11y: chaining-mode-picker — trigger button accessible name via title only at narrow viewports #1201 replacedtitlewitharia-label)web/tests/a11y.spec.ts(Playwright/axe) not run to completion locally — the dev container's nginx enforces aHost: localhostcheck that a browser navigation from a peer container can't satisfy, and the host port isn't reachable viahost.docker.internalin this sandbox. Verified out-of-band that every audited route (/,/pages,/schedule,/integrations,/settings,/login) serves a valid<html lang="en">+<title>(the only axe deltas seen weredocument-title/html-has-langon bare-shell error pages produced by the host-header artifact, identical on untouched routes). CI'sa11y-tests/ e2e jobs should run this spec cleanly — please confirm the axe spec is green on CI before merge.Manual verification checklist
sm: the chaining-mode trigger still has an accessible name announced by a screen reader (VoiceOver/NVDA)Closes #1202
Closes #1201
Closes #1199
Closes #1198
Closes #1196
Closes #1194
Closes #1193
Closes #1192
🤖 Generated with Claude Code