Skip to content

fix(a11y): frontend batch — 8 WCAG 2.2 AA findings#1230

Open
jeffredodd wants to merge 10 commits into
mainfrom
fix-a11y-frontend-batch
Open

fix(a11y): frontend batch — 8 WCAG 2.2 AA findings#1230
jeffredodd wants to merge 10 commits into
mainfrom
fix-a11y-frontend-batch

Conversation

@jeffredodd

Copy link
Copy Markdown
Member

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 custom useTranslations
wrapper (@/i18n/translations) and added to all 14 locale files with
genuine, 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

# Component WCAG 2.2 AA Fix
#1202 ai-action-confirmation.tsx 4.1.2 Name, Role, Value Migrated all strings to a new aiActionConfirmation namespace; aria-hidden on decorative action icons
#1201 chaining-mode-picker.tsx 4.1.2 Name, Role, Value Reliable, always-present aria-label on the trigger (replaces title-only name that vanished at narrow widths); decorative icon + hidden visible label aria-hidden
#1199 boot-gate.tsx 4.1.3 Status Messages; 4.1.2 Wrapped state container in role="status" aria-live="polite" aria-atomic="true"; strings moved to a bootGate namespace
#1198 global-ai-chat-drawer.tsx 2.4.3 Focus Order; 4.1.2 Panel is now role="dialog" aria-modal="true" with an accessible name, a focus trap while open, and focus restored to the trigger on close
#1196 home-assistant-entity-picker.tsx 1.3.1 Info & Relationships; 4.1.2 Search input given a persistent aria-label (was placeholder-only); searchAriaLabel added to the existing homeAssistantPicker namespace
#1194 config-display.tsx 4.1.2 Name, Role, Value Service toggles now expose aria-pressed + translatable aria-label; "Configuration"/"On"/"Off"/"(click to toggle preview)" localized via the existing configDisplay namespace; decorative Vulcan-salute emoji aria-hidden
#1193 account-section.tsx 4.1.2 Name, Role, Value Full string migration to a new accountSection namespace, including the "Change username" / "Change password" form aria-labels so the forms' accessible names are locale-aware
#1192 ai-chat-panel.tsx 4.1.3 Status Messages; 4.1.2 Chat transcript is an aria-live="polite" region (aria-relevant="additions text", aria-atomic="false") so streamed replies are announced; TaskListPanel becomes a role="status" live region for task progress; labels from a new aiChatPanel namespace

Note: five of these (#1202, #1201, #1199, #1198, #1196) were already
committed earlier on this branch; this PR completes the batch with
#1194, #1193, and #1192 and ships all eight together.

Test plan

  • i18n 14-locale parityvitest run src/__tests__/i18n-config.test.ts10 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)
  • Full web unit suitevitest run1065 passed, 13 skipped, 0 failed (87 files), including the ai-chat-panel and config-display component tests
  • Prettierprettier --check clean on every touched .tsx and all 14 messages/*.json
  • Stale-test repair — updated ai-chat-panel.test.tsx to assert the ChainingModePicker's accessible name via aria-label (the title-based query broke when a11y: chaining-mode-picker — trigger button accessible name via title only at narrow viewports #1201 replaced title with aria-label)
  • ⚠️ web/tests/a11y.spec.ts (Playwright/axe) not run to completion locally — the dev container's nginx enforces a Host: localhost check that a browser navigation from a peer container can't satisfy, and the host port isn't reachable via host.docker.internal in 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 were document-title/html-has-lang on bare-shell error pages produced by the host-header artifact, identical on untouched routes). CI's a11y-tests / e2e jobs should run this spec cleanly — please confirm the axe spec is green on CI before merge.

Manual verification checklist

Closes #1202
Closes #1201
Closes #1199
Closes #1198
Closes #1196
Closes #1194
Closes #1193
Closes #1192

🤖 Generated with Claude Code

…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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-label on both <form> elements; labels properly associated to inputs via htmlFor/id. ✓
  • ai-action-confirmation.tsx — all ActionIcon renders and the CheckCircle/XCircle status icons are now aria-hidden="true"; label and description strings flow through i18n. ✓
  • ai-chat-panel.tsx — message list promoted to aria-live="polite" aria-relevant="additions text"; task panel gets role="status" aria-live="polite" — appropriate for streaming chat and progress updates. ✓
  • chaining-mode-picker.tsxtitle attribute (hover-only, not reliably exposed to AT) correctly replaced with aria-label on the trigger button; icon and visible label-text both aria-hidden="true" to avoid double-reading. ✓
  • config-display.tsx — toggle buttons gain aria-pressed (state) and aria-label="Toggle {name}" (name); VulcanSalute emoji span gains aria-hidden="true". ✓
  • global-ai-chat-drawer.tsx — panel promoted to role="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.tsxaria-label added to the search <input> that previously had only a placeholder (placeholder is not a label substitute). ✓
  • i18n — every new aria-label, formAriaLabel, and status string goes through next-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>
@jeffredodd jeffredodd enabled auto-merge June 19, 2026 16:24
@github-actions github-actions Bot added patch and removed patch labels Jun 19, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@jeffredodd

Copy link
Copy Markdown
Member Author

@claude can you fix the lint issues?

@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jeffredodd's task in 7m 33s —— View job


Fixed 3 lint/review issues

  • Gather context — identified duplicate import, boot-gate spinner, and duplicate aiChatPanel JSON keys
  • Fix duplicate import { useTranslations } in ai-chat-panel.tsx (lint error, lines 35 & 46)
  • Fix boot-gate spinner double-announcement — replaced role="img" with aria-hidden="true"(the sibling

    already carries the state inside therole="status" aria-atomic="true"` wrapper)

  • Merge duplicate aiChatPanel blocks + add missing taskProgressAriaLabel in all 14 locale files — the two split aiChatPanel blocks are now one, and taskProgressAriaLabel has moved from the removed aiChat namespace into aiChatPanel where the component expects it

All 3 issues were in a single commit: 6568ee2

Root cause of the JSON issue: Two separate commits each added an "aiChatPanel" key at different positions in the files. JSON parsers silently keep only the last duplicate, so messagesAriaLabel, taskStatusAriaLabel, and taskProgressAriaLabel were all falling back to raw key strings at runtime. The fix merges both blocks into one (5 keys) and drops the now-empty aiChat namespace.
· Branch

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment