# 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 `NavLink`s (`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, 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 `<button className="flex flex-1 items-center gap-2 text-left">`, `authorities-page.tsx` tab `NavLink`s.
- `authorities-page.tsx`: `<div role="tablist" className="mb-3 flex gap-2">` + `NavLink ... role="tab" aria-selected={k === currentKind}`. `NavLink` adds `aria-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.tsx` ring: `focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50`.
- Tests to update: `authorities.test.tsx` ("kind tabs link…" + "aria-selected…" use `getByRole("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`:**
```ts
export const focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
```

- [ ] **Step 2: i18n** — add `"language"` to the `common` namespace (en "Language" / sv "Språk"), both locales (parity).

- [ ] **Step 3: lang-switch.tsx** — wrap in a labelled group + add `type="button"` + ring (import `useTranslation`, `focusRing`, `cn`):
```tsx
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 `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 `<button>` className: add `focusRing`. Use `cn` (import it) or append the string:
  `className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}`. Import `focusRing` + `cn`.

- [ ] **Step 6: field-list.tsx** — the row `<button className="flex flex-1 items-center gap-2 text-left">`: add `rounded-sm` + `focusRing` (import `focusRing` + `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:
```tsx
<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 assert `href="/authorities/place"`).
  - "aria-selected…": rename to active-kind via `aria-current`: `expect(await screen.findByRole("link", { name: /^person$/i })).toHaveAttribute("aria-current", "page");` and `expect(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 with `within`. Don't weaken.)

- [ ] **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**
```bash
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"` to `common` (en "Skip to content" / sv "Hoppa till innehåll"), both locales (parity).

- [ ] **Step 2: app-shell.tsx — skip link + focusable main + route focus.**
```tsx
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:
```ts
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>` has `id="main-content"` (query `document.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 assert `document.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.)
  - **i18n.test.tsx — html lang.** Add a test: after `await i18n.changeLanguage("sv")`, `expect(document.documentElement.lang).toBe("sv")`; after `await i18n.changeLanguage("en")`, `toBe("en")`. (The file already toggles language; the `afterEach` resets to en, so assert within the test.)

- [ ] **Step 5: FULL GATE (run tests EXACTLY ONCE):**
```bash
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:**
```bash
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**
```bash
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-none` is 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.
