Files
biggus-dickus/docs/superpowers/plans/2026-06-08-a11y-defect-bundle.md
T

15 KiB

Accessibility Defect Bundle — 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: Fix five remaining a11y defects — label-id collision, unnamed drawer/breadcrumb, untranslated combobox strings (Task 1); invalid table-row semantics, missing pill focus ring, unannounced table load/error states (Task 2).

Architecture: Task 1 is a labelling/i18n cluster across four small components plus 5 new i18n keys. Task 2 reworks the objects-table data rows to use a real <Link> with aria-current, restores focusRing on the filter pills, and adds aria-busy + a live <caption> + role="alert" for load/error announcement.

Tech Stack: React 19 + TS + pnpm, React Router 7, Base UI, react-i18next, Vitest 4 (jsdom) + RTL + MSW.

Conventions: pnpm; no any/eslint-disable/@ts-ignore; no codename; en/sv parity; app source double-quote+semicolon; components/ui/* untouched; token classes only (focusRing is token-based). Run a single test pass.

Spec: docs/superpowers/specs/2026-06-08-a11y-defect-bundle-design.md

Key facts:

  • Existing i18n keys: common.noMatches ("No matches"), common.loading ("Loading"), nav.objects, objects.loadError ("Could not load objects"), actions.closeDetail. NEW keys to add (en/sv): common.clear, common.open, nav.breadcrumb, objects.detailTitle, objects.tableLabel.
  • lib/focus-ring.ts exports focusRing (a class string). Imported elsewhere as import { focusRing } from "../lib/focus-ring";.
  • components/label-editor.test.tsx, objects/options-combobox.test.tsx, shell/breadcrumb.test.tsx exist. objects/object-detail-drawer.test.tsx does NOT.
  • objects/objects-table.test.tsx: imports renderApp, objectsPage (from ../test/fixtures), ObjectsTable, ObjectDetail, i18n, Routes/Route. Its tree() mounts ObjectsTable at /objects and ObjectDetail at /objects/:id as siblings. Fixtures: objectsPage.items[0] = { object_number: "LM-0042", object_name: "Amphora", … }, [1] = "LM-0043"/"Bronze fibula". The "clicking a row deep-links…" test clicks the name ("Amphora"), which stays plain text — it survives unchanged.
  • combobox.tsx wrapper: ComboboxClear/ComboboxTrigger pass aria-label through to Base UI; ComboboxEmpty renders children. Do NOT modify components/ui/combobox.tsx.
  • object-detail-drawer.tsx: DrawerContent spreads ...props onto the Base UI Drawer.Popup, so aria-label passes through.

Task 1: Labelling + i18n cluster (label-editor, combobox, breadcrumb, drawer)

Files: Modify web/src/i18n/en.json, web/src/i18n/sv.json, web/src/components/label-editor.tsx, web/src/objects/options-combobox.tsx, web/src/shell/breadcrumb.tsx, web/src/objects/object-detail-drawer.tsx; tests web/src/components/label-editor.test.tsx, web/src/objects/options-combobox.test.tsx, web/src/shell/breadcrumb.test.tsx.

  • Step 1: Add the 5 i18n keys (both locales, parity). In web/src/i18n/en.json, add to the relevant blocks: under common"clear": "Clear", "open": "Open"; under nav"breadcrumb": "Breadcrumb"; under objects"detailTitle": "Object detail", "tableLabel": "Objects". In web/src/i18n/sv.json, the same keys: common.clear = "Rensa", common.open = "Öppna", nav.breadcrumb = "Brödsmulor", objects.detailTitle = "Objektdetalj", objects.tableLabel = "Objekt". (Valid JSON; mind commas. Place each new key beside its existing siblings in the same nested object.)

  • Step 2: label-editor.tsxuseId(). Add useId to the React import (import { useId } from "react";). Inside the component, add const inputId = useId(); and change the two lines to:

      <Label htmlFor={inputId}>{t("labels.label")}</Label>
      <Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />
  • Step 3: options-combobox.tsx — translate. Add import { useTranslation } from "react-i18next"; and const { t } = useTranslation(); at the top of the component body. Change:
        <ComboboxClear aria-label={t("common.clear")} />
        <ComboboxTrigger aria-label={t("common.open")} />

and

        <ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty>
  • Step 4: breadcrumb.tsx — translate the nav label. Add import { useTranslation } from "react-i18next"; and const { t } = useTranslation(); inside Breadcrumb (before the if (trail.length === 0) guard). Change <nav aria-label="Breadcrumb" …> to <nav aria-label={t("nav.breadcrumb")} …>.

  • Step 5: object-detail-drawer.tsx — name the dialog. Change <DrawerContent> to <DrawerContent aria-label={t("objects.detailTitle")}> (the t from useTranslation is already in scope in this file).

  • Step 6: Tests.

    • label-editor.test.tsx — append (reuse the file's existing render harness / providers, e.g. renderApp or whatever wraps useConfig; read the top of the file first):
test("each LabelEditor instance gets a unique input id", () => {
  renderApp(
    <>
      <LabelEditor value={[]} onChange={() => {}} />
      <LabelEditor value={[]} onChange={() => {}} />
    </>,
  );
  const inputs = screen.getAllByLabelText(/label/i);
  expect(inputs).toHaveLength(2);
  expect(inputs[0].id).not.toBe("");
  expect(inputs[0].id).not.toBe(inputs[1].id);
});

(If LabelEditor needs config context that renderApp doesn't provide, mirror the wrapper the existing tests in this file use. Keep existing tests green.)

  • options-combobox.test.tsx — append (mirror the file's existing render of OptionsCombobox):
test("the clear and open controls and empty text are translated", async () => {
  // render OptionsCombobox with empty options so the empty state is reachable,
  // using the same harness the other tests in this file use.
  // …render…
  expect(screen.getByRole("button", { name: /open/i })).toBeInTheDocument();
});

(Adapt to the existing test's setup. The key assertion: the open/clear controls have accessible names from t(). If the existing test already opens the popup, also assert screen.getByText("No matches").)

  • breadcrumb.test.tsx — append:
test("the breadcrumb nav has a translated accessible name", () => {
  // render Breadcrumb with a non-empty trail using the file's existing harness
  // (it needs a BreadcrumbContext provider with a trail).
  expect(screen.getByRole("navigation", { name: /breadcrumb/i })).toBeInTheDocument();
});

(Mirror the existing breadcrumb test's provider setup; if the file already renders a trail, just add the getByRole("navigation", { name }) assertion.)

  • Step 7: Verify (vitest ONCE), typecheck, lint:
cd web && pnpm vitest run src/components/label-editor.test.tsx src/objects/options-combobox.test.tsx src/shell/breadcrumb.test.tsx src/i18n && pnpm typecheck && pnpm lint

Expected: green (incl. i18n parity covering the 5 new keys). Keep all existing tests in those files green.

  • Step 8: Commit
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/i18n/en.json web/src/i18n/sv.json web/src/components/label-editor.tsx web/src/objects/options-combobox.tsx web/src/shell/breadcrumb.tsx web/src/objects/object-detail-drawer.tsx web/src/components/label-editor.test.tsx web/src/objects/options-combobox.test.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "fix(web): a11y labelling — useId, named drawer/breadcrumb, translated combobox (#62)"

Task 2: objects-table — real-link rows, pill focus ring, announced load/error

Files: Modify web/src/objects/objects-table.tsx, web/src/objects/objects-table.test.tsx.

  • Step 1: Import focusRing. Add import { focusRing } from "../lib/focus-ring"; to objects-table.tsx. (Link is already imported from react-router-dom.)

  • Step 2: Filter pills — add the focus ring. In the toolbar, change the pill className to:

              className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
  • Step 3: Rows — real link + aria-current, plain <tr>. Replace the data-row <tr> (the role="link" one) with:
            <tr
              key={object.id}
              onClick={() => navigate(`/objects/${object.id}?${params}`)}
              className={`cursor-pointer border-b text-sm ${
                selected ? "bg-primary/10" : "hover:bg-muted"
              }`}
            >
              <td className="px-3 py-2 text-muted-foreground">
                <Link
                  to={`/objects/${object.id}?${params}`}
                  aria-current={selected ? "page" : undefined}
                  onClick={(event) => event.stopPropagation()}
                  className={`${focusRing} rounded-sm hover:underline`}
                >
                  {object.object_number}
                </Link>
              </td>
              <td className="px-3 py-2 font-medium">{object.object_name}</td>
              <td className="px-3 py-2">
                <VisibilityBadge visibility={object.visibility} />
              </td>
              <td className="px-3 py-2 text-muted-foreground">{object.current_location ?? "—"}</td>
              <td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
              <td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>
            </tr>

(Drops role="link", tabIndex={0}, aria-selected, and onKeyDown from the <tr>; the object-number cell now holds the <Link>. Every other cell is unchanged.)

  • Step 4: Error cell — role="alert". In the isError branch, change the error <td> to:
          <td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
            {t("objects.loadError")}
          </td>
  • Step 5: Table — aria-busy + live caption. Change the <table> element and add the caption as its first child:
        <table className="w-full border-collapse" aria-busy={isLoading || undefined}>
          <caption className="sr-only" aria-live="polite">
            {isLoading ? t("common.loading") : t("objects.tableLabel")}
          </caption>
          {columns}
          {body}
        </table>
  • Step 6: Tests — extend objects-table.test.tsx. Add a nested-route helper (so ObjectsTable is mounted WITH a :id param, mirroring the real ObjectsPage nesting) and the new assertions. Add near the existing tree():
function nestedTree() {
  return (
    <Routes>
      <Route
        path="/objects"
        element={
          <>
            <ObjectsTable />
            <Outlet />
          </>
        }
      >
        <Route path=":id" element={<div>detail pane</div>} />
      </Route>
    </Routes>
  );
}

(Add Outlet to the react-router-dom import.) Then add these tests:

test("the object number cell is a real link", async () => {
  renderApp(tree(), { route: "/objects" });
  expect(await screen.findByRole("link", { name: "LM-0042" })).toBeInTheDocument();
});

test("the selected row's link is marked aria-current=page", async () => {
  // objectsPage.items[0] has object_number "LM-0042"; read its id from the fixture.
  const first = objectsPage.items[0];
  renderApp(nestedTree(), { route: `/objects/${first.id}` });
  const link = await screen.findByRole("link", { name: first.object_number });
  expect(link).toHaveAttribute("aria-current", "page");
  // a different row's link is not current
  const other = await screen.findByRole("link", { name: objectsPage.items[1].object_number });
  expect(other).not.toHaveAttribute("aria-current");
});

test("the table is marked aria-busy while loading", async () => {
  server.use(
    http.get("/api/admin/objects", async () => {
      await delay(50);
      return HttpResponse.json(objectsPage);
    }),
  );
  renderApp(tree(), { route: "/objects" });
  expect(screen.getByRole("table")).toHaveAttribute("aria-busy", "true");
  await screen.findByRole("link", { name: "LM-0042" });
  expect(screen.getByRole("table")).not.toHaveAttribute("aria-busy");
});

test("a failed objects fetch is announced via role=alert", async () => {
  server.use(http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })));
  renderApp(tree(), { route: "/objects" });
  expect(await screen.findByRole("alert")).toHaveTextContent(/could not load/i);
});

(Add delay to the msw import: import { delay, http, HttpResponse } from "msw";. The existing "clicking a row deep-links…" test clicks "Amphora" — the name cell, still plain text + whole-row onClick — so it stays green. If objectsPage.items[0] doesn't carry an id, read src/test/fixtures.ts to use the correct id field.)

  • Step 7: FULL FRONTEND 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 (gz), and the check:colors line.

  • Step 8: Codename + status:
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short

Expected: no matches (codename-exit=1).

  • Step 9: Manual smoke (recommended). pnpm dev: tab into the objects table — the visibility pills show a focus ring; Tab reaches each row's object-number link (Enter opens; Cmd/middle-click opens a new tab); the open object's row link is aria-current; a slow/failed load is announced.

  • Step 10: Commit

cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/objects/objects-table.tsx web/src/objects/objects-table.test.tsx
git commit -m "fix(web): objects-table a11y — real-link rows, pill focus ring, announced load/error (#62)"

Self-Review (completed)

Spec coverage: AC1 LabelEditor useId (T1 S2); AC2 row real-link + aria-current + plain tr + pill focusRing (T2 S2-S3); AC3 aria-busy + live caption + role=alert (T2 S4-S5); AC4 drawer + breadcrumb names + combobox translation (T1 S3-S5); AC5 gate/parity/codename (T2 S7-S8, T1 S1/S7). ✓

Placeholder scan: every code step shows full code; tests give concrete role/name assertions; the two "mirror the existing harness" notes (label-editor/options-combobox/breadcrumb tests) point at named existing files to copy from, not vague TODOs; the fixture-id note names the exact field to read. No TBD. ✓

Type/consistency: focusRing (string) imported once in T2 and used on pills + row link; aria-current={selected ? "page" : undefined} consistent; the 5 i18n keys added in T1 S1 are consumed in T1 S3-S5 (common.clear/open, nav.breadcrumb, objects.detailTitle) and T2 S5 (objects.tableLabel). ✓

Notes

  • No new dependency. components/ui/* untouched (combobox/drawer wrappers unchanged; only props passed from callers). check:colors stays green — focusRing uses ring-ring tokens, no raw palette.
  • The combobox wrapper's own raw-palette internals and the segmented-control extraction are #66, not here.