<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Adds hermetic WCAG regression coverage for two closed surfaces: the Budget Bot Google Docs add-on sidebar (idle/busy/success/error for the review status region and the "Ask Claire" chat transcript) and the Board Doc editor/chat surfaces (ChatPanel, RichTextEditor, the chat-panel close/focus flow in DocumentEditorPage). This is test-led hardening of known core states, not a claim of full WCAG conformance.
## Why It's Needed
Neither surface had any accessibility regression coverage. Writing the failing tests first surfaced concrete, provable defects rather than hypothetical ones:
- The add-on's #status region (busy/success/error text for the whole review flow) and #log (chat transcript) had no ARIA live-region role at all — screen readers never announced progress, completion, or errors.
- onErr and two chat-log failure handlers fell through to rendering callBackendGet/callBackendPost's raw 'Backend HTTP <code>: <body>' Error.message verbatim — leaking a raw JSON/HTML/plain-text backend response body (potential stack traces, request IDs) directly into the sidebar. The exact same detection was already applied narrowly to one retry path (withSectionIdentityRepair_, KLAIR-3233) but not shared with every other call site.
- ChatPanel's busy indicator and message transcript had no live-region role, so a Coach Claire reply or the "Thinking…" status was silent to assistive tech; its spinner icon had no aria-hidden.
- Closing the Board Doc chat panel via its own header "Close chat" button unmounted it with no focus management, dropping keyboard/screen-reader focus to <body> instead of back to the "Toggle chat panel" button.
- RichTextEditor's TipTap contenteditable region had no accessible name/role.
## Changes
Production fixes (each proven necessary by a failing test written first):
- budget-bot-addon/Sidebar.html: #status → role="status"; #log → role="log" + aria-label; new shared sanitizeUnrecognizedBackendError_ (+ BACKEND_HTTP_ERROR_RE_) wired into onErr, sendChat's failure handler, and addressAllFindings's per-section failure handler.
- klair-client/.../ChatPanel.tsx: message list → role="log" + aria-label; busy indicator → role="status"; its spinner icon → aria-hidden.
- klair-client/.../DocumentEditorPage.tsx: chat panel close now returns focus to the top-bar toggle button (requestAnimationFrame-deferred, matching the existing sync-chip-dismiss pattern in the same file).
- klair-client/.../RichTextEditor.tsx: the editable region now carries role="textbox", aria-multiline="true", and an aria-label (from placeholder, or a generic fallback).
Test additions:
- budget-bot-addon/tests/production-vm.js: two new exact-source bounded-span loaders (loadSidebarChatFlow, loadSidebarStaticMarkup) — no copied reimplementation, unions the existing renderer spans.
- budget-bot-addon/tests/accessibility-baseline.test.js (24 tests): static-markup roles, idle button semantics, busy pointer+keyboard unavailability, success announcement-once, JSON/HTML/plain-text raw-body sanitization (onErr + sendChat failure), null/undefined handling, repeated transitions.
- klair-client/.../ChatPanel.a11y.spec.tsx (24 tests): roles/names, keyboard-only Tab order + native Enter/Space activation, busy/success/error live-region behavior, sanitized-error fixtures, decorative-icon aria-hidden, theme-token styling, and an automated jest-axe scan across all four states.
- klair-client/.../RichTextEditor.a11y.spec.tsx (4 tests): mounts the real TipTap component (not a stub — confirmed to render under happy-dom) for toolbar/editable-region roles and native keyboard activation.
- klair-client/.../editor-styles.reducedMotion.spec.ts (3 tests): structural proof that the existing klair-section-flash prefers-reduced-motion CSS block disables the animation with a static-tint fallback, scoped to only that rule.
- klair-client/.../DocumentEditorPage.chat.spec.tsx (+1 test): proves the new close-panel focus-restoration fix.
- klair-client/package.json: added jest-axe (smallest test-only addition — the installed stack had no automated a11y scanner; confirmed axe-core produces sane results under the project's happy-dom environment via a throwaway spike before adopting it).
All fixtures are synthetic and deterministic; no live Google Doc, Google/Clerk credentials, backend, LLM, AWS, or network access is used anywhere in these tests.
## Breaking Changes
None.
## Test Plan
budget-bot-addon/ (pnpm test → vitest run):
Test Files 12 passed (12)Tests 300 passed (300)
(276 pre-existing + 24 new, all in accessibility-baseline.test.js.)
klair-client/ (scoped to changed files, matching CI):
npx eslint --max-warnings 0 --no-warn-ignored <every changed .ts/.tsx> → 0 errorsnpx prettier --check <every changed file> → clean
pnpm tsc -p tsconfig.app.json --noEmit → clean
pnpm build → succeeds
klair-client/ full suite (pnpm test:run, not scoped — run in full since production files changed):
Test Files 663 passed (663)Tests 6819 passed | 16 skipped (6835)
(32 new tests: 24 in ChatPanel.a11y.spec.tsx + 4 in RichTextEditor.a11y.spec.tsx + 3 in editor-styles.reducedMotion.spec.ts + 1 added to DocumentEditorPage.chat.spec.tsx; the pre-existing 807 BoardDoc-scoped tests plus every other klair-client test all still pass unchanged.)
No repository-wide Python/backend command was run — this PR touches no klair-api/klair-udm files.
## Verification Artifact
- Automated-check output: included verbatim above (budget-bot-addon 300/300; klair-client 6819 passed/16 pre-existing skipped/0 failed; lint/prettier/tsc/build all clean on every changed file).
- Keyboard/focus/live-announcement evidence: exercised directly in the new specs — e.g. ChatPanel.a11y.spec.tsx's Tab-order + native-Enter-activation tests, its busy-state pointer-vs-keyboard unavailability tests, its role="log"/role="status" announcement assertions, and DocumentEditorPage.chat.spec.tsx's new close-panel focus-restoration test (await vi.waitFor(() => expect(toggle).toHaveFocus())). All pass per the Test Plan output above.
- Raw-body sentinel rejection: proven for JSON, HTML, and plain-text fixtures in both accessibility-baseline.test.js (onErr + sendChat, asserting the sentinels sentinel-12345/sentinel-html-999/sentinel-text-777/<h1>/<pre>/Traceback never appear) and ChatPanel.a11y.spec.tsx (same sentinel set, asserting alert.textContent/alert.innerHTML never contain them).
- Browser capture: pending browser capture — sanctioned Board Doc fixture unavailable. This cloud-agent sandbox has no klair-api/.env/klair-client/.env (only .env.example) and no sanctioned Clerk test-user/browser state or seeded Board Doc session, so the live /board-doc walkthrough (idle → busy → success → sanitized-error → reduced-motion/high-contrast → browser a11y scan) specified in this task could not be run. The Apps Script sidebar's objective coverage is the exact-source hermetic harness above, per this task's own Auth section.
## Impact Estimate
Business value: Prevents keyboard, focus, announcement, motion/contrast, and error-sanitization regressions in Budget Bot's core add-on and Board Doc flows before they reach users who rely on assistive technology.
Pre-AI estimate: 3 points - a human must inventory two frontend substrates, build deterministic state fixtures, add semantic/focus/live-region assertions, and wire objective accessibility checks into CI.
Closes KLAIR-3482
<!-- drones:impact-actual:begin -->
Agent time: 22 m (implementer 0 m · reviewer 22 m · addresser 0 m)
Summed across phases. The 9 reviewer dimensions ran concurrently, so this exceeds elapsed wall-clock.
Efficiency vs. estimate: ~65.8× (3 points = 24 h of pre-AI effort)
<!-- drones:impact-actual:end -->
## Review Round Completeness
- outcome: indeterminate
- round: 1
- dispatched: 5
- reported: 4
- missing: cross-cutting-review
- cause: dimension_shortfall
- head: 14b2e2d9aad71c23845f93335309a3fc6df2aec1
- run: fanout-3706-2026-09-03T15-42-29-120Z
<!-- drones:round-completeness head=14b2e2d9aad71c23845f93335309a3fc6df2aec1 run=fanout-3706-2026-09-03T15-42-29-120Z -->
An incomplete review round is not a clean round. Do not merge without re-firing review (drones review --pr <N> --post), which re-stamps this section, or an explicit operator override.
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-ffbbe917-2464-48fa-a279-9015a52f2050?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-ffbbe917-2464-48fa-a279-9015a52f2050&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>