diff --git a/docs/superpowers/plans/2026-06-08-a11y-focus.md b/docs/superpowers/plans/2026-06-08-a11y-focus.md new file mode 100644 index 0000000..ddc86af --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-a11y-focus.md @@ -0,0 +1,217 @@ +# Accessibility — Focus, Route Management, Honest Semantics — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every custom control a keyboard focus ring, make the authority "tabs" + lang switch honest, add a skip link + route focus management, and sync `` on language change. + +**Architecture:** A shared `focusRing` class is applied to the five bare controls. Authority tabs become honest `NavLink`s (`aria-current`), the lang switch gains a `role="group"`. The app-shell adds a skip link, a focusable `
`, and a route-change focus effect. A single `i18n.on("languageChanged")` listener syncs `document.documentElement.lang`. + +**Tech Stack:** React 19 + TS + pnpm, react-router 7 (`NavLink`/`useLocation`), react-i18next, Tailwind v4, Vitest + RTL. Test runner: `pnpm test` (single pass). + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (2 new keys); app source double-quote+semicolon; token classes only (`focus-visible:ring-ring` is a token); `focus-visible:` (not `:focus`) so mouse clicks don't ring. + +**Spec:** `docs/superpowers/specs/2026-06-08-a11y-focus-design.md` + +**Key facts (from code):** +- Bare controls lacking a ring: `lang-switch.tsx` (2 buttons, no `type`, inactive `text-muted-foreground`), `theme-switch.tsx` (3 icon buttons, `cn(...)`), `search-panel.tsx` facet chips (`className={\`rounded-md px-2 py-0.5 ${active ? … : "border"}\`}`), `field-list.tsx` row ` + ))} + + ); +} +``` + +- [ ] **Step 4: theme-switch.tsx** — add `focusRing` to the button `cn(...)`: change the `cn("rounded-md p-1 transition-colors", active ? … : …)` to include `focusRing` as a class arg. Import `focusRing`. + +- [ ] **Step 5: search-panel.tsx** — the facet chip ` diff --git a/web/src/shell/app-shell.test.tsx b/web/src/shell/app-shell.test.tsx index 728e4f3..b1182ef 100644 --- a/web/src/shell/app-shell.test.tsx +++ b/web/src/shell/app-shell.test.tsx @@ -21,6 +21,7 @@ function tree() { }> objects outlet} /> + fields outlet} /> login page} /> @@ -36,6 +37,35 @@ test("shows active and disabled nav and renders the outlet", async () => { expect(screen.getByRole("link", { name: /fields/i })).toBeInTheDocument(); }); +test("renders a skip link targeting the focusable main region", async () => { + renderApp(tree(), { route: "/objects" }); + await screen.findByText("objects outlet"); + + expect(screen.getByRole("link", { name: /skip to content/i })).toHaveAttribute( + "href", + "#main-content", + ); + + const main = document.getElementById("main-content"); + expect(main).toBeTruthy(); + expect(main?.tabIndex).toBe(-1); +}); + +test("moves focus to the main region on route change", async () => { + renderApp(tree(), { route: "/objects" }); + await screen.findByText("objects outlet"); + + // Initial mount must NOT steal focus to main. + expect(document.activeElement).not.toBe(document.getElementById("main-content")); + + await userEvent.click(screen.getByRole("link", { name: /fields/i })); + await screen.findByText("fields outlet"); + + await waitFor(() => + expect(document.activeElement).toBe(document.getElementById("main-content")), + ); +}); + test("language switch toggles to Swedish", async () => { renderApp(tree(), { route: "/objects" }); await userEvent.click(await screen.findByRole("button", { name: "SV" })); diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 650429f..819d5df 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -1,4 +1,6 @@ -import { Outlet } from "react-router-dom"; +import { useEffect, useRef } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { LangSwitch } from "./lang-switch"; import { ThemeSwitch } from "./theme-switch"; @@ -9,8 +11,27 @@ import { HeaderSearch } from "./header-search"; import { UserMenu } from "./user-menu"; export function AppShell() { + const { t } = useTranslation(); + const location = useLocation(); + const mainRef = useRef(null); + const didMount = useRef(false); + + useEffect(() => { + if (!didMount.current) { + didMount.current = true; + return; + } + mainRef.current?.focus(); + }, [location.pathname]); + return (
+ + {t("common.skipToContent")} +
@@ -21,7 +42,7 @@ export function AppShell() { -
+
diff --git a/web/src/shell/lang-switch.tsx b/web/src/shell/lang-switch.tsx index 0e2e2a2..628fe2d 100644 --- a/web/src/shell/lang-switch.tsx +++ b/web/src/shell/lang-switch.tsx @@ -1,17 +1,23 @@ +import { useTranslation } from "react-i18next"; + import { useLocale } from "../i18n/use-locale"; +import { focusRing } from "../lib/focus-ring"; +import { cn } from "@/lib/utils"; export function LangSwitch() { + const { t } = useTranslation(); const { locale, setLocale } = useLocale(); const base = locale.startsWith("sv") ? "sv" : "en"; return ( -
+
{(["sv", "en"] as const).map((lng) => ( diff --git a/web/src/shell/theme-switch.tsx b/web/src/shell/theme-switch.tsx index e366376..266751b 100644 --- a/web/src/shell/theme-switch.tsx +++ b/web/src/shell/theme-switch.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "../theme/use-theme"; import type { Theme } from "../theme/theme"; +import { focusRing } from "../lib/focus-ring"; import { cn } from "@/lib/utils"; const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [ @@ -29,6 +30,7 @@ export function ThemeSwitch() { title={t(`theme.${value}`)} className={cn( "rounded-md p-1 transition-colors", + focusRing, active ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",