13 KiB
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 <html lang> on language change.
Architecture: A shared focusRing class is applied to the five bare controls. Authority tabs become honest NavLinks (aria-current), the lang switch gains a role="group". The app-shell adds a skip link, a focusable <main>, 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, notype, inactivetext-muted-foreground),theme-switch.tsx(3 icon buttons,cn(...)),search-panel.tsxfacet chips (className={\rounded-md px-2 py-0.5 ${active ? … : "border"}`}),field-list.tsxrow,authorities-page.tsxtabNavLink`s. authorities-page.tsx:<div role="tablist" className="mb-3 flex gap-2">+NavLink ... role="tab" aria-selected={k === currentKind}.NavLinkaddsaria-current="page"to the active link by default.app-shell.tsx:<main className="flex-1 overflow-hidden"><Outlet/></main>, no id/tabIndex/skip link/route effect.i18n/index.ts: i18n init;i18n.language; no html-lang sync.ui/button.tsxring:focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50.- Tests to update:
authorities.test.tsx("kind tabs link…" + "aria-selected…" usegetByRole("tab")/aria-selected);app-shell.test.tsx(tree()has/objects+/login; nav + lang tests).
Task 1: Focus rings + honest control semantics
Files: web/src/lib/focus-ring.ts (new), web/src/shell/lang-switch.tsx, web/src/shell/theme-switch.tsx, web/src/search/search-panel.tsx, web/src/fields/field-list.tsx, web/src/authorities/authorities-page.tsx, web/src/i18n/en.json, web/src/i18n/sv.json, web/src/authorities/authorities.test.tsx.
- Step 1:
web/src/lib/focus-ring.ts:
export const focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
-
Step 2: i18n — add
"language"to thecommonnamespace (en "Language" / sv "Språk"), both locales (parity). -
Step 3: lang-switch.tsx — wrap in a labelled group + add
type="button"+ ring (importuseTranslation,focusRing,cn):
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 (
<div role="group" aria-label={t("common.language")} className="flex gap-1 text-xs">
{(["sv", "en"] as const).map((lng) => (
<button
key={lng}
type="button"
onClick={() => setLocale(lng)}
aria-pressed={base === lng}
className={cn("rounded-sm px-1", focusRing, base === lng ? "font-bold" : "text-muted-foreground")}
>
{lng.toUpperCase()}
</button>
))}
</div>
);
}
-
Step 4: theme-switch.tsx — add
focusRingto the buttoncn(...): change thecn("rounded-md p-1 transition-colors", active ? … : …)to includefocusRingas a class arg. ImportfocusRing. -
Step 5: search-panel.tsx — the facet chip
<button>className: addfocusRing. Usecn(import it) or append the string:className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}. ImportfocusRing+cn. -
Step 6: field-list.tsx — the row
<button className="flex flex-1 items-center gap-2 text-left">: addrounded-sm+focusRing(importfocusRing+cn):className={cn("flex flex-1 items-center gap-2 rounded-sm text-left", focusRing)}. -
Step 7: authorities-page.tsx — honest semantics + ring. Replace the
<div role="tablist">block:
<nav aria-label={t("nav.authorities")} className="mb-3 flex gap-2">
{KINDS.map((k) => (
<NavLink
key={k}
to={`/authorities/${k}`}
className={({ isActive }) =>
cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")
}
>
{t(`authorities.${k}`)}
</NavLink>
))}
</nav>
Drop role="tab" + aria-selected (NavLink applies aria-current="page" to the active link automatically). Import focusRing + cn.
-
Step 8: Update
authorities.test.tsx— the two tab tests:- "kind tabs link to the other kinds":
findByRole("tab", { name: /place/i })→findByRole("link", { name: /place/i })(still asserthref="/authorities/place"). - "aria-selected…": rename to active-kind via
aria-current:expect(await screen.findByRole("link", { name: /^person$/i })).toHaveAttribute("aria-current", "page");andexpect(screen.getByRole("link", { name: /^place$/i })).not.toHaveAttribute("aria-current");. (Confirm no link-name ambiguity — the page renders only the 3 kind links + the breadcrumb/PageTitle; if the harness includes other "person/place"-named links, scope withwithin. Don't weaken.)
- "kind tabs link to the other kinds":
-
Step 9: Verify (vitest ONCE):
cd web && pnpm vitest run src/authorities src/shell src/search src/fields && pnpm typecheck && pnpm lint && pnpm check:colors. PASS. (The ring classes are token-based → check:colors clean. The other tests must stay green.) -
Step 10: Commit
git add web/src/lib/focus-ring.ts web/src/shell/lang-switch.tsx web/src/shell/theme-switch.tsx web/src/search/search-panel.tsx web/src/fields/field-list.tsx web/src/authorities/authorities-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/authorities/authorities.test.tsx
git commit -m "feat(web): focus-visible rings on custom controls; honest authority links + lang group (#52)"
Task 2: Skip link + route focus + html lang sync
Files: web/src/shell/app-shell.tsx, web/src/i18n/index.ts, web/src/i18n/en.json, web/src/i18n/sv.json, web/src/shell/app-shell.test.tsx, web/src/i18n/i18n.test.tsx.
-
Step 1: i18n — add
"skipToContent"tocommon(en "Skip to content" / sv "Hoppa till innehåll"), both locales (parity). -
Step 2: app-shell.tsx — skip link + focusable main + route focus.
import { useEffect, useRef } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
// …existing imports…
export function AppShell() {
const { t } = useTranslation();
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
const didMount = useRef(false);
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
mainRef.current?.focus();
}, [location.pathname]);
return (
<div className="flex min-h-screen">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:border focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:ring-3 focus:ring-ring/50"
>
{t("common.skipToContent")}
</a>
<Sidebar />
<BreadcrumbProvider>
<div className="flex flex-1 flex-col">
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
<main ref={mainRef} id="main-content" tabIndex={-1} className="flex-1 overflow-hidden outline-none">
<Outlet />
</main>
</div>
</BreadcrumbProvider>
</div>
);
}
Verify sr-only/focus:not-sr-only exist in this Tailwind v4 setup (they're standard utilities; if the focus reveal doesn't work, use an explicit visually-hidden style and confirm by running the test). The skip link is the FIRST focusable element.
- Step 3: i18n/index.ts — html lang sync. After the
i18n.init(...)call, add:
function syncHtmlLang(lng: string) {
if (typeof document !== "undefined") {
document.documentElement.lang = lng.startsWith("sv") ? "sv" : "en";
}
}
i18n.on("languageChanged", syncHtmlLang);
syncHtmlLang(i18n.language);
(Place before export default i18n;.)
-
Step 4: Tests.
- app-shell.test.tsx — skip link + route focus. Add:
- skip link:
expect(screen.getByRole("link", { name: /skip to content/i })).toHaveAttribute("href", "#main-content");and the<main>hasid="main-content"(querydocument.getElementById("main-content")→ truthy,tabIndex === -1). - route focus: extend
tree()with a second route under<AppShell>(e.g.<Route path="/fields" element={<div>fields outlet</div>} />); render at/objects, click the sidebar Fields link (screen.getByRole("link", { name: /fields/i })),await screen.findByText("fields outlet"), then assertdocument.activeElement === document.getElementById("main-content")(the route change focused main). (Initial mount must NOT focus main — optionally assert activeElement is body/not-main right after the first render.)
- skip link:
- i18n.test.tsx — html lang. Add a test: after
await i18n.changeLanguage("sv"),expect(document.documentElement.lang).toBe("sv"); afterawait i18n.changeLanguage("en"),toBe("en"). (The file already toggles language; theafterEachresets to en, so assert within the test.)
- app-shell.test.tsx — skip link + route focus. Add:
-
Step 5: FULL GATE (run tests EXACTLY ONCE):
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
All green. Report test totals, largest chunk, check:colors line. (Storybook-cache flake remedy if needed: rm -rf node_modules/.cache/storybook node_modules/.vite, re-run ONCE.)
- Step 6: Codename + status:
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
-
Step 7: Manual smoke (recommended).
pnpm dev: Tab from the top → "Skip to content" appears first and jumps focus to the content; every custom control shows a focus ring on keyboard focus; navigating routes moves focus to the content region; the authority kind links read as links with the current one marked; switching to SV sets<html lang="sv">(check devtools). -
Step 8: Commit
git add web/src/shell/app-shell.tsx web/src/i18n/index.ts web/src/i18n/en.json web/src/i18n/sv.json web/src/shell/app-shell.test.tsx web/src/i18n/i18n.test.tsx
git commit -m "feat(web): skip link + route focus management + html lang sync (#52)"
Self-Review (completed)
Spec coverage: focusRing + 5 controls (T1 S1,S3–S7); lang group + authority honest links (T1 S3,S7); i18n common.language/skipToContent (T1 S2, T2 S1); skip link + focusable main + route focus (T2 S2); html lang sync (T2 S3); tests for tabs→links, skip link, route focus, html lang (T1 S8, T2 S4); gate (T2 S5). Acceptance criteria 1–5 mapped. ✓
Placeholder scan: the sr-only/focus:not-sr-only reveal is "verify it works by running" (a real validation, with an explicit fallback), not a TODO. Test steps name exact queries + the harness extension. No vague steps. ✓
Type/consistency: focusRing (string) defined in T1 S1, imported by all 5 controls + applied via cn; NavLink aria-current (native) replaces role="tab"/aria-selected consistently in the component + the test; mainRef/didMount refs + useLocation().pathname dependency consistent. ✓
Notes
- No new dependency; 2 new i18n keys (
common.language,common.skipToContent), en+sv. focus-visible:(keyboard) vs:focus— rings only on keyboard focus.<main tabIndex={-1}>+outline-noneis programmatically focusable but not in the tab order and shows no container outline; the skip link + route effect both target it.- The i18n parity test (#60) will guard the 2 new keys.