# Unify Vocabulary + Authority CRUD — 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:** Collapse the duplicated Vocabulary-terms + Authorities CRUD (~280 lines across 4 files) into three shared components, with the rows and pages reduced to thin adapters — behavior-preserving.

**Architecture:** Build `LabelledRecordRow`, `LabelledRecordCreateForm`, and `FilteredRecordList<T>` in `src/components/` (Tasks 1-3, additive — existing app untouched, all existing tests stay green). Then rewire `term-row`/`authority-row` and `authorities-page`/`vocabulary-terms` onto them (Task 4) and run the full gate. Variance (mutation hooks, arg shapes, i18n keys, page chrome) lives entirely in the adapters.

**Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, react-i18next, Base UI, Vitest 4 (jsdom) + RTL.

**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon; token classes only; `components/ui/*` untouched. Run a single test pass per task.

**Spec:** `docs/superpowers/specs/2026-06-08-unify-record-crud-design.md`

**Key facts:**
- Types: `type LabelView = components["schemas"]["LabelView"]`, `type LabelInput = components["schemas"]["LabelInput"]`. `labelText(labels: LabelView[], lang)` (`lib/labels`), `byLabel(lang)` returns a comparator over `{ labels: LabelView[] }` (`lib/sort`).
- Shared building blocks (in `src/components/`): `label-editor` (`LabelEditor`, uses `useId` since #62, needs `useConfig` which defaults to `DEFAULTS` — works under `renderApp`), `delete-confirm-dialog` (`DeleteConfirmDialog` — props `description`, `onConfirm: () => Promise<void>`), `mutation-error` (`MutationError` — prop `error: unknown`), `external-uri-link` (`ExternalUriLink` — prop `uri`).
- UI kit: `Button`, `Input`, `Label` from `@/components/ui/*`; `ListSkeleton` from `@/components/ui/skeletons` (props `className`, `rows`).
- Test harness: `renderApp(ui, { route })` from `../test/render` (wraps QueryClient + memory router + i18n; NO ConfigProvider, but `useConfig` falls back to defaults). `HttpError` is exported from `../api/queries`.
- Existing tests that MUST stay green unchanged: `vocab/term-row.test.tsx`, `authorities/authorities.test.tsx`, `vocab/vocabularies.test.tsx`.
- Current `term-row.tsx`/`authority-row.tsx` are twins; `authorities-page.tsx` has a kind `<nav>` + `PageTitle` + `Navigate` guard + breadcrumb; `vocabulary-terms.tsx` has a `vocab.terms` caption + breadcrumb. Both pages render the filter input ALWAYS, then `isLoading ? <ListSkeleton/> : <ul>…</ul>`.

---

# Task 1: `LabelledRecordRow`

**Files:** Create `web/src/components/labelled-record-row.tsx`, `web/src/components/labelled-record-row.test.tsx`.

- [ ] **Step 1: Create `web/src/components/labelled-record-row.tsx`:**
```tsx
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { LabelEditor } from "./label-editor";
import { DeleteConfirmDialog } from "./delete-confirm-dialog";
import { MutationError } from "./mutation-error";
import { ExternalUriLink } from "./external-uri-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";

type LabelView = components["schemas"]["LabelView"];
type LabelInput = components["schemas"]["LabelInput"];

export type RecordLike = { id: string; labels: LabelView[]; external_uri: string | null };

/** One labelled record (term/authority): a display row with edit + delete, or an
 *  inline editor. All variance (mutation hooks, arg shapes, delete-confirm key) is
 *  supplied by the caller via callbacks/state — see term-row.tsx / authority-row.tsx. */
export function LabelledRecordRow({
  record,
  lang,
  deleteConfirmKey,
  savePending,
  saveError,
  onEditOpen,
  onSave,
  onDelete,
}: {
  record: RecordLike;
  lang: string;
  deleteConfirmKey: string;
  savePending: boolean;
  saveError: unknown;
  onEditOpen: () => void;
  onSave: (labels: LabelInput[], uri: string | null, done: () => void) => void;
  onDelete: () => Promise<void>;
}) {
  const { t } = useTranslation();
  const uriId = useId();

  const [editing, setEditing] = useState(false);
  const [labels, setLabels] = useState<LabelInput[]>(record.labels as LabelInput[]);
  const [uri, setUri] = useState(record.external_uri ?? "");

  if (editing) {
    return (
      <li className="space-y-2 border-b py-2">
        <LabelEditor value={labels} onChange={setLabels} />
        <div className="space-y-1">
          <Label htmlFor={uriId}>{t("labels.externalUri")}</Label>
          <Input
            id={uriId}
            type="url"
            placeholder={t("labels.uriPlaceholder")}
            value={uri}
            onChange={(e) => setUri(e.target.value)}
          />
        </div>
        <div className="flex gap-2">
          <Button
            type="button"
            size="sm"
            disabled={savePending}
            onClick={() => onSave(labels, uri.trim() || null, () => setEditing(false))}
          >
            {t("actions.save")}
          </Button>
          <Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
            {t("form.cancel")}
          </Button>
        </div>
        <MutationError error={saveError} />
      </li>
    );
  }

  return (
    <li className="flex items-center gap-2 border-b py-1 text-sm">
      <div className="flex-1">
        <div>{labelText(record.labels, lang)}</div>
        {record.external_uri && <ExternalUriLink uri={record.external_uri} />}
      </div>
      <Button
        type="button"
        variant="ghost"
        size="sm"
        onClick={() => {
          onEditOpen();
          setLabels(record.labels as LabelInput[]);
          setUri(record.external_uri ?? "");
          setEditing(true);
        }}
      >
        {t("actions.edit")}
      </Button>
      <DeleteConfirmDialog description={t(deleteConfirmKey)} onConfirm={onDelete} />
    </li>
  );
}
```

- [ ] **Step 2: Create `web/src/components/labelled-record-row.test.tsx`** (write + run). Type the test record as `RecordLike` (import it) — no `any`/`never`:
```tsx
import { expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { renderApp } from "../test/render";
import { LabelledRecordRow, type RecordLike } from "./labelled-record-row";
import { HttpError } from "../api/queries";

const record: RecordLike = { id: "r1", external_uri: null, labels: [{ lang: "en", label: "Bronze" }] };

test("edit → save calls onSave and closes via done()", async () => {
  const onSave = vi.fn((_labels: unknown, _uri: unknown, done: () => void) => done());
  renderApp(
    <ul>
      <LabelledRecordRow
        record={record}
        lang="en"
        deleteConfirmKey="actions.confirmDeleteTerm"
        savePending={false}
        saveError={null}
        onEditOpen={() => {}}
        onSave={onSave}
        onDelete={async () => {}}
      />
    </ul>,
  );
  await userEvent.click(screen.getByRole("button", { name: /edit/i }));
  await userEvent.click(screen.getByRole("button", { name: /save/i }));
  expect(onSave).toHaveBeenCalled();
  expect(screen.queryByRole("button", { name: /save/i })).toBeNull();
});

test("a save error renders inline and the row stays editable", async () => {
  renderApp(
    <ul>
      <LabelledRecordRow
        record={record}
        lang="en"
        deleteConfirmKey="actions.confirmDeleteTerm"
        savePending={false}
        saveError={new HttpError(403)}
        onEditOpen={() => {}}
        onSave={() => {}}
        onDelete={async () => {}}
      />
    </ul>,
  );
  await userEvent.click(screen.getByRole("button", { name: /edit/i }));
  expect(screen.getByRole("alert")).toHaveTextContent(/permission/i);
  expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});

test("confirming delete invokes onDelete", async () => {
  const onDelete = vi.fn(async () => {});
  renderApp(
    <ul>
      <LabelledRecordRow
        record={record}
        lang="en"
        deleteConfirmKey="actions.confirmDeleteTerm"
        savePending={false}
        saveError={null}
        onEditOpen={() => {}}
        onSave={() => {}}
        onDelete={onDelete}
      />
    </ul>,
  );
  // open the confirm dialog (trigger button is labelled "Delete")
  await userEvent.click(screen.getByRole("button", { name: /delete/i }));
  // the dialog renders in a portal on document.body with a confirm "Delete" action
  const dialog = within(document.body);
  const confirmButtons = await dialog.findAllByRole("button", { name: /delete/i });
  await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  expect(onDelete).toHaveBeenCalled();
});
```
Run: `cd web && pnpm vitest run src/components/labelled-record-row.test.tsx`. (If the delete-dialog DOM differs, mirror the portal/confirm pattern used in `web/src/shell/user-menu.test.tsx` / the delete-confirm-dialog story — the key assertion is that confirming calls `onDelete`. Don't weaken the save/error assertions.)

- [ ] **Step 3: Verify + lint:** `cd web && pnpm vitest run src/components/labelled-record-row.test.tsx && pnpm typecheck && pnpm lint` — all green.

- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/labelled-record-row.tsx web/src/components/labelled-record-row.test.tsx
git commit -m "feat(web): shared LabelledRecordRow component (#64)"
```

---

# Task 2: `LabelledRecordCreateForm`

**Files:** Create `web/src/components/labelled-record-create-form.tsx`, `web/src/components/labelled-record-create-form.test.tsx`.

- [ ] **Step 1: Create `web/src/components/labelled-record-create-form.tsx`:**
```tsx
import { useId, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { LabelEditor } from "./label-editor";
import { MutationError } from "./mutation-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

type LabelInput = components["schemas"]["LabelInput"];

/** Create form for a labelled record (term/authority): single-language label +
 *  optional external URI, with required-label validation and a status-aware error.
 *  `onCreate` performs the mutation and is handed a `reset` to clear the inputs on success. */
export function LabelledRecordCreateForm({
  heading,
  submitLabel,
  pending,
  error,
  onCreate,
}: {
  heading: ReactNode;
  submitLabel: string;
  pending: boolean;
  error: unknown;
  onCreate: (labels: LabelInput[], uri: string | null, reset: () => void) => void;
}) {
  const { t } = useTranslation();
  const uriId = useId();

  const [labels, setLabels] = useState<LabelInput[]>([]);
  const [uri, setUri] = useState("");
  const [requiredError, setRequiredError] = useState(false);

  const onSubmit = (event: FormEvent) => {
    event.preventDefault();

    if (!labels.some((l) => l.label)) {
      setRequiredError(true);
      return;
    }

    setRequiredError(false);
    onCreate(labels, uri.trim() || null, () => {
      setLabels([]);
      setUri("");
    });
  };

  return (
    <form onSubmit={onSubmit} className="space-y-2 border-t pt-3">
      <div className="text-sm font-medium">{heading}</div>
      <LabelEditor value={labels} onChange={setLabels} />
      <div className="space-y-1">
        <Label htmlFor={uriId}>{t("labels.externalUri")}</Label>
        <Input
          id={uriId}
          type="url"
          placeholder={t("labels.uriPlaceholder")}
          value={uri}
          onChange={(e) => setUri(e.target.value)}
        />
      </div>
      {requiredError && (
        <p role="alert" className="text-xs text-destructive">
          {t("form.required")}
        </p>
      )}
      <MutationError error={error} />
      <Button type="submit" size="sm" disabled={pending}>
        {submitLabel}
      </Button>
    </form>
  );
}
```

- [ ] **Step 2: Create `web/src/components/labelled-record-create-form.test.tsx`** (write + run):
```tsx
import { expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { renderApp } from "../test/render";
import { LabelledRecordCreateForm } from "./labelled-record-create-form";

test("submitting with empty labels shows the required error and does not call onCreate", async () => {
  const onCreate = vi.fn();
  renderApp(
    <LabelledRecordCreateForm heading="New" submitLabel="Create" pending={false} error={null} onCreate={onCreate} />,
  );
  await userEvent.click(screen.getByRole("button", { name: /create/i }));
  expect(screen.getByRole("alert")).toBeInTheDocument(); // form.required (MutationError is null → no alert)
  expect(onCreate).not.toHaveBeenCalled();
});

test("a valid submit calls onCreate and the reset clears the inputs", async () => {
  const onCreate = vi.fn((_labels: unknown, _uri: unknown, reset: () => void) => reset());
  renderApp(
    <LabelledRecordCreateForm heading="New" submitLabel="Create" pending={false} error={null} onCreate={onCreate} />,
  );
  const labelInput = screen.getByLabelText(/^label$/i) as HTMLInputElement;
  await userEvent.type(labelInput, "Bronze");
  await userEvent.click(screen.getByRole("button", { name: /create/i }));
  expect(onCreate).toHaveBeenCalled();
  expect((screen.getByLabelText(/^label$/i) as HTMLInputElement).value).toBe("");
});
```
Run: `cd web && pnpm vitest run src/components/labelled-record-create-form.test.tsx`. (The required-error `<p role="alert">` is the only alert when `error={null}`; `LabelEditor`'s label is "Label".)

- [ ] **Step 3: Verify + lint:** `cd web && pnpm vitest run src/components/labelled-record-create-form.test.tsx && pnpm typecheck && pnpm lint`.

- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/labelled-record-create-form.tsx web/src/components/labelled-record-create-form.test.tsx
git commit -m "feat(web): shared LabelledRecordCreateForm component (#64)"
```

---

# Task 3: `FilteredRecordList<T>`

**Files:** Create `web/src/components/filtered-record-list.tsx`, `web/src/components/filtered-record-list.test.tsx`.

- [ ] **Step 1: Create `web/src/components/filtered-record-list.tsx`:**
```tsx
import { Fragment, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { byLabel } from "../lib/sort";
import { labelText } from "../lib/labels";
import { Input } from "@/components/ui/input";
import { ListSkeleton } from "@/components/ui/skeletons";

type LabelView = components["schemas"]["LabelView"];

/** Filterable, alphabetically-sorted list of labelled records with the standard
 *  loading / error / empty / no-matches states. The filter input stays visible
 *  during load (matching the prior page behaviour). */
export function FilteredRecordList<T extends { id: string; labels: LabelView[] }>({
  records,
  lang,
  isLoading,
  isError,
  loadErrorText,
  emptyText,
  renderRow,
}: {
  records: T[] | undefined;
  lang: string;
  isLoading: boolean;
  isError: boolean;
  loadErrorText: string;
  emptyText: string;
  renderRow: (record: T) => ReactNode;
}) {
  const { t } = useTranslation();
  const [filter, setFilter] = useState("");

  const q = filter.trim().toLowerCase();
  const rows = [...(records ?? [])]
    .filter((r) => !q || labelText(r.labels, lang).toLowerCase().includes(q))
    .sort(byLabel(lang));

  return (
    <>
      <div className="mb-3">
        <Input
          aria-label={t("common.filter")}
          placeholder={t("common.filter")}
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
        />
      </div>
      {isLoading ? (
        <ListSkeleton className="mb-4" rows={5} />
      ) : (
        <ul className="mb-4">
          {isError && <li className="text-sm text-destructive">{loadErrorText}</li>}
          {!isError && records?.length === 0 && (
            <li className="text-sm text-muted-foreground">{emptyText}</li>
          )}
          {!isError && records && records.length > 0 && rows.length === 0 && (
            <li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
          )}
          {rows.map((r) => (
            <Fragment key={r.id}>{renderRow(r)}</Fragment>
          ))}
        </ul>
      )}
    </>
  );
}
```

- [ ] **Step 2: Create `web/src/components/filtered-record-list.test.tsx`** (write + run):
```tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { renderApp } from "../test/render";
import { FilteredRecordList } from "./filtered-record-list";
import { labelText } from "../lib/labels";

type Rec = { id: string; labels: { lang: string; label: string }[] };
const recs: Rec[] = [
  { id: "a", labels: [{ lang: "en", label: "Alpha" }] },
  { id: "b", labels: [{ lang: "en", label: "Beta" }] },
];
const row = (r: Rec) => <li>{labelText(r.labels, "en")}</li>;

test("filtering narrows the rendered rows", async () => {
  renderApp(
    <FilteredRecordList records={recs} lang="en" isLoading={false} isError={false}
      loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
  );
  expect(screen.getByText("Alpha")).toBeInTheDocument();
  expect(screen.getByText("Beta")).toBeInTheDocument();
  await userEvent.type(screen.getByLabelText(/filter/i), "alph");
  expect(screen.getByText("Alpha")).toBeInTheDocument();
  expect(screen.queryByText("Beta")).toBeNull();
});

test("empty records show the empty text", () => {
  renderApp(
    <FilteredRecordList records={[]} lang="en" isLoading={false} isError={false}
      loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
  );
  expect(screen.getByText("EmptyMsg")).toBeInTheDocument();
});

test("non-empty records with a non-matching filter show no-matches", async () => {
  renderApp(
    <FilteredRecordList records={recs} lang="en" isLoading={false} isError={false}
      loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
  );
  await userEvent.type(screen.getByLabelText(/filter/i), "zzz");
  expect(screen.getByText(/no matches/i)).toBeInTheDocument();
});

test("an error shows the load-error text", () => {
  renderApp(
    <FilteredRecordList records={undefined} lang="en" isLoading={false} isError={true}
      loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
  );
  expect(screen.getByText("LoadErr")).toBeInTheDocument();
});
```
Run: `cd web && pnpm vitest run src/components/filtered-record-list.test.tsx`.

- [ ] **Step 3: Verify + lint:** `cd web && pnpm vitest run src/components/filtered-record-list.test.tsx && pnpm typecheck && pnpm lint`.

- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/filtered-record-list.tsx web/src/components/filtered-record-list.test.tsx
git commit -m "feat(web): shared FilteredRecordList component (#64)"
```

---

# Task 4: Wire the adapters + pages, then full gate

**Files:** Modify `web/src/vocab/term-row.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`.

- [ ] **Step 1: Rewrite `web/src/vocab/term-row.tsx`** (keep the `<TermRow vocabularyId term lang />` API):
```tsx
import type { components } from "../api/schema";
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
import { LabelledRecordRow } from "../components/labelled-record-row";

type TermView = components["schemas"]["TermView"];

export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) {
  const update = useUpdateTerm();
  const del = useDeleteTerm();

  return (
    <LabelledRecordRow
      record={term}
      lang={lang}
      deleteConfirmKey="actions.confirmDeleteTerm"
      savePending={update.isPending}
      saveError={update.error}
      onEditOpen={() => update.reset()}
      onSave={(labels, uri, done) =>
        update.mutate({ vocabularyId, termId: term.id, external_uri: uri, labels }, { onSuccess: done })}
      onDelete={() => del.mutateAsync({ vocabularyId, termId: term.id })}
    />
  );
}
```

- [ ] **Step 2: Rewrite `web/src/authorities/authority-row.tsx`** (keep `<AuthorityRow authority kind lang />`):
```tsx
import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelledRecordRow } from "../components/labelled-record-row";

type AuthorityView = components["schemas"]["AuthorityView"];

export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) {
  const update = useUpdateAuthority();
  const del = useDeleteAuthority();

  return (
    <LabelledRecordRow
      record={authority}
      lang={lang}
      deleteConfirmKey="actions.confirmDeleteAuthority"
      savePending={update.isPending}
      saveError={update.error}
      onEditOpen={() => update.reset()}
      onSave={(labels, uri, done) =>
        update.mutate({ id: authority.id, kind, external_uri: uri, labels }, { onSuccess: done })}
      onDelete={() => del.mutateAsync({ id: authority.id, kind })}
    />
  );
}
```

- [ ] **Step 3: Rewrite `web/src/authorities/authorities-page.tsx`** to use the shared list + form. Keep the `PageTitle`, kind `<nav>`, `Navigate` guard, `useDocumentTitle`, `useBreadcrumb`. Remove the local `labels`/`uri`/`error`/`filter` state, the `onCreate` handler, and now-unused imports (`useState`, `FormEvent`, `LabelEditor`, `MutationError`, `Input`, `Label`, `ListSkeleton`, `byLabel`, `labelText`). New file:
```tsx
import { NavLink, Navigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";

import { useAuthorities, useCreateAuthority } from "../api/queries";
import { FilteredRecordList } from "../components/filtered-record-list";
import { LabelledRecordCreateForm } from "../components/labelled-record-create-form";
import { PageTitle } from "@/components/ui/page-title";
import { AuthorityRow } from "./authority-row";
import { focusRing } from "../lib/focus-ring";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { cn } from "@/lib/utils";

const KINDS = ["person", "organisation", "place"] as const;

export function AuthoritiesPage() {
  const { t, i18n } = useTranslation();
  const { kind } = useParams();
  const lang = i18n.language.startsWith("sv") ? "sv" : "en";

  const isValidKind = (KINDS as readonly string[]).includes(kind ?? "");
  const currentKind = isValidKind ? (kind as string) : "person";

  const { data: authorities, isLoading, isError } = useAuthorities(currentKind);
  const create = useCreateAuthority();

  useDocumentTitle(t("nav.authorities"));
  useBreadcrumb([{ label: t("nav.authorities") }]);

  if (!isValidKind) return <Navigate to="/authorities/person" replace />;

  return (
    <div className="overflow-auto p-4">
      <PageTitle className="mb-3">{t("nav.authorities")}</PageTitle>
      <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>

      <FilteredRecordList
        records={authorities}
        lang={lang}
        isLoading={isLoading}
        isError={isError}
        loadErrorText={t("authorities.loadError")}
        emptyText={t("authorities.empty")}
        renderRow={(a) => <AuthorityRow authority={a} kind={currentKind} lang={lang} />}
      />

      <LabelledRecordCreateForm
        heading={`${t("authorities.new")} · ${t(`authorities.${currentKind}`)}`}
        submitLabel={t("authorities.create")}
        pending={create.isPending}
        error={create.error}
        onCreate={(labels, uri, reset) =>
          create.mutate({ kind: currentKind, external_uri: uri, labels }, { onSuccess: reset })}
      />
    </div>
  );
}
```
(Note: the original passed `kind: kind as string` to `create.mutate`; `currentKind` is equivalent here since the `isValidKind` guard already returned otherwise — use `currentKind`.)

- [ ] **Step 4: Rewrite `web/src/vocab/vocabulary-terms.tsx`** to use the shared list + form. Keep the `vocab.terms` caption + breadcrumb + the `useVocabularies` lookup. Remove the local form/list state + now-unused imports:
```tsx
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";

import { useTerms, useAddTerm, useVocabularies } from "../api/queries";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { FilteredRecordList } from "../components/filtered-record-list";
import { LabelledRecordCreateForm } from "../components/labelled-record-create-form";
import { TermRow } from "./term-row";

export function VocabularyTerms() {
  const { t, i18n } = useTranslation();
  const { id } = useParams();
  const lang = i18n.language.startsWith("sv") ? "sv" : "en";

  const { data: terms, isLoading, isError } = useTerms(id);
  const addTerm = useAddTerm();

  const { data: vocabularies } = useVocabularies();
  const vocabKey = vocabularies?.find((v) => v.id === id)?.key;

  useBreadcrumb(
    vocabKey
      ? [{ label: t("nav.vocabularies"), to: "/vocabularies" }, { label: vocabKey }]
      : [{ label: t("nav.vocabularies"), to: "/vocabularies" }],
  );

  if (!id) return null;

  return (
    <div className="overflow-auto p-4">
      <div className="mb-2 label-caption">{t("vocab.terms")}</div>

      <FilteredRecordList
        records={terms}
        lang={lang}
        isLoading={isLoading}
        isError={isError}
        loadErrorText={t("vocab.loadError")}
        emptyText={t("vocab.noTerms")}
        renderRow={(term) => <TermRow vocabularyId={id} term={term} lang={lang} />}
      />

      <LabelledRecordCreateForm
        heading={t("vocab.addTerm")}
        submitLabel={t("vocab.addTerm")}
        pending={addTerm.isPending}
        error={addTerm.error}
        onCreate={(labels, uri, reset) =>
          addTerm.mutate({ vocabularyId: id, external_uri: uri, labels }, { onSuccess: reset })}
      />
    </div>
  );
}
```
(Note: `useTerms(id)` and `if (!id) return null` — `id` is `string | undefined`; the hooks accept it, and the `!id` guard runs after the hooks, matching the original order. `renderRow`/`onCreate` use the narrowed `id` inside JSX where `!id` already returned — but to satisfy TS, `id` is `string` after the guard since the guard is before the `return` JSX. Confirm typecheck is clean; if TS still widens, the original already used `id` directly in the same spot, so it resolves.)

- [ ] **Step 5: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. The existing `term-row.test.tsx`, `authorities.test.tsx`, `vocabularies.test.tsx` MUST pass unchanged (behavior-preserving). Report test totals, largest chunk (gz), and the `check:colors` line. If an existing test fails, the refactor changed behavior — fix the adapter/page to match, do NOT edit the test.

- [ ] **Step 6: Codename + status:**
```bash
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 7: Manual smoke (recommended).** `pnpm dev`: on Authorities and Vocabulary-terms — filter narrows the list; create with an empty label shows the required error; create/edit/delete work; a failed save shows the inline message and keeps the row editable; the authorities kind-tabs + breadcrumbs are unchanged.

- [ ] **Step 8: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/vocab/term-row.tsx web/src/authorities/authority-row.tsx web/src/authorities/authorities-page.tsx web/src/vocab/vocabulary-terms.tsx
git commit -m "refactor(web): term/authority rows + pages adopt shared CRUD components (#64)"
```

---

## Self-Review (completed)

**Spec coverage:** AC1 three components with the prop shapes (T1-T3); AC2 rows + pages de-duplicated (T4 S1-S4); AC3 existing tests green + 3 new component tests (T1-T3 tests, T4 S5); AC4 behavior preserved — edit/save/delete/create/validation/filter/4-states/kind-nav/breadcrumb (T4 + the existing-test guard); AC5 gate/check:size/no-new-keys/codename (T4 S5-S6). ✓

**Placeholder scan:** every component + adapter shown in full; tests have concrete assertions; the two soft spots (delete-dialog portal DOM in T1; `id` narrowing in T4) name the exact mitigation/precedent. No TBD. ✓

**Type/consistency:** `RecordLike = { id; labels: LabelView[]; external_uri }` (T1) is the row's `record`; `TermView`/`AuthorityView` structurally satisfy it (both have those fields). `onSave(labels: LabelInput[], uri: string | null, done)` (T1) matches the adapters' `update.mutate({…, external_uri: uri, labels}, { onSuccess: done })` (T4). `LabelledRecordCreateForm.onCreate(labels, uri, reset)` (T2) matches `create.mutate({…}, { onSuccess: reset })` (T4). `FilteredRecordList<T extends { id; labels: LabelView[] }>` (T3) consumed with `authorities`/`terms` (T4). ✓

## Notes
- No new dependency, no new i18n keys, `components/ui/*` untouched. Net code reduction → `check:size` should not grow.
- `TermRow`/`AuthorityRow` keep their public props so `term-row.test.tsx` stays valid unchanged.
- `vocabulary-list.tsx` (key-based vocabularies) is deliberately NOT touched.
