Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions tests/test_tenants_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,66 @@ def test_fe_keyboard_nav_and_url_toggle_and_tab_title_pins():
# Format pins: `[<tenant>] <title> · Hermes Agent`.
assert "[all tenants]" in pageheader_src
assert "document.title = formatBrowserTabTitle(" in pageheader_src


def test_fe_a11y_and_recent_tenants_pins():
"""KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — pin the recent-
tenants storage key + the tenant-change live-region role/
politeness + the skip-to-main anchor target. Each is part of
the operator/assistive-tech-facing contract.
"""
repo = Path(__file__).parent.parent

# B — recent-tenants storage key + RECENT_TENANTS_CAP.
hook_src = (
repo / "web" / "src" / "hooks" / "useActiveTenant.ts"
).read_text(encoding="utf-8")
assert (
'RECENT_TENANTS_STORAGE_KEY = "kora_recent_tenants"' in hook_src
), (
"recent-tenants localStorage key must be 'kora_recent_tenants' — "
"operator-facing key, stable across releases"
)
assert "RECENT_TENANTS_CAP = 5" in hook_src
# pushRecentTenant pure helper exported so callers/tests can
# reuse the dedupe+cap semantics without round-tripping storage.
assert "export function pushRecentTenant" in hook_src
# Picker reads the new field on the hook.
picker_src = (
repo / "web" / "src" / "components" / "TenantPicker.tsx"
).read_text(encoding="utf-8")
assert "recentTenants" in picker_src
# Decorative section header renders for the two-section layout.
assert "PickerSectionHeader" in picker_src

# A.3 — aria-live announcer role + politeness + sr-only class.
announcer_src = (
repo / "web" / "src" / "components" / "TenantChangeAnnouncer.tsx"
).read_text(encoding="utf-8")
assert 'TENANT_ANNOUNCER_LIVE_REGION_ROLE = "status"' in announcer_src
assert 'TENANT_ANNOUNCER_ARIA_LIVE = "polite"' in announcer_src
assert 'className="sr-only"' in announcer_src
# App mounts the announcer once at the shell layer.
app_src = (repo / "web" / "src" / "App.tsx").read_text(encoding="utf-8")
assert "<TenantChangeAnnouncer />" in app_src

# A.1 — skip-to-main link targets #kora-main; PageHeaderProvider
# carries the matching id on <main>.
assert 'href="#kora-main"' in app_src
assert "Skip to main content" in app_src
pageheader_src = (
repo / "web" / "src" / "contexts" / "PageHeaderProvider.tsx"
).read_text(encoding="utf-8")
assert 'id="kora-main"' in pageheader_src

