Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

feat(kora): KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — a11y audit + recent tenants + wizard skip - #211

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH-MEGABUCKET
May 24, 2026
Merged

feat(kora): KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — a11y audit + recent tenants + wizard skip#211
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH-MEGABUCKET

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

Three deliverables. CC#1 NousResearch#447 (which would unlock the per-tenant audit-filter verification, the #1 recommendation from #210) is still in flight, so this bucket picks up the #2 recommendation (ad-hoc a11y audit) and bundles two small multi-tenant ergonomics items.

A — Ad-hoc a11y audit. Top 4 findings fixed (skip-to-main, tenant-change live region, picker trigger aria-label, OAuth external-link aria-label). Remaining findings documented in the deferred section below.

B — TenantPicker recent-tenants list. Recent section (up to 3) above All section when ≥3 tenants observed; capped 5-entry localStorage; corruption + since-deleted-tenant safe.

C — Wizard skip with confirmation + per-step link. Header skip button now routes through a confirmation modal; new per-step "Skip wizard, configure manually" subtle link inside each step card.

A11y audit findings + fixes

Severity Area Status
BLOCKER No skip-to-main-content link fixed — new <a href=\"#kora-main\"> in App shell; <main> gets id + tabIndex={-1} so the anchor moves keyboard focus
MAJOR No aria-live for tenant selection changes fixed<TenantChangeAnnouncer /> sr-only aria-live=\"polite\" role=\"status\" mounted in App shell; suppresses first-render + single-tenant noise
MAJOR TenantPicker trigger button aria-label doesn't convey listbox affordance fixed — "Active tenant: X. Press Enter to choose a different tenant."
MAJOR OAuthProvidersCard external-link icon button has no aria-label fixedaria-label on the anchor; nested <Button> marked aria-hidden + tabIndex={-1} so the link is the single accessible name
MINOR No forced-colors styles anywhere in web/src/ deferred — needs a focused theming pass; standard fallback is @media (forced-colors: active) { color: CanvasText; border: 1px solid CanvasText; } on tone-only badges
MINOR DeleteConfirmDialog doesn't enforce description prop deferred — works in current call sites; harden via Required description prop or runtime check in a follow-on

