diff --git a/tests/test_tenants_endpoint.py b/tests/test_tenants_endpoint.py index 55f0bd5c1654..544e7added87 100644 --- a/tests/test_tenants_endpoint.py +++ b/tests/test_tenants_endpoint.py @@ -289,3 +289,66 @@ def test_fe_keyboard_nav_and_url_toggle_and_tab_title_pins(): # Format pins: `[] · 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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 48c83f268b92..aaeb078d14c0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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"; @@ -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" /> diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 987f4c0eeef4..b5a917fe26ef 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -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> diff --git a/web/src/components/TenantChangeAnnouncer.tsx b/web/src/components/TenantChangeAnnouncer.tsx new file mode 100644 index 000000000000..9b69d5db3d5c --- /dev/null +++ b/web/src/components/TenantChangeAnnouncer.tsx @@ -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> + ); +} diff --git a/web/src/components/TenantPicker.tsx b/web/src/components/TenantPicker.tsx index eca61acf540b..93d43dcbd349 100644 --- a/web/src/components/TenantPicker.tsx +++ b/web/src/components/TenantPicker.tsx @@ -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). @@ -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); @@ -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; @@ -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; } @@ -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", @@ -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; @@ -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, diff --git a/web/src/contexts/PageHeaderProvider.tsx b/web/src/contexts/PageHeaderProvider.tsx index 55485d940a19..85daed24a600 100644 --- a/web/src/contexts/PageHeaderProvider.tsx +++ b/web/src/contexts/PageHeaderProvider.tsx @@ -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 diff --git a/web/src/hooks/useActiveTenant.ts b/web/src/hooks/useActiveTenant.ts index 255c89f085ff..0104ff0bc623 100644 --- a/web/src/hooks/useActiveTenant.ts +++ b/web/src/hooks/useActiveTenant.ts @@ -39,6 +39,15 @@ export const TENANT_ID_QUERY_PARAM = "tenant" as const; export const DEFAULT_TENANT_ID = "default" as const; export const ALL_TENANTS_SENTINEL = "__all__" as const; +// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — operator's most- +// recent picks (most-recent-first), capped at 5 entries. Drives +// the TenantPicker's "Recent" section that surfaces fast-access +// switching for operators who toggle between 2-3 tenants. Empty +// on single-tenant deployments. Updated on every setActiveTenant +// call (including aggregate sentinel and "default"). +export const RECENT_TENANTS_STORAGE_KEY = "kora_recent_tenants" as const; +export const RECENT_TENANTS_CAP = 5 as const; + // KR-FE-MULTI-TENANT-COCKPIT-AGGREGATE-AND-DEEPLINK — operator-friendly // alias accepted in URL deep-links: ``?tenant=all`` resolves to the // ALL_TENANTS_SENTINEL pseudo-id. Keeps shareable URLs readable @@ -75,6 +84,61 @@ function writeStored(value: string): void { } } +// KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — recent-tenants +// localStorage read. Returns a sanitized list: only string +// entries, deduped, capped. Corrupt JSON / wrong shape silently +// resets to []; the picker then renders without the Recent +// section. A subsequent setActiveTenant call rebuilds the list +// cleanly. +function readRecent(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(RECENT_TENANTS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const out: string[] = []; + const seen = new Set<string>(); + for (const entry of parsed) { + if (typeof entry !== "string") continue; + if (!entry.trim()) continue; + if (seen.has(entry)) continue; + seen.add(entry); + out.push(entry); + if (out.length >= RECENT_TENANTS_CAP) break; + } + return out; + } catch { + return []; + } +} + +function writeRecent(next: string[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + RECENT_TENANTS_STORAGE_KEY, + JSON.stringify(next), + ); + window.dispatchEvent(new Event("kora:recent-tenants-changed")); + } catch { + // Best-effort. + } +} + +/** + * Push ``picked`` onto the head of the recent list; dedupe; cap. + * Pure helper so the test in the picker test-doubles can exercise + * the order semantics without round-tripping through localStorage. + */ +export function pushRecentTenant( + current: readonly string[], + picked: string, +): string[] { + const next = [picked, ...current.filter((t) => t !== picked)]; + return next.slice(0, RECENT_TENANTS_CAP); +} + export interface UseActiveTenantResult { /** "default", another tenant_id, or ALL_TENANTS_SENTINEL for aggregate view. */ activeTenant: string; @@ -87,6 +151,14 @@ export interface UseActiveTenantResult { isMultiTenant: boolean; /** True until the initial /api/tenants/list resolves. */ loadingTenants: boolean; + /** + * Operator's most-recent picks (most-recent-first, deduped, capped + * at RECENT_TENANTS_CAP). Filtered to entries still present in + * availableTenants — a corrupt localStorage entry or a + * since-deleted tenant won't leak into the picker. Includes the + * ALL_TENANTS_SENTINEL when the operator has picked aggregate. + */ + recentTenants: string[]; } /** @@ -132,6 +204,11 @@ function useResolvedTenant(): [string, (next: string) => void] { if (readUrlToggle()) { updateUrlTenantParam(next); } + // KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — track the recent + // list. Read fresh from localStorage to avoid stale closure + // capture (cross-tab activity may have updated it). pushRecentTenant + // handles dedupe + cap. + writeRecent(pushRecentTenant(readRecent(), next)); }, []); return [resolved, setActiveTenant]; @@ -258,6 +335,19 @@ export function useActiveTenant(): UseActiveTenantResult { DEFAULT_TENANT_ID, ]); const [loadingTenants, setLoadingTenants] = useState(true); + // KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — recent-tenants + // state. Live-synced via the same custom-event pattern as the + // active-tenant store so two picker instances stay coherent. + const [recentRaw, setRecentRaw] = useState<string[]>(() => readRecent()); + useEffect(() => { + const reread = () => setRecentRaw(readRecent()); + window.addEventListener("storage", reread); + window.addEventListener("kora:recent-tenants-changed", reread); + return () => { + window.removeEventListener("storage", reread); + window.removeEventListener("kora:recent-tenants-changed", reread); + }; + }, []); const fetchTenants = useCallback(async () => { try { @@ -288,6 +378,16 @@ export function useActiveTenant(): UseActiveTenantResult { return () => window.removeEventListener("focus", onFocus); }, [fetchTenants]); + // KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — filter recents to + // entries that still exist in availableTenants (a since-deleted + // tenant_id in localStorage must not leak into the picker). The + // ALL_TENANTS_SENTINEL is always valid in multi-tenant mode. + const recentTenants = useMemo(() => { + const allowed = new Set<string>(availableTenants); + allowed.add(ALL_TENANTS_SENTINEL); + return recentRaw.filter((t) => allowed.has(t)); + }, [recentRaw, availableTenants]); + return { activeTenant, setActiveTenant, @@ -297,5 +397,6 @@ export function useActiveTenant(): UseActiveTenantResult { // default tenant has ever been observed. isMultiTenant: availableTenants.length >= 2, loadingTenants, + recentTenants, }; } diff --git a/web/src/pages/WizardPage.tsx b/web/src/pages/WizardPage.tsx index 0af686ff0e1a..e354952c8fe0 100644 --- a/web/src/pages/WizardPage.tsx +++ b/web/src/pages/WizardPage.tsx @@ -50,6 +50,7 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@/components/NouiTypography"; import { Card, CardContent } from "@/components/ui/card"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { usePanelView } from "@/hooks/usePanelView"; import { api } from "@/lib/api"; import { @@ -904,6 +905,12 @@ export default function WizardPage() { const [config, setConfig] = useState<WizardConfig>(emptyConfig()); const [stepIdx, setStepIdx] = useState(0); const [skipError, setSkipError] = useState<string | null>(null); + // KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — every skip path + // (header button + per-step link) routes through this flag so + // the operator gets a confirmation modal before the wizard + // dismisses. Avoids accidental dismissal on multi-step setups. + const [skipPending, setSkipPending] = useState(false); + const [skipBusy, setSkipBusy] = useState(false); const navigate = useNavigate(); // Persist every config / step change. Creds + validation results @@ -941,7 +948,17 @@ export default function WizardPage() { const currentStep = STEP_DEFS[stepIdx]; - const skip = useCallback(async () => { + // KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — open the confirmation + // modal. The actual skip work happens in confirmSkip after the + // operator acknowledges (or here-and-now if they cancel — no work + // done at all). + const requestSkip = useCallback(() => { + setSkipError(null); + setSkipPending(true); + }, []); + + const confirmSkip = useCallback(async () => { + setSkipBusy(true); setSkipError(null); try { await api.completeWizard({ @@ -953,12 +970,22 @@ export default function WizardPage() { // KR-FE-WIZARD-RESUME-FROM-PARTIAL — clear the resume blob // on skip; the wizard is done for this session. clearPersisted(); + setSkipPending(false); navigate("/"); } catch (e) { setSkipError(e instanceof Error ? e.message : String(e)); + // Keep the modal open on error so the operator sees what + // went wrong instead of being silently redirected. + } finally { + setSkipBusy(false); } }, [config.tenantId, currentStep.key, navigate]); + const cancelSkip = useCallback(() => { + if (skipBusy) return; + setSkipPending(false); + }, [skipBusy]); + const advance = useCallback(() => { setStepIdx((idx) => Math.min(idx + 1, STEP_DEFS.length - 1)); }, []); @@ -1035,7 +1062,7 @@ export default function WizardPage() { <WandSparkles className="h-5 w-5" /> First-run setup </H2> - <Button size="sm" ghost onClick={() => void skip()}> + <Button size="sm" ghost onClick={requestSkip}> Skip — I'll configure manually </Button> </div> @@ -1063,6 +1090,21 @@ export default function WizardPage() { setConfig={setConfig} onAdvance={advance} /> + {/* KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — per-step + "Skip wizard" link at the bottom-left of each card. + Subtle (not a Button) so it doesn't compete with the + step's primary "Next" CTA. Routes through requestSkip + → ConfirmDialog so operators don't lose context by + accident. */} + <div className="pt-2 border-t border-current/10"> + <button + type="button" + onClick={requestSkip} + className="text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground underline-offset-2 hover:underline" + > + Skip wizard, configure manually + </button> + </div> </CardContent> </Card> @@ -1079,6 +1121,28 @@ export default function WizardPage() { </div> </CardContent> </Card> + + {/* KR-FE-A11Y-AUDIT-AND-MULTI-TENANT-POLISH — skip-confirmation + modal. Reuses the cockpit's shared ConfirmDialog (focus + management + Escape + portal already correct). Operator + can still download a partial .env from StepPromotionIntro + before triggering skip — that surface is reached via the + per-step body; this confirm only governs the marker write + + navigate-to-Dashboard. */} + <ConfirmDialog + open={skipPending} + title="Skip the wizard?" + description={ + skipError + ? `Skip failed: ${skipError}. Try again or cancel to stay in the wizard.` + : "Kora will load with whatever you've configured so far. You can still configure the remaining settings via your .env file manually." + } + confirmLabel="Skip wizard" + cancelLabel="Cancel" + loading={skipBusy} + onConfirm={() => void confirmSkip()} + onCancel={cancelSkip} + /> </div> ); }