# C — wizard skip routes through ConfirmDialog; per-step link
# text is operator-facing so pin it.
wizard_src = (
repo / "web" / "src" / "pages" / "WizardPage.tsx"
).read_text(encoding="utf-8")
assert "ConfirmDialog" in wizard_src
assert "Skip wizard, configure manually" in wizard_src
# Both header skip + per-step link route through requestSkip;
# the actual completeWizard call lives behind confirmSkip.
assert "requestSkip" in wizard_src
assert "confirmSkip" in wizard_src
25 changes: 25 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { TenantPicker } from "@/components/TenantPicker";
import { TenantChangeAnnouncer } from "@/components/TenantChangeAnnouncer";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
Expand Down Expand Up @@ -738,6 +739,30 @@ export default function App() {
data-layout-variant={layoutVariant}
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
>
{/* KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — skip-to-main
link. Visually hidden until focused; first tab-stop on the
page. Targets the <main> element inside PageHeaderProvider
(id="kora-main"). Standard a11y pattern; keyboard users
skip the entire sidebar nav. */}
<a
href="#kora-main"
className={cn(
"sr-only focus:not-sr-only",
"focus:fixed focus:top-2 focus:left-2 focus:z-[100]",
"focus:rounded focus:border focus:border-current/30",
"focus:bg-background-base focus:px-3 focus:py-2",
"focus:text-xs focus:font-mono focus:text-midground",
"focus:outline-none focus:ring-2 focus:ring-midground/60",
)}
>
Skip to main content
</a>

{/* KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — single
aria-live region in the app shell that announces tenant
changes to assistive tech. */}
<TenantChangeAnnouncer />

<SelectionSwitcher />
<Backdrop />
<PluginSlot name="backdrop" />
Expand Down
6 changes: 5 additions & 1 deletion web/src/components/OAuthProvidersCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
rel="noopener noreferrer"
className="inline-flex"
title={`Open ${p.name} docs`}
// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH —
// accessible name on the link (title alone
// doesn't reach every assistive tech reliably).
aria-label={`Open ${p.name} documentation in a new tab`}
>
<Button ghost size="icon">
<Button ghost size="icon" aria-hidden tabIndex={-1}>
<ExternalLink />
</Button>
</a>
Expand Down
73 changes: 73 additions & 0 deletions web/src/components/TenantChangeAnnouncer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — visually-hidden
// aria-live region that announces active-tenant changes to
// assistive tech (VoiceOver / NVDA / Narrator). Single instance,
// mounted in the App shell.
//
// Why polite (not assertive): tenant switching is operator-
// initiated UI navigation, not an emergency. Polite lets the
// screen reader finish what it's reading before announcing,
// which matches every other navigation event.
//
// First-mount suppression: announcing "Active tenant is default"
// on every page load is noise. Only fire when the tenant actually
// changes after first observation.
//
// Skips firing on single-tenant deployments — there's no value to
// announce since there's nothing to switch to.

import { useEffect, useRef, useState } from "react";
import {
ALL_TENANTS_SENTINEL,
useActiveTenant,
} from "@/hooks/useActiveTenant";

export const TENANT_ANNOUNCER_LIVE_REGION_ROLE = "status" as const;
export const TENANT_ANNOUNCER_ARIA_LIVE = "polite" as const;

function describeTenant(tenant: string): string {
if (tenant === ALL_TENANTS_SENTINEL) return "all tenants";
return tenant;
}

export function TenantChangeAnnouncer() {
const { activeTenant, isMultiTenant } = useActiveTenant();
const [message, setMessage] = useState("");
// Track the last announced tenant. ``null`` ≡ first render;
// updating only on actual change avoids the noisy "default" /
// "default" / "default" repeats that happen as availableTenants
// resolves.
const lastRef = useRef<string | null>(null);

useEffect(() => {
// Hide announcements on single-tenant deployments (mirrors
// the picker auto-hide behavior — no tenant to switch to
// means no announcement worth making).
if (!isMultiTenant) {
lastRef.current = activeTenant;
return;
}
// Suppress the first observation post-multi-tenant becoming
// true; only announce on subsequent changes.
if (lastRef.current === null) {
lastRef.current = activeTenant;
return;
}
if (lastRef.current === activeTenant) return;
lastRef.current = activeTenant;
setMessage(`Active tenant changed to ${describeTenant(activeTenant)}`);
}, [activeTenant, isMultiTenant]);

return (
<div
role={TENANT_ANNOUNCER_LIVE_REGION_ROLE}
aria-live={TENANT_ANNOUNCER_ARIA_LIVE}
aria-atomic="true"
// Visually-hidden but reachable by assistive tech. Avoids
// ``display: none`` which removes the region from the
// accessibility tree entirely.
className="sr-only"
>
{message}
</div>
);
}
128 changes: 102 additions & 26 deletions web/src/components/TenantPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,43 @@ export function TenantPicker() {
isAllTenants,
isMultiTenant,
loadingTenants,
recentTenants,
} = useActiveTenant();
const [urlToggle, setUrlToggle] = useTenantUrlToggle();
const [open, setOpen] = useState(false);

// Render order mirrors the dropdown: real tenants in the order
// /api/tenants/list returned (default-first), then the aggregate
// sentinel as the final pseudo-row.
const options = useMemo(
() => [...availableTenants, ALL_TENANTS_SENTINEL],
[availableTenants],
);
// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — Recent section
// visible only when there are ≥ 3 tenants available (i.e.
// enough to warrant a fast-access surface; on 2 tenants the
// flat list is faster to scan than two sections of one). Take
// up to 3 entries from recentTenants. Currently-active tenant
// is excluded from Recent — it's already labelled "active" in
// the All section so showing it twice is noise.
const recentSection = useMemo<string[]>(() => {
if (availableTenants.length < 3) return [];
return recentTenants
.filter((t) => t !== activeTenant)
.slice(0, 3);
}, [recentTenants, availableTenants.length, activeTenant]);

// Flat option list for keyboard nav. Each entry carries its
// section so the visible header can render between sections;
// tenantId duplicates are fine (same id may appear once in
// Recent and once in All — keyboard nav indexes operate on
// the flat list so each visible row has a unique position).
type Option = { kind: "recent" | "all"; tenantId: string };
const options = useMemo<Option[]>(() => {
const all: Option[] = availableTenants.map((t) => ({
kind: "all" as const,
tenantId: t,
}));
all.push({ kind: "all", tenantId: ALL_TENANTS_SENTINEL });
const recent: Option[] = recentSection.map((t) => ({
kind: "recent" as const,
tenantId: t,
}));
return [...recent, ...all];
}, [availableTenants, recentSection]);

// Index of the highlighted option for keyboard nav. -1 ≡ none
// highlighted (closed-state default; reset when picker closes).
Expand Down Expand Up @@ -99,7 +125,14 @@ export function TenantPicker() {
// return focus to the trigger so keyboard flow continues.
useEffect(() => {
if (open) {
const activeIdx = options.indexOf(activeTenant);
// Prefer the All-section instance of the active tenant for
// the initial highlight — that section is the canonical
// ordering operators expect. Recent-section duplicate also
// matches activeTenant but never selects (currently-active
// is filtered out of recentSection upstream).
const activeIdx = options.findIndex(
(o) => o.kind === "all" && o.tenantId === activeTenant,
);
setHighlight(activeIdx >= 0 ? activeIdx : 0);
} else {
setHighlight(-1);
Expand Down Expand Up @@ -146,9 +179,11 @@ export function TenantPicker() {
(letter: string) => {
const lower = letter.toLowerCase();
const matches: number[] = [];
options.forEach((id, idx) => {
options.forEach((opt, idx) => {
const label =
id === ALL_TENANTS_SENTINEL ? "all" : id.toLowerCase();
opt.tenantId === ALL_TENANTS_SENTINEL
? "all"
: opt.tenantId.toLowerCase();
if (label.startsWith(lower)) matches.push(idx);
});
if (matches.length === 0) return;
Expand Down Expand Up @@ -203,7 +238,7 @@ export function TenantPicker() {
if (e.key === "Enter") {
e.preventDefault();
if (highlight >= 0 && highlight < options.length) {
onPick(options[highlight]);
onPick(options[highlight].tenantId);
}
return;
}
Expand Down Expand Up @@ -239,7 +274,11 @@ export function TenantPicker() {
onKeyDown={onTriggerKeyDown}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Active tenant: ${label}`}
// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — clarify the
// listbox affordance in the accessible name so screen
// readers announce both the current value and the
// interaction model on focus.
aria-label={`Active tenant: ${label}. Press Enter to choose a different tenant.`}
className={cn(
"flex w-full items-center justify-between gap-2",
"rounded border border-current/20 bg-card/60 px-2 py-1.5",
Expand Down Expand Up @@ -286,20 +325,37 @@ export function TenantPicker() {
"focus-visible:outline-none",
)}
>
{options.map((id, idx) => (
<TenantOption
key={id}
id={`tenant-option-${idx}`}
tenantId={id}
active={id === activeTenant}
highlighted={idx === highlight}
onPick={onPick}
onHover={() => setHighlight(idx)}
optionRef={(node) => {
optionRefs.current[idx] = node;
}}
/>
))}
{options.map((opt, idx) => {
// Render a section header above the first "all" option
// when a Recent section is present (recentSection.length > 0).
// The header is decorative (role="presentation") so it
// doesn't get counted as a listbox option.
const showAllHeader =
recentSection.length > 0 &&
opt.kind === "all" &&
(idx === 0 || options[idx - 1].kind !== "all");
const showRecentHeader =
recentSection.length > 0 && opt.kind === "recent" && idx === 0;
return (
<div key={`${opt.kind}:${opt.tenantId}`}>
{showRecentHeader && <PickerSectionHeader label="Recent" />}
{showAllHeader && <PickerSectionHeader label="All tenants" />}
<TenantOption
id={`tenant-option-${idx}`}
tenantId={opt.tenantId}
active={
opt.kind === "all" && opt.tenantId === activeTenant
}
highlighted={idx === highlight}
onPick={onPick}
onHover={() => setHighlight(idx)}
optionRef={(node) => {
optionRefs.current[idx] = node;
}}
/>
</div>
);
})}

{/* KR-FE-TENANT-PICKER-KEYBOARD-NAV-AND-URL-TOGGLE-AND-TAB-TITLE —
opt-in URL-toggle. Persisted via useTenantUrlToggle;
Expand Down Expand Up @@ -347,6 +403,26 @@ interface TenantOptionProps {
optionRef: (node: HTMLButtonElement | null) => void;
}

// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — decorative section
// header rendered between Recent + All sections. Role
// "presentation" keeps screen readers from counting it as a
// listbox option (the listbox aria-activedescendant pattern relies
// on each role="option" having a stable index).
function PickerSectionHeader({ label }: { label: string }) {
return (
<div
role="presentation"
className={cn(
"px-3 pt-2 pb-0.5",
"text-[9px] uppercase tracking-wider text-muted-foreground",
"border-t border-current/10 first:border-t-0",
)}
>
{label}
</div>
);
}

function TenantOption({
id,
tenantId,
Expand Down
6 changes: 6 additions & 0 deletions web/src/contexts/PageHeaderProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export function PageHeaderProvider({
</header>

<main
id="kora-main"
// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — target for
// the App-shell skip-to-main link. tabIndex=-1 makes the
// element programmatically focusable so the anchor jump
// moves keyboard focus, not just the viewport.
tabIndex={-1}
className={cn(
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
// Bottom inset for scrolled pages lives on the route outlet wrapper in
Expand Down
Loading
Loading