Audit method: ad-hoc, source-level grep + traversal — no axe-core / Lighthouse CI tooling added per §A.1 (CC#2 design call: ad-hoc this bucket, follow-on bucket adds CI tooling if findings warrant). Modals (ConfirmDialog, OAuthLoginModal, ModelPickerDialog) correctly trap focus, restore focus on close, have aria-modal + aria-labelledby. Sidebar nav groups use <button> (keyboard accessible). Toast already has a live region. Picker keyboard nav from #210 verified intact.

What changed

web/src/components/TenantChangeAnnouncer.tsx (new)

Single sr-only live region in the App shell. Exports TENANT_ANNOUNCER_LIVE_REGION_ROLE = \"status\" + TENANT_ANNOUNCER_ARIA_LIVE = \"polite\" for drift-pinning. First-render suppression via lastRef === null guard avoids announcing the boot state. Skipped entirely on single-tenant deployments.

web/src/App.tsx

Skip-to-main link (sr-only focus:not-sr-only Tailwind utility pattern) + <TenantChangeAnnouncer /> instance mounted before the existing layout chrome.

web/src/contexts/PageHeaderProvider.tsx

<main> gains id=\"kora-main\" + tabIndex={-1} (programmatic focus for the anchor jump).

web/src/components/TenantPicker.tsx

  • aria-label on trigger now announces the interaction model
  • recentTenants consumed from useActiveTenant
  • New <PickerSectionHeader role=\"presentation\"> rendered between Recent + All sections (presentation role keeps it out of the listbox option-index pattern)
  • Option list reshaped to {kind, tenantId} so duplicates between Recent + All are unique focus targets (keyboard nav stays correct); active-tenant matching prefers the All-section instance for initial highlight
  • Letter-jump + Enter still work — both updated to read opt.tenantId

web/src/hooks/useActiveTenant.ts

  • RECENT_TENANTS_STORAGE_KEY = \"kora_recent_tenants\" + RECENT_TENANTS_CAP = 5
  • readRecent / writeRecent with corruption-safe parse (array-only, string-only, deduped, capped)
  • pushRecentTenant(current, picked) pure helper for dedupe + cap
  • Cross-tab + in-tab sync via kora:recent-tenants-changed event
  • setActiveTenant now also pushes onto the recent list (in addition to localStorage write + opt-in URL write from feat(kora): KR-FE-TENANT-PICKER-KEYBOARD-NAV-AND-URL-TOGGLE-AND-TAB-TITLE — picker keyboard nav + URL-toggle + tab-title prefix #210)
  • recentTenants exposed on the hook result — filtered to entries still in availableTenants (since-deleted tenant won't leak into the picker; aggregate sentinel always allowed)

web/src/components/OAuthProvidersCard.tsx

External-link icon button: aria-label moved to anchor (Open <name> documentation in a new tab); nested <Button> becomes aria-hidden tabIndex={-1}.

web/src/pages/WizardPage.tsx

  • Reuses existing ConfirmDialog (focus + escape + portal already correct — no new modal infra per §C.2 STOP-ASK avoidance)
  • requestSkip opens modal; confirmSkip does the actual api.completeWizard + clearPersisted + navigate
  • Header skip button + per-step link both route through requestSkip
  • Per-step link is a subtle text-button at bottom of each step card (not a Button — keeps emphasis on the step's primary CTA)
  • Modal stays open on error (with error in description) so operator sees what went wrong instead of silent redirect

tests/test_tenants_endpoint.py

New test_fe_a11y_and_recent_tenants_pins cross-stack pin: recent-tenants storage key + cap + helper export, announcer role + politeness + sr-only class + App-shell mount, skip-to-main anchor target + matching id on <main>, wizard skip routes through ConfirmDialog with requestSkip / confirmSkip naming.

Build

  • tsc -b && vite build ✓ clean (pre-existing chunk-size warning; not introduced by this bucket)
  • python3 -m py_compile tests/test_tenants_endpoint.py
  • Pytest runtime not in CC#2 worktree (no .venv); CI runs on PR

Test plan

  • Keyboard nav: Tab from page load → first focus lands on "Skip to main content" link; Enter → focus jumps past sidebar to <main>
  • Open VoiceOver/NVDA → switch tenant in picker → screen reader announces "Active tenant changed to marvin"
  • Multi-tenant deployment with 3+ tenants: open picker → Recent section visible above All section; currently-active not duplicated in Recent
  • 2-tenant deployment: flat list (no Recent section)
  • Corrupt kora_recent_tenants localStorage value (e.g., \"not-an-array\") → picker still renders, Recent stays empty
  • Set recent to include a since-removed tenant_id → it's filtered out of the rendered Recent section
  • Wizard step 3: click "Skip wizard, configure manually" link → confirmation modal opens
  • Cancel in modal → returns to wizard step 3 with no state change
  • Confirm in modal → marker written, navigated to Dashboard, sessionStorage cleared
  • Wizard step 3 with api.completeWizard simulated failure → modal stays open with error in description; cancel returns to wizard
  • pytest tests/test_tenants_endpoint.py — 8 tests pass (3 endpoint + 5 drift-guard incl. new one)

Recommendation for next CC#2 dispatch

Per-tenant audit BE filter verification (once CC#1 NousResearch#447 lands). Still the #1 outstanding item from #208/#210. Check gh pr view 447 --json state,merged before dispatch.

Lighter alternatives if NousResearch#447 is still in flight:

  1. A11y follow-up: forced-colors styling pass. Add @media (forced-colors: active) rules to badge/tone classes + tenant-picker option states so high-contrast (Windows) mode doesn't lose color-only cues. Run cockpit in Chrome devtools forced-colors: active emulation; fix what fails. ~2-3 hours.
  2. A11y follow-up: axe-core in CI. If the forced-colors pass goes well, add @axe-core/react dev-only audit + add a GitHub Actions step to fail PRs that introduce new violations. ~2 hours (most time is wiring CI; the axe-core integration itself is small).

🤖 Generated with Claude Code

…ecent tenants + wizard skip

Deliverable A — accessibility audit (top 4 findings fixed):
  A.1 skip-to-main link in App shell (sr-only until focused;
      first tab-stop; targets new id="kora-main" on
      PageHeaderProvider's <main>; tabIndex=-1 so the anchor
      actually moves keyboard focus)
  A.2 TenantPicker trigger aria-label expanded to announce the
      listbox interaction model ("Active tenant: X. Press Enter
      to choose a different tenant.")
  A.3 <TenantChangeAnnouncer> sr-only aria-live="polite"
      role="status" region mounted in App shell; announces
      "Active tenant changed to <X>" on switch. First-render +
      single-tenant suppressed to avoid noise.
  A.4 OAuthProvidersCard external-link gets aria-label on the
      anchor; nested icon-only Button marked aria-hidden +
      tabIndex=-1 so it doesn't double-announce or steal focus
  A.5 / forced-colors styles deferred — listed in PR body
      "deferred findings" section. Modal infra + sidebar
      group-header buttons already accessible per audit.

Deliverable B — TenantPicker recent tenants:
  * localStorage kora_recent_tenants capped at 5 (drift-pinned)
  * useActiveTenant exposes recentTenants[]; filtered to
    entries still present in availableTenants so a since-
    deleted tenant won't leak into the picker
  * TenantPicker renders Recent (up to 3) + All sections when
    availableTenants.length >= 3; flat list otherwise
  * pushRecentTenant() pure helper exported for test
    accessibility; PickerSectionHeader renders role="presentation"
    so it doesn't break the listbox option-index pattern
  * Currently-active tenant filtered from Recent (no dupe)
  * Cross-tab + in-tab sync via kora:recent-tenants-changed event

Deliverable C — wizard skip with confirmation + per-step link:
  * Header skip button + new per-step "Skip wizard, configure
    manually" link (subtle text-button, bottom of step card)
    both route through requestSkip → ConfirmDialog reuse
  * confirmSkip writes marker via api.completeWizard then
    clears the resume blob and navigates to Dashboard
  * Modal stays open on error so operator sees the cause; can
    cancel back into the wizard or retry
  * .env partial download stays reachable via StepPromotionIntro
    body (operator can download before triggering skip)

Drift-guard extension (tests/test_tenants_endpoint.py):
  * RECENT_TENANTS_STORAGE_KEY + RECENT_TENANTS_CAP pins
  * pushRecentTenant export pin
  * TENANT_ANNOUNCER_LIVE_REGION_ROLE = "status" +
    TENANT_ANNOUNCER_ARIA_LIVE = "polite" pins
  * App.tsx mounts <TenantChangeAnnouncer /> + skip-to-main
    anchor targets #kora-main; PageHeaderProvider <main> has
    matching id
  * WizardPage routes skip through ConfirmDialog +
    requestSkip/confirmSkip naming pins

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker
rafe-walker merged commit bf6383b into feature/phase2-upgrades May 24, 2026
2 of 4 checks passed
@rafe-walker
rafe-walker deleted the feat/kora-KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH-MEGABUCKET branch May 24, 2026 19:16
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant