Compare commits

..

221 Commits

Author SHA1 Message Date
logaritmisk 3aff10557c ci: make the logout pending-state test deterministic (gate, not delay)
CI / web (push) Successful in 4m35s
Same timing-window race as object-new-page: the 50ms delay let the logout
resolve (menu unmounts on me=null) before findByText caught 'Signing out…'
on the slow CI runner. Hold the logout open with a promise released only
after the pending state is asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:51:35 +02:00
logaritmisk e8fe24f755 ci: raise vitest testTimeout to 20s for the resource-constrained runner
CI / web (push) Failing after 3m50s
The 'narrow: detail renders inside a portaled drawer' test lazy-loads the
drawer chunk and exceeded the 5s default on the slow CI container (setup
alone took ~486s). Bump testTimeout on both vitest projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:44:30 +02:00
logaritmisk fc170ccf10 ci: install Playwright chromium for the storybook vitest project; deterministic in-flight test
CI / web (push) Failing after 4m5s
- CI runs the @vitest/browser-playwright (storybook) project, which needs the
  chromium browser downloaded — add 'playwright install --with-deps chromium'.
- object-new-page in-flight test held the create mutation open with a 50ms
  delay and raced the pending-state assertion (failed under CI timing); gate it
  on a promise released only after asserting 'saving…', so it's deterministic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:37:20 +02:00
logaritmisk 3ae9d87e6e ci: bump Node 20 → 22 so pnpm 11 (needs Node ≥22.13) runs
CI / web (push) Failing after 2m25s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:29:58 +02:00
logaritmisk 3dbede6bc2 and use correct container image
CI / web (push) Failing after 55s
2026-06-09 11:25:18 +02:00
logaritmisk ba238ca962 run ci on correct runner
CI / web (push) Failing after 4s
2026-06-09 11:24:08 +02:00
logaritmisk 7cabebc338 merge: design-kit consistency — useLang, class recipes, kit adoption (#66)
CI / web (push) Has been cancelled
2026-06-09 00:02:33 +02:00
logaritmisk 74cde67a54 refactor(web): kit consistency — focusRing, PageTitle, Badge, size-4, icon buttons (#66)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:49:35 +02:00
logaritmisk 900f85f8ac refactor(web): adopt useLang + segmentClass/rowStateClass across sites (#66) 2026-06-08 23:45:24 +02:00
logaritmisk 00a7ce772e feat(web): useLang + segmentClass/rowStateClass helpers; delete dead Card (#66) 2026-06-08 23:41:08 +02:00
logaritmisk 71dee23028 docs(plans): design-kit consistency — 3-task plan (#66) 2026-06-08 23:31:35 +02:00
logaritmisk 91716e628a docs(specs): design-kit consistency — useLang, class recipes, kit adoption (#66) 2026-06-08 22:32:34 +02:00
logaritmisk 002af9d1f8 merge: split queries.ts — errors + key factory + domain modules; invalidate search on object writes (#65)
CI / web (push) Has been cancelled
2026-06-08 22:26:41 +02:00
logaritmisk d8d8035850 refactor(web): split queries.ts into api/queries/ domain modules behind a barrel (#65) 2026-06-08 21:35:02 +02:00
logaritmisk 704b159d48 refactor(web): central query-key factory + invalidate search on object writes (#65)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:30:57 +02:00
logaritmisk c1bddb47c4 refactor(web): extract API error classes to api/errors.ts (#65) 2026-06-08 21:27:21 +02:00
logaritmisk a21ab85576 docs(plans): split queries.ts — 3-task plan (#65) 2026-06-08 20:46:29 +02:00
logaritmisk 7ddf6967ce docs(specs): split queries.ts — errors + key factory + domain modules (#65) 2026-06-08 20:37:11 +02:00
logaritmisk 404cf67f35 merge: unify vocabulary + authority CRUD into shared components (#64)
CI / web (push) Has been cancelled
2026-06-08 20:22:36 +02:00
logaritmisk 50d2512123 refactor(web): term/authority rows + pages adopt shared CRUD components (#64) 2026-06-08 20:16:17 +02:00
logaritmisk c689b8c0e9 feat(web): shared FilteredRecordList component (#64) 2026-06-08 20:11:29 +02:00
logaritmisk acdaf8d07f feat(web): shared LabelledRecordCreateForm component (#64) 2026-06-08 20:08:10 +02:00
logaritmisk 77c56f7a9d feat(web): shared LabelledRecordRow component (#64) 2026-06-08 20:05:05 +02:00
logaritmisk 030472c2da docs(plans): unify vocab + authority CRUD — 4-task plan (#64) 2026-06-08 19:56:42 +02:00
logaritmisk f1eb6a9ba5 docs(specs): unify vocabulary + authority CRUD (#64) 2026-06-08 19:52:35 +02:00
logaritmisk 285a1323ad merge: accessibility defect bundle — label-id, table rows, drawer/breadcrumb names, announced states (#62)
CI / web (push) Has been cancelled
2026-06-08 19:47:58 +02:00
logaritmisk da3e078fbc fix(web): objects-table a11y — real-link rows, pill focus ring, announced load/error (#62) 2026-06-08 19:07:00 +02:00
logaritmisk 0def81ab42 fix(web): a11y labelling — useId, named drawer/breadcrumb, translated combobox (#62) 2026-06-08 19:00:28 +02:00
logaritmisk 546680017d docs(plans): a11y defect bundle — 2-task plan (#62) 2026-06-08 18:52:21 +02:00
logaritmisk 3efb7e175d docs(specs): accessibility defect bundle (#62) 2026-06-08 18:49:27 +02:00
logaritmisk 56076c4daa merge: consistent status-aware mutation error feedback (#63)
CI / web (push) Has been cancelled
2026-06-08 18:41:37 +02:00
logaritmisk aeb1b084d9 feat(web): adopt MutationError across create/object forms; distinguish edit-form fetch error (#63) 2026-06-08 17:32:36 +02:00
logaritmisk 6e02ac874f feat(web): inline status-aware errors on term/authority edit rows + delete dialog (#63) 2026-06-08 17:27:02 +02:00
logaritmisk dd131ee740 feat(web): mutations throw HttpError(status) so failures are status-aware (#63) 2026-06-08 17:21:50 +02:00
logaritmisk cad5a980c5 feat(web): shared status-aware error-message helper + MutationError component (#63) 2026-06-08 17:17:14 +02:00
logaritmisk 17bfd3e9d8 docs(plans): mutation error feedback — 4-task plan (#63) 2026-06-08 16:43:10 +02:00
logaritmisk d90aa75468 docs(specs): consistent status-aware mutation error feedback (#63) 2026-06-08 16:22:45 +02:00
logaritmisk 7a43f794e5 merge: session-expiry soft redirect + auth feedback (#48)
CI / web (push) Has been cancelled
2026-06-08 15:13:03 +02:00
logaritmisk af3f1a5367 feat(web): return-to-destination on auth redirect; logout pending state (#48) 2026-06-08 15:06:50 +02:00
logaritmisk ec6e90ef5b feat(web): login reason banner + return-to + empty-field guard (#48) 2026-06-08 15:02:55 +02:00
logaritmisk 3c59f47f81 feat(web): soft-redirect to login on 401 via a navigate bridge (#48) 2026-06-08 14:58:25 +02:00
logaritmisk 76f65a95dd docs(plans): session-expiry soft redirect — 3-task plan (#48) 2026-06-08 14:56:44 +02:00
logaritmisk a0aab6571f docs(specs): session-expiry soft redirect + auth feedback (#48) 2026-06-08 14:52:16 +02:00
logaritmisk 6e72f24f0a merge: group object-form flexible fields by definition group (#45 follow-up)
CI / web (push) Has been cancelled
2026-06-08 14:21:00 +02:00
logaritmisk d447e2d8a8 feat(web): group object-form flexible fields by definition group (#45)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:03:33 +02:00
logaritmisk a9a0c4d477 refactor(web): extract groupDefinitions helper; object-detail uses it (#45)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:58:12 +02:00
logaritmisk c0c86a5859 docs(plans): object-form field grouping — 2-task plan (#45) 2026-06-08 13:56:07 +02:00
logaritmisk faca2670a4 docs(specs): object-form flexible-field grouping via shared helper (#45 follow-up) 2026-06-08 13:54:53 +02:00
logaritmisk c68bbb9460 merge: search-row recording_date + softened estimated count (#61)
CI / web (push) Has been cancelled
2026-06-08 13:49:31 +02:00
logaritmisk 30da072d96 feat(web): show recording_date on search rows; flag estimated count as approximate (#61) 2026-06-08 13:45:35 +02:00
logaritmisk 1cdfa21259 feat(search): index + return recording_date on search hits (#61)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:41:17 +02:00
logaritmisk d37ac821f0 docs(plans): search-row recording_date + count copy — 2-task plan (#61) 2026-06-08 13:38:13 +02:00
logaritmisk 150ca63fc0 docs(specs): search-row recording_date + softened estimated count (#61) 2026-06-08 10:03:15 +02:00
logaritmisk d082836529 merge: a11y — focus rings, route focus, skip link, honest semantics, html lang (#52)
CI / web (push) Has been cancelled
2026-06-08 09:54:17 +02:00
logaritmisk 69d3d2be15 feat(web): skip link + route focus management + html lang sync (#52) 2026-06-08 09:46:17 +02:00
logaritmisk 57504c941d feat(web): focus-visible rings on custom controls; honest authority links + lang group (#52) 2026-06-08 09:42:33 +02:00
logaritmisk 4530004d87 docs(plans): a11y focus/route/skip/semantics — 2-task plan (#52) 2026-06-08 09:39:28 +02:00
logaritmisk 1948d09d16 docs(specs): a11y — focus rings, route focus, skip link, honest semantics, html lang (#52) 2026-06-08 09:36:44 +02:00
logaritmisk 4c24f0387c merge: enforce en/sv i18n key parity test (#60)
CI / web (push) Has been cancelled
2026-06-08 09:31:14 +02:00
logaritmisk 0209638552 docs: consolidate frontend guardrails + test-harness gotchas
CI / web (push) Has been cancelled
Adds web/GUARDRAILS.md capturing the recurring CI-guardrail and
test-harness lessons in one place: the check:size (250 KB-gz largest
chunk) and check:colors (design-token) guards, the jsdom/storybook
vitest split, MSW onUnhandledRequest:"error" overrides, RTL
accessible-name collisions, Storybook nested-router/portal handling,
and the components/ui code-style split. Wires a pointer from CLAUDE.md.

All claims verified against the live scripts, ci.yaml, and src/test/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:21:16 +02:00
logaritmisk 2b6ea1b4a4 test(web): enforce en/sv i18n key parity + non-empty values (#60) 2026-06-08 09:15:35 +02:00
logaritmisk 3575282dc2 merge: reference-data scannability + parity — sort/filter/external_uri/counts (#50)
CI / web (push) Has been cancelled
2026-06-08 09:10:33 +02:00
logaritmisk 882d0c828f feat(web): field-list filter, within-group label sort, group order, count badges (#50) 2026-06-08 09:06:17 +02:00
logaritmisk 75e7cf9047 feat(web): authorities sort+filter, create external_uri, external_uri in rows, url input (#50)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:02:13 +02:00
logaritmisk 76b2cbde1d feat(web): vocab list/terms sort+filter, external_uri in rows, rename guard, url input (#50)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:57:52 +02:00
logaritmisk 6c2fa63cac feat(web): collator sort helpers + ExternalUriLink + filter/uri i18n (#50) 2026-06-08 08:54:04 +02:00
logaritmisk a4fb05a175 docs(plans): reference-data scannability + parity — 4-task plan (#50) 2026-06-08 08:18:58 +02:00
logaritmisk 0678cefd13 docs(specs): reference-data scannability + parity (sort/filter/uri/counts) (#50) 2026-06-08 07:37:40 +02:00
logaritmisk 53c98102d2 merge: standardize loading states on shared Skeleton recipes (#53)
CI / web (push) Has been cancelled
2026-06-08 06:58:28 +02:00
logaritmisk 0d4026a968 feat(web): standardize loading on shared skeleton recipes; retire '…' + empty status divs (#53) 2026-06-08 06:50:57 +02:00
logaritmisk d0da77a004 feat(web): shared loading skeleton recipes (List/Form/AppShell) + common.loading (#53) 2026-06-08 06:46:24 +02:00
logaritmisk 6bce1e6782 docs(plans): loading skeletons — 2-task plan (#53) 2026-06-08 06:27:38 +02:00
logaritmisk 506bfd63dd docs(specs): standardize loading states on Skeleton recipes (#53) 2026-06-08 06:22:25 +02:00
logaritmisk f45f1d8807 merge: token-styled ui/Select replacing raw selects (#51)
CI / web (push) Has been cancelled
2026-06-08 06:09:23 +02:00
logaritmisk ede32551be feat(web): field-form selects use ui/Select; rewrite select tests (#51)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:03:54 +02:00
logaritmisk 71d899cbdc feat(web): object-form visibility uses ui/Select (#51)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:00:21 +02:00
logaritmisk 09e9b3f4d4 feat(web): ui/select Base UI Select wrapper matching Input + story (#51) 2026-06-08 05:54:46 +02:00
logaritmisk e54ea89b1e docs(plans): token-styled select — 3-task plan (#51) 2026-06-08 05:50:00 +02:00
logaritmisk 3782120b49 docs(specs): token-styled ui/Select replacing raw selects (#51) 2026-06-08 05:47:08 +02:00
logaritmisk 28e444c6c5 merge: object form robustness — data router, dirty guard, validation, batch entry (#46)
CI / web (push) Has been cancelled
2026-06-07 23:41:23 +02:00
logaritmisk d3ee4365e0 feat(web): unify create/edit partial-failure recovery with 'created' banner (#46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:31:15 +02:00
logaritmisk e18cad9c6a feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:26:15 +02:00
logaritmisk 537b847acb feat(web): code-aware field errors + min count validation (#46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:20:30 +02:00
logaritmisk 3900bc362c feat(web): disable submit while saving + Save & create another + Cmd/Ctrl+Enter (#46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:15:21 +02:00
logaritmisk ed0c13907c refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)
Convert app.tsx route tree verbatim to a module-level data router via
createRoutesFromElements + RouterProvider, and the test harness to
createMemoryRouter + RouterProvider. The search NavLink-click test now mounts
its routes as real data-router routes so RouterProvider intercepts the link
(descendant <Routes> under a catch-all let it fall through to a jsdom navigation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:07:03 +02:00
logaritmisk f3881e8c7c build(web): upgrade Vitest 3→4 (browser-playwright provider) (#46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:03:21 +02:00
logaritmisk 6ed137f49e docs(plans): object form robustness — 5-task plan (#46) 2026-06-07 21:18:53 +02:00
logaritmisk e005e76f5b docs(specs): object form robustness — data router, dirty guard, partial-failure, validation (#46) 2026-06-07 21:14:01 +02:00
logaritmisk b7242caf51 merge: app header wayfinding — breadcrumb, user menu, search, app_name brand (#54)
CI / web (push) Has been cancelled
2026-06-07 19:45:23 +02:00
logaritmisk 6efe09d40c feat(web): assemble header — breadcrumb, search, user menu; remove standalone sign out (#54)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:27:43 +02:00
logaritmisk 5c8fe3cd81 feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54) 2026-06-07 19:23:43 +02:00
logaritmisk 4b55218c69 feat(web): set breadcrumb trails on all AppShell routes (#54) 2026-06-07 19:18:43 +02:00
logaritmisk af6004f731 refactor(web): remove eslint-disable from useBreadcrumb via ref (#54) 2026-06-07 19:15:03 +02:00
logaritmisk 18cb35beff feat(web): page-driven breadcrumb context + header render + objects wiring (#54)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:11:31 +02:00
logaritmisk dbaf22500e feat(web): ui/menu Base UI dropdown wrapper + story (#54) 2026-06-07 19:05:25 +02:00
logaritmisk 4fad3c43f0 feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:01:15 +02:00
logaritmisk e4badbdefc docs(plans): app header wayfinding — 6-task plan (#54) 2026-06-07 18:58:04 +02:00
logaritmisk 285d35601b docs(specs): app header wayfinding — breadcrumb, user menu, search, app_name (#54) 2026-06-07 18:18:03 +02:00
logaritmisk 9b3a587eab merge: typography hierarchy + page <h1> + per-route document.title (#57)
CI / web (push) Has been cancelled
2026-06-07 17:44:53 +02:00
logaritmisk 8511aebb53 feat(web): object-detail tab title, caption element fix, login title (#57)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:22:18 +02:00
logaritmisk 6e1f5ea50f feat(web): page <h1> + document.title on list/form routes (#57)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:17:01 +02:00
logaritmisk 70025e1e71 feat(web): useDocumentTitle hook (restores prior title on unmount) (#57) 2026-06-07 17:12:41 +02:00
logaritmisk 40384d91dd style(web): match ui/ no-semicolon convention in PageTitle (#57) 2026-06-07 17:11:29 +02:00
logaritmisk d3e88be70f feat(web): PageTitle h1 component + story (#57) 2026-06-07 17:09:30 +02:00
logaritmisk 03f6e1d7ed docs(plans): typography page titles — 4-task plan (#57) 2026-06-07 17:07:43 +02:00
logaritmisk aab1bb37dc docs(specs): typography hierarchy + page <h1> + per-route document.title (#57) 2026-06-07 16:59:37 +02:00
logaritmisk 9323c608ee merge: dark-mode theme toggle — tri-state Light/Dark/System, FOUC-safe (#59)
CI / web (push) Has been cancelled
2026-06-07 16:45:55 +02:00
logaritmisk eead013ccd fix(web): raise dark --primary contrast to AA for button labels (#59) 2026-06-07 16:40:47 +02:00
logaritmisk 4f3db60ed2 feat(web): mount ThemeSwitch in header + pre-paint theme init (#59) 2026-06-07 16:37:04 +02:00
logaritmisk 6d17e5f84d feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59) 2026-06-07 16:33:16 +02:00
logaritmisk d452dd9b35 feat(web): useTheme hook with live system tracking (#59) 2026-06-07 16:29:59 +02:00
logaritmisk e5c03383fe feat(web): theme core — resolve/read/apply tri-state theme (#59)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:28:21 +02:00
logaritmisk 5e7a80e377 docs(plans): dark-mode theme toggle — 5-task plan (#59) 2026-06-07 15:25:42 +02:00
logaritmisk 5d63f06863 docs(specs): dark-mode theme toggle — tri-state, icon segmented, FOUC-safe (#59) 2026-06-07 15:19:29 +02:00
logaritmisk d0e3772c34 merge: LabelEditor preserves other-language labels on edit (#55)
CI / web (push) Has been cancelled
2026-06-07 14:41:22 +02:00
logaritmisk a9e6788b0b fix(web): LabelEditor preserves other-language labels on edit (#55)
Editing a term/authority/field that already had labels in other languages
silently replaced the whole multilingual set with one default-language entry.
onChange now keeps non-default-language entries; the editor shows only the
default-language label (no longer falling back to an other-language one, which
made the clear/edit path write the wrong language) and surfaces a hint when
other-language labels exist on the record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:37:25 +02:00
logaritmisk 48edb0391e merge: design-token adoption — indigo brand accent, status tokens, check:colors guard (#49)
CI / web (push) Has been cancelled
2026-06-07 14:27:49 +02:00
logaritmisk 93234aae29 chore(web): add check:colors guard banning raw color utilities outside ui/ (#49)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:22:15 +02:00
logaritmisk cde7be9f2a refactor(web): migrate feature screens to design tokens + radius token (#49)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:15:54 +02:00
logaritmisk 04ed0c50e2 feat(web): indigo brand token + status tokens + Badge success/warning variants (#49)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:08:08 +02:00
logaritmisk 67e486df46 docs(plans): design-token adoption across feature screens (#49)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:04:15 +02:00
logaritmisk d408464e91 docs(specs): design-token adoption across feature screens (#49)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:00:42 +02:00
logaritmisk 1bfa44a0ed merge: toast notifications + consistent mutation feedback (#47)
CI / web (push) Has been cancelled
Base UI toast region bridged to the QueryClient via an out-of-React manager; global
MutationCache gives every mutation feedback (opt-in success + catch-all type-aware
error toast, suppressible where errors show inline). Inline 422/409 UX preserved.
Closes #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:47:05 +02:00
logaritmisk 303c986d40 chore(web): raise bundle budget 180→250 KB gz (#47)
Generous ceiling so the budget stops blocking per-feature work (this is the third
raise in three frontend milestones) while still catching gross regressions. A
vendor-split / bundle audit can revisit always-loaded weight later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:35:35 +02:00
logaritmisk fcad638549 feat(web): per-mutation success/error toast metadata (#47)
Declare meta on all 18 mutation hooks: meta.successMessage (toast.* key)
on every discrete user action, meta.suppressErrorToast where the consuming
component already renders the error inline. Corrected useUpdateFieldDefinition
to suppress (FieldForm renders update.isError as form.rejected inline).

Add an RTL+MSW integration test wiring the real MutationCache via
makeQueryClient() + ToastRegion: success toast, catch-all error toast, and
suppressed-no-toast. Tidy the toast Close aria-label to t("common.close")
with en/sv parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:55:54 +02:00
logaritmisk 604d4f6005 feat(web): Base UI toast region + global mutation feedback wiring (#47)
Add a module-scope Base UI toast manager bridged to the QueryClient so
every mutation can give consistent feedback. A MutationCache (extracted
into a makeQueryClient() factory for test reuse) emits a catch-all,
type-aware error toast (unless meta.suppressErrorToast) and an opt-in
success toast (meta.successMessage), reading mutation.meta + i18n.t
outside React. meta is type-checked via a react-query Register
augmentation. ToastRegion is mounted app-wide in main.tsx. Adds a toast
i18n namespace (en/sv parity) and a validated story.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:46:26 +02:00
logaritmisk 63bfff417b docs(plans): toast notifications + mutation feedback (#47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:30:30 +02:00
logaritmisk 8eb527957b docs(specs): toast notifications + consistent mutation feedback (#47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:09:09 +02:00
logaritmisk e2ae093ed8 merge: object detail readability — resolve labels, group fields (#45)
Object detail resolves term/authority ids to labels, localized_text to the active
language, dates locale-formatted; flexible fields grouped by definition; core fields
always shown with placeholders; Edit/Delete actions toolbar. Closes #45.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:54:53 +02:00
logaritmisk 03d5b59b48 feat(web): readable, grouped object detail (labels, placeholders, actions toolbar) (#45)
Refactor object-detail.tsx to resolve term/authority ids to labels via
FlexibleFieldValue, group flexible fields by def.group in definition order
(ungrouped → trailing "Other"), always show core fields with "—" placeholders,
and move Edit (button-styled Link) + Delete into a right-aligned toolbar.

Move formatDate into lib/format-date.ts so the component module no longer
co-exports a non-component (clears the react-refresh/only-export-components
warning); both flexible-field-value.tsx and object-detail.tsx import it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:44:35 +02:00
logaritmisk 2e38af565a feat(web): FlexibleFieldValue — resolve term/authority/localized field values (#45)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:32:17 +02:00
logaritmisk 7258b3fd03 docs(plans): object detail readability (#45)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:24:58 +02:00
logaritmisk 6ec31b6c51 docs(specs): object detail readability — resolve labels, group fields (#45)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:54:41 +02:00
logaritmisk 0a88a86bb3 merge: objects data-overview table + responsive shell (#44, #58 shell)
CI / web (push) Has been cancelled
Full-width sortable/filterable objects table (server-side sort/filter/quick-search,
exposed timestamps, URL-synced state); collapsible icon sidebar; responsive object
detail (pane wide / Base UI drawer narrow) at canonical /objects/:id. Closes #44.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:48:50 +02:00
logaritmisk 6a62cf64bf chore(web): drop dead objects.selectPrompt i18n key (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:13:32 +02:00
logaritmisk c052ddc5af test(web): widen findBy timeout for the lazy/portaled narrow-drawer detail test (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:03:46 +02:00
logaritmisk e7b0f65686 chore(web): raise bundle budget 165→180 KB gz (#44)
The collapsed-sidebar tooltips use Base UI's Tooltip, which pulls floating-ui
into the always-loaded shell chunk. Kept the richer tooltip over native title;
the index is a feature-rich admin SPA bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:58:03 +02:00
logaritmisk b8f70212a1 feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)
Wide (>=1024px): right-hand pane beside the table with a close control.
Narrow: Base UI Drawer sliding from the right (lazy-loaded so its code splits
out of the main chunk). Both preserve the table's query string on close.

Remove the index SelectPrompt route (the table is the landing view) and delete
the now-unused SelectPrompt. Make table rows keyboard-activatable
(role=link, tabIndex, Enter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:53:10 +02:00
logaritmisk 184e4ea2a5 feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:44:40 +02:00
logaritmisk 04c33cb1aa feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:40:19 +02:00
logaritmisk 49f694d1fb feat(web): full-width sortable/filterable objects table with URL state (#44)
Replace the narrow ObjectList with a full-width ObjectsTable whose state
(sort/order/q/visibility/limit/offset) lives entirely in the URL via
useSearchParams. Sortable headers toggle sort+dir with aria-sort, a
debounced quick-filter and visibility chips mirror the search-panel
pattern, and a pagination footer offers prev/next + page-size select.
Rows deep-link to /objects/:id preserving the query string.

useObjectsPage now takes an ObjectListParams object (sort/order/
visibility/q) with keepPreviousData. ObjectsPage renders the table as
the full-width landing view, surfacing the nested <Outlet/> detail as a
simple right-side panel only when a :id child route is active (Phase 3
makes this responsive). object-list.tsx and its test are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:34:13 +02:00
logaritmisk 98c00d3732 chore(web): regenerate API types (object list params + timestamps) (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:24:42 +02:00
logaritmisk 60a1b8dccf feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:21:04 +02:00
logaritmisk 5efa7b8a16 feat(api): expose object created_at/updated_at in AdminObjectView (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:17:50 +02:00
logaritmisk e7ff817c63 docs(plans): objects data-overview table + responsive shell (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:58:06 +02:00
logaritmisk fb80146430 docs(specs): objects data-overview table + responsive shell (#44, subsumes #58)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:50:13 +02:00
logaritmisk b49699175d feat(server): load .env via dotenvy on startup
CI / web (push) Has been cancelled
The binary now reads a .env file itself (dotenvy::dotenv() at the top of main),
so 'cargo run -p server' / the release binary pick up config without relying on
just's 'set dotenv-load'. Missing .env is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:39:24 +02:00
logaritmisk e700e1d3cf chore: add 'just run-release' (build web + run server with embed-web)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:30:56 +02:00
logaritmisk de035bd032 merge: searchable term/authority combobox picker (#27)
CI / web (push) Has been cancelled
Replace the native <select> for term/authority object fields with a searchable
Base UI combobox (client-side filter by active-locale label; value=id contract
preserved). Server-side ?q= search deferred to a follow-up. Closes #27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:00:50 +02:00
logaritmisk 4267aae4e5 chore(web): raise bundle budget 150→165 KB gz (#27)
The index chunk is a feature-rich admin SPA bundle (Base UI + TanStack Query +
react-hook-form + i18n); 150 KB was set early and now trips on most features.
The combobox itself lands in the lazy object-form chunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:59:12 +02:00
logaritmisk c84b84b153 feat(web): use searchable combobox for term/authority fields on the object form (#27) 2026-06-06 10:41:28 +02:00
logaritmisk 0188e730e8 feat(web): searchable combobox (Base UI) for term/authority options (#27) 2026-06-06 10:37:01 +02:00
logaritmisk 6e52a331bc docs(plans): searchable term/authority combobox picker (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:24:39 +02:00
logaritmisk 8e57789dd7 docs(specs): searchable term/authority combobox picker (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:20:26 +02:00
logaritmisk 8ed747c6a7 merge: wire Spectrum seed into runtime via 'server seed' (#14)
CI / web (push) Has been cancelled
server seed subcommand (idempotent; migrates then seeds the baseline Spectrum
cataloguing vocabularies + field definitions), just seed recipe, README step.
Closes #14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:18:40 +02:00
logaritmisk dd02bddb07 docs: 'just seed' recipe + README seed step (#14) 2026-06-06 00:18:11 +02:00
logaritmisk 6ebcc10405 feat(server): 'seed' subcommand wiring the Spectrum cataloguing seed (#14) 2026-06-06 00:15:19 +02:00
logaritmisk 325917a98e docs(plans): wire Spectrum seed via 'server seed' subcommand (#14)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:12:15 +02:00
logaritmisk d74500f901 docs(specs): wire Spectrum seed into runtime via 'server seed' (#14)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:55:27 +02:00
logaritmisk 7d40a2cd56 chore: use cargo-nextest as the test runner
- .config/nextest.toml: hang-timeout profile (warn 60s, kill 120s)
- justfile: 'just test' = cargo nextest run --workspace + cargo test --doc
- CLAUDE.md: refresh stale Status + Commands for the real workspace + nextest

163 tests run in ~7s (vs multi-minute serial cargo test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:36:59 +02:00
logaritmisk 873efe199f merge: reference-data edit/delete lifecycle (#30 + #36)
CI / web (push) Has been cancelled
Backend update/delete endpoints (audited, 409+count when referenced) and in-place
frontend edit/delete UI for vocabularies (rename), terms, authorities, and field
definitions. Shared DeleteConfirmDialog; Storybook stories. Closes #30, #36.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:30:00 +02:00
logaritmisk 27caaa9787 test+refactor: audit-row assertions + uniform PATCH rollback (review follow-ups)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:04:09 +02:00
logaritmisk c9120848f5 feat(web): edit/delete authorities in place (#30) 2026-06-05 20:35:26 +02:00
logaritmisk 83ca506702 feat(web): rename vocabularies + edit/delete terms in place (#30) 2026-06-05 20:32:35 +02:00
logaritmisk 65ca79f2bd feat(web): edit/delete field definitions on /fields (in-place edit pane) (#36) 2026-06-05 20:24:57 +02:00
logaritmisk 194f18c8ed feat(web): reusable DeleteConfirmDialog with in-use handling + stories 2026-06-05 20:12:23 +02:00
logaritmisk 282e6430d4 feat(web): mutation hooks + InUseError + i18n for reference-data edit/delete 2026-06-05 20:06:23 +02:00
logaritmisk 78c950d2ee chore(web): regenerate API types for reference-data edit/delete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:04:03 +02:00
logaritmisk 3e7c6ad712 feat: edit/delete field definitions — audited, blocked when in use (#36)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:58:38 +02:00
logaritmisk 47240dafcc feat: edit/delete authorities, blocked when referenced (#30) 2026-06-05 19:53:20 +02:00
logaritmisk 83a7202861 feat: rename + delete vocabularies, blocked when in use (#30) 2026-06-05 19:41:39 +02:00
logaritmisk 09baf2949f feat: edit/delete terms — audited, blocked when referenced (#30)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:30:24 +02:00
logaritmisk f6053068be docs(plans): reference-data edit/delete lifecycle (#30 + #36)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:30:55 +02:00
logaritmisk e58b150ab2 docs(specs): reference-data edit/delete lifecycle (#30 + #36)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:15:53 +02:00
logaritmisk e7ae41362e chore: add 'just storybook' recipe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:00:09 +02:00
logaritmisk ffcfb41c7e merge: set up Storybook (preview + MSW + stories for real components)
CI / web (push) Has been cancelled
Storybook 10.4.2 (react-vite) with the addon-vitest/a11y/docs/mcp addons.
.storybook/preview.tsx wired to the real provider tree (QueryClient + ConfigProvider
+ MemoryRouter), real CSS + i18n, and MSW reusing src/test/handlers.ts. 8 colocated
stories for real components (Button/Badge/Input/Checkbox/VisibilityBadge/LabelEditor/
Highlight/SearchResultRow) incl. one CssCheck. Boilerplate removed.

112 web tests (existing 85 survived + 27 storybook); typecheck/lint/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:58:08 +02:00
logaritmisk b2d026f217 feat(web): set up Storybook (preview + MSW + stories for real components) 2026-06-05 16:55:40 +02:00
logaritmisk 4e1138f8ce merge: follow-ups batch (#38 #28 #41 #26)
CI / web (push) Has been cancelled
#38 enum-type SearchHitView.visibility + tighten VisibilityBadge prop.
#28 set_fields 422 carries the offending field (FieldErrorView); the object form
highlights it (both direct-edit and create-redirect paths name the field).
#41 normalize localized_text values to the default language on save.
#26 pin pnpm via packageManager + align CI to pnpm 11.

85 web tests; api suite green; bundle 145.8 KB gz; en/sv parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:54:04 +02:00
logaritmisk e6fc3eaf2c build(web): pin pnpm via packageManager + align CI to pnpm 11 (#26)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:50:40 +02:00
logaritmisk b4d71b0f80 fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:46:48 +02:00
logaritmisk 0a29127f7e fix(web): name the field in the edit banner on create->fields-error redirect (#28)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:43:35 +02:00
logaritmisk 0c9db7bcdb feat(web): highlight the offending field on a set_fields 422 (#28)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:39:49 +02:00
logaritmisk d6dc1c9b57 feat(api): field-level set_fields 422 body (#28); enum-type SearchHitView.visibility (#38)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:32:48 +02:00
logaritmisk cd3606c0e9 docs(plans): follow-ups batch (#38 #28 #41 #26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:29:35 +02:00
logaritmisk 260eac903e merge: instance locale (env) + single-language content authoring
CI / web (push) Has been cancelled
DEFAULT_LANGUAGE/DEFAULT_TIMEZONE env config surfaced via public GET /api/config;
SPA config provider defaults the UI language from the instance (overridable per
browser). Content authoring collapsed to a single language (LabelEditor +
localized_text) at the instance default. The multilingual content SCHEMA is left
completely untouched (dormant) — re-enabling is UI-only, zero migration. Storage
stays UTC; timezone exposed for future display/PDF use.

81 web tests; full backend green; bundle 145.7 KB gz; en/sv parity 106/106.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:16:12 +02:00
logaritmisk 9d0475e8ec feat(web): single-language content authoring (LabelEditor + localized_text at default lang)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:05:20 +02:00
logaritmisk 04e9c95c52 refactor(web): split config hook/context (.ts) from provider (.tsx) to clear react-refresh lint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:01:33 +02:00
logaritmisk de11292203 feat(web): config provider — fetch /api/config, default UI language from instance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:58:01 +02:00
logaritmisk 825b23adec test(server): assert default_language/default_timezone config defaults 2026-06-05 14:55:37 +02:00
logaritmisk 2460a1368d feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config 2026-06-05 14:52:09 +02:00
logaritmisk 4a76d6043a docs(plans): instance locale + single-language content authoring (4 tasks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:29:40 +02:00
logaritmisk 0f43c75b24 docs(specs): instance locale (env) + single-language content authoring
Keep the multilingual content schema (dormant); simplify authoring inputs to one
language. Default language + timezone via env vars (no settings table). Public
/api/config endpoint surfaces them to the SPA. Per-account UI language deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:23:32 +02:00
logaritmisk 3c6a41a80a merge: tier 4 hardening batch 1 (#1 #2 #21)
#1 graceful shutdown on SIGINT/SIGTERM (axum with_graceful_shutdown).
#2 configurable DB pool size (--db-max-connections / DB_MAX_CONNECTIONS, default 5).
#21 audit vocabulary/term/authority creation atomically, attributing the acting
user; ~15 call sites threaded an AuditActor.

173 workspace tests; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:10:13 +02:00
logaritmisk 146e0164e7 refactor(db): name audit entity_type constants for vocab/term/authority (#21)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:05:06 +02:00
logaritmisk 984be697ac feat: audit vocabulary/term/authority creation, attributing the acting user (#21)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:54:50 +02:00
logaritmisk 7181437625 feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:46:41 +02:00
logaritmisk 7e235ffd3e feat(server): graceful shutdown on SIGINT/SIGTERM (#1) 2026-06-04 21:42:55 +02:00
logaritmisk b0d2c247df docs(plans): tier 4 hardening batch 1 (#1 #2 #21)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:41:29 +02:00
logaritmisk e9a5a10524 chore: sync Cargo.lock — domain gains utoipa (tier 3 #3)
CI / web (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:26:21 +02:00
logaritmisk df113bd7ac merge: tier 3 typed-client (#3 #24 #29)
Decision #3 = Option A (utoipa::ToSchema allowed in domain, no I/O deps).
domain: ToSchema on Visibility/AuthorityKind + new DataType enum.
api: open-map fields (#24); enum value_types for visibility/data_type/kind (#29);
domain enums registered in OpenAPI; client regenerated. Frontend: dropped the
now-redundant fields/visibility casts. Wire format unchanged; schema diff additive.

domain 26 + api 41 + web 78 tests; bundle 145.5 KB gz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:25:54 +02:00
logaritmisk 0ee3b970cb refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:20:13 +02:00
logaritmisk 5a72f85989 feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:14:30 +02:00
logaritmisk d3c33a6c5d feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:08:41 +02:00
logaritmisk 331a6d7f34 docs(plans): tier 3 typed-client (#3 Option A, #24, #29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:06:27 +02:00
logaritmisk 0d971cda15 merge: tier 2 papercuts (#22 #18 #9 #4 #34 #31 #32 #37)
CI / web (push) Has been cancelled
Backend: add_term FK→404 (#22); log discarded errors on public 500 paths (#18);
enum↔CHECK cross-ref comments (#9); drop dead clone + harden serve smoke test (#4).
Frontend: search 503 'unavailable' vs generic error (#34); loading/error states on
terms & authorities lists (#31); authority kind-tab ARIA + dead i18n key removal
(#32); authority-kind reveal test (#37).

78 web tests; 72 backend tests; bundle 145.5 KB gz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:39:35 +02:00
logaritmisk 914527edc6 fix(web): place aria-selected on the role=tab element (#32) + assert it
Per WAI-ARIA, aria-selected must be on the same element carrying role="tab".
The previous impl placed it on an inner <span> via the render-prop, making it
invisible to assistive technology. Move both role="tab" and aria-selected to
the NavLink, compute currentKind once from useParams, drop the render-prop.
Add a Vitest assertion that the selected tab has aria-selected="true" and an
unselected tab has aria-selected="false".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:32:18 +02:00
logaritmisk ff513e1712 fix(web): search 503 vs error (#34); terms/authorities list error states (#31); authority-tab a11y + dead keys (#32); authority-kind test (#37) 2026-06-04 17:28:01 +02:00
logaritmisk 1a91b8a242 chore: cross-ref enum/CHECK constraints (#9); drop dead clone + harden smoke test (#4)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:21:33 +02:00
logaritmisk 2bce469ed2 fix(api): 404 when adding a term to a missing vocabulary (#22); log public 500s (#18)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:17:35 +02:00
logaritmisk fbb7a297a6 docs(plans): tier 2 papercuts — #22/#18/#9/#4/#34/#31/#32/#37
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:14:39 +02:00
logaritmisk 8442afbf02 chore: gitignore local docker-compose.override.yml
CI / web (push) Has been cancelled
2026-06-04 14:56:21 +02:00
logaritmisk 869a2c6e50 docs(dev): Running locally + tests README; add Meilisearch to compose; env defaults
Single docker compose (Postgres + Meilisearch) for dev and tests. .env.example
now works out-of-the-box (SESSION_COOKIE_SECURE=false for http://localhost login;
MEILI_* matching compose).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:52:34 +02:00
logaritmisk 126f84962a merge: fields management — create field definitions (endpoint + /fields UI)
CI / web (push) Has been cancelled
POST /api/admin/field-definitions (EditCatalogue) over the existing db layer,
validated through FieldType::from_parts (422 on inconsistent type/binding),
dup key -> 409, nonexistent vocabulary / empty key -> 422. /fields two-pane
screen: grouped list + create form with conditional vocabulary/authority-kind
selects, reused LabelEditor; creating a field invalidates the shared
[field-definitions] cache so it appears in the object editor too. Fields nav
enabled — no disabled nav stubs remain.

api tests green (8 admin_fields + suite); web 72 tests; bundle 145.4 KB gz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:36:14 +02:00
logaritmisk daad9438ba fix(api): map CHECK-constraint violation (empty key) to 422 2026-06-04 14:35:56 +02:00
logaritmisk fd1c22191b polish(web): fields form resets error, shared labelText, drop dead nav.soon, a11y required marker, Other group last
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:29:25 +02:00
logaritmisk 37c80121ed feat(web): /fields two-pane screen (grouped list + create form) + nav (no stubs left)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:22:57 +02:00
logaritmisk 6ad1304efd feat(web): useCreateFieldDefinition mutation + MSW handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:16:55 +02:00
logaritmisk df8f31d14d fix(api): map nonexistent-vocabulary FK violation to 422; cover term/authority create paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:15:19 +02:00
logaritmisk b508273a52 feat(api): POST /api/admin/field-definitions (create field definition)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:09:08 +02:00
logaritmisk b490db13b1 docs(plans): fields management — POST endpoint + /fields UI, 4 tasks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:50:30 +02:00
logaritmisk 19408f6282 docs(specs): fields management — POST field-definitions + /fields two-pane UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:35:18 +02:00
289 changed files with 29584 additions and 1620 deletions
+11
View File
@@ -0,0 +1,11 @@
# cargo-nextest configuration. https://nexte.st/book/configuration
#
# nextest runs each test in its own process: live per-test output, and a hard
# per-test timeout so a genuinely wedged test is killed + named rather than
# stalling the whole run.
[profile.default]
# Warn at 60s, terminate a test after 2×60s = 120s. The slowest real test is a
# couple of seconds (each #[sqlx::test] provisions its own temp DB), so this
# only ever fires on an actual hang.
slow-timeout = { period = "60s", terminate-after = 2 }
+18 -1
View File
@@ -1,5 +1,22 @@
# Connection string for local development and tests. # Copy to .env for local development: cp .env.example .env
# These defaults match the services in docker-compose.yml.
# PostgreSQL connection string (used for local dev and the test suite).
# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs). # The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs).
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev
# HTTP bind address.
BIND_ADDR=0.0.0.0:8080 BIND_ADDR=0.0.0.0:8080
# User-facing product name (OpenAPI title, page title). Set the real name at deploy time.
APP_NAME=Collection Management System APP_NAME=Collection Management System
# Local development is plain HTTP. Browsers drop `Secure` cookies on http://localhost,
# so the session cookie must NOT be Secure-only or login will silently fail. Set this
# back to `true` (the default) for any HTTPS deployment.
SESSION_COOKIE_SECURE=false
# Meilisearch (matches docker-compose.yml). Both must be set to enable search;
# leave them unset to run with search disabled.
MEILI_URL=http://localhost:7700
MEILI_MASTER_KEY=masterKey
+7 -3
View File
@@ -7,7 +7,9 @@ on:
jobs: jobs:
web: web:
runs-on: ubuntu-latest runs-on: aceofba-cluster
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
defaults: defaults:
run: run:
working-directory: web working-directory: web
@@ -15,15 +17,17 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
version: 9 version: 11
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
cache: pnpm cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm typecheck - run: pnpm typecheck
- run: pnpm lint - run: pnpm lint
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test - run: pnpm test
- run: pnpm build - run: pnpm build
- run: pnpm check:size - run: pnpm check:size
- run: pnpm check:colors
+3
View File
@@ -1,6 +1,9 @@
/target /target
.env .env
# Local-only Docker Compose overrides (machine-specific port remaps, etc.)
docker-compose.override.yml
.superpowers/ .superpowers/
web/node_modules/ web/node_modules/
+13 -7
View File
@@ -4,22 +4,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Status ## Status
Freshly scaffolded Rust binary crate (edition 2024). `src/main.rs` is still the `cargo new` "Hello, world!" stub and `Cargo.toml` has no dependencies yet. There is no architecture to document — update this file as real structure emerges. Rust (edition 2024) workspace + React SPA collection-management system. Backend crates: `domain`, `db`, `api`, `auth`, `search`, `server` (axum 0.8 + sqlx/Postgres + Meilisearch). Frontend in `web/` (React 19 + Vite + pnpm). Tests need the docker-compose stack up (Postgres on **:5442**, Meilisearch on **:7700**); each `#[sqlx::test]` provisions its own temp DB.
## Commands ## Commands
```bash ```bash
cargo build # build just check # fmt + lint + test — the standard pre-commit gate
cargo run # run the binary docker compose up -d # start Postgres (:5442) + Meilisearch (:7700) for tests
cargo test # run all tests cargo build --workspace # build
cargo test <name> # run a single test by name substring cargo run -p server # run the server (or: just run — loads .env)
cargo +nightly fmt # format — always nightly, not stable cargo nextest run --workspace # run all tests — PREFERRED (per-test isolation, live output, hang timeouts)
cargo clippy # lint before committing cargo nextest run -E 'test(<name>)' # run tests matching a name substring
cargo test --workspace --doc # doctests (nextest does not run these)
cargo +nightly fmt # format — always nightly, not stable
cargo clippy --workspace --all-targets -- -D warnings # lint before committing
``` ```
(`just test` runs nextest + doctests; config in `.config/nextest.toml`.)
## Conventions ## Conventions
- **CLI args & env vars:** use `clap` with the `derive` feature. - **CLI args & env vars:** use `clap` with the `derive` feature.
- **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.) - **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.)
- **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand. - **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand.
- **Formatting:** `cargo +nightly fmt` (nightly toolchain required). - **Formatting:** `cargo +nightly fmt` (nightly toolchain required).
- **Frontend guardrails:** before touching `web/`, read **[web/GUARDRAILS.md](web/GUARDRAILS.md)** — it covers the CI gate (`check:size` 250 KB-gz budget, `check:colors` design-token enforcement) and the test-harness quirks (MSW `onUnhandledRequest: "error"`, the jsdom/storybook vitest split, RTL accessible-name collisions, Storybook nested-router and portal handling, and the `components/ui/` code-style split).
Generated
+2
View File
@@ -564,6 +564,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"time", "time",
"utoipa",
"uuid", "uuid",
] ]
@@ -2076,6 +2077,7 @@ dependencies = [
"clap", "clap",
"db", "db",
"domain", "domain",
"dotenvy",
"http-body-util", "http-body-util",
"memory-serve", "memory-serve",
"reqwest", "reqwest",
+1
View File
@@ -28,4 +28,5 @@ argon2 = "0.5"
tower-sessions = "0.14" tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
rpassword = "7" rpassword = "7"
dotenvy = "0.15"
memory-serve = "2.1" memory-serve = "2.1"
+87
View File
@@ -1,3 +1,90 @@
# Biggus Dickus # Biggus Dickus
![Biggus Dickus](docs/biggus-dickus.jpg) ![Biggus Dickus](docs/biggus-dickus.jpg)
A museum collection-management system: a Rust (axum + sqlx + Postgres) API with a
React + TypeScript admin SPA and optional Meilisearch-backed full-text search.
## Running locally
The whole backing stack runs from one `docker compose` file (PostgreSQL + Meilisearch).
### Prerequisites
- Docker (PostgreSQL + Meilisearch)
- Rust (stable; plus a nightly toolchain for `cargo +nightly fmt`)
- Node.js and [`pnpm`](https://pnpm.io/) (web frontend)
- [`just`](https://github.com/casey/just) — optional, for the shortcuts below
### 1. Start the backing services
```bash
docker compose up -d
```
PostgreSQL listens on `localhost:5432` (database `cms_dev`) and Meilisearch on
`localhost:7700`. Give them a few seconds to become healthy on first start.
### 2. Configure the environment
```bash
cp .env.example .env
```
The defaults already match the compose services. Note **`SESSION_COOKIE_SECURE=false`**:
local development is plain HTTP, and browsers drop `Secure` cookies on `http://localhost`,
so leaving it `true` would make login silently fail. Set it back to `true` for any HTTPS
deployment.
### 3. Run the API server
```bash
just run # or: cargo run -p server
```
On startup the server connects to PostgreSQL, **runs database migrations automatically**,
ensures the Meilisearch index exists, and listens on `http://localhost:8080`. (If the
`MEILI_*` variables are unset, search is disabled and everything else still works.)
### 4. Create a login user
There is no seeded account — create one (you'll be prompted for a password, minimum 8
characters):
```bash
cargo run -p server -- create-user --email you@example.com --role admin
# non-interactive:
BOOTSTRAP_PASSWORD=changeme123 cargo run -p server -- create-user --email you@example.com --role editor
```
Roles are `admin` or `editor`.
### 5. Seed the baseline cataloguing fields (idempotent)
```bash
just seed # or: cargo run -p server -- seed
```
Populates the baseline Spectrum cataloguing vocabularies and field definitions. Safe to
re-run — the seed is idempotent.
### 6. Run the web frontend
The API server serves JSON only; in development the SPA is served by Vite, which proxies
`/api` to `:8080`:
```bash
cd web
pnpm install
pnpm dev # http://localhost:5173
```
Open **http://localhost:5173** and sign in with the user from step 4.
### Single-binary alternative
To serve the built SPA and the API from one process (no Vite), build the web assets and
enable the `embed-web` feature:
```bash
cd web && pnpm build # outputs web/dist
cargo run -p server --features embed-web # SPA + API on http://localhost:8080
```
Assets are embedded at compile time, so rebuild `web/dist` and recompile after frontend
changes.
## Running tests
Backend tests reuse the same compose services — PostgreSQL provisions a throwaway database
per test (`sqlx::test`) and Meilisearch tests use isolated, unique index names, so they
don't touch your dev data. With `docker compose up -d` running and `.env` in place:
```bash
just test # cargo test --workspace (reads .env via dotenv)
cd web && pnpm test # frontend tests (Vitest + MSW; no services needed)
```
`just check` runs format + lint + the Rust test suite. Run `cargo test` directly only if
`DATABASE_URL`/`MEILI_URL`/`MEILI_MASTER_KEY` are exported in your shell — the Meilisearch
tests require them; `just` loads them from `.env` for you.
-1
View File
@@ -32,7 +32,6 @@ pub(crate) struct UserView {
/// Desired visibility for a publish/unpublish request. /// Desired visibility for a publish/unpublish request.
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub(crate) struct VisibilityRequest { pub(crate) struct VisibilityRequest {
#[schema(value_type = String)]
pub visibility: Visibility, pub visibility: Visibility,
} }
+131 -12
View File
@@ -3,23 +3,25 @@
use auth::{Authorized, EditCatalogue, ViewInternal}; use auth::{Authorized, EditCatalogue, ViewInternal};
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response},
routing::get, routing::get,
}; };
use domain::{AuthorityKind, LocalizedLabel, NewAuthority}; use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{ use crate::{
AppState, AppState,
admin_objects::LabelView, admin_objects::LabelView,
admin_vocab::{CreatedId, LabelInput}, admin_vocab::{CreatedId, InUseView, LabelInput},
}; };
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub(crate) struct AuthorityView { pub(crate) struct AuthorityView {
pub id: String, pub id: String,
#[schema(value_type = domain::AuthorityKind)]
pub kind: String, pub kind: String,
pub external_uri: Option<String>, pub external_uri: Option<String>,
pub labels: Vec<LabelView>, pub labels: Vec<LabelView>,
@@ -90,7 +92,7 @@ pub(crate) async fn list_authorities(
) )
)] )]
pub(crate) async fn create_authority( pub(crate) async fn create_authority(
_auth: Authorized<EditCatalogue>, auth: Authorized<EditCatalogue>,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<NewAuthorityRequest>, Json(req): Json<NewAuthorityRequest>,
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> { ) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
@@ -116,9 +118,10 @@ pub(crate) async fn create_authority(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let id = db::authority::create_authority(&mut tx, &new) let id =
.await db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit() tx.commit()
.await .await
@@ -127,9 +130,125 @@ pub(crate) async fn create_authority(
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() }))) Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
} }
pub(crate) fn routes() -> Router<AppState> { #[derive(Deserialize, ToSchema)]
Router::new().route( pub(crate) struct UpdateAuthorityRequest {
"/api/admin/authorities", pub external_uri: Option<String>,
get(list_authorities).post(create_authority), pub labels: Vec<LabelInput>,
) }
#[utoipa::path(
patch, path = "/api/admin/authorities/{id}",
request_body = UpdateAuthorityRequest,
params(("id" = String, Path, description = "Authority id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404)
)
)]
pub(crate) async fn update_authority(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<UpdateAuthorityRequest>,
) -> Result<StatusCode, StatusCode> {
let id = id
.parse::<AuthorityId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let labels: Vec<LocalizedLabel> = req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect();
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let existed = db::authority::update_authority(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
id,
req.external_uri.as_deref(),
&labels,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
} else {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
delete, path = "/api/admin/authorities/{id}",
params(("id" = String, Path, description = "Authority id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
)
)]
pub(crate) async fn delete_authority(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let Ok(id) = id.parse::<AuthorityId>() else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id)
.await
{
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/admin/authorities",
get(list_authorities).post(create_authority),
)
.route(
"/api/admin/authorities/{id}",
axum::routing::patch(update_authority).delete(delete_authority),
)
} }
+379 -30
View File
@@ -6,14 +6,18 @@ use axum::{
Json, Router, Json, Router,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::{IntoResponse, Response},
routing::{get, put}, routing::{get, put},
}; };
use domain::{AuditActor, CatalogueObject, ObjectId, ObjectInput, Visibility}; use domain::{
AuditActor, AuthorityKind, CatalogueObject, FieldType, LocalizedLabel, NewFieldDefinition,
ObjectId, ObjectInput, Visibility, VocabularyId,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{AppState, pagination::Pagination, reindex}; use crate::{AppState, admin_vocab::LabelInput, reindex};
/// A localized label `{ lang, label }` (shared across admin views). /// A localized label `{ lang, label }` (shared across admin views).
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -36,10 +40,15 @@ pub(crate) struct AdminObjectView {
/// `YYYY-MM-DD` or null. /// `YYYY-MM-DD` or null.
pub recording_date: Option<String>, pub recording_date: Option<String>,
/// "draft" | "internal" | "public". /// "draft" | "internal" | "public".
#[schema(value_type = domain::Visibility)]
pub visibility: String, pub visibility: String,
/// Flexible field values (key -> value). /// Flexible field values (key -> value).
#[schema(value_type = Object)] #[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
pub fields: serde_json::Value, pub fields: serde_json::Value,
/// RFC3339 UTC timestamp.
pub created_at: String,
/// RFC3339 UTC timestamp.
pub updated_at: String,
} }
impl AdminObjectView { impl AdminObjectView {
@@ -56,6 +65,14 @@ impl AdminObjectView {
recording_date: o.recording_date.map(format_date), recording_date: o.recording_date.map(format_date),
visibility: o.visibility.as_str().to_owned(), visibility: o.visibility.as_str().to_owned(),
fields: o.fields.clone(), fields: o.fields.clone(),
created_at: o
.created_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
updated_at: o
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
} }
} }
} }
@@ -83,12 +100,73 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY) time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
} }
/// Query parameters for the object list: pagination plus whitelisted sort/order and
/// optional visibility/quick-filter. All values are validated/clamped server-side; the
/// `sort` token maps onto an enum (never a raw column name) before reaching SQL.
#[derive(Deserialize)]
pub(crate) struct ObjectListParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
pub sort: Option<String>,
pub order: Option<String>,
pub visibility: Option<String>,
pub q: Option<String>,
}
impl ObjectListParams {
fn limit(&self) -> i64 {
self.limit
.unwrap_or(crate::pagination::DEFAULT_LIMIT)
.clamp(1, crate::pagination::MAX_LIMIT)
}
fn offset(&self) -> i64 {
self.offset.unwrap_or(0).max(0)
}
fn sort(&self) -> db::catalog::ObjectSort {
use db::catalog::ObjectSort;
match self.sort.as_deref() {
Some("object_name") => ObjectSort::ObjectName,
Some("updated_at") => ObjectSort::UpdatedAt,
Some("created_at") => ObjectSort::CreatedAt,
Some("visibility") => ObjectSort::Visibility,
// Unknown or absent → stable default.
_ => ObjectSort::ObjectNumber,
}
}
fn descending(&self) -> bool {
self.order.as_deref() == Some("desc")
}
/// Validate `visibility` against the domain enum; an unknown value is ignored
/// (treated as no filter) so hand-edited URLs degrade gracefully instead of 500ing.
fn visibility(&self) -> Option<&str> {
self.visibility
.as_deref()
.filter(|v| Visibility::from_db(v).is_some())
}
fn q(&self) -> Option<&str> {
self.q.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
}
/// List objects (paginated, all visibility levels). Requires `ViewInternal`. /// List objects (paginated, all visibility levels). Requires `ViewInternal`.
#[utoipa::path( #[utoipa::path(
get, path = "/api/admin/objects", get, path = "/api/admin/objects",
params( params(
("limit" = Option<i64>, Query, description = "1..=200, default 50"), ("limit" = Option<i64>, Query, description = "1..=200, default 50"),
("offset" = Option<i64>, Query, description = "default 0") ("offset" = Option<i64>, Query, description = "default 0"),
("sort" = Option<String>, Query,
description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"),
("order" = Option<String>, Query, description = "asc | desc (default asc)"),
("visibility" = Option<String>, Query,
description = "draft | internal | public — filter; unknown values ignored"),
("q" = Option<String>, Query,
description = "quick filter: ILIKE match on object_number or object_name")
), ),
responses( responses(
(status = 200, body = AdminObjectPage), (status = 200, body = AdminObjectPage),
@@ -99,15 +177,22 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
pub(crate) async fn list_objects( pub(crate) async fn list_objects(
_auth: Authorized<ViewInternal>, _auth: Authorized<ViewInternal>,
State(state): State<AppState>, State(state): State<AppState>,
Query(page): Query<Pagination>, Query(params): Query<ObjectListParams>,
) -> Result<Json<AdminObjectPage>, StatusCode> { ) -> Result<Json<AdminObjectPage>, StatusCode> {
let (limit, offset) = (page.limit(), page.offset()); let (limit, offset) = (params.limit(), params.offset());
let objects = db::catalog::list_objects_paged(state.db.pool(), limit, offset) let query = db::catalog::ObjectQuery {
sort: params.sort(),
descending: params.descending(),
visibility: params.visibility(),
q: params.q(),
};
let objects = db::catalog::list_objects_query(state.db.pool(), &query, limit, offset)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let total = db::catalog::count_objects(state.db.pool()) let total = db::catalog::count_objects_query(state.db.pool(), query.visibility, query.q)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -158,7 +243,6 @@ pub(crate) struct ObjectCreateRequest {
pub recorder: Option<String>, pub recorder: Option<String>,
pub recording_date: Option<String>, pub recording_date: Option<String>,
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint). /// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
#[schema(value_type = String)]
pub visibility: Visibility, pub visibility: Visibility,
} }
@@ -356,12 +440,31 @@ pub(crate) async fn delete_object(
pub(crate) struct FieldDefinitionView { pub(crate) struct FieldDefinitionView {
pub key: String, pub key: String,
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". /// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
#[schema(value_type = domain::DataType)]
pub data_type: String,
pub vocabulary_id: Option<String>,
#[schema(value_type = Option<domain::AuthorityKind>)]
pub authority_kind: Option<String>,
pub required: bool,
pub group: Option<String>,
pub labels: Vec<LabelView>,
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub(crate) struct NewFieldDefinitionRequest {
pub key: String,
/// text | localized_text | integer | date | boolean | term | authority
pub data_type: String, pub data_type: String,
pub vocabulary_id: Option<String>, pub vocabulary_id: Option<String>,
pub authority_kind: Option<String>, pub authority_kind: Option<String>,
pub required: bool, pub required: bool,
pub group: Option<String>, pub group: Option<String>,
pub labels: Vec<LabelView>, pub labels: Vec<LabelInput>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct CreatedField {
pub key: String,
} }
/// List all field definitions. Requires `ViewInternal`. /// List all field definitions. Requires `ViewInternal`.
@@ -407,6 +510,222 @@ pub(crate) async fn list_field_definitions(
)) ))
} }
/// Create a field definition. Requires `EditCatalogue`. All type/binding consistency
/// (term needs a vocabulary, authority takes no vocabulary, scalars take no binding) is
/// validated by `FieldType::from_parts`, which returns `None` for any bad combination.
#[utoipa::path(
post, path = "/api/admin/field-definitions",
request_body = NewFieldDefinitionRequest,
responses(
(status = 201, body = CreatedField),
(status = 400, description = "Malformed vocabulary_id or authority_kind"),
(status = 401),
(status = 403),
(status = 409, description = "Duplicate key"),
(status = 422, description = "Inconsistent type/binding")
)
)]
pub(crate) async fn create_field_definition(
_auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Json(req): Json<NewFieldDefinitionRequest>,
) -> Result<(StatusCode, Json<CreatedField>), StatusCode> {
let vocabulary_id = match req.vocabulary_id.as_deref() {
None | Some("") => None,
Some(s) => Some(
s.parse::<VocabularyId>()
.map_err(|_| StatusCode::BAD_REQUEST)?,
),
};
let authority_kind = match req.authority_kind.as_deref() {
None | Some("") => None,
Some(s) => Some(AuthorityKind::from_db(s).ok_or(StatusCode::BAD_REQUEST)?),
};
let field_type = FieldType::from_parts(&req.data_type, vocabulary_id, authority_kind)
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
let new = NewFieldDefinition {
key: req.key,
field_type,
required: req.required,
group_key: req.group,
labels: req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect(),
};
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match db::fields::create_field_definition(&mut tx, &new).await {
Ok(_) => {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(CreatedField { key: new.key })))
}
Err(err) => {
match err.as_database_error().and_then(|e| e.code()).as_deref() {
// Duplicate `key` violates the unique index.
Some("23505") => Err(StatusCode::CONFLICT),
// Referenced vocabulary doesn't exist — client error, not server fault.
Some("23503") => Err(StatusCode::UNPROCESSABLE_ENTITY),
// CHECK constraint violated (e.g. empty key) — client error.
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
}
/// Fields that may be changed on an existing field definition. `key`, `data_type`, and
/// binding are immutable and intentionally absent from this request.
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateFieldDefinitionRequest {
pub required: bool,
pub group: Option<String>,
pub labels: Vec<LabelInput>,
}
/// Update a field definition's mutable attributes (labels, group, required).
/// `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
#[utoipa::path(
patch, path = "/api/admin/field-definitions/{key}",
request_body = UpdateFieldDefinitionRequest,
params(("key" = String, Path, description = "Field definition key")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 422, description = "CHECK constraint violated (e.g. empty label)")
)
)]
pub(crate) async fn update_field_definition(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
Json(req): Json<UpdateFieldDefinitionRequest>,
) -> Result<StatusCode, StatusCode> {
let labels: Vec<LocalizedLabel> = req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect();
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let result = db::fields::update_field_definition(
&mut tx,
actor(&auth.user),
&key,
req.required,
req.group.as_deref(),
&labels,
)
.await;
match result {
Ok(true) => {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
Ok(false) => {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
Err(err) => {
let _ = tx.rollback().await;
match err.as_database_error().and_then(|e| e.code()).as_deref() {
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
}
/// Delete a field definition. Blocked (409) when catalogue objects store a value under
/// this key. Requires `EditCatalogue`.
#[utoipa::path(
delete, path = "/api/admin/field-definitions/{key}",
params(("key" = String, Path, description = "Field definition key")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = crate::admin_vocab::InUseView,
description = "Field is used by catalogue objects")
)
)]
pub(crate) async fn delete_field_definition(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
) -> Response {
use crate::admin_vocab::InUseView;
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await {
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => {
let _ = tx.rollback().await;
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
#[derive(Serialize, ToSchema)]
pub(crate) struct FieldErrorView {
/// The flexible-field key that was rejected.
pub field: String,
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
pub code: String,
}
/// Replace an object's flexible-field values (validated against the registry). /// Replace an object's flexible-field values (validated against the registry).
/// ///
/// **Replace semantics:** the body is the *complete* desired field set. Omitting a key /// **Replace semantics:** the body is the *complete* desired field set. Omitting a key
@@ -422,7 +741,7 @@ pub(crate) async fn list_field_definitions(
(status = 401), (status = 401),
(status = 403), (status = 403),
(status = 404, description = "Object not found"), (status = 404, description = "Object not found"),
(status = 422, description = "Unknown field, type mismatch, or unresolved reference") (status = 422, body = FieldErrorView, description = "A field was rejected")
) )
)] )]
pub(crate) async fn set_fields( pub(crate) async fn set_fields(
@@ -430,34 +749,57 @@ pub(crate) async fn set_fields(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(values): Json<serde_json::Map<String, serde_json::Value>>, Json(values): Json<serde_json::Map<String, serde_json::Value>>,
) -> Result<StatusCode, StatusCode> { ) -> axum::response::Response {
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?; use axum::response::IntoResponse;
let mut tx = state let Ok(object_id) = id.parse::<ObjectId>() else {
.db return StatusCode::NOT_FOUND.into_response();
.pool() };
.begin()
.await let mut tx = match state.db.pool().begin().await {
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(tx) => tx,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let result = let result =
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await; db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
match result { match result {
Ok(()) => { Ok(()) => {
tx.commit() if tx.commit().await.is_err() {
.await return StatusCode::INTERNAL_SERVER_ERROR.into_response();
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; }
reindex(&state, object_id).await; reindex(&state, object_id).await;
Ok(StatusCode::NO_CONTENT) StatusCode::NO_CONTENT.into_response()
} }
Err(db::catalog::FieldError::ObjectNotFound) => Err(StatusCode::NOT_FOUND), Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(),
Err(db::catalog::FieldError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(db::catalog::FieldError::UnknownField(_)) => Err(StatusCode::UNPROCESSABLE_ENTITY), Err(db::catalog::FieldError::UnknownField(field)) => (
Err(db::catalog::FieldError::TypeMismatch { .. }) => Err(StatusCode::UNPROCESSABLE_ENTITY), StatusCode::UNPROCESSABLE_ENTITY,
Err(db::catalog::FieldError::Unresolved { .. }) => Err(StatusCode::UNPROCESSABLE_ENTITY), Json(FieldErrorView {
field,
code: "unknown".to_owned(),
}),
)
.into_response(),
Err(db::catalog::FieldError::TypeMismatch { field, .. }) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView {
field,
code: "type_mismatch".to_owned(),
}),
)
.into_response(),
Err(db::catalog::FieldError::Unresolved { field, .. }) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView {
field,
code: "unresolved".to_owned(),
}),
)
.into_response(),
} }
} }
@@ -470,5 +812,12 @@ pub(crate) fn routes() -> Router<AppState> {
get(get_object).put(update_object).delete(delete_object), get(get_object).put(update_object).delete(delete_object),
) )
.route("/api/admin/objects/{id}/fields", put(set_fields)) .route("/api/admin/objects/{id}/fields", put(set_fields))
.route("/api/admin/field-definitions", get(list_field_definitions)) .route(
"/api/admin/field-definitions",
get(list_field_definitions).post(create_field_definition),
)
.route(
"/api/admin/field-definitions/{key}",
axum::routing::patch(update_field_definition).delete(delete_field_definition),
)
} }
+3
View File
@@ -28,7 +28,9 @@ pub(crate) struct SearchHitView {
pub object_number: String, pub object_number: String,
pub object_name: String, pub object_name: String,
pub brief_description: Option<String>, pub brief_description: Option<String>,
#[schema(value_type = domain::Visibility)]
pub visibility: String, pub visibility: String,
pub recording_date: Option<String>,
pub snippet: Option<String>, pub snippet: Option<String>,
} }
@@ -102,6 +104,7 @@ pub(crate) async fn search_objects(
object_name: h.object_name, object_name: h.object_name,
brief_description: h.brief_description, brief_description: h.brief_description,
visibility: h.visibility, visibility: h.visibility,
recording_date: h.recording_date,
snippet: h.snippet, snippet: h.snippet,
}) })
.collect(), .collect(),
+275 -6
View File
@@ -5,9 +5,10 @@ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response},
routing::get, routing::get,
}; };
use domain::{LocalizedLabel, NewTerm, VocabularyId}; use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
@@ -85,11 +86,23 @@ pub(crate) async fn list_vocabularies(
) )
)] )]
pub(crate) async fn create_vocabulary( pub(crate) async fn create_vocabulary(
_auth: Authorized<EditCatalogue>, auth: Authorized<EditCatalogue>,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<NewVocabularyRequest>, Json(req): Json<NewVocabularyRequest>,
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> { ) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key) let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let vocab =
db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit()
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -156,7 +169,7 @@ pub(crate) async fn list_terms(
) )
)] )]
pub(crate) async fn add_term( pub(crate) async fn add_term(
_auth: Authorized<EditCatalogue>, auth: Authorized<EditCatalogue>,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(req): Json<NewTermRequest>, Json(req): Json<NewTermRequest>,
@@ -185,9 +198,17 @@ pub(crate) async fn add_term(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let term_id = db::vocab::add_term(&mut tx, &new) let term_id = db::vocab::add_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
StatusCode::NOT_FOUND
} else {
tracing::error!(?err, "adding term");
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
tx.commit() tx.commit()
.await .await
@@ -201,14 +222,262 @@ pub(crate) async fn add_term(
)) ))
} }
/// 409 body: how many catalogue objects still reference the entity.
#[derive(Serialize, ToSchema)]
pub(crate) struct InUseView {
pub count: i64,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateTermRequest {
pub external_uri: Option<String>,
pub labels: Vec<LabelInput>,
}
#[utoipa::path(
patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
request_body = UpdateTermRequest,
params(
("id" = String, Path, description = "Vocabulary id (UUID)"),
("term_id" = String, Path, description = "Term id (UUID)")
),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404)
)
)]
pub(crate) async fn update_term(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path((id, term_id)): Path<(String, String)>,
Json(req): Json<UpdateTermRequest>,
) -> Result<StatusCode, StatusCode> {
let vocabulary_id = id
.parse::<VocabularyId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let term_id = term_id
.parse::<TermId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let labels: Vec<LocalizedLabel> = req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect();
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let existed = db::vocab::update_term(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
vocabulary_id,
term_id,
req.external_uri.as_deref(),
&labels,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
} else {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
params(
("id" = String, Path, description = "Vocabulary id (UUID)"),
("term_id" = String, Path, description = "Term id (UUID)")
),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
)
)]
pub(crate) async fn delete_term(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path((id, term_id)): Path<(String, String)>,
) -> Response {
let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>())
else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let outcome = db::vocab::delete_term(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
vocab_id,
term_id,
)
.await;
match outcome {
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameVocabularyRequest {
pub key: String,
}
#[utoipa::path(
patch, path = "/api/admin/vocabularies/{id}",
request_body = RenameVocabularyRequest,
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, description = "Key already in use")
)
)]
pub(crate) async fn rename_vocabulary(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<RenameVocabularyRequest>,
) -> Result<StatusCode, StatusCode> {
let id = id
.parse::<VocabularyId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let existed = db::vocab::rename_vocabulary(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
id,
&req.key,
)
.await
.map_err(|err| {
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
if existed {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
} else {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
delete, path = "/api/admin/vocabularies/{id}",
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = InUseView, description = "Has terms or is bound by a field")
)
)]
pub(crate) async fn delete_vocabulary(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let Ok(id) = id.parse::<VocabularyId>() else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await
{
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
pub(crate) fn routes() -> Router<AppState> { pub(crate) fn routes() -> Router<AppState> {
Router::new() Router::new()
.route( .route(
"/api/admin/vocabularies", "/api/admin/vocabularies",
get(list_vocabularies).post(create_vocabulary), get(list_vocabularies).post(create_vocabulary),
) )
.route(
"/api/admin/vocabularies/{id}",
axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
)
.route( .route(
"/api/admin/vocabularies/{id}/terms", "/api/admin/vocabularies/{id}/terms",
get(list_terms).post(add_term), get(list_terms).post(add_term),
) )
.route(
"/api/admin/vocabularies/{id}/terms/{term_id}",
axum::routing::patch(update_term).delete(delete_term),
)
} }
+29
View File
@@ -0,0 +1,29 @@
use axum::{Json, Router, extract::State, routing::get};
use serde::Serialize;
use utoipa::ToSchema;
use crate::AppState;
/// Public, non-sensitive instance configuration the SPA needs before login.
#[derive(Serialize, ToSchema)]
pub(crate) struct ConfigView {
/// User-facing product name.
pub app_name: String,
/// Default UI/content language (i18n key, e.g. "sv").
pub default_language: String,
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
pub default_timezone: String,
}
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
Json(ConfigView {
app_name: state.app_name.clone(),
default_language: state.default_language.clone(),
default_timezone: state.default_timezone.clone(),
})
}
pub(crate) fn routes() -> Router<AppState> {
Router::new().route("/api/config", get(get_config))
}
+6
View File
@@ -5,6 +5,7 @@ mod admin_authorities;
mod admin_objects; mod admin_objects;
mod admin_search; mod admin_search;
mod admin_vocab; mod admin_vocab;
mod config;
mod health; mod health;
mod openapi; mod openapi;
mod pagination; mod pagination;
@@ -30,6 +31,10 @@ pub struct AppState {
/// Search client for on-write index sync. `None` disables indexing (search is a /// Search client for on-write index sync. `None` disables indexing (search is a
/// best-effort feature; absent when Meilisearch is not configured). /// best-effort feature; absent when Meilisearch is not configured).
pub search: Option<search::SearchClient>, pub search: Option<search::SearchClient>,
/// Instance default UI/content language (from config).
pub default_language: String,
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
pub default_timezone: String,
} }
/// Best-effort: keep the search index in step with a catalogue write that has already /// Best-effort: keep the search index in step with a catalogue write that has already
@@ -58,6 +63,7 @@ pub fn build_app(state: AppState) -> Router {
.with_expiry(Expiry::OnInactivity(Duration::hours(8))); .with_expiry(Expiry::OnInactivity(Duration::hours(8)));
Router::new() Router::new()
.merge(config::routes())
.merge(health::routes()) .merge(health::routes())
.merge(openapi::routes()) .merge(openapi::routes())
.merge(public::routes()) .merge(public::routes())
+26 -3
View File
@@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get};
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{ use crate::{
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public, AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, config, health,
public,
}; };
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
config::get_config,
health::live, health::live,
health::ready, health::ready,
public::list_objects, public::list_objects,
@@ -23,16 +25,26 @@ use crate::{
admin_objects::update_object, admin_objects::update_object,
admin_objects::delete_object, admin_objects::delete_object,
admin_objects::list_field_definitions, admin_objects::list_field_definitions,
admin_objects::create_field_definition,
admin_objects::update_field_definition,
admin_objects::delete_field_definition,
admin_objects::set_fields, admin_objects::set_fields,
admin_vocab::list_vocabularies, admin_vocab::list_vocabularies,
admin_vocab::create_vocabulary, admin_vocab::create_vocabulary,
admin_vocab::list_terms, admin_vocab::list_terms,
admin_vocab::add_term, admin_vocab::add_term,
admin_vocab::update_term,
admin_vocab::delete_term,
admin_vocab::rename_vocabulary,
admin_vocab::delete_vocabulary,
admin_search::search_objects, admin_search::search_objects,
admin_authorities::list_authorities, admin_authorities::list_authorities,
admin_authorities::create_authority admin_authorities::create_authority,
admin_authorities::update_authority,
admin_authorities::delete_authority
), ),
components(schemas( components(schemas(
config::ConfigView,
health::Live, health::Live,
health::Ready, health::Ready,
public::PublicView, public::PublicView,
@@ -47,16 +59,27 @@ use crate::{
admin_objects::ObjectUpdateRequest, admin_objects::ObjectUpdateRequest,
admin_objects::CreatedObject, admin_objects::CreatedObject,
admin_objects::FieldDefinitionView, admin_objects::FieldDefinitionView,
admin_objects::NewFieldDefinitionRequest,
admin_objects::UpdateFieldDefinitionRequest,
admin_objects::CreatedField,
admin_objects::FieldErrorView,
admin_vocab::VocabularyView, admin_vocab::VocabularyView,
admin_vocab::NewVocabularyRequest, admin_vocab::NewVocabularyRequest,
admin_vocab::NewTermRequest, admin_vocab::NewTermRequest,
admin_vocab::LabelInput, admin_vocab::LabelInput,
admin_vocab::TermView, admin_vocab::TermView,
admin_vocab::CreatedId, admin_vocab::CreatedId,
admin_vocab::UpdateTermRequest,
admin_vocab::InUseView,
admin_vocab::RenameVocabularyRequest,
admin_search::SearchHitView, admin_search::SearchHitView,
admin_search::SearchResultsView, admin_search::SearchResultsView,
admin_authorities::AuthorityView, admin_authorities::AuthorityView,
admin_authorities::NewAuthorityRequest admin_authorities::NewAuthorityRequest,
admin_authorities::UpdateAuthorityRequest,
domain::Visibility,
domain::AuthorityKind,
domain::DataType
)), )),
info(title = "Collection Management System", version = "0.0.0") info(title = "Collection Management System", version = "0.0.0")
)] )]
+12 -3
View File
@@ -71,11 +71,17 @@ pub(crate) async fn list_objects(
// public read surface. // public read surface.
let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset) let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
tracing::error!(?err, "listing public objects");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let total = db::catalog::count_public_objects(state.db.pool()) let total = db::catalog::count_public_objects(state.db.pool())
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|err| {
tracing::error!(?err, "counting public objects");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(PublicObjectPage { Ok(Json(PublicObjectPage {
items: objects.iter().map(PublicView::from_object).collect(), items: objects.iter().map(PublicView::from_object).collect(),
@@ -106,7 +112,10 @@ pub(crate) async fn get_object(
match db::catalog::public_object_by_id(state.db.pool(), object_id).await { match db::catalog::public_object_by_id(state.db.pool(), object_id).await {
Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(), Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Err(err) => {
tracing::error!(?err, "fetching public object");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
} }
} }
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+722 -2
View File
@@ -1,8 +1,8 @@
use api::{AppState, build_app, migrate_sessions}; use api::{AppState, build_app, migrate_sessions};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode, header}; use axum::http::{Request, StatusCode, header};
use db::users; use db::{audit, users};
use domain::{AuditActor, Email, NewUser, Role}; use domain::{AuditAction, AuditActor, Email, NewUser, Role};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use sqlx::PgPool; use sqlx::PgPool;
use tower::ServiceExt; use tower::ServiceExt;
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
@@ -263,3 +265,721 @@ async fn create_and_list_authorities_by_kind(pool: PgPool) {
let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await; let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await;
assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY);
} }
#[sqlx::test(migrations = "../db/migrations")]
async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"labels":[{"lang":"en","label":"X"}]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool.clone()));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/vocabularies")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"key":"audit-test"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vocab_id: uuid::Uuid = body["id"].as_str().unwrap().parse().unwrap();
let history = audit::history_for(&pool, "vocabulary", vocab_id)
.await
.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].action, AuditAction::Created);
assert!(
matches!(history[0].actor, AuditActor::User(_)),
"expected actor to be a user"
);
}
async fn send(
app: &axum::Router,
cookie: &str,
method: &str,
uri: &str,
body: Option<&str>,
) -> axum::http::Response<Body> {
let mut req = Request::builder()
.method(method)
.uri(uri)
.header(header::COOKIE, cookie);
if body.is_some() {
req = req.header(header::CONTENT_TYPE, "application/json");
}
let body = body
.map(|b| Body::from(b.to_owned()))
.unwrap_or_else(Body::empty);
app.clone().oneshot(req.body(body).unwrap()).await.unwrap()
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_term(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let v = send(
&app,
&cookie,
"POST",
"/api/admin/vocabularies",
Some(r#"{"key":"material"}"#),
)
.await;
let vid: serde_json::Value =
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vid = vid["id"].as_str().unwrap().to_owned();
let t = send(
&app,
&cookie,
"POST",
&format!("/api/admin/vocabularies/{vid}/terms"),
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
)
.await;
let tid: serde_json::Value =
serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap();
let tid = tid["id"].as_str().unwrap().to_owned();
let patched = send(
&app,
&cookie,
"PATCH",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
let deleted = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
let again = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
None,
)
.await;
assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn term_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let term_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000";
let patch_resp = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(term_uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"labels":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
let delete_resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(term_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn vocabulary_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let vocab_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000";
let patch_resp = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(vocab_uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"key":"x"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
let delete_resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(vocab_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_and_delete_vocabulary(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let v = send(
&app,
&cookie,
"POST",
"/api/admin/vocabularies",
Some(r#"{"key":"old"}"#),
)
.await;
let vid: serde_json::Value =
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vid = vid["id"].as_str().unwrap().to_owned();
let renamed = send(
&app,
&cookie,
"PATCH",
&format!("/api/admin/vocabularies/{vid}"),
Some(r#"{"key":"new"}"#),
)
.await;
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
let deleted = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}"),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_vocabulary_with_terms_is_409(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let v = send(
&app,
&cookie,
"POST",
"/api/admin/vocabularies",
Some(r#"{"key":"material"}"#),
)
.await;
let vid: serde_json::Value =
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vid = vid["id"].as_str().unwrap().to_owned();
send(
&app,
&cookie,
"POST",
&format!("/api/admin/vocabularies/{vid}/terms"),
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
)
.await;
let blocked = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}"),
None,
)
.await;
assert_eq!(blocked.status(), StatusCode::CONFLICT);
let body: serde_json::Value =
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_authority_referenced_is_409(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
// create an authority
let a = send(
&app,
&cookie,
"POST",
"/api/admin/authorities",
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Astrid"}]}"#),
)
.await;
assert_eq!(a.status(), StatusCode::CREATED);
let aid: serde_json::Value =
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
let aid = aid["id"].as_str().unwrap().to_owned();
// create an authority-typed field definition
send(
&app,
&cookie,
"POST",
"/api/admin/field-definitions",
Some(
r#"{"key":"maker","data_type":"authority","vocabulary_id":null,"authority_kind":"person","required":false,"group":null,"labels":[{"lang":"sv","label":"Tillverkare"}]}"#,
),
)
.await;
// create an object
let obj = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(
r#"{"object_number":"T-1","object_name":"test object","number_of_objects":1,"visibility":"draft"}"#,
),
)
.await;
assert_eq!(obj.status(), StatusCode::CREATED);
let obj_json: serde_json::Value =
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
// set the object's maker field to the authority id
let fields_body = format!(r#"{{"maker":"{aid}"}}"#);
let set = send(
&app,
&cookie,
"PUT",
&format!("/api/admin/objects/{obj_id}/fields"),
Some(&fields_body),
)
.await;
assert_eq!(set.status(), StatusCode::NO_CONTENT);
// delete the authority — must be blocked
let blocked = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/authorities/{aid}"),
None,
)
.await;
assert_eq!(blocked.status(), StatusCode::CONFLICT);
let body: serde_json::Value =
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_authority(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let a = send(
&app,
&cookie,
"POST",
"/api/admin/authorities",
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#),
)
.await;
let aid: serde_json::Value =
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
let aid = aid["id"].as_str().unwrap().to_owned();
let patched = send(
&app,
&cookie,
"PATCH",
&format!("/api/admin/authorities/{aid}"),
Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
let deleted = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/authorities/{aid}"),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_field_definition(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
// create a field definition
send(
&app,
&cookie,
"POST",
"/api/admin/field-definitions",
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
)
.await;
// PATCH: update required + group + labels
let patched = send(
&app,
&cookie,
"PATCH",
"/api/admin/field-definitions/weight",
Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
// PATCH unknown key → 404
let missing = send(
&app,
&cookie,
"PATCH",
"/api/admin/field-definitions/nope",
Some(r#"{"required":false,"group":null,"labels":[]}"#),
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
// DELETE the (unreferenced) field definition
let deleted = send(
&app,
&cookie,
"DELETE",
"/api/admin/field-definitions/weight",
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
// DELETE again → 404
let again = send(
&app,
&cookie,
"DELETE",
"/api/admin/field-definitions/weight",
None,
)
.await;
assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_field_definition_referenced_is_409(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
// create a field definition
send(
&app,
&cookie,
"POST",
"/api/admin/field-definitions",
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
)
.await;
// create an object and set the field
let obj = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(r#"{"object_number":"T-2","object_name":"test","number_of_objects":1,"visibility":"draft"}"#),
)
.await;
assert_eq!(obj.status(), StatusCode::CREATED);
let obj_json: serde_json::Value =
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
let set = send(
&app,
&cookie,
"PUT",
&format!("/api/admin/objects/{obj_id}/fields"),
Some(r#"{"weight":42}"#),
)
.await;
assert_eq!(set.status(), StatusCode::NO_CONTENT);
// delete the field definition — must be blocked
let blocked = send(
&app,
&cookie,
"DELETE",
"/api/admin/field-definitions/weight",
None,
)
.await;
assert_eq!(blocked.status(), StatusCode::CONFLICT);
let body: serde_json::Value =
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn listed_object_carries_timestamps(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let created = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(r#"{"object_number":"TS-1","object_name":"clock","number_of_objects":1,"visibility":"draft"}"#),
)
.await;
assert_eq!(created.status(), StatusCode::CREATED);
let list = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
assert_eq!(list.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
let item = &body["items"][0];
let created_at = item["created_at"].as_str().unwrap();
let updated_at = item["updated_at"].as_str().unwrap();
assert!(!created_at.is_empty(), "created_at must be non-empty");
assert!(!updated_at.is_empty(), "updated_at must be non-empty");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn list_objects_sort_filter_quick_search(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let create = |number: &str, name: &str| {
format!(
r#"{{"object_number":"{number}","object_name":"{name}","number_of_objects":1,"visibility":"draft"}}"#
)
};
for (number, name) in [
("FOO-1", "foo apple"),
("FOO-2", "foo banana"),
("BAR-1", "bar cherry"),
] {
let resp = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(&create(number, name)),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
// No params → default order is object_number ascending.
let default = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
let body: serde_json::Value =
serde_json::from_slice(&default.into_body().collect().await.unwrap().to_bytes()).unwrap();
let numbers: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["object_number"].as_str().unwrap())
.collect();
assert_eq!(numbers, ["BAR-1", "FOO-1", "FOO-2"]);
assert_eq!(body["total"], 3);
// sort=object_name&order=desc&visibility=draft&q=foo
let filtered = send(
&app,
&cookie,
"GET",
"/api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo",
None,
)
.await;
assert_eq!(filtered.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap();
let names: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["object_name"].as_str().unwrap())
.collect();
// Only the two "foo …" objects, name descending.
assert_eq!(names, ["foo banana", "foo apple"]);
assert_eq!(body["total"], 2);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let patch_resp = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/admin/field-definitions/weight")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"required":false,"group":null,"labels":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
let delete_resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/admin/field-definitions/weight")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
+309
View File
@@ -0,0 +1,309 @@
use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::users;
use domain::{AuditActor, Email, NewUser, Role};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
async fn post_json(
app: &axum::Router,
cookie: &str,
uri: &str,
body: &str,
) -> axum::http::Response<Body> {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap()
}
fn state(pool: PgPool) -> AppState {
AppState {
db: db::Db::from_pool(pool),
app_name: "Test".into(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
users::create_user(
&mut tx,
AuditActor::System,
&NewUser {
email: Email::parse(email).unwrap(),
password_hash: auth::hash_password(password).unwrap(),
role,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
}
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(
r#"{{"email":"{email}","password":"{password}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
resp.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn post_field(app: &axum::Router, cookie: &str, body: &str) -> axum::http::Response<Body> {
post_json(app, cookie, "/api/admin/field-definitions", body).await
}
#[sqlx::test(migrations = "../db/migrations")]
async fn create_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/field-definitions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"key":"x","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn create_scalar_field_then_lists_it(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"height_cm","data_type":"integer","required":true,"group":"Dimensions","labels":[{"lang":"en","label":"Height"},{"lang":"sv","label":"Höjd"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let body: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["key"], "height_cm");
let list = app
.oneshot(
Request::builder()
.uri("/api/admin/field-definitions")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let defs: serde_json::Value =
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert!(
defs.as_array()
.unwrap()
.iter()
.any(|d| d["key"] == "height_cm")
);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn term_without_vocabulary_is_422(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"material","data_type":"term","required":false,"labels":[{"lang":"en","label":"Material"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn duplicate_key_is_409(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let body = r#"{"key":"dup","data_type":"text","required":false,"labels":[{"lang":"en","label":"Dup"}]}"#;
assert_eq!(
post_field(&app, &cookie, body).await.status(),
StatusCode::CREATED
);
assert_eq!(
post_field(&app, &cookie, body).await.status(),
StatusCode::CONFLICT
);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn create_term_field_with_valid_vocabulary(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let vocab_resp = post_json(
&app,
&cookie,
"/api/admin/vocabularies",
r#"{"key":"material"}"#,
)
.await;
assert_eq!(vocab_resp.status(), StatusCode::CREATED);
let vocab_body: serde_json::Value =
serde_json::from_slice(&vocab_resp.into_body().collect().await.unwrap().to_bytes())
.unwrap();
let vocab_id = vocab_body["id"].as_str().unwrap();
let resp = post_field(
&app,
&cookie,
&format!(
r#"{{"key":"material_ref","data_type":"term","vocabulary_id":"{vocab_id}","required":false,"labels":[{{"lang":"en","label":"Material"}}]}}"#
),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn term_with_nonexistent_vocabulary_is_422(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"bad_ref","data_type":"term","vocabulary_id":"00000000-0000-0000-0000-000000000000","required":false,"labels":[{"lang":"en","label":"Bad"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn create_authority_field(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"maker_ref","data_type":"authority","authority_kind":"person","required":false,"labels":[{"lang":"en","label":"Maker"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn empty_key_is_422(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
+48
View File
@@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
@@ -432,6 +434,52 @@ async fn set_fields_and_list_field_definitions(pool: PgPool) {
assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY);
} }
#[sqlx::test(migrations = "../db/migrations")]
async fn set_fields_unknown_field_returns_field_detail(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(
&mut tx,
AuditActor::System,
&obj("A-1", "amphora", Visibility::Draft),
)
.await
.unwrap();
tx.commit().await.unwrap();
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/admin/objects/{id}/fields"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"definitely_not_a_field":"x"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["field"], "definitely_not_a_field");
assert_eq!(body["code"], "unknown");
}
#[sqlx::test(migrations = "../db/migrations")] #[sqlx::test(migrations = "../db/migrations")]
async fn create_requires_auth(pool: PgPool) { async fn create_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())) migrate_sessions(&db::Db::from_pool(pool.clone()))
+2
View File
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search, search,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+41
View File
@@ -0,0 +1,41 @@
use api::{AppState, build_app};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
fn state(pool: PgPool) -> AppState {
AppState {
db: db::Db::from_pool(pool),
app_name: "Test Museum".into(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
#[sqlx::test(migrations = "../db/migrations")]
async fn config_is_public_and_reflects_state(pool: PgPool) {
let app = build_app(state(pool));
let resp = app
.oneshot(
Request::builder()
.uri("/api/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["app_name"], "Test Museum");
assert_eq!(body["default_language"], "sv");
assert_eq!(body["default_timezone"], "Europe/Stockholm");
}
+2
View File
@@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
app_name: app_name.to_string(), app_name: app_name.to_string(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".to_string(), app_name: "Test".to_string(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: Some(search), search: Some(search),
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+133 -3
View File
@@ -1,16 +1,25 @@
//! Authority records (person / organisation / place). //! Authority records (person / organisation / place).
use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority}; use domain::{
AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel,
NewAuditEvent, NewAuthority,
};
use sqlx::Row; use sqlx::Row;
use crate::audit;
const AUTHORITY_ENTITY_TYPE: &str = "authority";
/// Labels aggregated per row as JSON, to read an authority and its labels in one query. /// Labels aggregated per row as JSON, to read an authority and its labels in one query.
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \ const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)"; ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
/// Insert an authority and its labels. Multiple statements — pass a transaction /// Insert an authority and its labels, then record a `created` audit entry. Multiple
/// connection (`&mut *tx`) for atomicity. /// statements — pass a transaction connection (`&mut *tx`) so everything commits
/// atomically.
pub async fn create_authority( pub async fn create_authority(
conn: &mut sqlx::PgConnection, conn: &mut sqlx::PgConnection,
actor: AuditActor,
new: &NewAuthority, new: &NewAuthority,
) -> Result<AuthorityId, sqlx::Error> { ) -> Result<AuthorityId, sqlx::Error> {
let id = AuthorityId::new(); let id = AuthorityId::new();
@@ -31,6 +40,18 @@ pub async fn create_authority(
.await?; .await?;
} }
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(id) Ok(id)
} }
@@ -103,6 +124,115 @@ where
} }
} }
/// Update an authority's `external_uri` and labels (full replace), recording an
/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable.
pub async fn update_authority(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: AuthorityId,
external_uri: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1")
.bind(id.to_uuid())
.bind(external_uri)
.execute(&mut *conn)
.await?
.rows_affected();
if updated == 0 {
return Ok(false);
}
sqlx::query("DELETE FROM authority_label WHERE authority_id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
.bind(id.to_uuid())
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects referencing `id` through an `authority`-typed field.
pub async fn count_objects_referencing_authority<'e, E>(
executor: E,
id: AuthorityId,
) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar(
"SELECT count(*) FROM object o WHERE EXISTS ( \
SELECT 1 FROM field_definition fd \
WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )",
)
.bind(id.to_string())
.fetch_one(executor)
.await
}
/// Delete an authority (labels cascade) unless catalogue objects reference it,
/// recording a `deleted` audit entry.
pub async fn delete_authority(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: AuthorityId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1")
.bind(id.to_uuid())
.fetch_optional(&mut *conn)
.await?;
if exists.is_none() {
return Ok(crate::DeleteOutcome::NotFound);
}
let count = count_objects_referencing_authority(&mut *conn, id).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM authority WHERE id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> { fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
let kind_str: String = row.try_get("kind")?; let kind_str: String = row.try_get("kind")?;
let kind = AuthorityKind::from_db(&kind_str) let kind = AuthorityKind::from_db(&kind_str)
+107 -23
View File
@@ -96,37 +96,121 @@ where
rows.into_iter().map(map_object).collect() rows.into_iter().map(map_object).collect()
} }
/// List objects (all visibility levels) ordered by object number, with paging. /// Whitelisted, injection-safe sort columns for the object list. The client never
pub async fn list_objects_paged<'e, E>( /// supplies a column name directly — the API layer maps an opaque token onto a variant,
executor: E, /// and only [`ObjectSort::column`] (returning a `'static str`) reaches the SQL string.
#[derive(Debug, Clone, Copy)]
pub enum ObjectSort {
ObjectNumber,
ObjectName,
UpdatedAt,
CreatedAt,
Visibility,
}
impl ObjectSort {
fn column(self) -> &'static str {
match self {
ObjectSort::ObjectNumber => "object_number",
ObjectSort::ObjectName => "object_name",
ObjectSort::UpdatedAt => "updated_at",
ObjectSort::CreatedAt => "created_at",
ObjectSort::Visibility => "visibility",
}
}
}
/// Filters + ordering for a paged object query. `visibility`/`q` are optional;
/// both are bound as parameters, never interpolated into the SQL string.
pub struct ObjectQuery<'a> {
pub sort: ObjectSort,
pub descending: bool,
pub visibility: Option<&'a str>,
pub q: Option<&'a str>,
}
/// Build the optional `WHERE` clause and its ordered bind values from the filters.
/// Each clause references a positional placeholder (`$1`, `$2`, …) matching the order
/// the returned `binds` are applied; the client's strings only ever arrive as binds.
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
let mut clauses = Vec::new();
let mut binds = Vec::new();
if let Some(v) = visibility {
binds.push(v.to_owned());
clauses.push(format!("visibility = ${}", binds.len()));
}
if let Some(term) = q {
binds.push(format!("%{term}%"));
let p = binds.len();
clauses.push(format!(
"(object_number ILIKE ${p} OR object_name ILIKE ${p})"
));
}
let sql = if clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", clauses.join(" AND "))
};
(sql, binds)
}
/// List objects (all visibility levels) with whitelisted sort, optional visibility/quick
/// filters, and paging. Ordering uses [`ObjectSort::column`] (a `'static str`) plus a
/// stable secondary key, so no client-controlled string ever reaches the SQL text.
pub async fn list_objects_query(
pool: &sqlx::PgPool,
query: &ObjectQuery<'_>,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error> ) -> Result<Vec<CatalogueObject>, sqlx::Error> {
where let (where_sql, binds) = where_clause(query.visibility, query.q);
E: sqlx::PgExecutor<'e>,
{
let sql =
format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2");
let rows = sqlx::query(&sql) let dir = if query.descending { "DESC" } else { "ASC" };
.bind(limit)
.bind(offset) // Secondary key keeps ordering stable when the primary sort has ties.
.fetch_all(executor) let sql = format!(
.await?; "SELECT {OBJECT_COLUMNS} FROM object{where_sql} \
ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
query.sort.column(),
binds.len() + 1,
binds.len() + 2,
);
let mut sql_query = sqlx::query(&sql);
for bind in &binds {
sql_query = sql_query.bind(bind);
}
let rows = sql_query.bind(limit).bind(offset).fetch_all(pool).await?;
rows.into_iter().map(map_object).collect() rows.into_iter().map(map_object).collect()
} }
/// Count all objects (for pagination totals). /// Count objects matching the optional visibility/quick filters (for pagination totals).
pub async fn count_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error> pub async fn count_objects_query(
where pool: &sqlx::PgPool,
E: sqlx::PgExecutor<'e>, visibility: Option<&str>,
{ q: Option<&str>,
let row = sqlx::query("SELECT count(*) AS n FROM object") ) -> Result<i64, sqlx::Error> {
.fetch_one(executor) let (where_sql, binds) = where_clause(visibility, q);
.await?;
row.try_get("n") let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
let mut sql_query = sqlx::query(&sql);
for bind in &binds {
sql_query = sql_query.bind(bind);
}
sql_query.fetch_one(pool).await?.try_get("n")
} }
/// Fetch one **public** object by id. Returns `None` if the object is missing **or** /// Fetch one **public** object by id. Returns `None` if the object is missing **or**
+118 -2
View File
@@ -1,11 +1,15 @@
//! Registry of flexible field definitions. //! Registry of flexible field definitions.
use domain::{ use domain::{
AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel, AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
NewFieldDefinition, VocabularyId, LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
}; };
use sqlx::Row; use sqlx::Row;
use crate::audit;
const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
/// Labels aggregated per row as JSON, to read a definition and its labels in one query. /// Labels aggregated per row as JSON, to read a definition and its labels in one query.
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \ const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)"; ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
@@ -121,3 +125,115 @@ fn map_field_definition(row: sqlx::postgres::PgRow) -> Result<FieldDefinition, s
labels: labels.0, labels: labels.0,
}) })
} }
/// Update a field definition's mutable attributes (`required`, `group_key`, labels);
/// `key`, `data_type`, and binding are immutable and untouched. Records an `updated`
/// audit entry. Returns `false` if no such key. Pass a transaction connection.
pub async fn update_field_definition(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
required: bool,
group_key: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
.bind(key)
.fetch_optional(&mut *conn)
.await?;
let Some(id) = id else { return Ok(false) };
sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1")
.bind(id)
.bind(required)
.bind(group_key)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1")
.bind(id)
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query(
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
VALUES ($1, $2, $3)",
)
.bind(id)
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
entity_id: id,
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects that store a value under field `key`.
pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)")
.bind(key)
.fetch_one(executor)
.await
}
/// Delete a field definition (labels cascade) unless catalogue objects use its key,
/// recording a `deleted` audit entry. Pass a transaction connection.
pub async fn delete_field_definition(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
.bind(key)
.fetch_optional(&mut *conn)
.await?;
let Some(id) = id else {
return Ok(crate::DeleteOutcome::NotFound);
};
let count = count_objects_using_field(&mut *conn, key).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM field_definition WHERE id = $1")
.bind(id)
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
entity_id: id,
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
+15 -3
View File
@@ -10,6 +10,17 @@ pub mod vocab;
use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::postgres::{PgPool, PgPoolOptions};
/// Result of a delete that catalogue-object references may block.
#[derive(Debug, PartialEq, Eq)]
pub enum DeleteOutcome {
/// The row was deleted.
Deleted,
/// Refused: `count` catalogue objects still reference it.
InUse { count: i64 },
/// The row did not exist.
NotFound,
}
/// A handle to the organization's PostgreSQL database. /// A handle to the organization's PostgreSQL database.
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -17,10 +28,11 @@ pub struct Db {
} }
impl Db { impl Db {
/// Connect to the database at `database_url`, opening a connection pool. /// Connect to the database at `database_url`, opening a connection pool with at most
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> { /// `max_connections` connections.
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(5) .max_connections(max_connections)
.connect(database_url) .connect(database_url)
.await?; .await?;
+8 -2
View File
@@ -5,7 +5,9 @@
//! populated by the organization or a later import. The inventory-minimum fields //! populated by the organization or a later import. The inventory-minimum fields
//! (object number, name, location, …) live in the typed object core, not here. //! (object number, name, location, …) live in the typed object core, not here.
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId}; use domain::{
AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId,
};
use crate::{fields, vocab}; use crate::{fields, vocab};
@@ -119,7 +121,11 @@ async fn ensure_vocabulary(
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? { if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
Ok(existing.id) Ok(existing.id)
} else { } else {
Ok(vocab::create_vocabulary(&mut *conn, key).await?.id) Ok(
vocab::create_vocabulary(&mut *conn, AuditActor::System, key)
.await?
.id,
)
} }
} }
+247 -10
View File
@@ -1,25 +1,47 @@
//! Controlled vocabularies and terms. //! Controlled vocabularies and terms.
use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId}; use domain::{
AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef,
Vocabulary, VocabularyId,
};
use sqlx::Row; use sqlx::Row;
use crate::audit;
const VOCABULARY_ENTITY_TYPE: &str = "vocabulary";
const TERM_ENTITY_TYPE: &str = "term";
/// Labels aggregated per row as JSON, to read a term and its labels in one query. /// Labels aggregated per row as JSON, to read a term and its labels in one query.
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \ const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \
ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)"; ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)";
/// Create a vocabulary with the given key. /// Create a vocabulary with the given key and record a `created` audit entry, both on
pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result<Vocabulary, sqlx::Error> /// `conn` (pass a transaction connection `&mut *tx` so they commit atomically).
where pub async fn create_vocabulary(
E: sqlx::PgExecutor<'e>, conn: &mut sqlx::PgConnection,
{ actor: AuditActor,
key: &str,
) -> Result<Vocabulary, sqlx::Error> {
let id = VocabularyId::new(); let id = VocabularyId::new();
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)") sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
.bind(id.to_uuid()) .bind(id.to_uuid())
.bind(key) .bind(key)
.execute(executor) .execute(&mut *conn)
.await?; .await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(Vocabulary { Ok(Vocabulary {
id, id,
key: key.to_owned(), key: key.to_owned(),
@@ -54,9 +76,14 @@ where
row.map(map_vocabulary).transpose() row.map(map_vocabulary).transpose()
} }
/// Insert a term and its labels. Multiple statements — pass a transaction /// Insert a term and its labels, then record a `created` audit entry. Multiple
/// connection (`&mut *tx`) so the term and its labels commit atomically. /// statements — pass a transaction connection (`&mut *tx`) so everything commits
pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<TermId, sqlx::Error> { /// atomically.
pub async fn add_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
new: &NewTerm,
) -> Result<TermId, sqlx::Error> {
let id = TermId::new(); let id = TermId::new();
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)") sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
@@ -75,6 +102,18 @@ pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<Te
.await?; .await?;
} }
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: TERM_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(id) Ok(id)
} }
@@ -138,6 +177,204 @@ where
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id))) Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
} }
/// Update a term's `external_uri` and labels (full replace), recording an `updated`
/// audit entry. Returns `false` if no such term or the term does not belong to
/// `vocabulary_id`. Pass a transaction connection.
pub async fn update_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
vocabulary_id: VocabularyId,
term_id: TermId,
external_uri: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let updated =
sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1 AND vocabulary_id = $3")
.bind(term_id.to_uuid())
.bind(external_uri)
.bind(vocabulary_id.to_uuid())
.execute(&mut *conn)
.await?
.rows_affected();
if updated == 0 {
return Ok(false);
}
sqlx::query("DELETE FROM term_label WHERE term_id = $1")
.bind(term_id.to_uuid())
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
.bind(term_id.to_uuid())
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: TERM_ENTITY_TYPE.to_owned(),
entity_id: term_id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects that reference `term_id` through a `term`-typed field.
pub async fn count_objects_referencing_term<'e, E>(
executor: E,
term_id: TermId,
) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar(
"SELECT count(*) FROM object o WHERE EXISTS ( \
SELECT 1 FROM field_definition fd \
WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )",
)
.bind(term_id.to_string())
.fetch_one(executor)
.await
}
/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a
/// `deleted` audit entry. Pass a transaction connection.
pub async fn delete_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
vocabulary_id: VocabularyId,
term_id: TermId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let exists =
sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2")
.bind(term_id.to_uuid())
.bind(vocabulary_id.to_uuid())
.fetch_optional(&mut *conn)
.await?;
if exists.is_none() {
return Ok(crate::DeleteOutcome::NotFound);
}
let count = count_objects_referencing_term(&mut *conn, term_id).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM term WHERE id = $1")
.bind(term_id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: TERM_ENTITY_TYPE.to_owned(),
entity_id: term_id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no
/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505).
pub async fn rename_vocabulary(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: VocabularyId,
key: &str,
) -> Result<bool, sqlx::Error> {
let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1")
.bind(id.to_uuid())
.bind(key)
.execute(&mut *conn)
.await?
.rows_affected();
if updated == 0 {
return Ok(false);
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Delete a vocabulary unless it still has terms or is bound by a field definition
/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry.
pub async fn delete_vocabulary(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: VocabularyId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1")
.bind(id.to_uuid())
.fetch_optional(&mut *conn)
.await?;
if exists.is_none() {
return Ok(crate::DeleteOutcome::NotFound);
}
let count: i64 = sqlx::query_scalar(
"SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \
+ (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)",
)
.bind(id.to_uuid())
.fetch_one(&mut *conn)
.await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM vocabulary WHERE id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> { fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> {
Ok(Vocabulary { Ok(Vocabulary {
id: VocabularyId::from_uuid(row.try_get("id")?), id: VocabularyId::from_uuid(row.try_get("id")?),
+143 -7
View File
@@ -1,7 +1,23 @@
use db::{Db, authority}; use db::{Db, authority, catalog, fields};
use domain::{AuthorityKind, LocalizedLabel, NewAuthority}; use domain::{
AuditActor, AuthorityKind, LocalizedLabel, NewAuthority, NewFieldDefinition, Visibility,
};
use sqlx::PgPool; use sqlx::PgPool;
fn sample_object_input() -> domain::ObjectInput {
domain::ObjectInput {
object_number: "X.1".into(),
object_name: "Test".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority { fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
NewAuthority { NewAuthority {
kind: AuthorityKind::Person, kind: AuthorityKind::Person,
@@ -24,9 +40,13 @@ async fn authority_round_trips_with_labels(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(&mut tx, &new_person("Carl Larsson", "Carl Larsson")) let id = authority::create_authority(
.await &mut tx,
.unwrap(); AuditActor::System,
&new_person("Carl Larsson", "Carl Larsson"),
)
.await
.unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();
let got = authority::authority_by_id(db.pool(), id) let got = authority::authority_by_id(db.pool(), id)
@@ -47,11 +67,12 @@ async fn list_by_kind_filters(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
authority::create_authority(&mut tx, &new_person("A", "A")) authority::create_authority(&mut tx, AuditActor::System, &new_person("A", "A"))
.await .await
.unwrap(); .unwrap();
authority::create_authority( authority::create_authority(
&mut tx, &mut tx,
AuditActor::System,
&NewAuthority { &NewAuthority {
kind: AuthorityKind::Place, kind: AuthorityKind::Place,
external_uri: None, external_uri: None,
@@ -83,7 +104,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(&mut tx, &new_person("X", "X")) let id = authority::create_authority(&mut tx, AuditActor::System, &new_person("X", "X"))
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();
@@ -108,6 +129,7 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority( let id = authority::create_authority(
&mut tx, &mut tx,
AuditActor::System,
&NewAuthority { &NewAuthority {
kind: AuthorityKind::Organisation, kind: AuthorityKind::Organisation,
external_uri: None, external_uri: None,
@@ -125,3 +147,117 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
assert_eq!(got.kind, AuthorityKind::Organisation); assert_eq!(got.kind, AuthorityKind::Organisation);
assert!(got.labels.is_empty()); assert!(got.labels.is_empty());
} }
#[sqlx::test(migrations = "../db/migrations")]
async fn update_authority_changes_labels(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(
&mut tx,
AuditActor::System,
&NewAuthority {
kind: AuthorityKind::Person,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Anon".into(),
}],
},
)
.await
.unwrap();
let existed = authority::update_authority(
&mut tx,
AuditActor::System,
id,
Some("https://viaf.org/1"),
&[LocalizedLabel {
lang: "sv".into(),
label: "Astrid".into(),
}],
)
.await
.unwrap();
assert!(existed);
tx.commit().await.unwrap();
let a = authority::authority_by_id(db.pool(), id)
.await
.unwrap()
.unwrap();
assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1"));
assert_eq!(a.labels[0].label, "Astrid");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_authority_blocks_when_referenced(pool: PgPool) {
use db::DeleteOutcome;
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(
&mut tx,
AuditActor::System,
&NewAuthority {
kind: AuthorityKind::Person,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Astrid".into(),
}],
},
)
.await
.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "maker".into(),
field_type: domain::FieldType::Authority {
kind: Some(AuthorityKind::Person),
},
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Tillverkare".into(),
}],
},
)
.await
.unwrap();
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
.await
.unwrap();
let mut map = serde_json::Map::new();
map.insert("maker".into(), serde_json::Value::String(id.to_string()));
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
.await
.unwrap();
assert_eq!(
authority::delete_authority(&mut tx, AuditActor::System, id)
.await
.unwrap(),
DeleteOutcome::InUse { count: 1 }
);
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
.await
.unwrap();
assert_eq!(
authority::delete_authority(&mut tx, AuditActor::System, id)
.await
.unwrap(),
DeleteOutcome::Deleted
);
assert_eq!(
authority::delete_authority(&mut tx, AuditActor::System, id)
.await
.unwrap(),
DeleteOutcome::NotFound
);
}
+136
View File
@@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) {
assert_eq!(all[1].object_number, "LM-2"); assert_eq!(all[1].object_number, "LM-2");
} }
fn input(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
ObjectInput {
object_number: number.into(),
object_name: name.into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility,
}
}
async fn seed(pool: &PgPool, inputs: &[ObjectInput]) {
let db = Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
for it in inputs {
catalog::create_object(&mut tx, AuditActor::System, it)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
#[sqlx::test]
async fn query_orders_by_name_descending(pool: PgPool) {
let db = Db::from_pool(pool.clone());
seed(
&pool,
&[
input("LM-1", "alpha", Visibility::Draft),
input("LM-2", "gamma", Visibility::Draft),
input("LM-3", "beta", Visibility::Draft),
],
)
.await;
let query = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectName,
descending: true,
visibility: None,
q: None,
};
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
.await
.unwrap();
let names: Vec<&str> = rows.iter().map(|o| o.object_name.as_str()).collect();
assert_eq!(names, ["gamma", "beta", "alpha"]);
}
#[sqlx::test]
async fn query_filters_by_visibility(pool: PgPool) {
let db = Db::from_pool(pool.clone());
seed(
&pool,
&[
input("LM-1", "draft one", Visibility::Draft),
input("LM-2", "internal one", Visibility::Internal),
input("LM-3", "draft two", Visibility::Draft),
],
)
.await;
let query = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectNumber,
descending: false,
visibility: Some("draft"),
q: None,
};
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|o| o.visibility == Visibility::Draft));
let total = catalog::count_objects_query(db.pool(), Some("draft"), None)
.await
.unwrap();
assert_eq!(total, 2);
}
#[sqlx::test]
async fn query_quick_filter_matches_number_or_name(pool: PgPool) {
let db = Db::from_pool(pool.clone());
seed(
&pool,
&[
input("RED-1", "scarlet vase", Visibility::Draft),
input("BLU-1", "azure bowl", Visibility::Draft),
input("LM-9", "red kettle", Visibility::Internal),
],
)
.await;
// Matches the object_number of the first row.
let by_number = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectNumber,
descending: false,
visibility: None,
q: Some("red"),
};
let rows = catalog::list_objects_query(db.pool(), &by_number, 50, 0)
.await
.unwrap();
// ILIKE: "RED-1" by number and "red kettle" by name.
assert_eq!(rows.len(), 2);
let total = catalog::count_objects_query(db.pool(), None, Some("red"))
.await
.unwrap();
assert_eq!(total, 2);
// A term matching only a name.
let by_name = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectNumber,
descending: false,
visibility: None,
q: Some("azure"),
};
let rows = catalog::list_objects_query(db.pool(), &by_name, 50, 0)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].object_number, "BLU-1");
}
#[sqlx::test] #[sqlx::test]
async fn object_by_id_missing_is_none(pool: PgPool) { async fn object_by_id_missing_is_none(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
+141 -3
View File
@@ -1,7 +1,24 @@
use db::{Db, fields, vocab}; use db::{Db, DeleteOutcome, audit, catalog, fields, vocab};
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; use domain::{
AuditAction, AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition,
ObjectInput, Visibility,
};
use sqlx::PgPool; use sqlx::PgPool;
fn sample_object_input() -> ObjectInput {
ObjectInput {
object_number: "X.1".into(),
object_name: "Test".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
fn labels() -> Vec<LocalizedLabel> { fn labels() -> Vec<LocalizedLabel> {
vec![ vec![
LocalizedLabel { LocalizedLabel {
@@ -52,9 +69,11 @@ async fn text_field_round_trips(pool: PgPool) {
#[sqlx::test] #[sqlx::test]
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) { async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let material = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition( fields::create_field_definition(
@@ -169,3 +188,122 @@ async fn any_authority_scalar_and_zero_labels_round_trip(pool: PgPool) {
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect(); let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
assert_eq!(keys, vec!["donor", "on_display"]); assert_eq!(keys, vec!["donor", "on_display"]);
} }
#[sqlx::test(migrations = "../db/migrations")]
async fn update_field_definition_edits_labels_group_required(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "weight".into(),
field_type: FieldType::Integer,
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Vikt".into(),
}],
},
)
.await
.unwrap();
let existed = fields::update_field_definition(
&mut tx,
AuditActor::System,
"weight",
true,
Some("Mått"),
&[LocalizedLabel {
lang: "sv".into(),
label: "Vikt (g)".into(),
}],
)
.await
.unwrap();
assert!(existed);
tx.commit().await.unwrap();
let def = fields::field_definition_by_key(db.pool(), "weight")
.await
.unwrap()
.unwrap();
assert!(def.required);
assert_eq!(def.group_key.as_deref(), Some("Mått"));
assert_eq!(def.labels[0].label, "Vikt (g)");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "weight".into(),
field_type: FieldType::Integer,
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Vikt".into(),
}],
},
)
.await
.unwrap();
let field_def_id = fields::field_definition_by_key(&mut *tx, "weight")
.await
.unwrap()
.unwrap()
.id
.to_uuid();
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
.await
.unwrap();
let mut map = serde_json::Map::new();
map.insert("weight".into(), serde_json::Value::from(42));
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
.await
.unwrap();
assert_eq!(
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
.await
.unwrap(),
DeleteOutcome::InUse { count: 1 }
);
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
.await
.unwrap();
assert_eq!(
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
.await
.unwrap(),
DeleteOutcome::Deleted
);
let history = audit::history_for(&mut *tx, "field_definition", field_def_id)
.await
.unwrap();
assert!(
history.iter().any(|e| e.action == AuditAction::Deleted),
"expected a Deleted audit entry for the field_definition"
);
assert_eq!(
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
.await
.unwrap(),
DeleteOutcome::NotFound
);
}
+12 -3
View File
@@ -95,9 +95,12 @@ async fn sets_scalar_fields_and_audits(pool: PgPool) {
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) { async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let id = setup_object(&db).await; let id = setup_object(&db).await;
let material = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
define( define(
&db, &db,
"material", "material",
@@ -110,6 +113,7 @@ async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let wood = vocab::add_term( let wood = vocab::add_term(
&mut tx, &mut tx,
AuditActor::System,
&domain::NewTerm { &domain::NewTerm {
vocabulary_id: material.id, vocabulary_id: material.id,
external_uri: None, external_uri: None,
@@ -180,6 +184,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let person = db::authority::create_authority( let person = db::authority::create_authority(
&mut tx, &mut tx,
AuditActor::System,
&domain::NewAuthority { &domain::NewAuthority {
kind: domain::AuthorityKind::Person, kind: domain::AuthorityKind::Person,
external_uri: None, external_uri: None,
@@ -190,6 +195,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
.unwrap(); .unwrap();
let place = db::authority::create_authority( let place = db::authority::create_authority(
&mut tx, &mut tx,
AuditActor::System,
&domain::NewAuthority { &domain::NewAuthority {
kind: domain::AuthorityKind::Place, kind: domain::AuthorityKind::Place,
external_uri: None, external_uri: None,
@@ -219,12 +225,14 @@ async fn authority_field_enforces_kind(pool: PgPool) {
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) { async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let id = setup_object(&db).await; let id = setup_object(&db).await;
let material = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
let technique = vocab::create_vocabulary(db.pool(), "technique") let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
define( define(
&db, &db,
"material", "material",
@@ -238,6 +246,7 @@ async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let other = vocab::add_term( let other = vocab::add_term(
&mut tx, &mut tx,
AuditActor::System,
&domain::NewTerm { &domain::NewTerm {
vocabulary_id: technique.id, vocabulary_id: technique.id,
external_uri: None, external_uri: None,
+258 -9
View File
@@ -1,13 +1,18 @@
use db::{Db, vocab}; use db::{Db, audit, catalog, fields, vocab};
use domain::{LocalizedLabel, NewTerm}; use domain::{
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput,
Visibility,
};
use sqlx::PgPool; use sqlx::PgPool;
#[sqlx::test] #[sqlx::test]
async fn vocabulary_create_and_lookup(pool: PgPool) { async fn vocabulary_create_and_lookup(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let v = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
let found = vocab::vocabulary_by_key(db.pool(), "material") let found = vocab::vocabulary_by_key(db.pool(), "material")
.await .await
@@ -27,13 +32,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) {
#[sqlx::test] #[sqlx::test]
async fn term_with_multilingual_labels_round_trips(pool: PgPool) { async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let v = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let term_id = vocab::add_term( let term_id = vocab::add_term(
&mut tx, &mut tx,
AuditActor::System,
&NewTerm { &NewTerm {
vocabulary_id: v.id, vocabulary_id: v.id,
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()), external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
@@ -76,13 +84,16 @@ async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
#[sqlx::test] #[sqlx::test]
async fn term_with_no_labels_round_trips_empty(pool: PgPool) { async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let v = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let term_id = vocab::add_term( let term_id = vocab::add_term(
&mut tx, &mut tx,
AuditActor::System,
&NewTerm { &NewTerm {
vocabulary_id: v.id, vocabulary_id: v.id,
external_uri: None, external_uri: None,
@@ -103,10 +114,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
#[sqlx::test] #[sqlx::test]
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) { async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
let err = vocab::create_vocabulary(db.pool(), "material") tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let err = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap_err(); .unwrap_err();
assert!( assert!(
@@ -118,16 +133,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
#[sqlx::test] #[sqlx::test]
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) { async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
let material = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
let technique = vocab::create_vocabulary(db.pool(), "technique") let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
.await .await
.unwrap(); .unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap(); let mut tx = db.pool().begin().await.unwrap();
let term_id = vocab::add_term( let term_id = vocab::add_term(
&mut tx, &mut tx,
AuditActor::System,
&NewTerm { &NewTerm {
vocabulary_id: material.id, vocabulary_id: material.id,
external_uri: None, external_uri: None,
@@ -154,3 +172,234 @@ async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
.is_none() .is_none()
); );
} }
fn sample_object_input() -> ObjectInput {
ObjectInput {
object_number: "X.1".into(),
object_name: "Test".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test(migrations = "../db/migrations")]
async fn update_term_changes_labels_and_uri(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await
.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: vocab.id,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Trä".into(),
}],
},
)
.await
.unwrap();
let existed = vocab::update_term(
&mut tx,
AuditActor::System,
vocab.id,
term_id,
Some("https://example.org/wood"),
&[LocalizedLabel {
lang: "sv".into(),
label: "Träslag".into(),
}],
)
.await
.unwrap();
assert!(existed);
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
.await
.unwrap();
assert!(
history.iter().any(|e| e.action == AuditAction::Updated),
"expected an Updated audit entry for the term"
);
tx.commit().await.unwrap();
let term = vocab::term_by_id(db.pool(), term_id)
.await
.unwrap()
.unwrap();
assert_eq!(
term.external_uri.as_deref(),
Some("https://example.org/wood")
);
assert_eq!(term.labels.len(), 1);
assert_eq!(term.labels[0].label, "Träslag");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
use db::DeleteOutcome;
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await
.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: vocab.id,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Trä".into(),
}],
},
)
.await
.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "material".into(),
field_type: FieldType::Term {
vocabulary_id: vocab.id,
},
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Material".into(),
}],
},
)
.await
.unwrap();
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
.await
.unwrap();
let mut map = serde_json::Map::new();
map.insert(
"material".into(),
serde_json::Value::String(term_id.to_string()),
);
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
.await
.unwrap();
let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
.await
.unwrap();
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
.await
.unwrap();
let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
.await
.unwrap();
assert_eq!(ok, DeleteOutcome::Deleted);
assert!(
vocab::term_by_id(&mut *tx, term_id)
.await
.unwrap()
.is_none()
);
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
.await
.unwrap();
assert!(
history.iter().any(|e| e.action == AuditAction::Deleted),
"expected a Deleted audit entry for the term"
);
let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
.await
.unwrap();
assert_eq!(gone, DeleteOutcome::NotFound);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_vocabulary_changes_key(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old")
.await
.unwrap();
let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new")
.await
.unwrap();
assert!(existed);
tx.commit().await.unwrap();
assert!(
vocab::vocabulary_by_key(db.pool(), "new")
.await
.unwrap()
.is_some()
);
assert!(
vocab::vocabulary_by_key(db.pool(), "old")
.await
.unwrap()
.is_none()
);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) {
use db::DeleteOutcome;
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await
.unwrap();
vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: v.id,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Trä".into(),
}],
},
)
.await
.unwrap();
let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id)
.await
.unwrap();
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty")
.await
.unwrap();
assert_eq!(
vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
.await
.unwrap(),
DeleteOutcome::Deleted
);
let gone = vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
.await
.unwrap();
assert_eq!(gone, DeleteOutcome::NotFound);
}
+1
View File
@@ -9,3 +9,4 @@ uuid.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
time.workspace = true time.workspace = true
utoipa.workspace = true
+4
View File
@@ -4,6 +4,10 @@ use time::OffsetDateTime;
use uuid::Uuid; use uuid::Uuid;
/// What kind of change an audit entry records. /// What kind of change an audit entry records.
///
/// NOTE: kept in sync by hand with the
/// `CHECK (action IN ('created', 'updated', 'deleted'))` constraint in
/// `crates/db/migrations/0001_audit_log.sql` — add a variant in both places.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum AuditAction { pub enum AuditAction {
+5 -1
View File
@@ -3,7 +3,11 @@ use serde::{Deserialize, Serialize};
use crate::{AuthorityId, LocalizedLabel}; use crate::{AuthorityId, LocalizedLabel};
/// The kind of authority record. /// The kind of authority record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] ///
/// NOTE: kept in sync by hand with the
/// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum AuthorityKind { pub enum AuthorityKind {
Person, Person,
+31
View File
@@ -74,6 +74,23 @@ impl FieldType {
} }
} }
/// The stored `data_type` discriminant of a field definition — mirrors the strings from
/// [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
/// closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum DataType {
Text,
LocalizedText,
Integer,
Date,
Boolean,
Term,
Authority,
}
/// A registered flexible field, with its multilingual display labels. /// A registered flexible field, with its multilingual display labels.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldDefinition { pub struct FieldDefinition {
@@ -152,4 +169,18 @@ mod tests {
); );
assert_eq!(FieldType::from_parts("authority", Some(v), None), None); assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
} }
#[test]
fn data_type_serde_matches_kind_str() {
use serde_json::json;
assert_eq!(
serde_json::to_value(DataType::LocalizedText).unwrap(),
json!("localized_text")
);
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
assert_eq!(
serde_json::to_value(DataType::Authority).unwrap(),
json!("authority")
);
}
} }
+1 -1
View File
@@ -11,7 +11,7 @@ mod vocabulary;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; pub use field_definition::{DataType, FieldDefinition, FieldType, NewFieldDefinition};
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId}; pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
pub use label::{LocalizedLabel, pick_label}; pub use label::{LocalizedLabel, pick_label};
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
+1 -1
View File
@@ -4,7 +4,7 @@ use time::{Date, OffsetDateTime};
use crate::ObjectId; use crate::ObjectId;
/// Publication state of a catalogue record. /// Publication state of a catalogue record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Visibility { pub enum Visibility {
/// Work in progress; not shown anywhere public. /// Work in progress; not shown anywhere public.
+4
View File
@@ -34,6 +34,7 @@ pub struct SearchDocument {
pub brief_description: Option<String>, pub brief_description: Option<String>,
pub current_owner: Option<String>, pub current_owner: Option<String>,
pub recorder: Option<String>, pub recorder: Option<String>,
pub recording_date: Option<String>,
/// Filterable: "draft" | "internal" | "public". /// Filterable: "draft" | "internal" | "public".
pub visibility: String, pub visibility: String,
/// Flexible field values flattened to searchable text. /// Flexible field values flattened to searchable text.
@@ -55,6 +56,7 @@ pub struct SearchHit {
pub object_name: String, pub object_name: String,
pub brief_description: Option<String>, pub brief_description: Option<String>,
pub visibility: String, pub visibility: String,
pub recording_date: Option<String>,
pub snippet: Option<String>, pub snippet: Option<String>,
} }
@@ -233,6 +235,7 @@ impl SearchClient {
object_name: doc.object_name, object_name: doc.object_name,
brief_description: doc.brief_description, brief_description: doc.brief_description,
visibility: doc.visibility, visibility: doc.visibility,
recording_date: doc.recording_date,
snippet, snippet,
} }
}) })
@@ -367,6 +370,7 @@ pub async fn build_document(
brief_description: object.brief_description.clone(), brief_description: object.brief_description.clone(),
current_owner: object.current_owner.clone(), current_owner: object.current_owner.clone(),
recorder: object.recorder.clone(), recorder: object.recorder.clone(),
recording_date: object.recording_date.map(|d| d.to_string()),
visibility: object.visibility.as_str().to_owned(), visibility: object.visibility.as_str().to_owned(),
fields_text, fields_text,
}) })
+4 -3
View File
@@ -23,14 +23,15 @@ async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
// a material vocabulary with a "wood" term // a material vocabulary with a "wood" term
let material = vocab::create_vocabulary(db.pool(), "material") let mut tx = db.pool().begin().await.unwrap();
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await .await
.unwrap(); .unwrap();
let mut tx = db.pool().begin().await.unwrap();
let wood = vocab::add_term( let wood = vocab::add_term(
&mut tx, &mut tx,
AuditActor::System,
&NewTerm { &NewTerm {
vocabulary_id: material.id, vocabulary_id: material.id,
external_uri: None, external_uri: None,
+3
View File
@@ -19,6 +19,7 @@ fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
brief_description: None, brief_description: None,
current_owner: None, current_owner: None,
recorder: None, recorder: None,
recording_date: None,
visibility: "draft".to_string(), visibility: "draft".to_string(),
fields_text: fields_text.iter().map(|s| s.to_string()).collect(), fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
} }
@@ -66,6 +67,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
&["cast bronze with green patina"], &["cast bronze with green patina"],
); );
bronze_a.visibility = "public".to_string(); bronze_a.visibility = "public".to_string();
bronze_a.recording_date = Some("1962-04-03".to_string());
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]); let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
bronze_b.visibility = "public".to_string(); bronze_b.visibility = "public".to_string();
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]); let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
@@ -87,6 +89,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
"snippet must mark the match" "snippet must mark the match"
); );
assert!(snippet.contains(search::HL_POST)); assert!(snippet.contains(search::HL_POST));
assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));
let public = client let public = client
.search_objects("bronze", Some("public"), 0, 20) .search_objects("bronze", Some("public"), 0, 20)
+1
View File
@@ -27,6 +27,7 @@ db = { path = "../db" }
domain = { path = "../domain" } domain = { path = "../domain" }
search = { path = "../search" } search = { path = "../search" }
rpassword.workspace = true rpassword.workspace = true
dotenvy.workspace = true
memory-serve = { workspace = true, optional = true } memory-serve = { workspace = true, optional = true }
[build-dependencies] [build-dependencies]
+25
View File
@@ -42,4 +42,29 @@ pub struct Config {
/// Meilisearch index name for catalogue objects. /// Meilisearch index name for catalogue objects.
#[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")] #[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")]
pub meili_index: String, pub meili_index: String,
/// Maximum size of the PostgreSQL connection pool.
#[arg(
long = "db-max-connections",
env = "DB_MAX_CONNECTIONS",
default_value_t = 5
)]
pub db_max_connections: u32,
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
#[arg(
long = "default-language",
env = "DEFAULT_LANGUAGE",
default_value = "sv"
)]
pub default_language: String,
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
/// this is a display hint surfaced to clients (and, later, server-side renderers).
#[arg(
long = "default-timezone",
env = "DEFAULT_TIMEZONE",
default_value = "Europe/Stockholm"
)]
pub default_timezone: String,
} }
+60 -3
View File
@@ -15,7 +15,7 @@ use tokio::net::TcpListener;
/// Connect dependencies from `config` and serve until shutdown. /// Connect dependencies from `config` and serve until shutdown.
pub async fn run(config: Config) -> anyhow::Result<()> { pub async fn run(config: Config) -> anyhow::Result<()> {
let db = Db::connect(&config.database_url) let db = Db::connect(&config.database_url, config.db_max_connections)
.await .await
.context("connecting to the database")?; .context("connecting to the database")?;
@@ -50,9 +50,11 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
let state = AppState { let state = AppState {
db, db,
app_name: config.app_name.clone(), app_name: config.app_name,
cookie_secure: config.cookie_secure, cookie_secure: config.cookie_secure,
search, search,
default_language: config.default_language,
default_timezone: config.default_timezone,
}; };
let listener = TcpListener::bind(&config.bind_addr) let listener = TcpListener::bind(&config.bind_addr)
@@ -64,6 +66,34 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
serve(listener, state).await serve(listener, state).await
} }
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
/// drain in-flight requests before exiting.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received; draining");
}
/// Serve the API on an already-bound listener (used by `run` and tests). /// Serve the API on an already-bound listener (used by `run` and tests).
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
let app = build_app(state); let app = build_app(state);
@@ -72,6 +102,7 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()>
let app = app.merge(web_assets::routes()); let app = app.merge(web_assets::routes());
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await .await
.context("running the HTTP server")?; .context("running the HTTP server")?;
@@ -86,6 +117,31 @@ pub mod test_support {
} }
} }
/// One-shot: apply migrations (idempotent), then seed the baseline Spectrum cataloguing
/// vocabularies + field definitions. Safe to re-run (the seed is idempotent).
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
// CLI one-shot: a tiny pool is plenty.
let db = Db::connect(database_url, 2)
.await
.context("connecting to the database")?;
// Apply migrations first so `server seed` works on a fresh DB without first
// starting the server. Migrations are idempotent.
db.migrate().await.context("running database migrations")?;
let mut tx = db.pool().begin().await?;
db::seed::seed_spectrum_cataloguing(&mut tx)
.await
.context("seeding Spectrum cataloguing baseline")?;
tx.commit().await?;
println!("seeded Spectrum cataloguing baseline (idempotent)");
Ok(())
}
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI /// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set, /// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is /// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
@@ -107,7 +163,8 @@ pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow:
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))? auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
}; };
let db = Db::connect(database_url) // CLI one-shot: a tiny pool is plenty.
let db = Db::connect(database_url, 2)
.await .await
.context("connecting to the database")?; .context("connecting to the database")?;
+8 -1
View File
@@ -1,6 +1,6 @@
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use domain::Role; use domain::Role;
use server::{Config, create_user, run}; use server::{Config, create_user, run, seed};
#[derive(Parser)] #[derive(Parser)]
#[command(version, about = "Collection management system server")] #[command(version, about = "Collection management system server")]
@@ -20,6 +20,8 @@ enum Command {
#[arg(long, value_enum)] #[arg(long, value_enum)]
role: RoleArg, role: RoleArg,
}, },
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
Seed,
} }
#[derive(Clone, Copy, ValueEnum)] #[derive(Clone, Copy, ValueEnum)]
@@ -39,6 +41,10 @@ impl From<RoleArg> for Role {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Load a .env file (if present) so the binary picks up config when run directly,
// not only via `just` (which uses `set dotenv-load`). A missing .env is fine.
dotenvy::dotenv().ok();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init(); .init();
@@ -50,5 +56,6 @@ async fn main() -> anyhow::Result<()> {
Some(Command::CreateUser { email, role }) => { Some(Command::CreateUser { email, role }) => {
create_user(&cli.config.database_url, &email, role.into()).await create_user(&cli.config.database_url, &email, role.into()).await
} }
Some(Command::Seed) => seed(&cli.config.database_url).await,
} }
} }
+5 -1
View File
@@ -1,11 +1,13 @@
use clap::Parser; use clap::Parser;
use server::Config; use server::Config;
const CLEARED: [(&str, Option<&str>); 4] = [ const CLEARED: [(&str, Option<&str>); 6] = [
("DATABASE_URL", None), ("DATABASE_URL", None),
("BIND_ADDR", None), ("BIND_ADDR", None),
("APP_NAME", None), ("APP_NAME", None),
("SESSION_COOKIE_SECURE", None), ("SESSION_COOKIE_SECURE", None),
("DEFAULT_LANGUAGE", None),
("DEFAULT_TIMEZONE", None),
]; ];
#[test] #[test]
@@ -17,6 +19,8 @@ fn parses_from_args_with_defaults() {
assert_eq!(cfg.database_url, "postgres://localhost/test"); assert_eq!(cfg.database_url, "postgres://localhost/test");
assert_eq!(cfg.bind_addr, "0.0.0.0:8080"); assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
assert_eq!(cfg.app_name, "Collection Management System"); assert_eq!(cfg.app_name, "Collection Management System");
assert_eq!(cfg.default_language, "sv");
assert_eq!(cfg.default_timezone, "Europe/Stockholm");
}); });
} }
+33
View File
@@ -0,0 +1,33 @@
use db::{Db, fields, seed, vocab};
use sqlx::PgPool;
// Note: `server::seed` opens its own DB connection by URL, but `#[sqlx::test]`
// provisions a temporary database whose URL is not directly exposed. This test
// exercises the building block the command composes — `db::seed::seed_spectrum_cataloguing`
// — against the test pool, run twice to prove the idempotency the command relies on.
#[sqlx::test(migrations = "../db/migrations")]
async fn seed_is_idempotent_via_building_block(pool: PgPool) {
let db = Db::from_pool(pool);
for _ in 0..2 {
let mut tx = db.pool().begin().await.unwrap();
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
tx.commit().await.unwrap();
}
// A representative seeded vocabulary and field definition are present after two runs.
assert!(
vocab::vocabulary_by_key(db.pool(), "material")
.await
.unwrap()
.is_some(),
"vocabulary 'material' should be seeded"
);
assert!(
fields::field_definition_by_key(db.pool(), "title")
.await
.unwrap()
.is_some(),
"field definition 'title' should be seeded"
);
}
+16 -6
View File
@@ -9,7 +9,7 @@ use tokio::net::TcpListener;
async fn serves_health_live_over_tcp() { async fn serves_health_live_over_tcp() {
let database_url = let database_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test"); std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
let db = Db::connect(&database_url) let db = Db::connect(&database_url, 2)
.await .await
.expect("connect to database"); .expect("connect to database");
let state = AppState { let state = AppState {
@@ -17,18 +17,28 @@ async fn serves_health_live_over_tcp() {
app_name: "Test".to_string(), app_name: "Test".to_string(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}; };
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap(); let addr: SocketAddr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move { serve(listener, state).await });
serve(listener, state).await.unwrap();
});
let url = format!("http://{addr}/health/live"); let url = format!("http://{addr}/health/live");
let body: serde_json::Value = reqwest::get(&url) let response = reqwest::get(&url).await;
.await
// If the request failed and the server task already ended, it errored — surface that
// (a clear server error) instead of the opaque reqwest failure.
if response.is_err() && handle.is_finished() {
match handle.await {
Ok(Err(err)) => panic!("server failed: {err:?}"),
other => panic!("server task ended unexpectedly: {other:?}"),
}
}
let body: serde_json::Value = response
.expect("request succeeds") .expect("request succeeds")
.json() .json()
.await .await
+18
View File
@@ -9,6 +9,24 @@ services:
- "5432:5432" - "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.12
environment:
# Development mode relaxes the production master-key length requirement and
# enables the search-preview UI. The key below is for local use only.
MEILI_ENV: development
MEILI_MASTER_KEY: masterKey
ports:
- "7700:7700"
volumes:
- meilidata:/meili_data
volumes: volumes:
pgdata: pgdata:
meilidata:
@@ -0,0 +1,922 @@
# Fields Management 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:** Let admins create flexible field definitions — expose `POST /api/admin/field-definitions` over the existing db layer, and build a `/fields` two-pane screen (grouped list + create form) that enables the last nav stub.
**Architecture:** A thin axum write handler reuses `FieldType::from_parts` as the single type/binding validation chokepoint and `db::fields::create_field_definition`. The frontend reuses the Objects/Vocabularies two-pane idiom: a grouped read-only list (`useFieldDefinitions`, already cached and shared with the M2 object editor) plus a create form with native `<select>`s and conditional config (vocabulary for `term`, kind for `authority`). Creating a field invalidates `["field-definitions"]`, so it appears in both the list and the object editor.
**Tech Stack:** Rust (axum 0.8, utoipa, sqlx 0.8), React 19 + TS, TanStack Query v5, react-router-dom 7, react-i18next (sv/en), Vitest + RTL + MSW.
**Spec:** `docs/superpowers/specs/2026-06-04-fields-management-design.md`
**Conventions (every task):**
- Rust fmt with **nightly** (`cargo +nightly fmt`); `cargo clippy`.
- Frontend: no `any` / `eslint-disable` / `@ts-ignore`; en/sv i18n key parity; codename "biggus"/"dickus" nowhere; native `<select>` for dropdowns (matches `web/src/objects/field-input.tsx` — a deliberate bundle-lean choice).
- Test infra (running docker containers; start if down): `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev`, `MEILI_URL=http://localhost:7701`, `MEILI_MASTER_KEY=masterKey`. (Field-definition tests need only Postgres; `#[sqlx::test]` provisions its own DB.)
- Run web commands from `web/`; cargo from repo root.
---
## Task 1: Backend — `POST /api/admin/field-definitions`
The GET handler already lives in `crates/api/src/admin_objects.rs` and its route is registered there. **axum panics if the same path is declared in two merged routers**, so the POST handler goes in `admin_objects.rs` too and chains `.post(...)` onto the existing `.route("/api/admin/field-definitions", get(list_field_definitions))`. No domain or db changes — `FieldType::from_parts` and `db::fields::create_field_definition` already exist.
**Files:**
- Modify: `crates/api/src/admin_objects.rs` (add request/response structs, handler, chain `.post`)
- Modify: `crates/api/src/openapi.rs` (register path + schemas)
- Test: `crates/api/tests/admin_fields.rs` (new)
- Regenerate: `web/src/api/schema.d.ts`
- [ ] **Step 1: Write the failing API test** — create `crates/api/tests/admin_fields.rs`:
```rust
use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::users;
use domain::{AuditActor, Email, NewUser, Role};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
fn state(pool: PgPool) -> AppState {
AppState {
db: db::Db::from_pool(pool),
app_name: "Test".into(),
cookie_secure: false,
search: None,
}
}
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
users::create_user(
&mut tx,
AuditActor::System,
&NewUser {
email: Email::parse(email).unwrap(),
password_hash: auth::hash_password(password).unwrap(),
role,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
}
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(
r#"{{"email":"{email}","password":"{password}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
resp.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn post_field(app: &axum::Router, cookie: &str, body: &str) -> axum::http::Response<Body> {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/field-definitions")
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap()
}
#[sqlx::test(migrations = "../db/migrations")]
async fn create_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
let app = build_app(state(pool));
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/field-definitions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"key":"x","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn create_scalar_field_then_lists_it(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"height_cm","data_type":"integer","required":true,"group":"Dimensions","labels":[{"lang":"en","label":"Height"},{"lang":"sv","label":"Höjd"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let body: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["key"], "height_cm");
// It appears in the GET listing.
let list = app
.oneshot(
Request::builder()
.uri("/api/admin/field-definitions")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let defs: serde_json::Value =
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert!(defs.as_array().unwrap().iter().any(|d| d["key"] == "height_cm"));
}
#[sqlx::test(migrations = "../db/migrations")]
async fn term_without_vocabulary_is_422(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let resp = post_field(
&app,
&cookie,
r#"{"key":"material","data_type":"term","required":false,"labels":[{"lang":"en","label":"Material"}]}"#,
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn duplicate_key_is_409(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let body = r#"{"key":"dup","data_type":"text","required":false,"labels":[{"lang":"en","label":"Dup"}]}"#;
assert_eq!(post_field(&app, &cookie, body).await.status(), StatusCode::CREATED);
assert_eq!(post_field(&app, &cookie, body).await.status(), StatusCode::CONFLICT);
}
```
- [ ] **Step 2: Run it to confirm it fails**
```bash
cargo test -p api --test admin_fields
```
Expected: 401-test may pass incidentally, but the create tests fail (route has no POST → 405/404).
- [ ] **Step 3: Add the request/response structs + handler** — in `crates/api/src/admin_objects.rs`. First ensure the imports at the top include what's needed (the file already imports axum bits, `State`, `StatusCode`, `Json`, `db`, `auth::{Authorized, ViewInternal}`; add `EditCatalogue` and the domain types). Add to the `use auth::...` line: `EditCatalogue`. Add `use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId};` if not already present (the file may already import some domain types — merge, don't duplicate). Reuse `LabelInput` — it is defined in `admin_vocab`; import it: `use crate::admin_vocab::LabelInput;` (the file already imports from `crate`; add this).
Then add the structs (near `FieldDefinitionView`):
```rust
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub(crate) struct NewFieldDefinitionRequest {
pub key: String,
/// text | localized_text | integer | date | boolean | term | authority
pub data_type: String,
pub vocabulary_id: Option<String>,
pub authority_kind: Option<String>,
pub required: bool,
pub group: Option<String>,
pub labels: Vec<LabelInput>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct CreatedField {
pub key: String,
}
```
(If `serde::{Deserialize, Serialize}` and `utoipa::ToSchema` are already imported in this file, use the bare derive names to match the file's style.)
And the handler:
```rust
/// Create a field definition. Requires `EditCatalogue`. All type/binding consistency
/// (term needs a vocabulary, authority takes no vocabulary, scalars take no binding) is
/// validated by `FieldType::from_parts`, which returns `None` for any bad combination.
#[utoipa::path(
post, path = "/api/admin/field-definitions",
request_body = NewFieldDefinitionRequest,
responses(
(status = 201, body = CreatedField),
(status = 400, description = "Malformed vocabulary_id or authority_kind"),
(status = 401),
(status = 403),
(status = 409, description = "Duplicate key"),
(status = 422, description = "Inconsistent type/binding")
)
)]
pub(crate) async fn create_field_definition(
_auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Json(req): Json<NewFieldDefinitionRequest>,
) -> Result<(StatusCode, Json<CreatedField>), StatusCode> {
let vocabulary_id = match req.vocabulary_id.as_deref() {
None | Some("") => None,
Some(s) => Some(s.parse::<VocabularyId>().map_err(|_| StatusCode::BAD_REQUEST)?),
};
let authority_kind = match req.authority_kind.as_deref() {
None | Some("") => None,
Some(s) => Some(AuthorityKind::from_db(s).ok_or(StatusCode::BAD_REQUEST)?),
};
let field_type = FieldType::from_parts(&req.data_type, vocabulary_id, authority_kind)
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
let new = NewFieldDefinition {
key: req.key,
field_type,
required: req.required,
group_key: req.group,
labels: req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect(),
};
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match db::fields::create_field_definition(&mut tx, &new).await {
Ok(_) => {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(CreatedField { key: new.key })))
}
// Duplicate `key` violates the unique index (SQLSTATE 23505).
Err(err)
if err
.as_database_error()
.and_then(|e| e.code())
.as_deref()
== Some("23505") =>
{
Err(StatusCode::CONFLICT)
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
```
Note: `AuthorityKind::from_db` is the existing parser (`crates/domain/src/authority.rs`); confirm the method name there (it is `from_db`, returning `Option<AuthorityKind>`). `VocabularyId: FromStr` is used the same way `admin_vocab` parses ids.
- [ ] **Step 4: Chain `.post` onto the existing route** — in `admin_objects.rs` `routes()`, change:
```rust
.route("/api/admin/field-definitions", get(list_field_definitions))
```
to
```rust
.route(
"/api/admin/field-definitions",
get(list_field_definitions).post(create_field_definition),
)
```
- [ ] **Step 5: Register in OpenAPI** — in `crates/api/src/openapi.rs`: add `admin_objects::create_field_definition` to `paths(...)`; add `admin_objects::NewFieldDefinitionRequest` and `admin_objects::CreatedField` to `components(schemas(...))`.
- [ ] **Step 6: Run the API tests**`cargo test -p api --test admin_fields` → 4 pass.
- [ ] **Step 7: Regenerate the typed web client**
```bash
cargo build -p server
DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \
MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \
./target/debug/server &
SERVER_PID=$!
sleep 2
( cd web && pnpm gen:api )
kill "$SERVER_PID"
grep -n "NewFieldDefinitionRequest\|CreatedField" web/src/api/schema.d.ts
```
The grep must show both schemas. Then `cd web && pnpm typecheck` to confirm the regenerated file is well-formed (the diff should be purely additive — the existing `/api/admin/field-definitions` GET path gains a `post` operation; no existing paths removed). If a stale server occupies :8080, kill it first (`lsof -ti :8080 | xargs kill`).
- [ ] **Step 8: Format, lint, commit**
```bash
cargo +nightly fmt
cargo clippy -p api --all-targets
cd /Users/olsson/Laboratory/biggus-dickus
git add crates/api web/src/api/schema.d.ts
git commit -m "feat(api): POST /api/admin/field-definitions (create field definition)"
```
---
## Task 2: Frontend data layer — `useCreateFieldDefinition` + MSW handler
**Files:**
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`
- Test: `web/src/api/queries.fields.test.tsx` (new)
The `fieldDefinitions` GET fixture already exists (`web/src/test/fixtures.ts`) with a grouped entry (`inscription`, group "Description") and ungrouped entries, and the GET handler is already wired. Only the mutation + POST handler are new.
- [ ] **Step 1: Add the MSW POST handler** — in `web/src/test/handlers.ts`, add to the `handlers` array:
```ts
http.post("/api/admin/field-definitions", () =>
HttpResponse.json({ key: "new_field" }, { status: 201 }),
),
```
- [ ] **Step 2: Write the failing hook test** — create `web/src/api/queries.fields.test.tsx`:
```tsx
import { expect, test } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { useCreateFieldDefinition } from "./queries";
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
test("useCreateFieldDefinition POSTs the request body", async () => {
let body: unknown;
server.use(
http.post("/api/admin/field-definitions", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ key: "technique" }, { status: 201 });
}),
);
const { result } = renderHook(() => useCreateFieldDefinition(), { wrapper });
result.current.mutate({
key: "technique",
data_type: "term",
vocabulary_id: "v-technique",
authority_kind: null,
required: false,
group: "Provenance",
labels: [{ lang: "en", label: "Technique" }],
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect((body as { key: string; data_type: string }).key).toBe("technique");
expect((body as { data_type: string }).data_type).toBe("term");
});
```
- [ ] **Step 3: Run it to confirm it fails**`cd web && pnpm test src/api/queries.fields.test.tsx` → FAIL (no `useCreateFieldDefinition`).
- [ ] **Step 4: Implement the hook** — in `web/src/api/queries.ts`, append (it uses the already-imported `useMutation`, `useQueryClient`, `api`, and `components`):
```ts
type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"];
export function useCreateFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: NewFieldDefinitionRequest) => {
const { data, response } = await api.POST("/api/admin/field-definitions", { body });
if (response.status !== 201 || !data) throw new Error("failed to create field definition");
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
});
}
```
- [ ] **Step 5: Run it to confirm it passes**`pnpm test src/api/queries.fields.test.tsx` → PASS.
- [ ] **Step 6: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): useCreateFieldDefinition mutation + MSW handler"
```
---
## Task 3: Frontend — `/fields` two-pane screen, route, nav, i18n
**Files:**
- Create: `web/src/fields/fields-page.tsx`, `web/src/fields/field-list.tsx`, `web/src/fields/field-form.tsx`, `web/src/fields/fields.test.tsx`
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
- [ ] **Step 1: i18n** — add a `fields` namespace to BOTH `web/src/i18n/en.json` and `sv.json` (keep parity; authority-kind option labels reuse the existing `authorities.{person,organisation,place}` keys).
`en.json`:
```json
"fields": {
"title": "Fields",
"newField": "New field definition",
"key": "Key",
"type": "Type",
"vocabulary": "Vocabulary",
"authorityKind": "Authority kind",
"anyKind": "Any",
"group": "Group",
"required": "Required",
"create": "Create field",
"empty": "No field definitions yet",
"loadError": "Could not load",
"other": "Other",
"types": {
"text": "Text",
"localized_text": "Localized text",
"integer": "Integer",
"date": "Date",
"boolean": "Boolean",
"term": "Term",
"authority": "Authority"
}
}
```
`sv.json`:
```json
"fields": {
"title": "Fält",
"newField": "Nytt fältdefinition",
"key": "Nyckel",
"type": "Typ",
"vocabulary": "Vokabulär",
"authorityKind": "Auktoritetstyp",
"anyKind": "Alla",
"group": "Grupp",
"required": "Obligatoriskt",
"create": "Skapa fält",
"empty": "Inga fältdefinitioner ännu",
"loadError": "Kunde inte ladda",
"other": "Övrigt",
"types": {
"text": "Text",
"localized_text": "Lokaliserad text",
"integer": "Heltal",
"date": "Datum",
"boolean": "Boolesk",
"term": "Term",
"authority": "Auktoritet"
}
}
```
- [ ] **Step 2: Implement `FieldList`** — create `web/src/fields/field-list.tsx`:
```tsx
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useFieldDefinitions } from "../api/queries";
import { Skeleton } from "@/components/ui/skeleton";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
function labelText(labels: FieldDefinitionView["labels"], lang: string): string {
return (
labels.find((l) => l.lang === lang)?.label ??
labels.find((l) => l.lang === "en")?.label ??
labels[0]?.label ??
""
);
}
export function FieldList() {
const { t, i18n } = useTranslation();
const { data, isLoading, isError } = useFieldDefinitions();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
if (isLoading) {
return (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
);
}
if (isError) return <p className="p-4 text-sm text-red-600">{t("fields.loadError")}</p>;
if (!data || data.length === 0)
return <p className="p-4 text-sm text-neutral-500">{t("fields.empty")}</p>;
// Group by `group`; ungrouped (null/empty) collected under the "Other" heading.
const groups = new Map<string, FieldDefinitionView[]>();
for (const def of data) {
const key = def.group?.trim() ? def.group : t("fields.other");
const bucket = groups.get(key) ?? [];
bucket.push(def);
groups.set(key, bucket);
}
return (
<ul className="overflow-auto">
{[...groups.entries()].map(([group, defs]) => (
<li key={group}>
<div className="border-b bg-neutral-50 px-3 py-1 text-xs font-medium uppercase tracking-wide text-neutral-500">
{group}
</div>
<ul>
{defs.map((def) => (
<li key={def.key} className="flex items-center gap-2 border-b px-3 py-2 text-sm">
<span className="font-medium">{labelText(def.labels, lang)}</span>
<span className="text-xs text-neutral-400">{def.key}</span>
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
{t(`fields.types.${def.data_type}`)}
</span>
{def.required && <span className="text-xs text-red-600">*</span>}
</li>
))}
</ul>
</li>
))}
</ul>
);
}
```
- [ ] **Step 3: Implement `FieldForm`** — create `web/src/fields/field-form.tsx`. Native `<select>`s (matches `web/src/objects/field-input.tsx`). Reuses `LabelEditor` (sv/en, EN-required) and `useVocabularies`.
```tsx
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useCreateFieldDefinition, useVocabularies } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
type LabelInput = components["schemas"]["LabelInput"];
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
const KINDS = ["person", "organisation", "place"] as const;
export function FieldForm() {
const { t } = useTranslation();
const create = useCreateFieldDefinition();
const { data: vocabularies } = useVocabularies();
const [key, setKey] = useState("");
const [labels, setLabels] = useState<LabelInput[]>([]);
const [dataType, setDataType] = useState<string>("text");
const [vocabularyId, setVocabularyId] = useState("");
const [authorityKind, setAuthorityKind] = useState(""); // "" == any
const [group, setGroup] = useState("");
const [required, setRequired] = useState(false);
const [error, setError] = useState(false);
const reset = () => {
setKey("");
setLabels([]);
setDataType("text");
setVocabularyId("");
setAuthorityKind("");
setGroup("");
setRequired(false);
};
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const hasEn = labels.some((l) => l.lang === "en" && l.label);
const termNeedsVocab = dataType === "term" && !vocabularyId;
if (!key.trim() || !hasEn || termNeedsVocab) {
setError(true);
return;
}
setError(false);
create.mutate(
{
key: key.trim(),
data_type: dataType,
vocabulary_id: dataType === "term" ? vocabularyId : null,
authority_kind: dataType === "authority" ? authorityKind || null : null,
required,
group: group.trim() || null,
labels,
},
{ onSuccess: reset },
);
};
return (
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
<div className="text-sm font-medium">{t("fields.newField")}</div>
<div className="space-y-1">
<Label htmlFor="field-key">{t("fields.key")}</Label>
<Input id="field-key" value={key} onChange={(e) => setKey(e.target.value)} />
</div>
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor="field-type">{t("fields.type")}</Label>
<select
id="field-type"
value={dataType}
onChange={(e) => setDataType(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
>
{TYPES.map((type) => (
<option key={type} value={type}>
{t(`fields.types.${type}`)}
</option>
))}
</select>
</div>
{dataType === "term" && (
<div className="space-y-1">
<Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
<select
id="field-vocab"
value={vocabularyId}
onChange={(e) => setVocabularyId(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
>
<option value="">{t("form.selectPlaceholder")}</option>
{vocabularies?.map((vocab) => (
<option key={vocab.id} value={vocab.id}>
{vocab.key}
</option>
))}
</select>
</div>
)}
{dataType === "authority" && (
<div className="space-y-1">
<Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
<select
id="field-kind"
value={authorityKind}
onChange={(e) => setAuthorityKind(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
>
<option value="">{t("fields.anyKind")}</option>
{KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`authorities.${kind}`)}
</option>
))}
</select>
</div>
)}
<div className="space-y-1">
<Label htmlFor="field-group">{t("fields.group")}</Label>
<Input id="field-group" value={group} onChange={(e) => setGroup(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={required} onCheckedChange={(checked) => setRequired(checked === true)} />
{t("fields.required")}
</label>
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
{create.isError && <p role="alert" className="text-xs text-red-600">{t("form.rejected")}</p>}
<Button type="submit" size="sm" disabled={create.isPending}>
{t("fields.create")}
</Button>
</form>
);
}
```
Before finishing: open `web/src/components/ui/checkbox.tsx` and confirm the controlled API is `checked` + `onCheckedChange(checked: boolean)` (base-ui). If the signature differs, adapt the `<Checkbox>` usage (no `any`). Also confirm `@/components/ui/label` exports `Label` (the vocab/object forms use it).
- [ ] **Step 4: Implement `FieldsPage`** — create `web/src/fields/fields-page.tsx`:
```tsx
import { FieldList } from "./field-list";
import { FieldForm } from "./field-form";
export function FieldsPage() {
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<FieldList />
</div>
<div className="overflow-hidden">
<FieldForm />
</div>
</div>
);
}
```
- [ ] **Step 5: Wire the route** — in `web/src/app.tsx`, import `import { FieldsPage } from "./fields/fields-page";` and add inside the `<AppShell>` group:
```tsx
<Route path="/fields" element={<FieldsPage />} />
```
- [ ] **Step 6: Enable the Fields nav** — in `web/src/shell/app-shell.tsx`:
- change `const DISABLED_NAV = ["fields"] as const;` to `const DISABLED_NAV = [] as const;`
- add a Fields `NavLink` after the Search NavLink (before the `DISABLED_NAV.map(...)`):
```tsx
<NavLink
to="/fields"
className={({ isActive }) =>
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
}
>
{t("nav.fields")}
</NavLink>
```
The `DISABLED_NAV.map(...)` block now renders nothing (empty array) — that is fine; leave it, or remove it if eslint flags an unused `nav.soon`. (`nav.soon` may become unused — if `pnpm lint`/parity complains, leave the key in both i18n files; an unused i18n key is harmless and keeps parity.)
- [ ] **Step 7: Write the integration test** — create `web/src/fields/fields.test.tsx`:
```tsx
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Route, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { FieldsPage } from "./fields-page";
function tree() {
return (
<Routes>
<Route path="/fields" element={<FieldsPage />} />
</Routes>
);
}
test("lists field definitions grouped, with an Other heading for ungrouped", async () => {
renderApp(tree(), { route: "/fields" });
// grouped fixture entry (group "Description") and an ungrouped one ("Other")
expect(await screen.findByText("Inscription")).toBeInTheDocument();
expect(screen.getByText(/^Description$/i)).toBeInTheDocument();
expect(screen.getByText(/^Other$/i)).toBeInTheDocument();
});
test("creates a text field — posts the body and clears the key input", async () => {
let body: { key: string; data_type: string } | undefined;
server.use(
http.post("/api/admin/field-definitions", async ({ request }) => {
body = (await request.json()) as { key: string; data_type: string };
return HttpResponse.json({ key: "notes" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/fields" });
await userEvent.type(screen.getByLabelText(/^key$/i), "notes");
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes");
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
await waitFor(() => expect(body?.key).toBe("notes"));
expect(body?.data_type).toBe("text");
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue(""));
});
test("selecting Term reveals the vocabulary picker and blocks submit until chosen", async () => {
let posted = false;
server.use(
http.post("/api/admin/field-definitions", () => {
posted = true;
return HttpResponse.json({ key: "x" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/fields" });
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material");
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
// Vocabulary select now present.
const vocab = await screen.findByLabelText(/^vocabulary$/i);
expect(vocab).toBeInTheDocument();
// Submit without choosing a vocabulary → blocked, alert shown, no POST.
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(posted).toBe(false);
// Choose one (fixture vocabularies: v-material/material, v-technique/technique) → posts.
await userEvent.selectOptions(vocab, "v-material");
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
await waitFor(() => expect(posted).toBe(true));
});
```
Run `pnpm test src/fields/fields.test.tsx`. If `getByLabelText(/^key$/i)` is ambiguous (the EN/SV label inputs from `LabelEditor` use `labels.en`/`labels.sv` text), the anchored `/^key$/i` should match only the "Key" `<Label htmlFor="field-key">`; if not, scope with the field id. The `vocabularies` fixture is the existing one (`v-material`/`material`, `v-technique`/`technique`).
- [ ] **Step 8: Update the app-shell test** — open `web/src/shell/app-shell.test.tsx`. It currently asserts `fields` (and/or `search`) is a disabled button. Update so **Fields is now a link** (`getByRole("link", { name: /fields/i })`); there are no disabled nav buttons left — if a test asserted a disabled button exists, remove/replace that assertion. Run `pnpm test src/shell/app-shell.test.tsx` → PASS.
- [ ] **Step 9: Full verify**`pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. Report the bundle gz number. If `check:size` > 150 KB gz, lazy-load `/fields` in `app.tsx` (mirror the `ObjectNewPage` lazy pattern: `const FieldsPage = lazy(() => import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })))` + wrap the route element in `<Suspense fallback={<FormFallback />}>`), then re-run check:size.
- [ ] **Step 10: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): /fields two-pane screen (grouped list + create form) + nav (no stubs left)"
```
---
## Task 4: i18n parity + full verification
**Files:** none expected (verification); fix-ups only if a check fails.
- [ ] **Step 1: i18n parity**
```bash
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
```
Expected `PARITY OK`; fix any mismatch.
- [ ] **Step 2: Full frontend verification**
```bash
cd web
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
```
Expected clean; all tests pass; bundle ≤150 KB gz (report the number).
- [ ] **Step 3: Full backend verification**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \
MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \
cargo test -p api
cargo clippy --workspace --all-targets
cargo +nightly fmt --check
```
Expected: all pass; clippy clean; fmt clean.
- [ ] **Step 4: Commit** — only if Steps 12 required a fix:
```bash
git add web
git commit -m "chore(web): fields management verification fix-ups"
```
---
## Self-Review (completed)
**Spec coverage:**
- `POST /api/admin/field-definitions`, `EditCatalogue`, `from_parts` validation (422), dup key (409), malformed binding (400), auth → Task 1. ✓
- OpenAPI registration + regenerated client → Task 1. ✓
- `useCreateFieldDefinition` invalidating `["field-definitions"]` (shared with M2 editor) → Task 2. ✓
- Two-pane `/fields`: grouped list (+ "Other"), create form with conditional vocabulary/kind, native selects, LabelEditor reuse, EN-required + term-needs-vocab client validation, `form.rejected` on backend error → Task 3. ✓
- Nav enabled, `DISABLED_NAV = []` (no stubs) → Task 3. ✓
- i18n sv/en parity, bundle ≤150 KB, full backend+frontend verification → Task 4. ✓
- Create + list only (no edit/delete) — respected. ✓
**Placeholder scan:** none — every code step is complete; the two "confirm the Checkbox API / Label export" notes are concrete verification instructions against named files.
**Type consistency:** `NewFieldDefinitionRequest`/`CreatedField` (api) ↔ `components["schemas"]["NewFieldDefinitionRequest"]` (web `useCreateFieldDefinition` arg) consistent; `FieldDefinitionView` reused for the list; `data_type` string values (`text|localized_text|integer|date|boolean|term|authority`) match the `TYPES` tuple and the `fields.types.*` i18n keys; the `["field-definitions"]` query key matches `useFieldDefinitions`; `AuthorityKind::from_db`, `FieldType::from_parts`, `db::fields::create_field_definition(&mut tx, &new)`, and `VocabularyId` parse usage all match the confirmed backend signatures.
## Notes for follow-on
- Edit/delete field definitions — needs new `db::fields` update/delete + a referential-integrity policy (block/handle deleting a field objects reference or that is required). File a backend follow-up when this lands.
- Per-field validation rules (min/max/regex) — #11. Field/group reordering and renaming. Immutable `key`/`type` after creation.
@@ -0,0 +1,217 @@
# Tier 2 Papercuts Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Clear a batch of small, well-specified correctness/observability/UX fixes from the issue tracker (#22, #18, #9, #4, #34, #31, #32, #37) — no new features.
**Architecture:** Independent small fixes grouped by area into four tasks: backend API behaviour (#22, #18), backend cleanup (#9, #4), frontend states/a11y (#34, #31, #32, #37), then verification.
**Tech Stack:** Rust (axum, sqlx, tracing), React + TS, TanStack Query, react-i18next, Vitest + RTL + MSW.
**Conventions (every task):** nightly `cargo +nightly fmt`; `cargo clippy`. Frontend: no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; no codename "biggus"/"dickus". Test infra via compose: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev` (this machine's override port), `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
---
## Task 1: Backend API — 404 for missing vocabulary (#22) + log public 500s (#18)
**Files:** Modify `crates/api/src/admin_vocab.rs`, `crates/api/src/public.rs`; Test in the existing `crates/api/tests/admin_catalog.rs` (vocab/authority harness).
### #22`add_term` returns 404 when the vocabulary doesn't exist
Today `db::vocab::add_term(...)` maps every error to 500; a well-formed `{id}` for a missing vocabulary triggers a foreign-key violation (SQLSTATE 23503) that should be **404**.
- [ ] **Step 1: Failing test** — add to `crates/api/tests/admin_catalog.rs` (mirror its existing seed-editor/login/oneshot harness). Read the file first to reuse its helpers:
```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
// (use this file's existing migrate_sessions + seed editor + login helpers)
let app = /* build_app with state */;
let cookie = /* login as editor */;
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"labels":[{"lang":"en","label":"X"}]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
```
(Match the exact helper names/signatures already in `admin_catalog.rs`. If that file doesn't have a login helper, copy the pattern from `crates/api/tests/admin_fields.rs`.)
- [ ] **Step 2: Run → fails** (currently 500): `cargo test -p api --test admin_catalog add_term_to_missing_vocabulary`.
- [ ] **Step 3: Fix** — in `crates/api/src/admin_vocab.rs` `add_term`, replace:
```rust
let term_id = db::vocab::add_term(&mut tx, &new)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
```
with:
```rust
let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| {
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
StatusCode::NOT_FOUND
} else {
tracing::error!(?err, "adding term");
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
```
- [ ] **Step 4: Run → passes**, and confirm adding a term to an existing vocab still returns 201 (existing tests cover this).
### #18 — log the discarded `sqlx::Error` on public 500 paths
`crates/api/src/public.rs` discards errors via `.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)` (lines ~74, ~78) and `Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response()` (line ~109). `tracing` is already a dependency of the `api` crate — just log.
- [ ] **Step 5:** In `list_objects`, change both `.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?` to:
```rust
.map_err(|err| {
tracing::error!(?err, "listing public objects");
StatusCode::INTERNAL_SERVER_ERROR
})?;
```
(use a message specific to each call site — e.g. "listing public objects" and "counting public objects" — match what each query does).
- [ ] **Step 6:** In `get_object`, change the `Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response()` arm to bind and log the error:
```rust
Err(err) => {
tracing::error!(?err, "fetching public object");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
```
- [ ] **Step 7: Verify**`cargo +nightly fmt`, `cargo clippy -p api --all-targets`, `cargo test -p api`. Commit:
```bash
git add crates/api
git commit -m "fix(api): 404 when adding a term to a missing vocabulary (#22); log public 500s (#18)"
```
---
## Task 2: Backend cleanup — enum/CHECK cross-refs (#9) + dead clone & test handle (#4)
**Files:** Modify `crates/domain/src/authority.rs`, `crates/domain/src/audit.rs`, `crates/server/src/lib.rs`, `crates/server/tests/serve.rs`.
> Do **not** edit any file under `crates/db/migrations/``sqlx::migrate!()` checksums applied migrations, so editing them (even a comment) breaks existing databases. The cross-reference comments go in the Rust enums only.
- [ ] **Step 1: #9 — cross-reference comments.**
- In `crates/domain/src/authority.rs`, above `pub enum AuthorityKind`, add:
```rust
/// Allowed kinds. NOTE: kept in sync by hand with the
/// `CHECK (kind IN ('person','organisation','place'))` constraint in
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — update both together.
```
- In `crates/domain/src/audit.rs`, above `pub enum AuditAction`, add an equivalent comment pointing at the `action` CHECK in `crates/db/migrations/0001_*.sql` (open the migration to name the exact file + values).
- [ ] **Step 2: #4 — remove the dead clone.** In `crates/server/src/lib.rs` `run`, the `AppState` is built with `app_name: config.app_name.clone()`. Since `config.app_name` is a `String` and the only later use of `config` is the disjoint field `config.bind_addr`, change it to a move:
```rust
app_name: config.app_name,
```
Confirm it still compiles (partial move of one field; `&config.bind_addr` afterward is fine).
- [ ] **Step 3: #4 — smoke-test handle.** Open `crates/server/tests/serve.rs`. The spawned `serve(...)` task's `.unwrap()` swallows server errors as a task panic, surfacing as a confusing client error. Capture the `JoinHandle` and, after the assertions, either abort it cleanly or check it didn't error — make a server-start failure surface as a clear test failure rather than a `reqwest` error. Read the file and apply the minimal change that propagates/surfaces the server error (e.g. keep the handle, assert it hasn't finished-with-error, or `handle.abort()` at the end). Keep the test green.
- [ ] **Step 4: Verify**`cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, `cargo test -p server -p domain`. Commit:
```bash
git add crates/domain crates/server
git commit -m "chore: cross-ref enum/CHECK constraints (#9); drop dead clone + harden smoke test (#4)"
```
---
## Task 3: Frontend — search 503 (#34), list error states (#31), a11y + dead keys (#32), authority-kind test (#37)
**Files:** Modify `web/src/api/queries.ts`, `web/src/search/search-panel.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/fields/fields.test.tsx`; Tests in `web/src/search/search.test.tsx`, plus the vocab/authorities test files.
### #34 — distinguish search 503 ("unavailable") from a generic error
- [ ] **Step 1:** In `web/src/api/queries.ts`, add a tiny typed error and have `useSearch` throw it with the HTTP status (so the UI can branch without `any`). Near the top:
```ts
export class HttpError extends Error {
constructor(public readonly status: number) {
super(`HTTP ${status}`);
this.name = "HttpError";
}
}
```
In `useSearch`'s `queryFn`, replace `if (error || !data) throw new Error("search failed");` with:
```ts
if (error || !data) throw new HttpError(response.status);
```
(`response` is already destructured from `api.GET`; if not, add it.)
- [ ] **Step 2: i18n** — add `search.unavailable` to BOTH `en.json` and `sv.json` (parity):
- en: `"unavailable": "Search is not available on this server"`
- sv: `"unavailable": "Sök är inte tillgängligt på den här servern"`
- [ ] **Step 3:** In `web/src/search/search-panel.tsx`, where `search.isError` renders `t("search.loadError")`, branch on a 503:
```tsx
{hasQuery && search.isError && (
<p className="p-4 text-sm text-red-600">
{search.error instanceof HttpError && search.error.status === 503
? t("search.unavailable")
: t("search.loadError")}
</p>
)}
```
Import `HttpError` from `../api/queries`.
- [ ] **Step 4: Tests** — in `web/src/search/search.test.tsx`, add: a `503` response → renders `search.unavailable`; a `500` response → renders `search.loadError`. (Use `server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 503 })))` etc., then type a query and assert the text.)
### #31 — loading/error states on the terms + authorities lists
- [ ] **Step 5:** In `web/src/vocab/vocabulary-terms.tsx`, the terms list uses `useTerms(id)` but renders empty/data only. Add `isLoading` (skeleton or `…`) and `isError` (`t("vocab.loadError")`) branches before the empty/data render, mirroring `vocabulary-list.tsx`'s state ladder.
- [ ] **Step 6:** In `web/src/authorities/authorities-page.tsx`, the list uses `useAuthorities(kind)`; add an `isError` branch rendering `t("authorities.loadError")` (currently a dead key — this uses it) and a loading branch. Keep the existing empty/data render.
- [ ] **Step 7: Tests** — add an error-state test to the vocab and authorities test files: MSW returns 500 for the terms / authorities GET → the respective `loadError` text appears. (Override the default handler with `server.use(...)`.)
### #32 — ARIA tab semantics + remove dead i18n keys
- [ ] **Step 8:** In `web/src/authorities/authorities-page.tsx`, the kind tabs are `NavLink`s. Add tab semantics: wrap them in a container with `role="tablist"`, give each `role="tab"` and `aria-selected={isActive}` (the `NavLink` className callback already exposes `isActive` — use the render-prop form to set `aria-selected`). Keep the existing styling.
- [ ] **Step 9:** Remove the unused keys `vocab.title` and `authorities.title` from BOTH `en.json` and `sv.json` (grep first: `grep -rn "vocab.title\|authorities.title\|\.title" web/src` — confirm only the i18n definitions match; nothing references them).
### #37 — frontend authority-kind reveal test
- [ ] **Step 10:** In `web/src/fields/fields.test.tsx`, add a test mirroring the existing Term test: type a key + EN label, `selectOptions(type, "authority")`, assert the authority-kind `<select>` (label `/authority kind/i`) appears, `selectOptions` it to `"person"`, submit, and assert the POST body's `authority_kind === "person"` (use a `server.use` POST handler that captures the body, like the Term test does).
- [ ] **Step 11: Verify**`cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. Commit:
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "fix(web): search 503 vs error (#34); terms/authorities list error states (#31); authority-tab a11y + dead keys (#32); authority-kind test (#37)"
```
---
## Task 4: Verification
- [ ] **Step 1: i18n parity**
```bash
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
```
Expected `PARITY OK`.
- [ ] **Step 2: Frontend**`pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
- [ ] **Step 3: Backend**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey \
cargo test -p api -p domain -p server
cargo clippy --workspace --all-targets
cargo +nightly fmt --check
```
All pass; clippy + fmt clean.
- [ ] **Step 4:** No codename: `git grep -in 'biggus\|dickus' -- crates web/src` → no matches.
---
## Self-Review (completed)
- **Spec coverage:** #22 (404), #18 (log 500s) → Task 1; #9 (Rust cross-ref comments), #4 (clone + smoke test) → Task 2; #34, #31, #32, #37 → Task 3; parity + suites → Task 4. ✓
- **Scope adjustments baked in:** #8 already closed (thiserror is used); #37 backend-403 omitted (no non-EditCatalogue role exists); #9 Rust-side only (migration checksums). ✓
- **Placeholder scan:** none — code is concrete; the "match the existing harness" notes are verification instructions against named files.
- **Type consistency:** `HttpError` defined in queries.ts and imported in search-panel; the 23503/FK pattern matches the field-def handler; `authorities.loadError` (existing key) now consumed; `search.unavailable` added at parity.
@@ -0,0 +1,201 @@
# Tier 3 — Typed-Client Quality Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Tighten the generated OpenAPI/TypeScript contract so the frontend drops its `as`-casts — type the free-form `fields` map as an open map (#24) and the enum-valued fields (`visibility`, `data_type`, authority `kind`) as string enums (#29). Architecture decision #3 = **Option A** (allow `utoipa::ToSchema` in `domain`).
**Architecture:** `domain`'s already-serde enums gain `ToSchema`; a new `DataType` enum is added to `domain` for the `data_type` discriminant. The `api` View DTOs reference these via `#[schema(value_type = …)]` (fields stay `String`/`Value` at runtime; only the *schema description* changes). Regenerate `schema.d.ts`; remove the now-redundant frontend casts.
**Tech Stack:** Rust (utoipa 5, sqlx), React + TS, openapi-typescript.
**Conventions:** nightly fmt; clippy; no `any`/`eslint-disable`/`@ts-ignore`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`.
---
## Task 1: `domain``ToSchema` on enums + new `DataType`
**Files:** `crates/domain/Cargo.toml`, `crates/domain/src/object.rs`, `crates/domain/src/authority.rs`, `crates/domain/src/field_definition.rs`.
- [ ] **Step 1: Add the utoipa dep.** In `crates/domain/Cargo.toml` `[dependencies]`, add:
```toml
utoipa.workspace = true
```
(The workspace already defines `utoipa = { version = "5", features = ["uuid"] }`.)
- [ ] **Step 2: Derive `ToSchema` on `Visibility`** (`crates/domain/src/object.rs:7-9`). Add `utoipa::ToSchema` to the derive list (keep everything else):
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
```
- [ ] **Step 3: Derive `ToSchema` on `AuthorityKind`** (`crates/domain/src/authority.rs:10-12`):
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthorityKind {
```
- [ ] **Step 4: Add a `DataType` enum** to `crates/domain/src/field_definition.rs` (it describes the `data_type` discriminant string that `FieldType::kind_str()` produces). NOTE: **`snake_case`**, so `LocalizedText``"localized_text"` (matching `kind_str`):
```rust
/// The stored `data_type` discriminant of a field definition. This mirrors the strings
/// produced by [`FieldType::kind_str`]; it exists so the OpenAPI schema can describe
/// `data_type` as a closed string enum (consumed by the typed web client). Kept in sync
/// by hand with `FieldType::kind_str`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DataType {
Text,
LocalizedText,
Integer,
Date,
Boolean,
Term,
Authority,
}
```
(If `serde::{Serialize, Deserialize}` are already imported at the top of the file, use the bare derive names; otherwise the fully-qualified `serde::Serialize` forms above are fine.)
- [ ] **Step 5: Verify**`cargo +nightly fmt`, `cargo build -p domain`, `cargo clippy -p domain --all-targets`. The existing `field_type_round_trips` etc. tests still pass: `cargo test -p domain`. Add a tiny test asserting `DataType` serializes correctly (it must match `kind_str`):
```rust
#[test]
fn data_type_serde_matches_kind_str() {
use serde_json::json;
assert_eq!(serde_json::to_value(DataType::LocalizedText).unwrap(), json!("localized_text"));
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
assert_eq!(serde_json::to_value(DataType::Authority).unwrap(), json!("authority"));
}
```
(place it in the existing `#[cfg(test)] mod tests` in `field_definition.rs`).
- [ ] **Step 6: Commit**
```bash
git add crates/domain
git commit -m "feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A)"
```
---
## Task 2: `api` — enum + open-map schema annotations + regenerate client
**Files:** `crates/api/src/admin_objects.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/admin.rs`, `crates/api/src/openapi.rs`; regenerate `web/src/api/schema.d.ts`.
> The View fields keep their runtime types (`String` / `serde_json::Value`); only the `#[schema(value_type = …)]` annotation changes what the OpenAPI document says. No handler/construction logic changes.
- [ ] **Step 1: #24 — open-map `fields`.** In `crates/api/src/admin_objects.rs:45`, change `AdminObjectView.fields`:
```rust
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
pub fields: serde_json::Value,
```
(This is the only `value_type = Object` site — confirmed by `grep -rn "value_type = Object" crates/api/src`.) This makes utoipa emit `additionalProperties`, which `openapi-typescript` renders as `{ [key: string]: unknown }` instead of `Record<string, never>`.
- [ ] **Step 2: #29`visibility` enums.**
- `AdminObjectView.visibility` (`admin_objects.rs:43`, currently `pub visibility: String`): add above it `#[schema(value_type = domain::Visibility)]`.
- `ObjectCreateRequest.visibility` (`admin_objects.rs:165-166`): **remove** the `#[schema(value_type = String)]` line so the field (`pub visibility: Visibility`) emits the enum.
- `VisibilityRequest.visibility` (`crates/api/src/admin.rs`, field is `pub visibility: Visibility`): if it has a `#[schema(value_type = String)]` override, **remove** it so it emits the enum. (Check — it may or may not have one.)
- [ ] **Step 3: #29 — `data_type` + `authority_kind` enums.** In `crates/api/src/admin_objects.rs`, `FieldDefinitionView` (~lines 360-366):
- `data_type` (line 363): add `#[schema(value_type = domain::DataType)]`.
- `authority_kind` (line 365): add `#[schema(value_type = Option<domain::AuthorityKind>)]`.
- The `NewFieldDefinitionRequest` (~lines 374-377) `data_type`/`authority_kind` are request inputs parsed as free strings by the handler — **leave these as `String`** (typing them would force handler conversion; out of scope, and the create form posts plain strings).
- [ ] **Step 4: #29 — authority `kind`.** In `crates/api/src/admin_authorities.rs`, `AuthorityView.kind` (line 23, `pub kind: String`): add `#[schema(value_type = domain::AuthorityKind)]`. Leave `NewAuthorityRequest.kind` (line 31) as `String` (request input parsed via `from_db`).
- [ ] **Step 5: Register the domain enums as OpenAPI components.** In `crates/api/src/openapi.rs` `components(schemas(...))`, add:
```rust
domain::Visibility,
domain::AuthorityKind,
domain::DataType,
```
(utoipa generates `$ref`s to these from the `value_type` annotations; they must be registered. The `api` crate already depends on `domain`.)
- [ ] **Step 6: Build + backend tests.**
```bash
cargo +nightly fmt
cargo clippy -p api --all-targets
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
```
All green (serialized values are unchanged — `visibility` still serializes "draft" etc., `data_type` still "text"/"localized_text").
- [ ] **Step 7: Regenerate the typed client.**
```bash
cargo build -p server
lsof -ti :8080 | xargs kill 2>/dev/null
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
SERVER_PID=$!
sleep 2
( cd web && pnpm gen:api )
kill "$SERVER_PID"
```
Verify the generated types:
```bash
grep -n "Visibility:\|AuthorityKind:\|DataType:" web/src/api/schema.d.ts
grep -n "additionalProperties\|\[key: string\]: unknown" web/src/api/schema.d.ts | head
```
Expect `Visibility: "draft" | "internal" | "public"`, `AuthorityKind: "person" | "organisation" | "place"`, `DataType: "text" | "localized_text" | ...`, and `AdminObjectView.fields` as `{ [key: string]: unknown }`. Then `cd web && pnpm typecheck` — it may now report errors at the cast sites (expected; Task 3 fixes them) OR pass (casts on a now-compatible type are just redundant). Either way, do NOT edit web source in this task beyond the regenerated `schema.d.ts`.
- [ ] **Step 8: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add crates/api web/src/api/schema.d.ts
git commit -m "feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29)"
```
---
## Task 3: Frontend — drop the now-redundant casts
**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-form.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/objects/publish-control.tsx` (+ check `visibility-badge.tsx`, `field-input.tsx`). Plus any local `Visibility` type alias.
- [ ] **Step 1: Remove the `fields` casts (#24).** `fields` is now `{ [key: string]: unknown }`:
- `object-detail.tsx:55`: `Object.entries(object.fields as Record<string, unknown>)``Object.entries(object.fields)`.
- `object-form.tsx:181`: `Object.entries(value as Record<string, unknown>)``Object.entries(value)` (only if `value` is the typed `fields`; if `value` is a generic RHF value, the cast may still be needed — verify the type and remove only if redundant).
- `object-edit-form.tsx:37`: `fields: object.fields as Record<string, unknown>``fields: object.fields` (if the target type accepts the open map; otherwise leave).
Remove a cast only when the typecheck confirms it's now redundant. Keep the code `any`-free.
- [ ] **Step 2: Remove the `visibility` cast (#29).** `publish-control.tsx:26`: `const current = object.visibility as Visibility;``const current = object.visibility;` (it's now the `"draft" | "internal" | "public"` union). If a local `type Visibility = ...` alias exists and is now identical to the schema union, prefer referencing `components["schemas"]["Visibility"]` or keep the alias if it's used as a shared name — but drop the cast. Check `visibility-badge.tsx`: if its prop is `visibility: string`, you may tighten it to the union or leave it (a union is assignable to `string`); do NOT introduce errors.
- [ ] **Step 3: `data_type` (#29).** `field-input.tsx` switches on `data_type` — now a union. No cast was present; confirm the switch still typechecks (a union improves exhaustiveness). If there's a `data_type as ...` cast anywhere, remove it.
- [ ] **Step 4: Verify.**
```bash
cd web
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
```
All green; no `any`/`@ts-ignore` introduced; bundle ≤150 KB. Grep to confirm the casts are gone:
```bash
grep -rn "as Record<string, unknown>\|as Visibility" web/src/objects | grep -v ".test."
```
(Test-file `as Record<string, unknown>` defaults may remain — they're test scaffolding, not contract casts; leaving them is fine.)
- [ ] **Step 5: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29)"
```
---
## Task 4: Verification
- [ ] **Step 1:** `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
- [ ] **Step 2:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p domain
cargo clippy --workspace --all-targets
cargo +nightly fmt --check
```
- [ ] **Step 3:** i18n parity check (unchanged keys, but run it); `git grep -in 'biggus\|dickus' -- crates web/src` → none.
- [ ] **Step 4:** Confirm acceptance: OpenAPI `fields` has `additionalProperties`; `visibility`/`data_type`/`kind` are string enums in `schema.d.ts`; the `as Record<string, unknown>`/`as Visibility` contract casts are gone.
---
## Self-Review (completed)
- **Spec coverage:** #3 decided (Option A, documented + closed) → this plan's architecture; #24 (open-map fields) → T2 Step 1 + T3 Step 1; #29 (visibility/data_type/kind enums) → T1 + T2 Steps 2-5 + T3 Steps 2-3. ✓
- **Placeholder scan:** none — exact files/lines/annotations given; the "remove cast only if typecheck confirms redundant" notes are correct verification guards (the generated types determine redundancy).
- **Type consistency:** `DataType` uses `snake_case` to match `FieldType::kind_str` (`localized_text`); `value_type = domain::X` references match the enums registered in `openapi.rs` components; runtime serialization is unchanged (backend tests prove it), so only the schema/TS types tighten.
## Notes
- Request-side enums (`NewFieldDefinitionRequest.data_type`/`authority_kind`, `NewAuthorityRequest.kind`) intentionally stay `String` — the handlers parse/validate them; typing them is a separate, larger change (would need handler conversion) and isn't required by #24/#29.
@@ -0,0 +1,148 @@
# Tier 4 Hardening — Batch 1 (#1, #2, #21) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** The mechanical, well-specified hardening items — graceful HTTP shutdown (#1), configurable DB pool size (#2), and audit logging for vocabulary/term/authority creation (#21). (The design-heavy Tier 4 items #20/#5/#7 are handled separately.)
**Tech Stack:** Rust (axum 0.8, sqlx, tokio, anyhow). Backend-only.
**Conventions:** nightly fmt; clippy `-D warnings`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey` (`#[sqlx::test]` provisions its own DB).
---
## Task 1: #1 — graceful shutdown
**Files:** `crates/server/src/lib.rs`, `crates/server/Cargo.toml` (tokio `signal` feature if missing).
- [ ] **Step 1: Ensure tokio `signal` feature.** Check `crates/server/Cargo.toml`'s `tokio` dependency features include `"signal"`. If the workspace `tokio` is `features = ["full"]` it's already included; otherwise add `"signal"` (and `"macros"`/`"rt-multi-thread"` if not already). Verify with `cargo build -p server`.
- [ ] **Step 2: Add a shutdown-signal future** in `crates/server/src/lib.rs` (above `serve`):
```rust
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
/// drain in-flight requests before exiting.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received; draining");
}
```
- [ ] **Step 3: Wire it into `serve`.** Change the `axum::serve(...)` call:
```rust
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("running the HTTP server")?;
```
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy -p server --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server` (the existing `serve.rs` smoke test still passes — it aborts the handle, which is unaffected). Commit:
```bash
git add crates/server
git commit -m "feat(server): graceful shutdown on SIGINT/SIGTERM (#1)"
```
---
## Task 2: #2 — configurable DB pool size
**Files:** `crates/db/src/lib.rs`, `crates/server/src/config.rs`, `crates/server/src/lib.rs`.
`Db::connect` currently hardcodes `.max_connections(5)`.
- [ ] **Step 1: Parameterize `Db::connect`.** In `crates/db/src/lib.rs`:
```rust
/// Connect to the database at `database_url`, opening a connection pool with at most
/// `max_connections` connections.
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?;
Ok(Self { pool })
}
```
- [ ] **Step 2: Add the config knob.** In `crates/server/src/config.rs`, add a field to `Config`:
```rust
/// Maximum size of the PostgreSQL connection pool.
#[arg(long = "db-max-connections", env = "DB_MAX_CONNECTIONS", default_value_t = 5)]
pub db_max_connections: u32,
```
- [ ] **Step 3: Thread it through the two `Db::connect` call sites** in `crates/server/src/lib.rs`:
- In `run`: `Db::connect(&config.database_url, config.db_max_connections)`.
- In `create_user` (the CLI one-shot — it has only `database_url: &str`, no `Config`): pass a small fixed default, `Db::connect(database_url, 2)` (a one-shot CLI needs minimal connections), and add a brief comment.
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server`. Confirm `cargo run -p server -- --help` shows the new `--db-max-connections` flag (optional). Commit:
```bash
git add crates/db crates/server
git commit -m "feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)"
```
---
## Task 3: #21 — audit vocabulary/term/authority creation
**Files:** `crates/db/src/vocab.rs`, `crates/db/src/authority.rs`, `crates/api/src/admin_vocab.rs`, `crates/api/src/admin_authorities.rs`; Test in `crates/api/tests/admin_catalog.rs`.
The three admin create paths (`create_vocabulary`, `add_term`, `create_authority`) take no `AuditActor` and write no audit entry. The catalogue object writes do — **`db::catalog::create_object` is the template**: it takes `actor: AuditActor` and calls `audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type, entity_id, ... })` inside the same transaction. READ `create_object` (`crates/db/src/catalog.rs`) and `audit::record` / `NewAuditEvent` (`crates/db/src/audit.rs`, `domain::NewAuditEvent`) first to copy the exact shape.
- [ ] **Step 1: Add `actor` + audit to the db functions.** Each must run the insert **and** the audit record in one transaction (so they're atomic), mirroring `create_object`:
- `db::vocab::create_vocabulary` — currently `(executor: E, key: &str)`. Change to `(conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str)` (tx-connection like `add_term`), insert the vocabulary, then `audit::record(&mut *conn, &NewAuditEvent { actor, action: Created, entity_type: "vocabulary", entity_id: <new vocab id>, ... })`. Return the `Vocabulary` as before.
- `db::vocab::add_term` — currently `(conn: &mut PgConnection, new: &NewTerm)`. Add `actor: AuditActor`; after inserting the term, record an audit entry (`entity_type: "term"`, `entity_id: <term id>`).
- `db::authority::create_authority` — add `actor: AuditActor`; record (`entity_type: "authority"`, `entity_id: <authority id>`).
Match `create_object`'s `NewAuditEvent` field names exactly (e.g. `changes`/`metadata` may be empty/None — copy whatever `create_object` passes for a creation with no field diff).
- [ ] **Step 2: Thread the actor through the handlers.** In `crates/api/src/admin_vocab.rs` (`create_vocabulary`, `add_term`) and `crates/api/src/admin_authorities.rs` (`create_authority`):
- Change `_auth: Authorized<EditCatalogue>``auth: Authorized<EditCatalogue>`.
- Build the actor as the object handlers do: `AuditActor::User(auth.user.id.to_uuid())`. To avoid duplicating the helper, either make `admin_objects::actor` `pub(crate)` and import it, or inline `AuditActor::User(auth.user.id.to_uuid())` at each site (it's a one-liner — pick the cleaner option; if you make the helper shared, take `&AuthUser`).
- `create_vocabulary` handler currently calls `db::vocab::create_vocabulary(state.db.pool(), &req.key)` on the **pool** — change it to open a transaction (`let mut tx = state.db.pool().begin().await...`), call the new `create_vocabulary(&mut tx, actor, &req.key)`, then `tx.commit()` (like `add_term`'s handler already does). `add_term`/`create_authority` handlers already use a tx — just pass the actor.
- [ ] **Step 3: Test** — add to `crates/api/tests/admin_catalog.rs` (it already seeds an editor + logs in). After creating a vocabulary (or term/authority) via the API, assert an audit row exists attributing the user. Use `db::audit::history_for` (or a direct `SELECT` on `audit_log`) to find the entry — read the file for how existing tests inspect audit rows (the object tests likely already do this; mirror them). Minimal: create a vocabulary, then query `audit_log` for `entity_type='vocabulary'` with the created id and assert `actor_kind='user'` + the right `actor_id`. Name it e.g. `creating_a_vocabulary_writes_an_audit_entry`.
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db`. All green. Commit:
```bash
git add crates/db crates/api
git commit -m "feat: audit vocabulary/term/authority creation, attributing the acting user (#21)"
```
---
## Task 4: Verification
- [ ] **Step 1:** `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace` — all green.
- [ ] **Step 2:** `cargo clippy --workspace --all-targets` and `cargo +nightly fmt --check` — clean.
- [ ] **Step 3:** `git grep -in 'biggus\|dickus' -- crates` → none.
- [ ] **Step 4:** Confirm `Cargo.lock` is committed if any dependency/feature changed (e.g. tokio `signal` feature does not add a new lockfile entry, but verify `git status` is clean after the commits — no dangling `M Cargo.lock`).
---
## Self-Review (completed)
- **Spec coverage:** #1 (graceful shutdown) → T1; #2 (configurable pool) → T2; #21 (audit 3 admin creates) → T3. ✓
- **Placeholder scan:** none — concrete code for #1/#2; #21 points at `create_object`/`audit::record` as the exact template to mirror (the audit-event field names live there and must match, so copying beats guessing).
- **Type consistency:** `Db::connect(url, max: u32)` updated at both call sites (run + create_user); `db_max_connections: u32` matches `max_connections(u32)`; the three db create fns gain `actor: AuditActor` and the handlers pass `AuditActor::User(auth.user.id.to_uuid())` consistently with `admin_objects::actor`.
## Notes
- #21 keeps within the current audit model (`AuditAction::Created` + non-null `entity_type`/`entity_id`) — no schema change needed (the auth-event model extension is the separate #7).
- Watch the `Cargo.lock`: if the tokio `signal` feature pulls a new transitive crate, stage the root `Cargo.lock` in the same commit (don't leave it dangling).
@@ -0,0 +1,317 @@
# Follow-ups Batch (#38, #28, #41, #26) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Four small, well-specified follow-ups: enum-type `SearchHitView.visibility` (#38); carry the offending field in the `set_fields` 422 so the UI can highlight it (#28); normalize `localized_text` to the default language on save (#41); pin the pnpm version (#26).
**Tech Stack:** Rust (axum, utoipa), React + TS, react-hook-form, Vitest + RTL + MSW.
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
---
## Task 1: Backend — `SearchHitView.visibility` enum (#38) + `set_fields` field-level 422 (#28)
**Files:** Modify `crates/api/src/admin_search.rs`, `crates/api/src/admin_objects.rs`, `crates/api/src/openapi.rs`; Test `crates/api/tests/admin_objects.rs`; Regenerate `web/src/api/schema.d.ts`.
### #38 — enum-type the search hit visibility
- [ ] **Step 1:** In `crates/api/src/admin_search.rs`, `SearchHitView.visibility` (line ~31, `pub visibility: String`): add the attribute above it:
```rust
#[schema(value_type = domain::Visibility)]
pub visibility: String,
```
(`domain::Visibility` already derives `ToSchema` and is registered in `openapi.rs` from #29 — no further registration needed.)
### #28 — carry the offending field in the 422
The db `FieldError` already names the field (`UnknownField(String)`, `TypeMismatch { field, .. }`, `Unresolved { field, .. }`). Surface it.
- [ ] **Step 2:** In `crates/api/src/admin_objects.rs`, add a response DTO near the other views:
```rust
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct FieldErrorView {
/// The flexible-field key that was rejected.
pub field: String,
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
pub code: String,
}
```
- [ ] **Step 3:** Change the `set_fields` handler to return a body on the field-error 422s. Its signature is `-> Result<StatusCode, StatusCode>`; change to `-> axum::response::Response` and build responses (import `axum::response::IntoResponse`):
```rust
) -> axum::response::Response {
use axum::response::IntoResponse;
let Ok(object_id) = id.parse::<ObjectId>() else {
return StatusCode::NOT_FOUND.into_response();
};
let mut tx = match state.db.pool().begin().await {
Ok(tx) => tx,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let result =
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
match result {
Ok(()) => {
if tx.commit().await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
reindex(&state, object_id).await;
StatusCode::NO_CONTENT.into_response()
}
Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(),
Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(db::catalog::FieldError::UnknownField(field)) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView { field, code: "unknown".to_owned() }),
)
.into_response(),
Err(db::catalog::FieldError::TypeMismatch { field, .. }) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView { field, code: "type_mismatch".to_owned() }),
)
.into_response(),
Err(db::catalog::FieldError::Unresolved { field, .. }) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(FieldErrorView { field, code: "unresolved".to_owned() }),
)
.into_response(),
}
}
```
Update the `#[utoipa::path(...)]` on `set_fields`: the 422 response now has a body — change/add `(status = 422, body = FieldErrorView, description = "A field was rejected")` in its `responses(...)`.
- [ ] **Step 4:** Register `admin_objects::FieldErrorView` in `crates/api/src/openapi.rs` `components(schemas(...))`.
- [ ] **Step 5: Test** — add to `crates/api/tests/admin_objects.rs` (reuse its harness: seed editor, login, create an object). Create an object, then PUT `/api/admin/objects/{id}/fields` with an **unknown** field key → assert `422` and the body `{ field: "<that key>", code: "unknown" }`. (Mirror an existing set-fields test if present; if a field-definition is needed for a type_mismatch case, the `unknown` case needs none — simplest.) Read the file for the exact request/parse helpers.
- [ ] **Step 6: Build + backend tests:**
```bash
cargo +nightly fmt
cargo clippy --workspace --all-targets
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
```
All green (existing set_fields tests still pass — success path still 204; the failure path now carries a body but the status is unchanged at 422).
- [ ] **Step 7: Regenerate client:**
```bash
cargo build -p server
lsof -ti :8080 | xargs kill 2>/dev/null
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
SERVER_PID=$!
sleep 2
( cd web && pnpm gen:api )
kill "$SERVER_PID"
grep -n "FieldErrorView" web/src/api/schema.d.ts
# confirm SearchHitView.visibility now references the Visibility union:
grep -n "SearchHitView" web/src/api/schema.d.ts
```
`FieldErrorView` present; `SearchHitView.visibility``components["schemas"]["Visibility"]`. `cd web && pnpm typecheck` clean. Diff additive.
- [ ] **Step 8: Commit:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add crates/api web/src/api/schema.d.ts
git commit -m "feat(api): field-level set_fields 422 body (#28); enum-type SearchHitView.visibility (#38)"
```
---
## Task 2: Frontend — surface the rejected field & highlight it (#28)
**Files:** Modify `web/src/api/queries.ts`, `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/{en,sv}.json`; Test `web/src/objects/object-form.test.tsx` or the relevant existing object test.
- [ ] **Step 1: i18n** — add `form.fieldRejected` to BOTH `en.json` and `sv.json` (interpolated):
- en `form`: `"fieldRejected": "The field \"{{field}}\" was rejected — check its value"`
- sv `form`: `"fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet"`
- [ ] **Step 2: A typed rejection in `useSetFields`** — in `web/src/api/queries.ts`, add near the other errors:
```ts
export class FieldRejection extends Error {
constructor(public readonly field: string, public readonly code: string) {
super(`field rejected: ${field}`);
this.name = "FieldRejection";
}
}
```
Update `useSetFields`'s `mutationFn` to parse the 422 body and throw `FieldRejection`:
```ts
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
params: { path: { id } },
body: fields as Record<string, never>,
});
if (response.status === 204) return;
if (response.status === 422 && error && typeof error === "object" && "field" in error) {
const detail = error as { field: string; code: string };
throw new FieldRejection(detail.field, detail.code);
}
throw new Error("set fields failed");
},
```
(openapi-fetch puts the 422 body in `error` because the operation declares a 422 body schema. If `error` typing is awkward, narrow defensively as above — no `any`.)
- [ ] **Step 3: Thread a field-error into the form**`object-form.tsx` owns the react-hook-form instance. Add an optional prop `fieldErrorKey?: string | null` and, via `useEffect`, set/clear the RHF error so the field highlights:
```tsx
// in the ObjectForm props type:
fieldErrorKey?: string | null;
// inside the component (form is the useForm instance; t available):
useEffect(() => {
if (fieldErrorKey) {
form.setError(`fields.${fieldErrorKey}` as never, {
type: "server",
message: t("form.fieldRejected", { field: fieldErrorKey }),
});
}
}, [fieldErrorKey, form, t]);
```
(The `as never` is to satisfy RHF's path typing for a dynamic flexible-field path; if a cleaner typed path is available without `any`, use it — `as never` is acceptable here and is NOT `as any`. Confirm lint accepts it; if `react-hooks/exhaustive-deps` complains, include the listed deps.)
- [ ] **Step 4: Parent catch sets the field key** — in `object-new-page.tsx` and `object-edit-form.tsx`, the `catch` currently does `setError(t("form.rejected"))`. Capture the rejected field too:
- Add state `const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(null);`
- In the catch: `if (e instanceof FieldRejection) { setFieldErrorKey(e.field); setError(t("form.fieldRejected", { field: e.field })); } else { setError(t("form.rejected")); }` (import `FieldRejection` from `../api/queries`).
- Pass `fieldErrorKey={fieldErrorKey}` to `<ObjectForm>`.
- Clear `setFieldErrorKey(null)` at the top of `onSubmit` (alongside `setError(null)`).
(For `object-edit-form.tsx`, which also reads a `location.state.fieldsError` flag, keep that path but layer the new typed handling on top.)
- [ ] **Step 5: Test** — add a test (in the object form/new-page test file, MSW) where PUT `/api/admin/objects/:id/fields` returns `422` with `{ field: "dimensions", code: "type_mismatch" }`. Submit the form; assert the field-rejected message appears (`/dimensions/i` + "rejected") and, if practical, that the field's input is marked invalid (`aria-invalid` or an error message near it). Use the existing object-form test setup; read it for the render/submit pattern.
- [ ] **Step 6: Verify + commit:**
```bash
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): highlight the offending field on a set_fields 422 (#28)"
```
---
## Task 3: Frontend — visibility-badge typing (#38) + localized_text normalize-on-save (#41)
**Files:** Modify `web/src/objects/visibility-badge.tsx`, `web/src/objects/object-form.tsx`; Test the object-form/field tests.
### #38 — tighten the VisibilityBadge prop
- [ ] **Step 1:** `web/src/objects/visibility-badge.tsx` — change the prop from `string` to the schema union (now that all callers pass it, incl. search hits after Task 1):
```tsx
import type { components } from "../api/schema";
type Visibility = components["schemas"]["Visibility"];
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
const { t } = useTranslation();
return (
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
{t(`visibility.${visibility}`)}
</Badge>
);
}
```
Run `pnpm typecheck` — every caller (`object-list`, `object-detail`, `search-result-row`) now passes the union (object/search hit `visibility` are the union post-#29/#38). Fix any caller that still has a widened `string` (there should be none).
### #41 — normalize localized_text to the default language on save
The edit path seeds `defaultValues.fields` from `object.fields` verbatim, so a `localized_text` value authored under another language keeps that key. Normalize in `pruneFields` so only the default-language key is saved.
- [ ] **Step 2:** In `web/src/objects/object-form.tsx`:
- Add `import { useConfig } from "../config/config-context";` and inside the component: `const { default_language } = useConfig();`.
- Compute the set of localized_text field keys from the loaded definitions:
```tsx
const localizedTextKeys = new Set(
(definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key),
);
```
- Pass both into `pruneFields` at its call site (`const fields = pruneFields(data.fields, localizedTextKeys, default_language);`).
- Update `pruneFields` to accept them and, for a localized_text key, keep only the default-language sub-value:
```tsx
function pruneFields(
fields: Record<string, unknown>,
localizedTextKeys: Set<string>,
defaultLang: string,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === "") continue;
if (typeof value === "object" && !Array.isArray(value)) {
const map = value as Record<string, unknown>;
// Single-language authoring: a localized_text value keeps only the default lang.
const entries = localizedTextKeys.has(key)
? Object.entries(map).filter(([lang]) => lang === defaultLang)
: Object.entries(map);
const inner = Object.fromEntries(
entries.filter(([, v]) => v !== undefined && v !== null && v !== ""),
);
if (Object.keys(inner).length > 0) out[key] = inner;
continue;
}
out[key] = value;
}
return out;
}
```
- [ ] **Step 3: Test** — add/extend a test: an object whose `localized_text` field value is `{ en: "Old", sv: "Ny" }`, edited on an `sv`-default instance, submits `fields` containing only `{ <key>: { sv: "Ny" } }` (the `en` key stripped). Use the object-form test harness (the `definitions` fixture has a `localized_text` field — `title_ml`). Assert the pruned payload via the submit handler / the PUT body.
- [ ] **Step 4: Verify + commit:**
```bash
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41)"
```
---
## Task 4: Pin pnpm (#26) + verification
**Files:** Modify `web/package.json`, `.gitea/workflows/ci.yaml`.
- [ ] **Step 1: Pin pnpm** — add a `packageManager` field to `web/package.json` matching the dev/CI version. The local pnpm is `11.5.1`; CI's `pnpm/action-setup` is pinned to `9` — a mismatch. Unify on the local version:
- In `web/package.json`, add (top level): `"packageManager": "pnpm@11.5.1"`.
- In `.gitea/workflows/ci.yaml`, change the `pnpm/action-setup@v4` `version: 9``version: 11` (matching the major).
- [ ] **Step 2: Confirm the lockfile is consistent** — run `cd web && pnpm install --frozen-lockfile`. If it passes, the committed `pnpm-lock.yaml` is compatible — done. If it FAILS (lockfile format/version mismatch from the pnpm-9→11 change), run `pnpm install` once to update the lockfile, confirm only the lockfile changed (`git status`), and include `web/pnpm-lock.yaml` in the commit. Report which case occurred.
- [ ] **Step 3: Commit:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/package.json .gitea/workflows/ci.yaml web/pnpm-lock.yaml
git commit -m "build(web): pin pnpm via packageManager + align CI (#26)"
```
### Final verification
- [ ] **Step 4: i18n parity**
```bash
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
```
Expected `PARITY OK`.
- [ ] **Step 5: Full suites**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
cargo clippy --workspace --all-targets && cargo +nightly fmt --check
```
All green; bundle ≤150 KB; clippy/fmt clean.
- [ ] **Step 6:** `git grep -in 'biggus\|dickus' -- crates web/src` → none.
---
## Self-Review (completed)
- **Spec coverage:** #38 (search visibility enum → T1 backend + T3 prop tighten); #28 (422 field body → T1 backend, T2 FE highlight); #41 (localized_text normalize → T3); #26 (pin pnpm → T4). ✓
- **Placeholder scan:** none — concrete code; the "read the test harness" notes are verification steps against named files. The `as never` in T2 Step 3 is a typed-RHF-path escape (NOT `as any`/ts-ignore) and is flagged for lint confirmation.
- **Type consistency:** `FieldErrorView { field, code }` (Rust) ↔ `components["schemas"]["FieldErrorView"]` (the 422 body openapi-fetch surfaces as `error`) ↔ `FieldRejection{field,code}`; `SearchHitView.visibility` union flows into the tightened `VisibilityBadge` prop; `pruneFields` new signature `(fields, localizedTextKeys, defaultLang)` updated at its single call site.
## Notes
- #28 changes the `set_fields` handler return type from `Result<StatusCode, StatusCode>` to `Response`; the success status (204) and the field-error status (422) are unchanged — only a body is added to the 422, so existing status-only tests still pass.
- #26: if `pnpm install --frozen-lockfile` forces a lockfile regen, that's expected and the regenerated `pnpm-lock.yaml` is committed; flag if dependency versions shifted.
@@ -0,0 +1,432 @@
# Instance Locale + Single-Language Content Authoring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Drive instance UI/content language + display timezone from environment variables (no settings table), surface them to the SPA via a public `GET /api/config`, default the UI language from it, and collapse content authoring (`LabelEditor` + `LocalizedText` field input) to a single language — **without touching the multilingual content schema** (dormant, re-enabled by UI alone).
**Architecture:** Two `server::Config` env knobs (`DEFAULT_LANGUAGE`, `DEFAULT_TIMEZONE`) flow into `AppState` and a public `ConfigView` endpoint. A frontend `ConfigProvider` fetches it once, sets the i18n language (when no per-browser override), and feeds the default language to the simplified content inputs. Storage stays UTC; timezone is exposed but has no frontend formatter yet (no timestamp displays exist — deferred to its first consumer).
**Tech Stack:** Rust (axum, utoipa, clap), React + TS, react-i18next, TanStack Query, Vitest + RTL + MSW.
**Spec:** `docs/superpowers/specs/2026-06-05-instance-locale-and-content-authoring-design.md`
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
---
## Task 1: Backend — config knobs + `AppState` + public `GET /api/config` + regen client
**Files:** Modify `crates/server/src/config.rs`, `crates/server/src/lib.rs`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; Create `crates/api/src/config.rs`; Modify all `AppState { … }` construction sites (server + api test harnesses); Test `crates/api/tests/config.rs`; Regenerate `web/src/api/schema.d.ts`.
- [ ] **Step 1: Config knobs.** In `crates/server/src/config.rs`, add to `Config` (clap derive, matching `app_name`'s style):
```rust
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
pub default_language: String,
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
/// this is a display hint surfaced to clients (and, later, server-side renderers).
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
pub default_timezone: String,
```
- [ ] **Step 2: `AppState` fields.** In `crates/api/src/lib.rs`, add to `pub struct AppState`:
```rust
/// Instance default UI/content language (from config).
pub default_language: String,
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
pub default_timezone: String,
```
In `crates/server/src/lib.rs` `run`, populate them when building `AppState`:
```rust
default_language: config.default_language,
default_timezone: config.default_timezone,
```
(place after `app_name: config.app_name,` — note these are moves; `config` fields are disjoint.)
- [ ] **Step 3: Update every other `AppState { … }` site.** Run `grep -rn "AppState {" crates/` — besides `crates/api/src/lib.rs` (the struct def) and `server/src/lib.rs` (done above), there are ~9 test `state(...)` helpers (`crates/server/tests/serve.rs`, `crates/api/tests/{admin,admin_objects,admin_search,public,reindex,admin_catalog,admin_fields,health}.rs`). Add to each literal:
```rust
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
```
(The build will fail to compile until all are updated — that's the checklist.)
- [ ] **Step 4: Write the failing API test** — create `crates/api/tests/config.rs`:
```rust
use api::{AppState, build_app};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
fn state(pool: PgPool) -> AppState {
AppState {
db: db::Db::from_pool(pool),
app_name: "Test Museum".into(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
#[sqlx::test(migrations = "../db/migrations")]
async fn config_is_public_and_reflects_state(pool: PgPool) {
let app = build_app(state(pool));
let resp = app
.oneshot(Request::builder().uri("/api/config").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["app_name"], "Test Museum");
assert_eq!(body["default_language"], "sv");
assert_eq!(body["default_timezone"], "Europe/Stockholm");
}
```
- [ ] **Step 5: Run → fails** (`/api/config` 404): `cargo test -p api --test config`.
- [ ] **Step 6: Implement the endpoint** — create `crates/api/src/config.rs` (mirror `health.rs`):
```rust
use axum::{Json, Router, extract::State, routing::get};
use serde::Serialize;
use utoipa::ToSchema;
use crate::AppState;
/// Public, non-sensitive instance configuration the SPA needs before login.
#[derive(Serialize, ToSchema)]
pub(crate) struct ConfigView {
/// User-facing product name.
pub app_name: String,
/// Default UI/content language (i18n key, e.g. "sv").
pub default_language: String,
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
pub default_timezone: String,
}
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
Json(ConfigView {
app_name: state.app_name.clone(),
default_language: state.default_language.clone(),
default_timezone: state.default_timezone.clone(),
})
}
pub(crate) fn routes() -> Router<AppState> {
Router::new().route("/api/config", get(get_config))
}
```
- [ ] **Step 7: Register the module + route + schema.**
- `crates/api/src/lib.rs`: add `mod config;` (alphabetical with other `mod`s) and `.merge(config::routes())` in `build_app` (next to `health::routes()`).
- `crates/api/src/openapi.rs`: add `config` to the `use crate::{…}` import; add `config::get_config` to `paths(…)`; add `config::ConfigView` to `components(schemas(…))`.
- [ ] **Step 8: Run → passes.** `cargo test -p api --test config`, then `cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, and full `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo test -p api -p server` (the AppState field additions compile everywhere).
- [ ] **Step 9: Regenerate the typed client.**
```bash
cargo build -p server
lsof -ti :8080 | xargs kill 2>/dev/null
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
SERVER_PID=$!
sleep 2
( cd web && pnpm gen:api )
kill "$SERVER_PID"
grep -n "ConfigView\|api/config" web/src/api/schema.d.ts
```
Both must appear; diff additive. `cd web && pnpm typecheck` clean.
- [ ] **Step 10: Commit.**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add crates/server crates/api web/src/api/schema.d.ts
git commit -m "feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config"
```
---
## Task 2: Frontend — config provider + i18n default wiring
**Files:** Create `web/src/config/config-context.tsx`; Modify `web/src/main.tsx`, `web/src/test/handlers.ts`; Test `web/src/config/config-context.test.tsx`.
- [ ] **Step 1: MSW handler.** In `web/src/test/handlers.ts`, add to the `handlers` array a default config response:
```ts
http.get("/api/config", () =>
HttpResponse.json({
app_name: "Test Museum",
default_language: "sv",
default_timezone: "Europe/Stockholm",
}),
),
```
- [ ] **Step 2: Failing provider test** — create `web/src/config/config-context.test.tsx`:
```tsx
import { expect, test, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import i18n from "../i18n";
import { LOCALE_KEY } from "../i18n";
import { ConfigProvider, useConfig } from "./config-context";
function Probe() {
const config = useConfig();
return <span data-testid="lang">{config.default_language}</span>;
}
function renderProvider() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<ConfigProvider><Probe /></ConfigProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
localStorage.clear();
void i18n.changeLanguage("en");
});
test("exposes config and applies default language when no stored preference", async () => {
renderProvider();
expect(await screen.findByText("sv")).toBeInTheDocument();
await waitFor(() => expect(i18n.language).toBe("sv"));
});
test("a stored locale preference wins over the instance default", async () => {
localStorage.setItem(LOCALE_KEY, "en");
void i18n.changeLanguage("en");
renderProvider();
await screen.findByText("sv"); // config still loads
await waitFor(() => expect(i18n.language).toBe("en")); // but language stays en
});
```
- [ ] **Step 3: Run → fails** (module missing): `cd web && pnpm test src/config/config-context.test.tsx`.
- [ ] **Step 4: Implement the provider** — create `web/src/config/config-context.tsx`:
```tsx
import { createContext, useContext, useEffect, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { components } from "../api/schema";
import { api } from "../api/client";
import i18n, { LOCALE_KEY } from "../i18n";
type ConfigView = components["schemas"]["ConfigView"];
const DEFAULTS: ConfigView = {
app_name: "Collection Management System",
default_language: "sv",
default_timezone: "Europe/Stockholm",
};
const ConfigContext = createContext<ConfigView>(DEFAULTS);
export function useConfig(): ConfigView {
return useContext(ConfigContext);
}
export function ConfigProvider({ children }: { children: ReactNode }) {
const { data } = useQuery({
queryKey: ["config"],
queryFn: async (): Promise<ConfigView> => {
const { data, error } = await api.GET("/api/config");
if (error || !data) throw new Error("failed to load config");
return data;
},
staleTime: Infinity,
});
// Default the UI language to the instance default, unless the user has chosen one
// for this browser (LangSwitch persists to localStorage[LOCALE_KEY]).
useEffect(() => {
if (data && !localStorage.getItem(LOCALE_KEY)) {
void i18n.changeLanguage(data.default_language);
}
}, [data]);
return <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
}
```
- [ ] **Step 5: Run → passes.** `pnpm test src/config/config-context.test.tsx`.
- [ ] **Step 6: Mount the provider.** In `web/src/main.tsx`, wrap `<App />` (inside `QueryClientProvider`, since the provider uses TanStack Query):
```tsx
import { ConfigProvider } from "./config/config-context";
// ...
<QueryClientProvider client={queryClient}>
<ConfigProvider>
<App />
</ConfigProvider>
</QueryClientProvider>
```
- [ ] **Step 7: Verify + commit.** `pnpm test && pnpm typecheck && pnpm lint && pnpm build`. All green (existing tests unaffected — MSW now answers `/api/config` so `onUnhandledRequest:"error"` stays happy app-wide).
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): config provider — fetch /api/config, default UI language from instance"
```
---
## Task 3: Frontend — single-language content authoring
**Files:** Modify `web/src/components/label-editor.tsx`, `web/src/objects/field-input.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/components/label-editor.test.tsx`, `web/src/vocab/vocabularies.test.tsx`, `web/src/fields/fields.test.tsx`, `web/src/authorities/authorities.test.tsx`.
> The content schema, DTOs (`LabelInput`/`LabelView`), DB tables, `LocalizedLabel`, and `FieldType::LocalizedText` are **unchanged**. Only the input components collapse to one language. Reading/display (`labelText`/`pick_label`) already falls back (UI lang → en → first), so single-language data still renders — no change to the read path.
- [ ] **Step 1: i18n key.** Add `labels.label` to BOTH `web/src/i18n/en.json` and `sv.json`:
- en `labels`: `"label": "Label"`
- sv `labels`: `"label": "Etikett"`
(Keep the existing `labels.en`/`labels.sv`/`labels.externalUri` keys — `externalUri` is still used; `labels.en`/`labels.sv` may become unused after this task — if `pnpm lint`/grep shows them unreferenced, remove them from BOTH files to keep parity, else leave.)
- [ ] **Step 2: Collapse `LabelEditor`** — replace `web/src/components/label-editor.tsx` body:
```tsx
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useConfig } from "../config/config-context";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type LabelInput = components["schemas"]["LabelInput"];
/** Single-language label editor. Authors one label at the instance default language;
* emits a one-entry LabelInput[] (empty array when blank). The multilingual data model
* is unchanged — this only simplifies authoring. */
export function LabelEditor({
value,
onChange,
}: {
value: LabelInput[];
onChange: (labels: LabelInput[]) => void;
}) {
const { t } = useTranslation();
const { default_language } = useConfig();
const current =
value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";
const set = (label: string) =>
onChange(label.trim() ? [{ lang: default_language, label }] : []);
return (
<div className="space-y-1">
<Label htmlFor="label">{t("labels.label")}</Label>
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
</div>
);
}
```
- [ ] **Step 3: Update `LabelEditor`'s own test**`web/src/components/label-editor.test.tsx` currently types into `/label \(en\)/i` + `/label \(sv\)/i` and asserts both langs. Rewrite it for the single input (it must render under a `ConfigProvider` so `useConfig` works — wrap with the test's existing `renderApp`/provider, adding `ConfigProvider`; the MSW `/api/config` handler returns `default_language: "sv"`). New test:
```tsx
import { useState } from "react";
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ConfigProvider } from "../config/config-context";
import { LabelEditor } from "./label-editor";
import type { components } from "../api/schema";
type LabelInput = components["schemas"]["LabelInput"];
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
const [value, setValue] = useState<LabelInput[]>([]);
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
}
test("emits a single label at the instance default language", async () => {
const seen: LabelInput[][] = [];
renderApp(<ConfigProvider><Harness onChange={(v) => seen.push(v)} /></ConfigProvider>);
// config (default_language "sv") must load before the editor authors
await screen.findByLabelText(/^label$/i);
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
await waitFor(() => {
const last = seen[seen.length - 1]!;
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
});
});
```
NOTE: if `renderApp` doesn't already provide a `QueryClientProvider` that `ConfigProvider` needs, check `web/src/test/render.tsx` — it does wrap `QueryClientProvider` (the vocab/search tests rely on it). The MSW `/api/config` default handler (Task 2) supplies the config.
- [ ] **Step 4: Update the consumer tests.** The forms that use `LabelEditor` have tests typing into `/label \(en\)/i`. They now render a single `/^label$/i` input writing `sv`. Update each:
- `web/src/vocab/vocabularies.test.tsx:48``getByLabelText(/label \(en\)/i)``getByLabelText(/^label$/i)`. These tests render the full app/route tree which must include `ConfigProvider` for `useConfig` — check `renderApp`/the test tree; if the tree doesn't wrap `ConfigProvider`, wrap the rendered subtree in `<ConfigProvider>` (the MSW `/api/config` handler answers). Adjust any assertion expecting an EN/SV pair to the single `sv` label.
- `web/src/fields/fields.test.tsx` (3 sites: lines ~38, ~58, ~79) — same `getByLabelText(/^label$/i)` swap + wrap `ConfigProvider` if needed.
- `web/src/authorities/authorities.test.tsx:28` — same.
Run each file and fix selector/provider issues until green.
- [ ] **Step 5: Collapse the `LocalizedText` field input** — in `web/src/objects/field-input.tsx`, the `case "localized_text":` block renders `${key}.en` + `${key}.sv` inputs. Replace with a single input registering `${key}.${default_language}`. Add `const { default_language } = useConfig();` near the top of the `FieldInput` component (alongside the existing `const lang = …`). New case:
```tsx
case "localized_text":
return (
<div className="space-y-1">
<Label htmlFor={definition.key}>{label}</Label>
<Input
id={definition.key}
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
required: definition.required,
})}
/>
</div>
);
```
(Imports: `useConfig` from `../config/config-context`.) The stored value remains a `{ lang: text }` map — now `{ [default_language]: text }`. The `field-input.test.tsx` may reference the EN/SV localized inputs — update it to the single input (register path `${key}.${default_language}`), wrapping with `ConfigProvider` if the test renders the component directly.
- [ ] **Step 6: Verify + commit.** `cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. en/sv parity holds.
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web
git commit -m "feat(web): single-language content authoring (LabelEditor + localized_text at default lang)"
```
---
## Task 4: Verification
- [ ] **Step 1: i18n parity**
```bash
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
```
Expected `PARITY OK`.
- [ ] **Step 2: Frontend**`pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
- [ ] **Step 3: Backend**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
cargo clippy --workspace --all-targets
cargo +nightly fmt --check
```
All pass; clippy + fmt clean.
- [ ] **Step 4: Acceptance spot-checks.**
- `cargo run -p server -- --help | grep -E "default-language|default-timezone"` shows both flags.
- Content schema untouched: `git diff main..HEAD -- crates/db/migrations crates/domain/src/label.rs` is empty (no schema/domain label changes).
- `git grep -in 'biggus\|dickus' -- crates web/src` → none.
---
## Self-Review (completed)
- **Spec coverage:** env knobs + AppState → T1; public `/api/config` → T1; config provider + i18n default → T2; single-language `LabelEditor` + `LocalizedText` → T3; UTC storage unchanged (no timestamp code touched); timezone exposed (no formatter — no consumer, per spec's "forward-ready if none"); parity/bundle → T4. ✓ Per-account UI language + da/no + server-side tz are out of scope (issue #40 / #39). ✓
- **Placeholder scan:** none — concrete code; the "wrap ConfigProvider if the test tree doesn't already" notes are real verification steps against named files (the provider dependency is new, so tests that mount label-authoring components need it).
- **Type consistency:** `ConfigView { app_name, default_language, default_timezone }` is the single shape across the Rust struct, the `components["schemas"]["ConfigView"]` TS type, the provider `DEFAULTS`, and the MSW handler; `LabelEditor` still emits `LabelInput[]` (one entry); `default_language` threaded from `useConfig()` consistently in both the editor and the field input.
## Notes
- **Timezone has no frontend consumer yet** (no timestamp is displayed — only `recording_date`, a plain DATE). The value is exposed via `/api/config` + `useConfig` so PDF export (#39) and any future audit/timestamp view can format with it; building a `formatTimestamp` helper now would be unused (YAGNI).
- **`AppState` gained two fields** → every `AppState { … }` literal (incl. all api/server test harnesses) must add them or the workspace won't compile; Task 1 Step 3 enumerates this.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
# Objects Data-Overview Table + Responsive Shell — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Turn `/objects` into a full-width, sortable, filterable data table (backed by Postgres sort/filter + exposed timestamps), with a collapsible icon sidebar and a responsive detail pane/drawer at a canonical `/objects/:id` URL.
**Architecture:** Phase 1 adds backend `sort`/`order`/`visibility`/`q` params (injection-safe) + a filtered count + exposes `created_at`/`updated_at`. Phase 2 replaces the narrow `ObjectList` with a full-width `ObjectsTable` whose state lives in the URL. Phase 3 makes the shell sidebar collapsible (lucide icons + Base UI tooltip) and renders detail as a right pane (wide) / Base UI `Drawer` (narrow) via the existing nested `/objects/:id` route.
**Tech Stack:** Rust (axum, sqlx/Postgres, utoipa), React 19 + TS + pnpm, `@base-ui/react` (drawer/collapsible/tooltip — already a dep), `lucide-react` 1.17 (already a dep), react-router 7, TanStack Query, Vitest+RTL+MSW, Storybook 10.
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity for new keys; **no codename**; portal queries in tests via `within(document.body)`; `pnpm check:size` budget **165 KB gz**. Test infra: Postgres 5442, Meili 7700; `#[sqlx::test(migrations="../db/migrations")]`.
**Spec:** `docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md`
---
## File Structure
**Backend:** `crates/db/src/catalog.rs` (filtered list+count, sort enum), `crates/api/src/admin_objects.rs` (query params, `AdminObjectView` timestamps), `crates/api/src/openapi.rs` (if new schema types). **Frontend:** `web/src/api/queries.ts` (`useObjectsPage` params), new `web/src/objects/objects-table.tsx` (+ `.stories.tsx`, `.test.tsx`), `web/src/objects/objects-page.tsx` (restructure to table + responsive detail), `web/src/shell/app-shell.tsx` (collapsible sidebar), new `web/src/components/ui/tooltip.tsx`, new `web/src/lib/use-media-query.ts`, `web/src/i18n/{en,sv}.json`. `web/src/objects/object-list.tsx` is removed (replaced by the table).
---
# PHASE 1 — Backend
## Task 1: Expose `created_at` / `updated_at` on `AdminObjectView`
**Files:** `crates/api/src/admin_objects.rs`; test `crates/api/tests/admin_catalog.rs`.
The domain `CatalogueObject` already carries `created_at`/`updated_at` (`time::OffsetDateTime`); only the API view omits them. No migration.
- [ ] **Step 1: Failing API test** in `admin_catalog.rs`: create an object, `GET /api/admin/objects`, assert the item has non-empty `created_at` and `updated_at` (RFC3339 strings). Run → fails (fields absent).
- [ ] **Step 2: Add fields.** In `AdminObjectView` add:
```rust
/// RFC3339 UTC timestamp.
pub created_at: String,
/// RFC3339 UTC timestamp.
pub updated_at: String,
```
In `from_object`, map them (the file already has a `format_date` for the `DATE`; for timestamps use RFC3339):
```rust
created_at: o.created_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
updated_at: o.updated_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
```
(Confirm `time` is a dep of the `api` crate; it is used transitively — if not in `Cargo.toml`, add `time.workspace = true`. Verify the `CatalogueObject` field names `created_at`/`updated_at` and their `OffsetDateTime` type in `crates/db/src/catalog.rs:210-211`.)
- [ ] **Step 3:** `cargo +nightly fmt`; `cargo clippy -p api`; run the test (compose up):
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run -p api -E 'test(admin_catalog)'
```
- [ ] **Step 4: Commit** `feat(api): expose object created_at/updated_at in AdminObjectView (#44)`.
## Task 2: Server-side sort / order / visibility / quick-filter for the object list
**Files:** `crates/db/src/catalog.rs`, `crates/api/src/admin_objects.rs`; tests in `crates/db/tests/object.rs` (or wherever catalog list is tested) + `crates/api/tests/admin_catalog.rs`.
- [ ] **Step 1: Define a sort enum + filtered db functions** in `crates/db/src/catalog.rs`. Add:
```rust
/// Whitelisted, injection-safe sort columns for the object list.
#[derive(Debug, Clone, Copy)]
pub enum ObjectSort { ObjectNumber, ObjectName, UpdatedAt, CreatedAt, Visibility }
impl ObjectSort {
fn column(self) -> &'static str {
match self {
ObjectSort::ObjectNumber => "object_number",
ObjectSort::ObjectName => "object_name",
ObjectSort::UpdatedAt => "updated_at",
ObjectSort::CreatedAt => "created_at",
ObjectSort::Visibility => "visibility",
}
}
}
/// Filters + ordering for a paged object query. `visibility`/`q` are optional.
pub struct ObjectQuery<'a> {
pub sort: ObjectSort,
pub descending: bool,
pub visibility: Option<&'a str>,
pub q: Option<&'a str>,
}
```
Add `list_objects_query` + `count_objects_query` that build SQL from the **enum** (never a raw client string). Both share a WHERE builder. Example:
```rust
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
let mut clauses = Vec::new();
let mut binds = Vec::new();
if let Some(v) = visibility { binds.push(v.to_owned()); clauses.push(format!("visibility = ${}", binds.len())); }
if let Some(term) = q {
binds.push(format!("%{term}%"));
let p = binds.len();
clauses.push(format!("(object_number ILIKE ${p} OR object_name ILIKE ${p})"));
}
let sql = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) };
(sql, binds)
}
pub async fn list_objects_query(
pool: &sqlx::PgPool, query: &ObjectQuery<'_>, limit: i64, offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
let (where_sql, binds) = where_clause(query.visibility, query.q);
let dir = if query.descending { "DESC" } else { "ASC" };
// Secondary key keeps ordering stable when the primary sort has ties.
let sql = format!(
"SELECT {OBJECT_COLUMNS} FROM object{where_sql} ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
query.sort.column(), binds.len() + 1, binds.len() + 2,
);
let mut q = sqlx::query(&sql);
for b in &binds { q = q.bind(b); }
let rows = q.bind(limit).bind(offset).fetch_all(pool).await?;
rows.into_iter().map(map_object).collect()
}
pub async fn count_objects_query(
pool: &sqlx::PgPool, visibility: Option<&str>, q: Option<&str>,
) -> Result<i64, sqlx::Error> {
let (where_sql, binds) = where_clause(visibility, q);
let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
let mut query = sqlx::query(&sql);
for b in &binds { query = query.bind(b); }
query.fetch_one(pool).await?.try_get("n")
}
```
Keep the existing `list_objects_paged`/`count_objects` if other callers use them (grep; if only the handler calls them, you may replace — verify). The `ObjectColumns`/`map_object` already include the timestamp columns.
- [ ] **Step 2: db tests** in the catalog test file: seed objects with distinct names/visibilities; assert `list_objects_query` orders by `object_name DESC`, filters by `visibility="draft"`, and `q` ILIKE matches number/name; `count_objects_query` returns the filtered count.
- [ ] **Step 3: Handler query params.** In `admin_objects.rs`, add a deserialize struct (don't overload the shared `Pagination`):
```rust
#[derive(Deserialize)]
pub(crate) struct ObjectListParams {
pub limit: Option<i64>, pub offset: Option<i64>,
pub sort: Option<String>, pub order: Option<String>,
pub visibility: Option<String>, pub q: Option<String>,
}
```
Parse `sort``ObjectSort` (unknown → default `ObjectNumber`), `order``descending = order == "desc"`, clamp limit (1..=200, default 50) / offset (>=0) like `Pagination`. Validate `visibility` against `domain::Visibility` (unknown → 422 or ignore — pick ignore-with-default for resilience to hand-edited URLs). Build `ObjectQuery`, call `list_objects_query` + `count_objects_query`. Update the `#[utoipa::path]` `params(...)` to document `sort`/`order`/`visibility`/`q`.
- [ ] **Step 4: API test**`GET /api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo` returns filtered+sorted items and a matching `total`; no params → unchanged default (object_number asc).
- [ ] **Step 5:** fmt + clippy + `cargo nextest run -p api -p db`. **Commit** `feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)`.
## Task 3: Regenerate web API types
- [ ] Start the built server on an alt port (8080 may be taken): `BIND_ADDR=127.0.0.1:8090 DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… ./target/debug/server`, then `cd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts`. Verify `created_at`/`updated_at` appear on `AdminObjectView`; `pnpm typecheck`. Stop the server. **Commit** `chore(web): regenerate API types (object list params + timestamps)`.
---
# PHASE 2 — The table
## Task 4: `useObjectsPage` gains sort/filter params
**Files:** `web/src/api/queries.ts`.
- [ ] Replace the `(limit, offset)` signature with a params object and `keepPreviousData`:
```ts
import { keepPreviousData } from "@tanstack/react-query";
export type ObjectListParams = {
limit: number; offset: number;
sort?: string; order?: "asc" | "desc";
visibility?: string; q?: string;
};
export function useObjectsPage(params: ObjectListParams) {
return useQuery({
queryKey: ["objects", params],
placeholderData: keepPreviousData,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/objects", {
params: { query: {
limit: params.limit, offset: params.offset,
sort: params.sort, order: params.order,
visibility: params.visibility, q: params.q,
} },
});
if (error || !data) throw new Error("failed to load objects");
return data;
},
});
}
```
(openapi-fetch drops `undefined` query params, so omit-by-undefined is fine.) Update the existing call site in `object-list.tsx` — but that file is being replaced in Task 5; if Task 5 lands in the same branch, just ensure typecheck passes after Task 5. **Commit with Task 5** (or standalone if you prefer). Keep `useObject` unchanged.
## Task 5: `ObjectsTable` — full-width table, URL-synced state, pagination, sort headers
**Files:** create `web/src/objects/objects-table.tsx`, `objects-table.stories.tsx`, `objects-table.test.tsx`; delete `web/src/objects/object-list.tsx`.
Behavior: reads all state from the URL (`useSearchParams`) — `sort`, `order`, `q`, `visibility`, `offset`, `limit` (default sort `object_number`/`asc`, limit 50, offset 0). Renders a real `<table>`; reuses `VisibilityBadge`; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with `aria-sort`); a row is a `<tr>` whose click navigates to `/objects/:id` **preserving the current search string** (so back restores state); pagination footer with prev/next + page-size `<select>` (or the future `ui/select`); a debounced quick-filter `Input` (`q`) and visibility chips live in a toolbar (Task 6 may own the toolbar — implement them here together to keep the table coherent).
- [ ] **Step 1: Component.** Concrete core (fill routine markup/classes to match the app; use token classes per #49 where easy, else existing patterns):
```tsx
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { VisibilityBadge } from "./visibility-badge";
// + ui/button, ui/input, ui/skeleton, lucide chevrons
const SORTABLE = ["object_number", "object_name", "updated_at"] as const;
const PAGE_SIZES = [25, 50, 100, 200];
const VIS = ["all", "draft", "internal", "public"] as const;
export function ObjectsTable() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { id: selectedId } = useParams(); // highlight the open row
const [params, setParams] = useSearchParams();
const sort = params.get("sort") ?? "object_number";
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
const visibility = params.get("visibility") ?? "all";
const limit = Number(params.get("limit")) || 50;
const offset = Number(params.get("offset")) || 0;
const qParam = params.get("q") ?? "";
const [qText, setQText] = useState(qParam);
const q = useDebouncedValue(qText, 300);
// sync debounced q → URL (reset offset)
useEffect(() => {
setParams((prev) => {
const next = new URLSearchParams(prev);
const term = q.trim();
if (term) next.set("q", term); else next.delete("q");
next.delete("offset");
return next;
}, { replace: true });
}, [q, setParams]);
const { data, isLoading, isError } = useObjectsPage({
limit, offset, sort, order,
visibility: visibility === "all" ? undefined : visibility,
q: q.trim() || undefined,
});
const setParam = (mutate: (n: URLSearchParams) => void) =>
setParams((prev) => { const n = new URLSearchParams(prev); mutate(n); return n; }, { replace: true });
const toggleSort = (col: string) =>
setParam((n) => {
const curOrder = n.get("order") === "desc" ? "desc" : "asc";
const curSort = n.get("sort") ?? "object_number";
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
n.set("sort", col); n.set("order", nextOrder); n.delete("offset");
});
// header cell: aria-sort = col===sort ? (order==='asc'?'ascending':'descending') : 'none'
// row: <tr onClick={() => navigate(`/objects/${o.id}?${params}`)} aria-selected={o.id===selectedId} ...>
// pagination: prev disabled offset===0; next disabled offset+limit>=total; page-size select sets limit + deletes offset
// ...
}
```
Render loading via `Skeleton` rows; error → `objects.loadError`; empty → `objects.empty`. Visibility chips mirror the search-panel `<button aria-pressed>` pattern (set `visibility` param, delete `offset`). The "Updated" cell: format `o.updated_at` with `Intl.DateTimeFormat(i18n.language, { dateStyle:'medium', timeZone: useConfig().default_timezone })` (or a relative-time helper) — keep it a small local helper. **No `any`** (cast page items as `components["schemas"]["AdminObjectView"]`).
- [ ] **Step 2: i18n** — add `objects.columns.{number,name,visibility,location,count,updated}`, `objects.filter` (quick-filter placeholder), `objects.pageSize`, `objects.all` (or reuse `search.all`) to **both** `en.json` and `sv.json`.
- [ ] **Step 3: Stories** `objects-table.stories.tsx` — render inside a `MemoryRouter` (the preview provides providers; add a router if needed) with MSW returning a small page: `Default` (rows render), `Sorted` (assert `aria-sort` on the active header), `Empty`. Mirror the visibility-badge story format.
- [ ] **Step 4: Unit test** `objects-table.test.tsx` (RTL + MSW + MemoryRouter): rows render the columns; clicking a sortable header updates the URL `sort`/`order` and sets `aria-sort`; typing in the filter (debounced) sets `q`; a visibility chip sets `visibility`; pagination next/prev change `offset`; page-size sets `limit`. Use the search-panel test as a reference for MSW + router wiring.
- [ ] **Step 5:** `pnpm typecheck && pnpm lint && pnpm test -- objects-table`. **Commit** `feat(web): full-width sortable/filterable objects table with URL state (#44)`.
## Task 6: Wire the table into the page (table full-width; detail via Outlet placeholder)
**Files:** `web/src/objects/objects-page.tsx` (interim — full restructure in Phase 3).
- [ ] Make `ObjectsPage` render `ObjectsTable` full-width for now, keeping the nested `<Outlet/>` available but not as a fixed 20rem column (Phase 3 makes it a pane/drawer). Interim acceptable state: table fills the area; if a `:id` child route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove the `index → SelectPrompt` route's visual prominence (the table is the landing view). **Verify** `pnpm test && pnpm build`. **Commit** `feat(web): objects table as the /objects landing view (#44)`.
> Note: Tasks 56 can be one commit if cleaner. The key is the table renders at `/objects` and row-click deep-links to `/objects/:id` with preserved query state.
---
# PHASE 3 — Shell & responsive detail
## Task 7: `useMediaQuery` hook + `ui/tooltip.tsx` wrapper
**Files:** create `web/src/lib/use-media-query.ts`, `web/src/components/ui/tooltip.tsx`.
- [ ] **`use-media-query.ts`** (tiny, SSR-safe, mirrors `use-debounced-value`):
```ts
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== "undefined" ? window.matchMedia(query).matches : false);
useEffect(() => {
const mql = window.matchMedia(query);
const on = () => setMatches(mql.matches);
on(); mql.addEventListener("change", on);
return () => mql.removeEventListener("change", on);
}, [query]);
return matches;
}
```
- [ ] **`ui/tooltip.tsx`** — wrap `@base-ui/react/tooltip` parts (Provider/Root/Trigger/Portal/Positioner/Popup) in the established `ui/*` style (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn`, `render=` where a trigger delegates). Export a simple `<Tooltip content=…>{trigger}</Tooltip>` convenience plus the raw parts. **RUN a quick story/test** to confirm the Base UI composition (first tooltip in the repo — verify the part tree by running, like the combobox was). No `any`.
- [ ] Typecheck/lint. **Commit** `feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)`.
## Task 8: Collapsible icon sidebar
**Files:** `web/src/shell/app-shell.tsx` (+ optional `sidebar.stories.tsx`).
- [ ] Add lucide icons to each nav item (e.g. `Boxes`/`BookMarked`/`Users`/`Search`/`Tags` — pick sensible icons). Add a collapse toggle button; persist `collapsed` to `localStorage` (`sidebar-collapsed`); auto-collapse when `useMediaQuery("(max-width: 768px)")`. Expanded: icon + label (`w-44`). Collapsed: icon only (`~w-14`) with the label via the `ui/tooltip` (and `aria-label`/`title`). Preserve `NavLink` active styling; add `focus-visible` rings.
- [ ] **Story** `app-shell` sidebar or a extracted `Sidebar` component: `Expanded` / `Collapsed` (assert labels hidden + tooltips/`aria-label` present). If extracting a `Sidebar` component from `app-shell` makes it testable/storyable, do so (keep `app-shell` thin).
- [ ] Typecheck/lint/test. **Commit** `feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58)`.
## Task 9: Responsive detail — right pane (wide) / Drawer (narrow) at canonical `/objects/:id`
**Files:** `web/src/objects/objects-page.tsx`; possibly a small `object-detail-panel.tsx`.
- [ ] Restructure `ObjectsPage`: always render `ObjectsTable`; detect an active detail child with `useMatch("/objects/:id")` / `useMatch("/objects/:id/edit")`. When matched:
- **Wide** (`useMediaQuery("(min-width: 1024px)")`): render a right-hand pane (e.g. `grid-cols-[1fr_28rem]` when open, else `1fr`) containing `<Outlet/>`, with a close control (`navigate("/objects?"+params)`).
- **Narrow:** render `<Outlet/>` inside a Base UI `Drawer` (`swipeDirection="right"`, edge = right) over the table; closing the drawer navigates back to `/objects` (preserve query). **RUN to confirm** the Drawer part tree (Root/Portal/Backdrop/Popup/Close) — first Drawer in the repo; mirror the alert-dialog wrapper conventions.
- Remove the `index → SelectPrompt` route (the table is the landing view); `SelectPrompt` can be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index).
- `:id/edit` continues to render through the same `<Outlet/>` (pane/drawer), preserving today's "edit in the right area" behavior.
- [ ] **Test:** with a mocked `matchMedia`, `/objects/:id` renders detail in a pane (wide) and in a portaled Drawer (narrow, query via `within(document.body)`); closing returns to `/objects` with the table's query string intact; deep-linking `/objects/:id` directly renders table + open detail.
- [ ] Typecheck/lint/test/build. **Commit** `feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)`.
---
# PHASE 4 — Verification
## Task 10: Final verification
- [ ] Backend: `cargo +nightly fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo nextest run --workspace` (single clean run — don't run two concurrently; sqlx temp-DB contention produces fake failures).
- [ ] Web: `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (index ≤ **165 KB gz** — lucide/tooltip/drawer land in the always-loaded shell; tree-shaken — verify and report the number).
- [ ] `pnpm test -- i18n` (en/sv parity for the new `objects.columns.*` etc.); `git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN`; `git status --short` clean.
---
## Self-Review (completed)
**Spec coverage:** sort/filter/q + filtered total + timestamps (T1T3); full-width table with columns/sort/filter/pagination/URL-state (T4T6); collapsible icon sidebar (T8); responsive pane/drawer + canonical `/objects/:id` (T7,T9); stories (T5,T7,T8); bundle/parity/codename (T10). ✓ Out of scope (Meili unification, detail-content #45, multi-select) not included. ✓
**Placeholder scan:** load-bearing logic (SQL builder, sort enum, URL-state wiring, sort toggle, responsive routing, media-query/tooltip) is concrete; routine table markup/classes are described to match existing patterns; the two novel Base UI primitives (Tooltip, Drawer) carry explicit "verify the part tree by running" steps (same approach that worked for the combobox), with canonical trees from the spec. No "TBD"/"add error handling".
**Type consistency:** `ObjectSort` enum + `ObjectQuery` (db) ↔ `ObjectListParams` (api) ↔ `useObjectsPage(ObjectListParams)` (web) align on sort/order/visibility/q; `AdminObjectView` gains `created_at`/`updated_at` (T1) consumed by the table's Updated column (T5). URL param names (`sort`/`order`/`visibility`/`q`/`limit`/`offset`) consistent across table read/write and the hook.
## Notes
- `lucide-react` + Base UI tooltip/drawer/collapsible are already deps → no `pnpm-lock` churn.
- No DB migration (timestamps already exist).
- Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if `check:size` exceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.
@@ -0,0 +1,451 @@
# Searchable Term/Authority Picker 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:** Replace the native `<select>` for term/authority object fields with a searchable combobox (type-to-filter by active-locale label, client-side), built on Base UI's `combobox` primitive — keeping the `value = id` contract.
**Architecture:** A styled wrapper `ui/combobox.tsx` over `@base-ui/react/combobox` (mirroring the existing `ui/alert-dialog.tsx` Base UI wrapper), consumed by a focused `OptionsCombobox` component with the **same prop contract** as today's `OptionsSelect`, dropped into `TermField`/`AuthorityField`. No backend change; `useTerms`/`useAuthorities` unchanged.
**Tech Stack:** React 19 + TypeScript + pnpm, `@base-ui/react` v1.5.0 (already a dependency), Tailwind v4, react-hook-form, Vitest + RTL + MSW, Storybook 10.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source = double quotes + semicolons, stories = single quotes + no semicolons; en/sv parity for any new keys; no codename ("biggus"/"dickus"); per-test portal queries use `within(document.body)`. Tests: `cd web && pnpm test` (vitest, jsdom + storybook projects), `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm check:size`.
**Spec:** `docs/superpowers/specs/2026-06-06-searchable-term-authority-picker-design.md`
**Base UI Combobox — canonical single-select composition** (import `@base-ui/react/combobox`; `value` is the **item object** or `null`; `onValueChange(item)` gives the item; filtering is built-in against `itemToStringLabel`):
```tsx
<Combobox.Root items={items} value={value} onValueChange={setValue}
itemToStringLabel={(it) => it.label} isItemEqualToValue={(a, b) => a?.id === b?.id}>
<Combobox.InputGroup>
<Combobox.Input placeholder="…" id={id} />
<Combobox.Clear aria-label="Clear" />
<Combobox.Trigger aria-label="Open" />
</Combobox.InputGroup>
<Combobox.Portal>
<Combobox.Positioner sideOffset={4}>
<Combobox.Popup>
<Combobox.Empty>No matches.</Combobox.Empty>
<Combobox.List>
{(item) => (
<Combobox.Item key={item.id} value={item}>
<Combobox.ItemIndicator>✓</Combobox.ItemIndicator>
{item.label}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
```
---
## File Structure
- `web/src/components/ui/combobox.tsx` (new) — styled passthrough wrappers over the Base UI `Combobox.*` parts (mirror `ui/alert-dialog.tsx`'s conventions: `data-slot`, `cn()`, re-export composed parts).
- `web/src/objects/options-combobox.tsx` (new) — `OptionsCombobox`, the drop-in picker (same prop contract as `OptionsSelect`), composing the wrapper parts for `{ id, labels }` options. Extracted to its own file so it is focused, unit-testable, and storyable.
- `web/src/objects/options-combobox.stories.tsx` (new) — Storybook stories.
- `web/src/objects/options-combobox.test.tsx` (new) — unit test (open/filter/select/clear).
- `web/src/objects/field-input.tsx` (modify) — `TermField`/`AuthorityField` render `OptionsCombobox`; delete `OptionsSelect`.
- `web/src/objects/field-input.test.tsx` (modify) — update the term/authority cases for the combobox.
---
## Task 1: Combobox component (`ui/combobox.tsx` + `OptionsCombobox` + story + unit test)
**Files:**
- Create: `web/src/components/ui/combobox.tsx`, `web/src/objects/options-combobox.tsx`, `web/src/objects/options-combobox.stories.tsx`, `web/src/objects/options-combobox.test.tsx`
**Before coding:** READ `web/src/components/ui/alert-dialog.tsx` (the Base UI wrapper conventions: `import { X as XPrimitive } from "@base-ui/react/x"`, `data-slot` attributes, `cn()` class merge, `render={…}` trigger style). The exact Base UI `Combobox.*` part prop types are in `node_modules/@base-ui/react/combobox/` — consult them if a passthrough type is unclear.
- [ ] **Step 1: Write the styled wrapper** `web/src/components/ui/combobox.tsx`. Wrap the Base UI parts the picker needs, with Tailwind classes consistent with the existing inputs/menus (the native `<select>` used `w-full rounded border px-2 py-1 text-sm`; the popup should look like a menu surface). Concrete starting implementation (adjust class details to match the app's look; keep the structure):
```tsx
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
import { cn } from "@/lib/utils";
function ComboboxRoot<Value>(props: ComboboxPrimitive.Root.Props<Value>) {
return <ComboboxPrimitive.Root data-slot="combobox" {...props} />;
}
function ComboboxInputGroup({ className, ...props }: ComboboxPrimitive.InputGroup.Props) {
return (
<ComboboxPrimitive.InputGroup
data-slot="combobox-input-group"
className={cn("relative flex items-center", className)}
{...props}
/>
);
}
function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-input"
className={cn("w-full rounded border px-2 py-1 pr-12 text-sm", className)}
{...props}
/>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
className={cn(
"absolute right-6 text-neutral-400 hover:text-neutral-700",
className,
)}
{...props}
/>
);
}
function ComboboxTrigger({ className, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("absolute right-1 text-neutral-500", className)}
{...props}
/>
);
}
function ComboboxPopup({ className, ...props }: ComboboxPrimitive.Popup.Props) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner sideOffset={4} className="z-50">
<ComboboxPrimitive.Popup
data-slot="combobox-popup"
className={cn(
"max-h-64 w-[var(--anchor-width)] overflow-auto rounded border bg-white p-1 text-sm shadow-md",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList(props: ComboboxPrimitive.List.Props) {
return <ComboboxPrimitive.List data-slot="combobox-list" {...props} />;
}
function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"flex cursor-default items-center gap-2 rounded px-2 py-1 data-[highlighted]:bg-indigo-50",
className,
)}
{...props}
/>
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn("px-2 py-1 text-neutral-500", className)}
{...props}
/>
);
}
export {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxClear,
ComboboxTrigger,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
};
```
If a part's `.Props` type path differs (verify against the d.ts), adjust the type annotation — do **not** fall back to `any`. (`--anchor-width` is Base UI's positioner CSS var for matching the input width; if it isn't exposed under that name, use `min-w-[12rem]` instead — confirm when you run the story.)
- [ ] **Step 2: Write `OptionsCombobox`** `web/src/objects/options-combobox.tsx` — the drop-in with the exact contract of the old `OptionsSelect`. It converts between the rhf `value` (id string) and the Base UI item object, and filters/displays by active-locale label.
```tsx
import type { components } from "../api/schema";
import {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxClear,
ComboboxTrigger,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/components/ui/combobox";
type LabelView = components["schemas"]["LabelView"];
export type Option = { id: string; labels: LabelView[] };
function labelIn(labels: LabelView[], lang: string): string {
return (
labels.find((l) => l.lang === lang)?.label ??
labels.find((l) => l.lang === "en")?.label ??
labels[0]?.label ??
""
);
}
export function OptionsCombobox({
id,
value,
onChange,
options,
lang,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
options: Option[];
lang: string;
placeholder: string;
}) {
const selected = options.find((o) => o.id === value) ?? null;
return (
<ComboboxRoot<Option | null>
items={options}
value={selected}
onValueChange={(option) => onChange(option?.id ?? "")}
itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")}
isItemEqualToValue={(a, b) => a?.id === b?.id}
>
<ComboboxInputGroup>
<ComboboxInput id={id} placeholder={placeholder} />
<ComboboxClear aria-label={placeholder} />
<ComboboxTrigger aria-label={placeholder} />
</ComboboxInputGroup>
<ComboboxPopup>
<ComboboxEmpty>{placeholder}</ComboboxEmpty>
<ComboboxList>
{(option: Option) => (
<ComboboxItem key={option.id} value={option}>
{labelIn(option.labels, lang)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
);
}
```
Notes:
- `labelIn` is duplicated here from `field-input.tsx`. In Task 2 you will **export `labelIn` from a shared spot** (see Task 2 Step 3) and import it in both — for now define it locally so this file compiles standalone; Task 2 dedupes.
- Confirm the generic on `ComboboxRoot<Option | null>` matches the wrapper's `Root.Props<Value>` signature; if Base UI's `value`/`onValueChange`/`itemToStringLabel`/`isItemEqualToValue` prop names differ from the canonical example, adjust to the real names from the d.ts (you already have: `items`, `value`, `onValueChange`, `itemToStringLabel`, `isItemEqualToValue`).
- [ ] **Step 3: Write the unit test** `web/src/objects/options-combobox.test.tsx`. Render with two options, exercise open → filter → select → clear. The popup is portaled — query via `within(document.body)`.
```tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OptionsCombobox, type Option } from "./options-combobox";
const options: Option[] = [
{ id: "t1", labels: [{ lang: "en", label: "Wood" }] },
{ id: "t2", labels: [{ lang: "en", label: "Bronze" }] },
];
function setup(value = "") {
const onChange = vi.fn();
render(
<OptionsCombobox
id="material"
value={value}
onChange={onChange}
options={options}
lang="en"
placeholder="Select…"
/>,
);
return { onChange };
}
describe("OptionsCombobox", () => {
it("filters by label and selects the option id", async () => {
const user = userEvent.setup();
const { onChange } = setup();
const input = screen.getByPlaceholderText("Select…");
await user.click(input);
await user.type(input, "bro");
const body = within(document.body);
// Only the matching option is listed.
expect(body.queryByText("Wood")).toBeNull();
await user.click(await body.findByText("Bronze"));
expect(onChange).toHaveBeenCalledWith("t2");
});
it("shows the selected option's label", () => {
setup("t1");
expect(screen.getByDisplayValue("Wood")).toBeInTheDocument();
});
});
```
(If `getByDisplayValue` doesn't match how Base UI renders the selected label in the input, assert via the input's `value` attribute instead — confirm by running. Run the test before finalizing the assertions.)
- [ ] **Step 4: Run the unit test.**
```
cd web && pnpm test -- options-combobox
```
Expected: PASS. If the Base UI composition needs adjustment (portal target, prop names, selected-label display), fix the wrapper/component and re-run until green. **Do not** weaken assertions to pass — the test must genuinely prove filter + select-by-id + selected-label.
- [ ] **Step 5: Write the Storybook story** `web/src/objects/options-combobox.stories.tsx` (mirror `web/src/objects/visibility-badge.stories.tsx` format: `@storybook/react-vite`, `storybook/test`, `tags: ['ai-generated']`, single quotes, no semicolons). Stories: `Default` (placeholder visible), `Selected` (value set → label shown), and `FiltersOnType` (type → only the match shows; portal → `within(document.body)`).
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent, fn, within } from 'storybook/test'
import { OptionsCombobox, type Option } from './options-combobox'
const options: Option[] = [
{ id: 't1', labels: [{ lang: 'en', label: 'Wood' }] },
{ id: 't2', labels: [{ lang: 'en', label: 'Bronze' }] },
]
const meta = {
component: OptionsCombobox,
tags: ['ai-generated'],
args: { id: 'material', value: '', onChange: fn(), options, lang: 'en', placeholder: 'Select…' },
} satisfies Meta<typeof OptionsCombobox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByPlaceholderText('Select…')).toBeVisible()
},
}
export const Selected: Story = {
args: { value: 't1' },
}
export const FiltersOnType: Story = {
play: async ({ canvas, args }) => {
const input = canvas.getByPlaceholderText('Select…')
await userEvent.click(input)
await userEvent.type(input, 'bro')
const body = within(document.body)
await userEvent.click(await body.findByText('Bronze'))
await expect(args.onChange).toHaveBeenCalledWith('t2')
},
}
```
- [ ] **Step 6: Run the stories + typecheck + lint.**
```
cd web && pnpm test -- options-combobox && pnpm typecheck && pnpm lint
```
Expected: PASS, no `any`/disable.
- [ ] **Step 7: Commit.**
```bash
git add web/src/components/ui/combobox.tsx web/src/objects/options-combobox.tsx web/src/objects/options-combobox.stories.tsx web/src/objects/options-combobox.test.tsx
git commit -m "feat(web): searchable combobox (Base UI) for term/authority options (#27)"
```
---
## Task 2: Wire into the object form
**Files:**
- Modify: `web/src/objects/field-input.tsx`
- Modify: `web/src/objects/field-input.test.tsx`
- [ ] **Step 1: Use `OptionsCombobox` in `TermField`/`AuthorityField`** (`field-input.tsx`). Replace the `<OptionsSelect … />` rendered inside each `Controller` with `<OptionsCombobox … />` (the props are identical: `id`, `value`, `onChange`, `options`, `lang`, `placeholder`). Add the import:
```tsx
import { OptionsCombobox } from "./options-combobox";
```
Then **delete the now-unused `OptionsSelect` function** and the stale comment above it ("A native `<select>` keeps the bundle lean…").
- [ ] **Step 2: Verify no other references to `OptionsSelect`.**
```
cd web && grep -rn "OptionsSelect" src
```
Expected: no matches (it's removed).
- [ ] **Step 3: Dedupe `labelIn`.** `field-input.tsx` and `options-combobox.tsx` both define `labelIn`. Export it from `options-combobox.tsx` (add `export` to its `labelIn`) and import it in `field-input.tsx`, removing `field-input.tsx`'s local copy:
```tsx
// field-input.tsx
import { OptionsCombobox, labelIn } from "./options-combobox";
```
Confirm `field-input.tsx` still uses `labelIn` for its `definition.labels` rendering (it does, in `FieldInput`). (If you prefer not to couple `field-input` to `options-combobox` for a helper, instead move `labelIn` to `web/src/lib/labels.ts` if that module exists — check `web/src/lib/` — and import from there in both. Pick one; do not leave two copies.)
- [ ] **Step 4: Update `field-input.test.tsx`** for the combobox. Find the existing term and/or authority test cases (they currently interact with a native `<select>` — e.g. `selectOptions` or asserting `<option>`s) and rewrite them to drive the combobox: render the object form (or the field), open the combobox, type to filter, click the option, and assert the submitted/registered value is the term/authority **id**. Use `within(document.body)` for the portaled popup. Leave the text/integer/date/boolean/localized_text cases unchanged.
- Read the current `field-input.test.tsx` to see exactly how the term/authority cases are set up (MSW handlers for `useTerms`/`useAuthorities`, the form wrapper) and adapt those specific cases; do not rewrite the whole file.
- [ ] **Step 5: Run the field-input tests + full web suite.**
```
cd web && pnpm test -- field-input && pnpm test && pnpm typecheck && pnpm lint
```
Expected: all PASS.
- [ ] **Step 6: Commit.**
```bash
git add web/src/objects/field-input.tsx web/src/objects/field-input.test.tsx
git commit -m "feat(web): use searchable combobox for term/authority fields on the object form (#27)"
```
---
## Task 3: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Full web gate.**
```
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
```
Expected: all green; `check:size` reports the index chunk ≤ 150 KB gz (the combobox lands in the lazy object-form chunk — confirm the index didn't materially grow).
- [ ] **Step 2: en/sv parity + codename + no leftover select.**
```
cd web && pnpm test -- i18n
git grep -in 'biggus\|dickus' -- web/src || echo "CODENAME CLEAN"
grep -rn "OptionsSelect" web/src || echo "OptionsSelect removed"
```
Expected: parity passes; codename clean; `OptionsSelect` gone.
- [ ] **Step 3: Manual smoke (recommended).** `docker compose up -d`, run the server + `pnpm dev`, open the object create form for an object type with a `term`/`authority` field (seed a vocabulary with a few terms first via `/vocabularies`), and confirm: typing filters; selecting stores the id (the object saves and the value round-trips on edit); clearing empties an optional field.
---
## Self-Review (completed)
**1. Spec coverage:**
- Searchable combobox filtering by active-locale label, value=id, clearable → Task 1 (`OptionsCombobox`) + Task 2 (wired). ✓
- Base UI `combobox` primitive, no new dep → Task 1 `ui/combobox.tsx`. ✓
- `useTerms`/`useAuthorities` unchanged (client-side) → Task 2 leaves them untouched. ✓
- Tests open/filter/select/clear + story → Task 1 Steps 3/5, Task 2 Step 4. ✓
- Bundle ≤150 KB gz index, typecheck/lint/test/build/parity/codename → Task 3. ✓
- Out of scope (server-side `?q=`, selected-id→label resolution, multi-select) → not implemented; filed as follow-up by the controller. ✓
**2. Placeholder scan:** No "TBD"/"handle errors"/"similar to". Concrete code for the wrapper, the component, the test, and the story. The few "verify against the d.ts / confirm by running" notes target the one genuinely novel piece (the repo's first Base UI Combobox) and are verification steps, not deferred implementation — the canonical composition is given in the header.
**3. Type consistency:** `Option = { id, labels }`; `OptionsCombobox` prop contract matches the old `OptionsSelect` exactly (`id/value/onChange/options/lang/placeholder`); `value` (id string) ↔ Base UI item object via `find`/`?.id`. `labelIn` is defined once after Task 2 (exported from `options-combobox.tsx` or `lib/labels.ts`). Wrapper part names match the canonical Base UI tree (Root/InputGroup/Input/Clear/Trigger/Portal/Positioner/Popup/List/Item/Empty).
## Notes
- No new npm dependency (`@base-ui/react` already present) → no `pnpm-lock.yaml` churn expected.
- The popup is portaled — every test/story that interacts with options must query `within(document.body)`, not `canvas` alone.
@@ -0,0 +1,237 @@
# Wire the Spectrum Seed into Runtime 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:** Expose the existing idempotent `db::seed::seed_spectrum_cataloguing` as a `server seed` CLI subcommand (plus a `just seed` recipe and README note), so an operator can seed an instance's baseline cataloguing fields.
**Architecture:** Mirror the existing `create-user` one-shot exactly — add a `Seed` variant to the clap `Command` enum, dispatch it to a new `server::seed(database_url)` that connects with a tiny pool, applies migrations (idempotent, so it works on a fresh DB), runs the seed inside a transaction, commits, and exits. The seed content and its idempotency are already tested at the db layer; the new code is thin glue.
**Tech Stack:** Rust (clap derive, sqlx/Postgres, anyhow, tokio). Backend-only + docs.
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; never write the codename ("biggus"/"dickus"). Test infra: compose Postgres on host **5442**, Meili **7700**; `#[sqlx::test(migrations = "../db/migrations")]` provisions its own temp DB. Env for manual runs comes from `.env` via the justfile's `set dotenv-load`.
**Spec:** `docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md`
---
## File Structure
- `crates/server/src/main.rs` — add a `Seed` variant to the `Command` enum + a dispatch arm.
- `crates/server/src/lib.rs` — add `pub async fn seed(database_url: &str) -> anyhow::Result<()>` (modeled on `create_user`, but with a `db.migrate()` step).
- `crates/server/tests/seed.rs` (new) — a server-crate building-block regression test mirroring `crates/server/tests/create_user.rs` (seed twice via the test pool; assert a known seeded vocabulary + field).
- `justfile` — add a `seed` recipe.
- `README.md` — add a seed step to the "Running locally" setup sequence.
The seed *content* + idempotency stay covered by the existing `crates/db/tests/seed.rs` (unchanged).
---
## Task 1: `server seed` subcommand
**Files:**
- Modify: `crates/server/src/main.rs`
- Modify: `crates/server/src/lib.rs`
- Create: `crates/server/tests/seed.rs`
**Reference (the template to mirror) — `server::create_user` in `crates/server/src/lib.rs`:**
```rust
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
// ...email parse + password hash...
let db = Db::connect(database_url, 2).await.context("connecting to the database")?;
let mut tx = db.pool().begin().await?;
let id = db::users::create_user(&mut tx, AuditActor::System, &NewUser { /* ... */ }).await
.context("creating the user (is the email already taken?)")?;
tx.commit().await?;
println!("created user {id} ({role:?})");
Ok(())
}
```
`Db::connect(url, n)`, `db.migrate()`, `db.pool()` all already exist (`run` calls `db.migrate()` at `lib.rs:22`). The seed fn `db::seed::seed_spectrum_cataloguing(conn: &mut sqlx::PgConnection)` is idempotent and uses `AuditActor::System` internally — no actor plumbing needed.
- [ ] **Step 1: Write the server-crate building-block test.** Create `crates/server/tests/seed.rs`. Mirror the harness comment + pool approach from `crates/server/tests/create_user.rs` (the temp-DB URL isn't exposed, so we exercise the building block the command composes — `db::seed::seed_spectrum_cataloguing` — against the test pool, including a second run to prove idempotency):
```rust
use db::{Db, fields, seed, vocab};
use sqlx::PgPool;
// Note: `server::seed` opens its own DB connection by URL, but `#[sqlx::test]`
// provisions a temporary database whose URL is not directly exposed. This test
// exercises the building block the command composes — `db::seed::seed_spectrum_cataloguing`
// — against the test pool, run twice to prove the idempotency the command relies on.
#[sqlx::test(migrations = "../db/migrations")]
async fn seed_is_idempotent_via_building_block(pool: PgPool) {
let db = Db::from_pool(pool);
for _ in 0..2 {
let mut tx = db.pool().begin().await.unwrap();
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
tx.commit().await.unwrap();
}
// A representative seeded vocabulary and field definition are present after two runs.
assert!(
vocab::vocabulary_by_key(db.pool(), "material").await.unwrap().is_some(),
"vocabulary 'material' should be seeded"
);
assert!(
fields::field_definition_by_key(db.pool(), "title").await.unwrap().is_some(),
"field definition 'title' should be seeded"
);
}
```
(Confirm the seeded keys by reading `crates/db/src/seed.rs` — it seeds vocabularies `material`/`object_name`/`technique` and a field def `title`; adjust the asserted keys if they differ.)
- [ ] **Step 2: Run the test — it should PASS immediately.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo nextest run -p server -E 'test(seed_is_idempotent_via_building_block)'
```
Expected: PASS. (Unlike classic TDD, this guards an already-working building block the new command depends on — there is no failing-first state because `db::seed` already exists. The genuinely new code is the glue in Steps 34, verified by build + the manual smoke in Step 6.)
- [ ] **Step 3: Add the `Seed` command variant + dispatch** in `crates/server/src/main.rs`. Add to the `Command` enum (after `CreateUser { … }`):
```rust
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
Seed,
```
And add a match arm in `main` (the `match cli.command { … }`), after the `CreateUser` arm:
```rust
Some(Command::Seed) => seed(&cli.config.database_url).await,
```
Update the import at the top of `main.rs` from `use server::{Config, create_user, run};` to:
```rust
use server::{Config, create_user, run, seed};
```
- [ ] **Step 4: Add the `seed` one-shot** in `crates/server/src/lib.rs`, next to `create_user`:
```rust
/// One-shot: apply migrations (idempotent), then seed the baseline Spectrum cataloguing
/// vocabularies + field definitions. Safe to re-run (the seed is idempotent).
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
// CLI one-shot: a tiny pool is plenty.
let db = Db::connect(database_url, 2)
.await
.context("connecting to the database")?;
// Apply migrations first so `server seed` works on a fresh DB without first
// starting the server. Migrations are idempotent.
db.migrate()
.await
.context("running database migrations")?;
let mut tx = db.pool().begin().await?;
db::seed::seed_spectrum_cataloguing(&mut tx)
.await
.context("seeding Spectrum cataloguing baseline")?;
tx.commit().await?;
println!("seeded Spectrum cataloguing baseline (idempotent)");
Ok(())
}
```
(`Db`, `anyhow::Context`/`context` are already imported in `lib.rs` — verify the `use` lines; `create_user` already uses `.context(...)` and `Db::connect`, so the imports exist.)
- [ ] **Step 5: Build, fmt, clippy, and run the server tests.**
```
cargo +nightly fmt
cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo nextest run -p server
```
Expected: builds clean, clippy clean, all server tests pass (including the existing `create_user` + `config` + `serve` + `embed` tests and the new seed test). Also confirm the subcommand is wired:
```
cargo run -p server -- --help
```
Expected: the help output lists a `seed` subcommand alongside `create-user`.
- [ ] **Step 6: Manual smoke — verify the real command (connect + migrate + commit glue).** With compose up (`docker compose up -d`):
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo run -p server -- seed
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo run -p server -- seed
```
Expected: both print `seeded Spectrum cataloguing baseline (idempotent)` and exit 0 (the second run is a no-op). (This exercises the URL-connect + migrate + commit path that `#[sqlx::test]` can't.)
- [ ] **Step 7: Commit.**
```bash
git add crates/server
git commit -m "feat(server): 'seed' subcommand wiring the Spectrum cataloguing seed (#14)"
```
---
## Task 2: `just seed` recipe + README note
**Files:**
- Modify: `justfile`
- Modify: `README.md`
- [ ] **Step 1: Add the `seed` recipe** to `justfile`. Insert after the `run` recipe (keeping the existing comment style), before `test`:
```
# Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent)
seed:
cargo run -p server -- seed
```
- [ ] **Step 2: Verify just parses it.**
```
just --list
```
Expected: `seed` appears in the recipe list with its description.
- [ ] **Step 3: Add a seed step to the README "Running locally" setup sequence.** Open `README.md`, find the "Running locally" section and the step that creates the admin user (the `create-user` instruction). Immediately after it, add a step:
```markdown
4. Seed the baseline cataloguing fields (idempotent):
```bash
just seed # or: cargo run -p server -- seed
```
```
(Match the surrounding numbering/formatting of the existing steps — renumber subsequent steps if the section is numbered. Read the section first and adapt the wording to its style; the content is: run `just seed` once after creating the admin user to populate the baseline Spectrum vocabularies + field definitions.)
- [ ] **Step 4: Commit.**
```bash
git add justfile README.md
git commit -m "docs: 'just seed' recipe + README seed step (#14)"
```
---
## Task 3: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Full suite + lints.**
```
cargo +nightly fmt --check
cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run --workspace
```
Expected: all green.
- [ ] **Step 2: Codename scan + tree hygiene.**
```
git grep -in 'biggus\|dickus' -- crates README.md justfile || echo "CLEAN"
git status --short
```
Expected: `CLEAN`; working tree clean after the task commits.
---
## Self-Review (completed)
**1. Spec coverage:**
- `server seed` subcommand → Task 1 (main.rs variant + dispatch). ✓
- `server::seed` one-shot mirroring create_user, migrate-first → Task 1 Step 4. ✓
- Idempotent / safe to re-run → asserted in Task 1 Step 1 test + Step 6 smoke. ✓
- `just seed` recipe + README note → Task 2. ✓
- Testing: existing db-layer seed tests unchanged + new server-crate building-block test + manual glue smoke → Task 1. ✓
- Acceptance: nextest green / fmt / clippy / no codename → Task 3. ✓
- Out of scope (no `--seed` flag, no auto-boot, no provisioning, no term seeding, create_user unchanged) → respected; only the four files above change. ✓
**2. Placeholder scan:** No TBD/“handle errors”/“similar to”. The two “confirm the seeded keys / read the section first” notes are verification steps against real files, not deferred implementation; concrete code is given for every code step.
**3. Type consistency:** `seed(database_url: &str) -> anyhow::Result<()>` is defined in Task 1 Step 4 and imported/dispatched in Step 3 (`use server::{… seed}`, `Some(Command::Seed) => seed(&cli.config.database_url).await`). The test uses `db::seed::seed_spectrum_cataloguing(&mut tx)` + `vocab::vocabulary_by_key` + `fields::field_definition_by_key`, all existing signatures (mirrored from `crates/db/tests/seed.rs` and `create_user.rs`).
## Notes
- No new dependencies → no `Cargo.lock` churn expected.
- `Command::Seed` has no clap args; it reuses the flattened `Config.database_url`, exactly like `CreateUser` does.
@@ -0,0 +1,520 @@
# Dark-Mode Theme Toggle 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:** Ship a tri-state (Light/Dark/System) theme toggle that activates the existing `.dark` token set, persists to `localStorage`, defaults to System (live-tracking the OS), and never flashes on reload.
**Architecture:** Client-only theming over CSS custom properties — no new dependency. A framework-free core (`theme.ts`) resolves/reads/applies the theme; a `useTheme` hook mirrors `use-locale`; a synchronous inline script in `index.html` applies the class before first paint; an icon segmented `ThemeSwitch` lives in the header next to `LangSwitch`. The `.dark` class on `<html>` activates the dark tokens migrated in #49.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), lucide-react (already a dep), Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (vitest, single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; source double-quote/semicolon, stories single-quote/no-semicolon; token classes only (no raw colors — `check:colors` must pass); guard DOM globals (`window`/`localStorage`/`matchMedia`/`document`) for jsdom/test safety.
**Spec:** `docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md`
**File structure:**
- `web/src/theme/theme.ts` (new) — `THEME_KEY`, `Theme`, `resolveTheme`, `readTheme`, `applyTheme`.
- `web/src/theme/theme.test.ts` (new) — unit tests for the core.
- `web/src/theme/use-theme.ts` (new) — `useTheme()` hook.
- `web/src/shell/theme-switch.tsx` (new) — the icon segmented control.
- `web/src/shell/theme-switch.test.tsx` (new) — interaction tests.
- `web/src/shell/theme-switch.stories.tsx` (new) — Storybook story.
- `web/src/shell/app-shell.tsx` (modify) — mount `<ThemeSwitch />`.
- `web/src/i18n/en.json`, `web/src/i18n/sv.json` (modify) — `theme.*` keys.
- `web/index.html` (modify) — inline FOUC-prevention script.
- `web/src/index.css` (modify) — dark `--primary`/`--ring` contrast tweak.
---
# Task 1: Theme core (`theme.ts`) + unit tests
**Files:**
- Create: `web/src/theme/theme.ts`
- Create: `web/src/theme/theme.test.ts`
- [ ] **Step 1: Write the failing tests**`web/src/theme/theme.test.ts`:
```ts
import { afterEach, expect, test, vi } from "vitest";
import { applyTheme, readTheme, resolveTheme, THEME_KEY } from "./theme";
function mockMatchMedia(matches: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
document.documentElement.classList.remove("dark");
});
test("resolveTheme returns explicit values verbatim", () => {
expect(resolveTheme("light")).toBe("light");
expect(resolveTheme("dark")).toBe("dark");
});
test("resolveTheme maps system via prefers-color-scheme", () => {
mockMatchMedia(true);
expect(resolveTheme("system")).toBe("dark");
mockMatchMedia(false);
expect(resolveTheme("system")).toBe("light");
});
test("readTheme defaults to system when unset or invalid", () => {
expect(readTheme()).toBe("system");
localStorage.setItem(THEME_KEY, "bogus");
expect(readTheme()).toBe("system");
localStorage.setItem(THEME_KEY, "dark");
expect(readTheme()).toBe("dark");
});
test("applyTheme toggles the dark class on documentElement", () => {
mockMatchMedia(false);
applyTheme("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
mockMatchMedia(true);
applyTheme("system");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
Expected: FAIL — cannot import from `./theme` (module not found).
- [ ] **Step 3: Implement**`web/src/theme/theme.ts`:
```ts
export const THEME_KEY = "theme";
export type Theme = "light" | "dark" | "system";
const THEMES: readonly Theme[] = ["light", "dark", "system"];
function prefersDark(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
export function resolveTheme(theme: Theme): "light" | "dark" {
if (theme === "light" || theme === "dark") return theme;
return prefersDark() ? "dark" : "light";
}
export function readTheme(): Theme {
if (typeof localStorage === "undefined") return "system";
const stored = localStorage.getItem(THEME_KEY);
return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
}
export function applyTheme(theme: Theme): void {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark");
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add web/src/theme/theme.ts web/src/theme/theme.test.ts
git commit -m "feat(web): theme core — resolve/read/apply tri-state theme (#59)"
```
---
# Task 2: `useTheme` hook
**Files:**
- Create: `web/src/theme/use-theme.ts`
(No standalone unit test — the hook is exercised by `theme-switch.test.tsx` in Task 3, which drives it through real UI per the project's testing style. `theme.ts` carries the logic and is unit-tested in Task 1.)
- [ ] **Step 1: Implement**`web/src/theme/use-theme.ts`:
```ts
import { useEffect, useState } from "react";
import { applyTheme, readTheme, type Theme } from "./theme";
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } {
const [theme, setThemeState] = useState<Theme>(readTheme);
const setTheme = (next: Theme) => {
if (typeof localStorage !== "undefined") localStorage.setItem("theme", next);
setThemeState(next);
applyTheme(next);
};
useEffect(() => {
applyTheme(theme);
if (theme !== "system") return;
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme("system");
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [theme]);
return { theme, setTheme };
}
```
Note: import `THEME_KEY` from `./theme` and use it instead of the literal `"theme"` for the
`localStorage.setItem` key (DRY with the core). Update the import line to
`import { applyTheme, readTheme, THEME_KEY, type Theme } from "./theme";` and use
`localStorage.setItem(THEME_KEY, next)`.
- [ ] **Step 2: Typecheck**
Run: `cd web && pnpm typecheck`
Expected: PASS (no errors).
- [ ] **Step 3: Commit**
```bash
git add web/src/theme/use-theme.ts
git commit -m "feat(web): useTheme hook with live system tracking (#59)"
```
---
# Task 3: `ThemeSwitch` UI + i18n + tests + story
**Files:**
- Create: `web/src/shell/theme-switch.tsx`
- Create: `web/src/shell/theme-switch.test.tsx`
- Create: `web/src/shell/theme-switch.stories.tsx`
- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json`
- [ ] **Step 1: Add i18n keys.** In `web/src/i18n/en.json`, add a top-level `theme` namespace (place after the `labels` entry):
```json
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
```
In `web/src/i18n/sv.json`, the matching entry:
```json
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
```
- [ ] **Step 2: Write the failing test**`web/src/shell/theme-switch.test.tsx`:
```tsx
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ThemeSwitch } from "./theme-switch";
beforeEach(() => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
document.documentElement.classList.remove("dark");
});
test("selecting Dark applies the dark class and persists", async () => {
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /dark/i }));
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: /dark/i })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test("selecting Light removes the dark class and persists", async () => {
localStorage.setItem("theme", "dark");
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /light/i }));
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(localStorage.getItem("theme")).toBe("light");
});
test("selecting System resolves via prefers-color-scheme", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: true,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /system/i }));
expect(localStorage.getItem("theme")).toBe("system");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
```
- [ ] **Step 3: Run to verify it fails**
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
Expected: FAIL — cannot import `ThemeSwitch`.
- [ ] **Step 4: Implement**`web/src/shell/theme-switch.tsx`:
```tsx
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "../theme/use-theme";
import type { Theme } from "../theme/theme";
import { cn } from "@/lib/utils";
const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [
{ value: "light", Icon: Sun },
{ value: "dark", Icon: Moon },
{ value: "system", Icon: Monitor },
];
export function ThemeSwitch() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<div className="flex gap-1">
{OPTIONS.map(({ value, Icon }) => {
const active = theme === value;
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
aria-pressed={active}
aria-label={t(`theme.${value}`)}
title={t(`theme.${value}`)}
className={cn(
"rounded-md p-1 transition-colors",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-4 w-4" aria-hidden />
</button>
);
})}
</div>
);
}
```
(Verify the `cn` import path matches the project — other `ui/*` files import `cn` from `@/lib/utils`. If `lib/utils` is absent, mirror whatever `button.tsx` uses.)
- [ ] **Step 5: Run to verify it passes**
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
Expected: PASS (3 tests).
- [ ] **Step 6: Write the Storybook story**`web/src/shell/theme-switch.stories.tsx`:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { ThemeSwitch } from './theme-switch'
const meta = {
component: ThemeSwitch,
tags: ['ai-generated'],
} satisfies Meta<typeof ThemeSwitch>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByRole('button', { name: /light/i })).toBeInTheDocument()
await expect(canvas.getByRole('button', { name: /dark/i })).toBeInTheDocument()
await expect(canvas.getByRole('button', { name: /system/i })).toBeInTheDocument()
},
}
```
(Note: the story exercises rendering only — it does not click options, to avoid mutating `<html>`
globally across the browser-mode test run.)
- [ ] **Step 7: Run the story as a test + lint**
Run: `cd web && pnpm vitest run src/shell/theme-switch.stories.tsx && pnpm lint`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add web/src/shell/theme-switch.tsx web/src/shell/theme-switch.test.tsx web/src/shell/theme-switch.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59)"
```
---
# Task 4: Mount in the header + FOUC inline script
**Files:**
- Modify: `web/src/shell/app-shell.tsx`
- Modify: `web/index.html`
- [ ] **Step 1: Mount `ThemeSwitch`.** In `web/src/shell/app-shell.tsx`, add the import:
```tsx
import { ThemeSwitch } from "./theme-switch";
```
and render it in the header immediately before `<LangSwitch />`:
```tsx
<div className="flex-1" />
<ThemeSwitch />
<LangSwitch />
```
(Match the existing header's exact JSX; only insert the one line. Do not change other markup.)
- [ ] **Step 2: Add the FOUC-prevention inline script.** In `web/index.html`, inside `<head>`
BEFORE the `<script type="module" src="/src/main.tsx">` tag, add:
```html
<script>
try {
var t = localStorage.getItem("theme") || "system";
var dark =
t === "dark" ||
(t === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch (e) {}
</script>
```
- [ ] **Step 3: Verify the app-shell test still passes** (the header now has an extra control):
Run: `cd web && pnpm vitest run src/shell/app-shell.test.tsx`
Expected: PASS (the existing "language switch" test is unaffected — ThemeSwitch buttons have distinct accessible names).
- [ ] **Step 4: Build to verify `index.html` is valid**
Run: `cd web && pnpm build`
Expected: built successfully (Vite processes the inline script).
- [ ] **Step 5: Commit**
```bash
git add web/src/shell/app-shell.tsx web/index.html
git commit -m "feat(web): mount ThemeSwitch in header + pre-paint theme init (#59)"
```
---
# Task 5: Dark `--primary` contrast tweak + final verification
**Files:**
- Modify: `web/src/index.css`
- [ ] **Step 1: Compute the new dark `--primary`.** The dark button label uses `--primary-foreground:
oklch(0.205 0 0)` (near-black) on `--primary: oklch(0.673 0.182 276.935)` (~3.21:1). Lower the
lightness (and keep it a recognizable indigo) until WCAG contrast vs `oklch(0.205 0 0)` is **≥4.5:1**.
A good starting point is `oklch(0.62 0.20 277)`; compute the exact value with a contrast check
(convert both to sRGB relative luminance, `(L1+0.05)/(L2+0.05) ≥ 4.5`). In the `.dark` block of
`web/src/index.css`, update BOTH `--primary` and `--ring` (they must match) to the chosen value:
```css
--primary: oklch(<chosen-L> <chosen-C> 277);
...
--ring: oklch(<chosen-L> <chosen-C> 277);
```
Leave `--primary-foreground: oklch(0.205 0 0)` and the entire `:root` (light) block unchanged.
- [ ] **Step 2: Verify the contrast.** State the computed ratio in the commit body (must be ≥4.5:1).
Sanity-check the value is still visibly indigo (hue ~277, chroma not flattened to gray).
- [ ] **Step 3: Full gate (single test pass).**
Run:
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
Expected: all green. `check:colors` passes (icons are not color utilities). `check:size` within 250 KB
gz (three lucide icons are negligible). Tests run exactly ONCE (no concurrent runs).
- [ ] **Step 4: Codename + status checks.**
```bash
git grep -in 'biggus\|dickus' -- web/src web/index.html; echo "codename-exit=$?"
git status --short
```
Expected: no codename matches; working tree shows only intended changes.
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`, toggle Light/Dark/System; confirm the app
switches, a dark reload doesn't flash light, primary buttons are legible in dark, and switching the
OS theme while in System updates the app live.
- [ ] **Step 6: Commit**
```bash
git add web/src/index.css
git commit -m "fix(web): raise dark --primary contrast to AA for button labels (#59)"
```
---
## Self-Review (completed)
**Spec coverage:** tri-state model + System default (T1 `resolveTheme`/`readTheme`, T3 UI); persisted
to localStorage (T2 `setTheme`, T3 tests); `.dark` on `<html>` (T1 `applyTheme`); live system tracking
(T2 `useEffect` matchMedia listener); FOUC prevention (T4 inline script); icon segmented control next
to LangSwitch (T3 + T4 mount); en/sv `theme.*` (T3); aria-pressed/aria-label (T3); dark `--primary`
contrast ≥4.5:1 + `--ring` sync (T5); gate incl. check:colors/check:size + no codename + no new dep
(T5). All acceptance criteria 16 mapped. ✓
**Placeholder scan:** the only "computed" value is the exact dark `--primary` OKLCH — a genuine WCAG
measurement step with a concrete starting point and an explicit acceptance threshold (≥4.5:1), not a
TODO. All code blocks are complete. ✓
**Type consistency:** `Theme` type defined in `theme.ts` (T1), imported by `use-theme.ts` (T2) and
`theme-switch.tsx` (T3); `THEME_KEY` from `theme.ts` used in T2's setter; `resolveTheme`/`readTheme`/
`applyTheme` signatures consistent across tasks; i18n keys `theme.light/dark/system` defined in T3 and
referenced by `t(\`theme.${value}\`)` in T3's component. ✓
## Notes
- No new dependency (lucide-react already present; `.dark` tokens already exist from #49).
- The inline FOUC script is intentionally plain ES5-ish + try/catch — it runs before the bundle and
must never throw.
- Cross-tab sync and per-account/server theme default are explicit follow-ups (not in this plan).
@@ -0,0 +1,126 @@
# Design-Token Adoption Across Feature Screens — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Route every feature screen through the OKLCH design tokens — one indigo brand accent (`--primary`), token-based status colors (success/warning/highlight), the radius token, and a shared caption utility — and add a guard that keeps raw color utilities out of `src` (outside `components/ui/`).
**Architecture:** Pure styling refactor. Phase 1 adds/changes tokens + `ui` Badge variants + the visibility badge / highlight / caption helpers. Phase 2 mechanically migrates ~120 raw utilities across 27 files to tokens + the radius token. Phase 3 adds the `check:colors` guard (which can only pass once the migration is complete) and runs the gate. No behavior, layout, routing, API, or data changes.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), Base UI, Vitest+RTL+MSW (incl. Storybook browser project).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv untouched (no strings); `check:size` budget 250 KB gz (no real change expected). Stories single-quote/no-semicolon; source double-quote/semicolon. **Do not change markup/layout/spacing** — only color/radius utilities + Badge variant selection.
**Spec:** `docs/superpowers/specs/2026-06-07-design-token-adoption-design.md`
**Migration surface (27 files with raw color utilities, outside `components/ui/`):** `app.tsx`, `auth/login-page.tsx`, `authorities/authorities-page.tsx`, `components/delete-confirm-dialog.tsx`, `fields/field-form.tsx`, `fields/field-list.tsx`, `objects/{delete-object-dialog,flexible-field-value,object-detail-drawer,object-detail,object-edit-form,object-form,objects-page,objects-table,options-combobox,publish-control,visibility-badge,visibility-badge.stories}.tsx`, `search/{highlight,search-panel,search-result-row,select-search-prompt}.tsx`, `shell/{lang-switch,sidebar}.tsx`, `vocab/{select-vocabulary-prompt,vocabulary-list,vocabulary-terms}.tsx`.
---
# Task 1: Token + component foundation
**Files:** `web/src/index.css`, `web/src/components/ui/badge.tsx` (+ `badge.stories.tsx` if present), `web/src/objects/visibility-badge.tsx`, `web/src/objects/visibility-badge.stories.tsx`, `web/src/search/highlight.tsx`.
- [ ] **Step 1: Indigo primary + status tokens** in `web/src/index.css`. In `:root`:
```css
--primary: oklch(0.511 0.262 276.966); /* indigo-600 */
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.511 0.262 276.966);
--success: oklch(0.627 0.194 149.214); /* green-600 — readable as text */
--success-foreground: oklch(0.985 0 0);
--warning: oklch(0.666 0.179 58.318); /* amber-700-ish — readable as text */
--warning-foreground: oklch(0.985 0 0);
--highlight: oklch(0.905 0.182 98.111); /* ~yellow-300 search highlight */
--highlight-foreground: oklch(0.205 0 0);
```
In `.dark` (keep coherent for #59): `--primary: oklch(0.673 0.182 276.935)` (indigo-400), `--primary-foreground: oklch(0.205 0 0)`, `--ring` to match; `--success`/`--warning` slightly lighter for dark; `--highlight` unchanged or darker-text. In `@theme inline` add the `--color-*` mappings: `--color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); --color-highlight: var(--highlight); --color-highlight-foreground: var(--highlight-foreground);`. Add a shared caption utility in `@layer components`:
```css
@layer components {
.label-caption { @apply text-xs font-medium uppercase tracking-wide text-muted-foreground; }
}
```
(Implementer may fine-tune the oklch to match exact Tailwind shades; keep `*-foreground` contrast ≥ AA.)
- [ ] **Step 2: Badge variants.** In `web/src/components/ui/badge.tsx`, add to the `cva` variants (mirror the `destructive` shape):
```ts
success:
"bg-success/10 text-success [a]:hover:bg-success/20",
warning:
"bg-warning/10 text-warning [a]:hover:bg-warning/20",
```
- [ ] **Step 3: VisibilityBadge → variants.** In `web/src/objects/visibility-badge.tsx`, replace the hardcoded `STYLES` (amber/green/neutral) with variant selection:
```tsx
const VARIANT: Record<Visibility, "secondary" | "warning" | "success"> = {
draft: "secondary",
internal: "warning",
public: "success",
};
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
const { t } = useTranslation();
return <Badge variant={VARIANT[visibility]}>{t(`visibility.${visibility}`)}</Badge>;
}
```
(Drop the `variant="outline" className={STYLES[...]}` patching.)
- [ ] **Step 4: Highlight token.** In `web/src/search/highlight.tsx`, `bg-yellow-200``bg-highlight text-highlight-foreground`.
- [ ] **Step 5: Update stories.** Add `Success`/`Warning` stories to the Badge story file (if `badge.stories.tsx` exists; else create alongside). **Update the `CssCheck` story** in `visibility-badge.stories.tsx`: it asserts the public badge background `oklch(0.962 0.044 156.743)` (old green-100). Public is now the `success` variant (`bg-success/10`). **Run the story, read the new `getComputedStyle(...).backgroundColor`, and pin that value** (keep the CssCheck — it proves Tailwind + tokens load). Update the comment.
- [ ] **Step 6:** `cd web && pnpm test -- visibility-badge badge && pnpm typecheck && pnpm lint`. The visibility badge renders with token colors; CssCheck passes with the new value. **Commit** `feat(web): indigo brand token + status tokens + Badge success/warning variants (#49)`.
---
# Task 2: Migrate feature screens to tokens + radius
**Files:** the 27 migration-surface files listed above (excluding `visibility-badge.tsx`/`.stories.tsx` + `highlight.tsx` done in Task 1).
Apply the migration map mechanically. **Use the guard regex (Task 3) as your completeness checker**: after migrating, `grep -rE "(text|bg|border|ring)-(neutral|gray|slate|red|amber|green|yellow|indigo|…)-[0-9]+" src --include="*.tsx" | grep -v "components/ui/"` must return **nothing**.
| From | To |
|---|---|
| `text-red-600` | `text-destructive` |
| `text-neutral-400` / `-500` / `-600` | `text-muted-foreground` |
| `text-neutral-700` / `-900` | `text-foreground` |
| `bg-neutral-50` / `-100` | `bg-muted` |
| `bg-neutral-200` (active nav, sidebar) | `bg-accent` |
| `bg-indigo-50` (selected row) | `bg-primary/10` |
| `bg-indigo-600` / `text-indigo-600` | `bg-primary` / `text-primary` (+ `text-primary-foreground` where on `bg-primary`) |
| `bg-neutral-800` (publish stepper / authority tabs active) | `bg-primary text-primary-foreground` |
| `border-red-300` (combobox/drawer error) | `border-destructive` (or keep neutral `border` if it's not an error state) |
| `border-green-300` | `border-success` (or neutral) |
| bare `rounded` (×23) | `rounded-md` |
- [ ] **Step 1: Migrate by area**, file-by-file, replacing per the map. Also collapse the uppercase-caption recipes (object-detail, object-form, publish-control, field-list, vocabulary-terms) to the shared `label-caption` class (`<div className="label-caption">…`). **Do not change any non-color/radius classes, markup, or layout.** For the few ambiguous one-offs, follow the map's intent (muted captions → `text-muted-foreground`; emphasized values → `text-foreground`; error text → `text-destructive`). Optionally adopt `ui/Card` for an obviously hand-rolled bordered panel (e.g. object-detail) — only if a clean swap; skip otherwise.
- [ ] **Step 2: Completeness check** — run the grep above; iterate until **zero** raw color utilities remain outside `components/ui/`. Also confirm no bare `rounded` remains (→ `rounded-md`).
- [ ] **Step 3: Verify no regressions**`cd web && pnpm typecheck && pnpm lint && pnpm test` (all existing tests pass; the styling change shouldn't break behavioral tests — if a test asserts a specific old color/class, update it to the token equivalent). `pnpm build`.
- [ ] **Step 4: Commit** `refactor(web): migrate feature screens to design tokens + radius token (#49)`.
---
# Task 3: Enforcement guard + final verification
**Files:** `web/scripts/check-no-raw-colors.mjs` (new), `web/package.json` (a `check:colors` script), wire into the gate.
- [ ] **Step 1: Guard script** `web/scripts/check-no-raw-colors.mjs` (mirror `check-bundle-size.mjs` style): recursively scan `web/src/**/*.{ts,tsx}` **excluding `src/components/ui/`**; fail (exit 1, printing each `file:line`) on any match of:
```
/(?:text|bg|border|ring|fill|stroke|from|to|via|decoration|outline|divide|placeholder)-(?:neutral|gray|slate|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/
```
Skip comments if practical; the goal is to catch real className usages. (It must NOT flag token utilities like `text-foreground`/`bg-primary` or numerics like `gap-2`.)
- [ ] **Step 2: Wire it in** — add `"check:colors": "node scripts/check-no-raw-colors.mjs"` to `web/package.json`; include it in the project's check/CI flow (e.g. the `.gitea/workflows` web job, or alongside `check:size`). Run it → it must **pass** now (Task 2 cleared the surface).
- [ ] **Step 3: Final verification:**
```
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. `pnpm test -- i18n` (parity unaffected). `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`. `git status --short` clean.
- [ ] **Step 4: Manual smoke (recommended):** run the app — buttons/links/selected rows/active nav share the indigo accent; visibility badges (success/warning/neutral) + search highlight use the status tokens; nothing renders an unstyled/transparent element from a removed color.
- [ ] **Step 5: Commit** `chore(web): add check:colors guard banning raw color utilities outside ui/ (#49)`.
---
## Self-Review (completed)
**Spec coverage:** indigo `--primary`/`--ring` + status tokens + `@theme` + `.dark` (T1 S1); Badge success/warning + VisibilityBadge + highlight + label-caption (T1 S2S4); ~120-utility migration + radius (T2); guard added last + gate (T3); CssCheck updated (T1 S5); dark-mode toggle out (#59), no behavior/layout change. ✓
**Placeholder scan:** concrete token values, badge variants, VisibilityBadge code, guard regex, and the explicit migration map + 27-file list. The CssCheck new value is "run to read" (the original story did the same — a genuine measurement step, not a placeholder). The few "ambiguous one-off" mappings are governed by the map's stated intent.
**Type/consistency:** `success`/`warning` Badge variants (T1) consumed by `VisibilityBadge` `VARIANT` map; `--color-success/warning/highlight` tokens (T1) back `bg-success`/`bg-warning`/`bg-highlight`; the guard regex (T3) matches exactly the palette utilities the migration (T2) removes.
## Notes
- No new dependency; CSS token churn only → `check:size` ≈ unchanged.
- The guard is the durable win — it makes the consistency self-enforcing (closes the loop that caused #49).
- If a behavioral test asserts an old raw class/color, update it to the token equivalent (don't weaken it).
@@ -0,0 +1,521 @@
# App Header Wayfinding 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:** Fill the empty app header with wayfinding — a route-driven breadcrumb (left), a signed-in user menu + compact global search (right) — and render the configured `app_name` for the brand + login.
**Architecture:** A page-driven breadcrumb (a `BreadcrumbProvider` context + `useBreadcrumb(trail)` hook, parallel to #57's `useDocumentTitle`) that each route sets and the header renders. A reusable `ui/menu.tsx` Base UI Menu wrapper powers a `UserMenu` (email/role + Sign out). A `HeaderSearch` input navigates to `/search?q=`. Brand + login read `useConfig().app_name`. No new dependency.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-router 7, react-i18next, Base UI (`@base-ui/react/menu` — namespace `Menu`), lucide-react, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; **ui/ files = no-semicolon base-nova style** (match `alert-dialog.tsx`); **app source (shell/, lib/, pages) = double-quote + semicolon**; stories = single-quote + no-semicolon; token classes only (`check:colors`); guard DOM globals.
**Spec:** `docs/superpowers/specs/2026-06-07-header-wayfinding-design.md`
**Key facts (verified):** `useMe()` (`api/queries.ts:30`) → `UserView | null` = `{ email, id, role }`. `useLogout()` (`queries.ts:129`). `useVocabularies()` (`queries.ts:258`) → `VocabularyView[]` with `.key` (the display name). Current logout flow in `app-shell.tsx`: `logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) })`. Base UI render-prop pattern: see `ui/alert-dialog.tsx` (namespace import, `data-slot`, `cn()`).
**File structure:**
- `web/src/components/ui/menu.tsx` (new) + `menu.stories.tsx` (new)
- `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new render component)
- `web/src/shell/user-menu.tsx` (new), `header-search.tsx` (new)
- Modify: `web/src/shell/app-shell.tsx`, `sidebar.tsx`, `auth/login-page.tsx`, the 9 page/detail components, `i18n/en.json`, `i18n/sv.json`, `shell/app-shell.test.tsx`, `auth/login-page.test.tsx`.
---
# Task 1: Render `app_name` for brand + login; remove dead `app.name` key
**Files:** `web/src/shell/sidebar.tsx`, `web/src/auth/login-page.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/auth/login-page.test.tsx`.
- [ ] **Step 1: Sidebar brand.** In `web/src/shell/sidebar.tsx` add `import { useConfig } from "../config/config-context";`, get `const { app_name } = useConfig();` in the component, and change line ~76:
`{!collapsed && <span className="font-semibold">{t("app.name")}</span>}`
`{!collapsed && <span className="font-semibold">{app_name}</span>}`.
- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `<h1>` (line ~38) to `{app_name}` and the title effect (line ~18) to `document.title = app_name;` with deps `[app_name]`. Remove the now-unused `t` for that purpose only if `t` is otherwise unused (check — login uses `t` for field labels/errors, so keep the `useTranslation` import).
- [ ] **Step 3: Remove the dead i18n key.** Delete the `"app": { "name": "..." }` entry from BOTH `web/src/i18n/en.json` and `web/src/i18n/sv.json` (grep first: `grep -rn 'app\.name\|"app"' web/src` — confirm no remaining `t("app.name")` after Steps 12). en/sv must stay in parity (remove from both).
- [ ] **Step 4: Update login test if needed.** Read `web/src/auth/login-page.test.tsx`. If it asserts the heading text via `t("app.name")` / "Collection", update it to the config default `"Collection Management System"` (the value `useConfig` returns in tests via `DEFAULTS`). Do NOT weaken; just match the new source.
- [ ] **Step 5: Verify (run vitest once for these files).**
`cd web && pnpm vitest run src/auth src/shell/app-shell.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS. The sidebar brand + login now show "Collection Management System" (config default) in tests.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/auth/login-page.test.tsx
git commit -m "feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)"
```
---
# Task 2: `ui/menu.tsx` Base UI Menu wrapper + story (validate by running)
**Files:** `web/src/components/ui/menu.tsx` (new), `web/src/components/ui/menu.stories.tsx` (new).
- [ ] **Step 1: Read the reference** `web/src/components/ui/alert-dialog.tsx` for the exact house pattern (namespace import, `data-slot`, `cn()`, no semicolons, token classes). The Base UI Menu API is `import { Menu } from "@base-ui/react/menu"` then `Menu.Root`, `Menu.Trigger`, `Menu.Portal`, `Menu.Positioner`, `Menu.Popup`, `Menu.Item`, `Menu.Separator`. **This is novel — you MUST validate the exact part tree by running the story (Step 3).**
- [ ] **Step 2: Implement** `web/src/components/ui/menu.tsx` (no-semicolon style). Export: `Menu` (Root re-export with data-slot), `MenuTrigger`, `MenuContent` (composes Portal + Positioner + Popup), `MenuItem`, `MenuSeparator`. Skeleton (adapt class/props to what runs):
```tsx
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
function Menu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menu" {...props} />
}
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
}
function MenuContent({
className,
sideOffset = 6,
align = "end",
...props
}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner sideOffset={sideOffset} align={align} className="z-50">
<MenuPrimitive.Popup
data-slot="menu-content"
className={cn(
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
data-slot="menu-item"
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator }
```
IMPORTANT: the exact prop names (`sideOffset`, `align`, `Popup` vs `Popup`+`Positioner` arrangement) MUST be confirmed against the installed `@base-ui/react` types — open `web/node_modules/@base-ui/react/menu/` or check via the editor/types and adjust. Do not guess; if a prop/part errors at typecheck or runtime, fix it to match the real API. No `data-[highlighted]` raw colors — `bg-accent`/`text-accent-foreground` are tokens (OK).
- [ ] **Step 3: Story** `web/src/components/ui/menu.stories.tsx` (single-quote, no-semicolon). Render a `Menu` with a `MenuTrigger` (a Button via `render` or as child) + `MenuContent` with two `MenuItem`s; a `play` test that opens the menu (click the trigger) and asserts an item is visible:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'
import { Button } from './button'
const meta = {
component: Menu,
tags: ['ai-generated'],
} satisfies Meta<typeof Menu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Menu>
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
<MenuContent>
<MenuItem>First</MenuItem>
<MenuSeparator />
<MenuItem>Second</MenuItem>
</MenuContent>
</Menu>
),
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
await expect(await canvas.findByText('First')).toBeInTheDocument()
},
}
```
If `MenuTrigger render={<Button/>}` isn't the right composition for Base UI Menu, use the pattern that works (e.g. `<MenuTrigger><Button/></MenuTrigger>` or `render` per the alert-dialog usage). The story passing IS the validation.
- [ ] **Step 4: Run the story-as-test + typecheck + lint.**
`cd web && pnpm vitest run src/components/ui/menu.stories.tsx && pnpm typecheck && pnpm lint`
Expected: PASS. If the menu doesn't open / portal isn't found, fix the part tree until the play test passes (this is the validate-by-running step). The portal renders to document.body — `findByText` on the canvas/body should find it; if the addon's `canvas` is scoped, query `within(document.body)` or use the screen — match how other portal-using stories (drawer/combobox/toast) assert.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/ui/menu.tsx web/src/components/ui/menu.stories.tsx
git commit -m "feat(web): ui/menu Base UI dropdown wrapper + story (#54)"
```
---
# Task 3: Breadcrumb infrastructure + mount in header + wire objects-page
**Files:** `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new), `web/src/shell/app-shell.tsx` (modify), `web/src/objects/objects-page.tsx` (modify), `web/src/shell/breadcrumb.test.tsx` (new).
- [ ] **Step 1: Context** `web/src/shell/breadcrumb-context.ts`:
```ts
import { createContext, useContext } from "react";
export type BreadcrumbItem = { label: string; to?: string };
type BreadcrumbContextValue = {
trail: BreadcrumbItem[];
setTrail: (trail: BreadcrumbItem[]) => void;
};
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
trail: [],
setTrail: () => {},
});
export function useBreadcrumbTrail(): BreadcrumbItem[] {
return useContext(BreadcrumbContext).trail;
}
```
- [ ] **Step 2: Provider** `web/src/shell/breadcrumb-provider.tsx`:
```tsx
import { useState, type ReactNode } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
return (
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
{children}
</BreadcrumbContext.Provider>
);
}
```
- [ ] **Step 3: Hook** `web/src/shell/use-breadcrumb.ts`:
```ts
import { useContext, useEffect } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
const { setTrail } = useContext(BreadcrumbContext);
const key = trail.map((i) => `${i.label}${i.to ?? ""}`).join("");
useEffect(() => {
setTrail(trail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, setTrail]);
}
```
NOTE on the disable: the effect intentionally depends on the serialized `key` (stable) instead of the `trail` array identity. **Do NOT add `eslint-disable` if the linter doesn't require it** — first try `[key, setTrail]` with no comment; only if `react-hooks/exhaustive-deps` errors on the missing `trail` dep, prefer refactoring (e.g. build the trail inside the effect from primitive args) over disabling. If a clean form isn't possible, a single scoped disable on that line is acceptable here (the serialization is the correct dep). Use judgment; document the choice in the commit.
- [ ] **Step 4: Render component** `web/src/shell/breadcrumb.tsx`:
```tsx
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() {
const trail = useBreadcrumbTrail();
if (trail.length === 0) return <div className="min-w-0 flex-1" />;
return (
<nav aria-label="Breadcrumb" className="flex min-w-0 flex-1 items-center gap-1 text-sm">
{trail.map((item, i) => {
const last = i === trail.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
{i > 0 && <span className="text-muted-foreground">/</span>}
{item.to && !last ? (
<Link to={item.to} className="truncate text-muted-foreground hover:text-foreground">
{item.label}
</Link>
) : (
<span className={last ? "truncate text-foreground" : "truncate text-muted-foreground"}>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}
```
(The empty-trail branch renders the `flex-1` spacer so the right-side controls stay right-aligned.)
- [ ] **Step 5: Mount in app-shell.** In `web/src/shell/app-shell.tsx`: wrap the inner `<div className="flex flex-1 flex-col">…</div>` (header+main) — actually wrap the whole returned tree's header+main region — in `<BreadcrumbProvider>`. Simplest: wrap the `return (<div className="flex min-h-screen">…)` content's right column. Concretely, import `BreadcrumbProvider` and `Breadcrumb`, and render `<BreadcrumbProvider>` around the `<div className="flex flex-1 flex-col">` (so both header and `<Outlet/>` are inside it). Replace the header's leading `<div className="flex-1" />` with `<Breadcrumb />` (which itself provides the flex-1). Leave ThemeSwitch/LangSwitch/Sign out as-is for now (Task 5/6 handle the right side).
- [ ] **Step 6: Wire objects-page** (proof of the pipe). In `web/src/objects/objects-page.tsx` add `import { useBreadcrumb } from "../shell/use-breadcrumb";` and call `useBreadcrumb([{ label: t("nav.objects") }]);` near the top (alongside the existing `useDocumentTitle`).
- [ ] **Step 7: Test** `web/src/shell/breadcrumb.test.tsx` — render the provider + a setter component + the Breadcrumb, assert it renders the crumbs and a non-leaf links:
```tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { renderApp } from "../test/render";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
import { useBreadcrumb } from "./use-breadcrumb";
function Setter() {
useBreadcrumb([
{ label: "Objects", to: "/objects" },
{ label: "LM-0042" },
]);
return null;
}
test("renders the trail with a link on non-leaf crumbs", async () => {
renderApp(
<BreadcrumbProvider>
<Breadcrumb />
<Setter />
</BreadcrumbProvider>,
);
const link = await screen.findByRole("link", { name: "Objects" });
expect(link).toHaveAttribute("href", "/objects");
expect(screen.getByText("LM-0042")).toBeInTheDocument();
});
```
(`renderApp` provides the Router so `<Link>` works.)
- [ ] **Step 8: Verify (vitest once).**
`cd web && pnpm vitest run src/shell src/objects/objects-page.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS (breadcrumb test + existing shell/objects tests). The objects-page test from #57 still passes; optionally assert the header crumb there too.
- [ ] **Step 9: Commit**
```bash
git add web/src/shell/breadcrumb-context.ts web/src/shell/breadcrumb-provider.tsx web/src/shell/use-breadcrumb.ts web/src/shell/breadcrumb.tsx web/src/shell/app-shell.tsx web/src/objects/objects-page.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "feat(web): page-driven breadcrumb context + header render + objects wiring (#54)"
```
---
# Task 4: Wire `useBreadcrumb` into the remaining routes
**Files (modify):** `web/src/objects/object-new-page.tsx`, `web/src/objects/object-detail.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/vocab/vocabularies-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
For each: add `import { useBreadcrumb } from "../shell/use-breadcrumb";` (verify depth: all these dirs are one level under `src/`, so `../shell/...` is correct) and call it near the top (after `useTranslation`). Reuse existing i18n keys.
- [ ] **Step 1: object-new-page**`useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);`
- [ ] **Step 2: object-detail** — in the inner `ObjectDetailLoaded({ object })` component (added in #57), add `useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number }]);` (it has `t` via `useTranslation` — add if missing). This covers `/objects/:id` AND `/search/:id` (reused).
- [ ] **Step 3: object-edit-form** — read the file; if it loads the object (has `object_number` + the `:id`), add `useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: object.object_number, to: \`/objects/${id}\` }, { label: t("actions.edit") }]);` in the loaded branch (split like ObjectDetail if it early-returns before data — same rules-of-hooks care). If it does NOT have the object loaded (only the form), use `[{ label: t("nav.objects"), to: "/objects" }, { label: t("actions.edit") }]`. Choose based on what the component actually has; don't add a fetch just for this.
- [ ] **Step 4: vocabularies-page**`useBreadcrumb([{ label: t("nav.vocabularies") }]);`
- [ ] **Step 5: vocabulary-terms** — it has only `id` (UUID). Add the vocab name via the cached list:
```tsx
import { useVocabularies } from "../api/queries";
// inside, after const { id } = useParams():
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" }],
);
```
(`useVocabularies()` is cache-shared with the vocabularies list — no extra request. `.key` is the display name, per `vocabulary-list.tsx`.) Place the hook BEFORE the existing `if (!id) return null;` early return.
- [ ] **Step 6: authorities-page**`useBreadcrumb([{ label: t("nav.authorities") }]);` (place before the `isValidKind` early return, like `useDocumentTitle`).
- [ ] **Step 7: fields-page**`useBreadcrumb([{ label: t("nav.fields") }]);`
- [ ] **Step 8: search-page**`useBreadcrumb([{ label: t("nav.search") }]);`
- [ ] **Step 9: Integration test.** Add a test (in `breadcrumb.test.tsx` or `objects-page.test.tsx`) that rendering a nested route shows the breadcrumb in the header. Easiest reliable one: at `/objects/new`, the header shows "Objects" (link → /objects) and "New". Use the existing app-shell/objects render setup (the route must render inside `AppShell` so the header + provider are present). If wiring a full-route render is heavy, assert via the objects route that the header `<nav aria-label="Breadcrumb">` contains the section label. Do not weaken; pick a route that reliably mounts AppShell.
- [ ] **Step 10: Verify (vitest once).**
`cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search src/shell && pnpm typecheck && pnpm lint`
Expected: PASS. Existing tests unaffected (breadcrumb context default is a no-op when no provider; inside AppShell the provider is present).
- [ ] **Step 11: Commit**
```bash
git add web/src/objects/object-new-page.tsx web/src/objects/object-detail.tsx web/src/objects/object-edit-form.tsx web/src/vocab/vocabularies-page.tsx web/src/vocab/vocabulary-terms.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/shell/breadcrumb.test.tsx
git commit -m "feat(web): set breadcrumb trails on all AppShell routes (#54)"
```
---
# Task 5: `UserMenu` + `HeaderSearch` components
**Files:** `web/src/shell/user-menu.tsx` (new), `web/src/shell/header-search.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`, plus tests `web/src/shell/user-menu.test.tsx` (new), `web/src/shell/header-search.test.tsx` (new).
- [ ] **Step 1: i18n** — add `"headerPlaceholder": "Search…"` to the `search` namespace in `en.json` and `"headerPlaceholder": "Sök…"` in `sv.json` (parity). (Confirm a `search` namespace exists; if not, add it in both.)
- [ ] **Step 2: UserMenu** `web/src/shell/user-menu.tsx`:
```tsx
import { CircleUser } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLogout, useMe } from "../api/queries";
import { Button } from "@/components/ui/button";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu";
export function UserMenu() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: me } = useMe();
const logout = useLogout();
const onSignOut = () =>
logout.mutate(undefined, {
onSuccess: () => navigate("/login", { replace: true }),
});
if (!me) return null;
return (
<Menu>
<MenuTrigger
render={
<Button variant="ghost" size="sm" className="max-w-44">
<CircleUser className="h-4 w-4" aria-hidden />
<span className="truncate">{me.email}</span>
</Button>
}
/>
<MenuContent>
<div className="px-2 py-1.5">
<div className="truncate text-sm font-medium">{me.email}</div>
<div className="text-xs text-muted-foreground">{me.role}</div>
</div>
<MenuSeparator />
<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>
</MenuContent>
</Menu>
);
}
```
Adjust `MenuTrigger`/`render` to the form Task 2 validated. The `MenuItem` action prop may be `onClick` or Base UI's `onClick`/`render` — match the wrapper. Ensure clicking it triggers `onSignOut`.
- [ ] **Step 3: HeaderSearch** `web/src/shell/header-search.tsx`:
```tsx
import { Search } from "lucide-react";
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input";
export function HeaderSearch() {
const { t } = useTranslation();
const navigate = useNavigate();
const [q, setQ] = useState("");
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const query = q.trim();
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={onSubmit} className="hidden sm:block">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("search.headerPlaceholder")}
aria-label={t("nav.search")}
className="w-48 pl-8 lg:w-64"
/>
</div>
</form>
);
}
```
- [ ] **Step 4: Tests.**
- `web/src/shell/user-menu.test.tsx`: render `<UserMenu/>` via `renderApp` with MSW returning a `me` user (reuse `web/src/test/handlers.ts`; if `/api/admin/me` isn't in handlers, add a handler or override per-test). Assert the email shows; open the menu; click Sign out → assert the logout POST fired (MSW) / navigation. Mirror how the existing `app-shell.test.tsx` tested sign-out. If asserting navigation is awkward, assert the logout request was made.
- `web/src/shell/header-search.test.tsx`: render `<HeaderSearch/>` via `renderApp`; type "amphora" + submit (Enter); assert navigation to `/search?q=amphora` (use a `MemoryRouter` location probe or render a small route tree that shows the location — mirror existing navigation tests; if none, render with a `*` route echoing `useLocation().search`).
- [ ] **Step 5: Verify (vitest once).**
`cd web && pnpm vitest run src/shell/user-menu.test.tsx src/shell/header-search.test.tsx && pnpm typecheck && pnpm lint`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/user-menu.tsx web/src/shell/header-search.tsx web/src/shell/user-menu.test.tsx web/src/shell/header-search.test.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): UserMenu (email/role + sign out) + HeaderSearch components (#54)"
```
---
# Task 6: Header assembly + app-shell test + final gate
**Files:** `web/src/shell/app-shell.tsx`, `web/src/shell/app-shell.test.tsx`.
- [ ] **Step 1: Assemble the header.** In `web/src/shell/app-shell.tsx`:
- Import `HeaderSearch` and `UserMenu`.
- Remove the standalone Sign out `<Button>` and the now-unused `onSignOut`/`useLogout`/`navigate`/`t` (the logout flow now lives in `UserMenu`). Keep imports only if still used.
- Header becomes:
```tsx
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
```
(`<Breadcrumb />` provides the `flex-1`; if both Breadcrumb's flex-1 and a spacer fight, ensure exactly one flex-1 between left and right — Breadcrumb already has `flex-1`, so no extra spacer.) Keep `BreadcrumbProvider` wrapping header+main (from Task 3).
- [ ] **Step 2: Update `app-shell.test.tsx`.** The Sign out button moved into `UserMenu` (a menu). Update the existing sign-out test: it must now open the user menu first, then click Sign out. Ensure `useMe` resolves a user in the test (MSW handler for `/api/admin/me`). If the test renders `AppShell` directly, the header now needs `me` + breadcrumb provider (provider is inside AppShell, fine). Don't weaken; adapt to the menu interaction. Keep the language-switch + nav-links tests.
- [ ] **Step 3: FULL GATE (single test pass — run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
Expected: all green. **Report the `check:size` value** — adding Base UI Menu to the always-loaded shell may increase the largest chunk. If it EXCEEDS 250 KB gz, STOP and report to the controller (do not raise the budget yourself). If under, report the number.
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
Expected: no codename matches.
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: header shows breadcrumb (left) that updates per route (Objects / New, Objects / {number}, Vocabularies / {key}); the user menu shows email/role + Sign out works; the search box navigates to /search?q=; brand + login show the configured app name; search hidden below sm.
- [ ] **Step 6: Commit**
```bash
git add web/src/shell/app-shell.tsx web/src/shell/app-shell.test.tsx
git commit -m "feat(web): assemble header — breadcrumb, search, user menu; remove standalone sign out (#54)"
```
---
## Self-Review (completed)
**Spec coverage:** app_name brand+login + dead-key removal (T1); ui/menu Base UI wrapper + validate-by-running (T2); breadcrumb context/provider/hook/render + header mount (T3) + all routes wired incl. object_number & vocab .key (T3/T4); UserMenu email/role/sign-out (T5) + HeaderSearch → /search?q= (T5); header assembly removing the standalone Sign out (T6); check:size reported/flagged (T6); tests for breadcrumb, menu story, user-menu, header-search, app-shell update; en/sv parity (one new key, one removed); no new dep. Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the Base UI Menu part tree/props are "confirm against installed types + validate by running" — a deliberate validation step (the primitive is novel), not a TODO; concrete skeleton + reference file given. object-edit-form trail is conditional on what the component already has (explicit branch). No vague steps. ✓
**Type consistency:** `BreadcrumbItem = { label: string; to?: string }` defined in T3, used by the hook (T3), render (T3), and all page trails (T3/T4); `useBreadcrumb(trail)` signature consistent; `useMe()``{email, role}` used in UserMenu (T5); `useVocabularies().key` used in T4. Menu exports (`Menu/MenuTrigger/MenuContent/MenuItem/MenuSeparator`) defined in T2, consumed in T5. ✓
## Notes
- No new dependency (Base UI + lucide already present); one new i18n key (`search.headerPlaceholder`), one removed (`app.name`).
- The breadcrumb mirrors #57's page-driven title pattern — pages now call both `useDocumentTitle` and `useBreadcrumb`; a future consolidation into one `usePageMeta` is possible but out of scope.
- `check:size` is the one budget risk (Menu in the shell) — measured and flagged in T6, not silently bumped.
- Validate-by-running (T2/T4) is mandatory for the novel Base UI Menu, per the established repo pattern (combobox/drawer/tooltip/toast).
@@ -0,0 +1,185 @@
# Object Detail Readability — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Make `web/src/objects/object-detail.tsx` readable: resolve term/authority ids → labels and `localized_text` → the active-language string, group flexible fields by `def.group` in definition order, and polish (date formatting, empty-core "—", an Edit/Delete actions toolbar).
**Architecture:** A new per-field `FlexibleFieldValue` component switches on `def.data_type` and resolves via the existing `useTerms`/`useAuthorities` + `labelText` (one hook call per component instance → rules-of-hooks safe; react-query dedups repeated vocabularies). `object-detail.tsx` iterates `definitions` (stable order) for grouping and renders core fields with placeholders. Frontend-only, no backend change.
**Tech Stack:** React 19 + TS + pnpm, react-i18next, TanStack Query, Vitest+RTL+MSW, Storybook 10.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity; no codename; tests query portals via `within(document.body)` (n/a here). `check:size` budget 180 KB gz (this is frontend-only, ~no bundle change).
**Spec:** `docs/superpowers/specs/2026-06-07-object-detail-readability-design.md`
**Facts:** flexible values in `object.fields` are: term/authority = UUID **string**, localized_text = `{lang: text}` **object**, others = primitive. `FieldDefinitionView` has `data_type`/`vocabulary_id`/`authority_kind`/`group`/`labels`/`key`. Helpers: `labelText(labels, lang)` (`web/src/lib/labels.ts`); hooks `useTerms(vocabularyId)` / `useAuthorities(kind)` (`web/src/api/queries.ts`). Core labels exist under `fieldsLabels.*`. `buttonVariants` is exported from `@/components/ui/button`. Test fixtures (`web/src/test/fixtures.ts`) have `fieldDefinitions` (covering all types), `materialTerms`, `personAuthorities`.
---
## Task 1: `FlexibleFieldValue` component + story + unit test
**Files:** create `web/src/objects/flexible-field-value.tsx`, `flexible-field-value.stories.tsx`, `flexible-field-value.test.tsx`. Modify `web/src/i18n/{en,sv}.json`.
- [ ] **Step 1: i18n keys.** Add to **both** locales: a `common` block `{ "yes": "Yes"/"Ja", "no": "No"/"Nej" }` and `objects.unknownRef` ("(unknown)" / "(okänd)").
- [ ] **Step 2: Write the component** `web/src/objects/flexible-field-value.tsx`:
```tsx
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useTerms, useAuthorities } from "../api/queries";
import { labelText } from "../lib/labels";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
/** Renders one flexible field value as human-readable text, resolving term/authority ids
* to labels and localized_text to the active language. */
export function FlexibleFieldValue({
def,
value,
lang,
}: {
def: FieldDefinitionView;
value: unknown;
lang: string;
}) {
switch (def.data_type) {
case "term":
return <TermValue vocabularyId={def.vocabulary_id} value={value} lang={lang} />;
case "authority":
return <AuthorityValue kind={def.authority_kind} value={value} lang={lang} />;
case "localized_text":
return <>{pickLocalized(value, lang)}</>;
case "date":
return <>{formatDate(value, lang)}</>;
case "boolean":
return <BooleanValue value={value} />;
default:
return <>{value == null ? "—" : String(value)}</>;
}
}
function TermValue({ vocabularyId, value, lang }: { vocabularyId: string | null; value: unknown; lang: string }) {
const { t } = useTranslation();
const { data: terms, isLoading } = useTerms(vocabularyId ?? undefined);
if (typeof value !== "string") return <>—</>;
const term = terms?.find((x) => x.id === value);
if (term) return <>{labelText(term.labels, lang)}</>;
if (isLoading) return <span className="text-neutral-400">…</span>;
return <span className="text-neutral-400">{value} {t("objects.unknownRef")}</span>;
}
function AuthorityValue({ kind, value, lang }: { kind: string | null; value: unknown; lang: string }) {
const { t } = useTranslation();
const { data: authorities, isLoading } = useAuthorities(kind ?? undefined);
if (typeof value !== "string") return <>—</>;
const authority = authorities?.find((x) => x.id === value);
if (authority) return <>{labelText(authority.labels, lang)}</>;
if (isLoading) return <span className="text-neutral-400">…</span>;
return <span className="text-neutral-400">{value} {t("objects.unknownRef")}</span>;
}
function BooleanValue({ value }: { value: unknown }) {
const { t } = useTranslation();
return <>{value ? t("common.yes") : t("common.no")}</>;
}
function pickLocalized(value: unknown, lang: string): string {
if (value && typeof value === "object" && !Array.isArray(value)) {
const map = value as Record<string, string>;
return map[lang] ?? map.en ?? Object.values(map)[0] ?? "—";
}
return value == null ? "—" : String(value);
}
function formatDate(value: unknown, lang: string): string {
if (typeof value !== "string") return value == null ? "—" : String(value);
// Parse as local midnight so a date-only value isn't shifted a day by tz when formatted.
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(lang, { dateStyle: "medium" }).format(date);
}
```
Confirm `useTerms`/`useAuthorities` accept `undefined` and short-circuit (they have `enabled: !!arg`) — yes; passing `undefined` disables the query and `data` is undefined → falls through to the `—`/`…` paths. Confirm `TermView`/`AuthorityView` have `id` + `labels` (they do).
- [ ] **Step 3: Unit test** `flexible-field-value.test.tsx` (RTL + MSW + a QueryClient wrapper; mirror existing component tests). Use fixtures `materialTerms`/`personAuthorities`/`fieldDefinitions`. Cover: a `term` def + a value that is a known term id → renders the label (e.g. "Bronze"); `authority` → label; an unknown term id → renders `<id> (unknown)`; `localized_text` `{sv:"…",en:"…"}` with lang sv → the sv string; `date` "2024-01-05" → a formatted date (assert it's not the raw ISO); `boolean` true → "Yes". MSW must serve `/api/admin/vocabularies/{id}/terms` and `/api/admin/authorities?kind=` (reuse `web/src/test/handlers.ts` patterns).
- [ ] **Step 4: Run the unit test.** `cd web && pnpm test -- flexible-field-value`. Iterate to green (genuine assertions — label not id; not vacuous).
- [ ] **Step 5: Storybook** `flexible-field-value.stories.tsx` (mirror `web/src/objects/visibility-badge.stories.tsx`): stories `Term`, `Authority`, `LocalizedText`, `Date`, `Boolean`, `UnknownRef`. The term/authority stories need the hooks' data — rely on the preview's MSW (`src/test/handlers.ts`) serving terms/authorities, passing a `def` with the matching `vocabulary_id`/`authority_kind` and a `value` that's a known id. Assert the resolved label text shows.
- [ ] **Step 6:** `pnpm typecheck && pnpm lint`. **Commit** `feat(web): FlexibleFieldValue — resolve term/authority/localized field values (#45)`.
---
## Task 2: Refactor `object-detail.tsx` (grouping, placeholders, toolbar) + tests
**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-detail.test.tsx` (create if absent).
- [ ] **Step 1: Failing/updated detail test.** In `object-detail.test.tsx` (RTL + MSW + MemoryRouter at `/objects/:id`, providers from the test harness), seed an object whose `fields` include a term (material → a known term id), a localized_text, and a date; assert the detail shows the **term label** (not the UUID), the **localized string** (not JSON), fields appear under **group subheadings** in definition order, an empty core field shows "—", and an **Edit** link/button points to `/objects/:id/edit`. Run → fails against the current JSON.stringify rendering.
- [ ] **Step 2: Refactor `object-detail.tsx`.**
- Update the local `Field` to take `value: React.ReactNode` and render "—" when the value is nullish/empty (instead of returning `null`) — so core fields are always shown:
```tsx
function Field({ label, value }: { label: string; value: React.ReactNode }) {
const empty = value === null || value === undefined || value === "";
return (
<div className="border-b py-2">
<div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div>
<div className="text-sm text-neutral-900">{empty ? "—" : value}</div>
</div>
);
}
```
- **Header → actions toolbar:** keep `object_name` (`<h2>`) + `VisibilityBadge` on the left; move Edit + Delete into a right-aligned toolbar. Make Edit a button-styled `Link`:
```tsx
import { buttonVariants } from "@/components/ui/button";
// ...
<div className="mb-4 flex items-center gap-3">
<h2 className="text-xl font-semibold">{object.object_name}</h2>
<VisibilityBadge visibility={object.visibility} />
<div className="ml-auto flex items-center gap-2">
<Link to={`/objects/${object.id}/edit`} className={buttonVariants({ size: "sm" })}>
{t("actions.edit")}
</Link>
<DeleteObjectDialog id={object.id} />
</div>
</div>
```
- **Core fields:** render the known core fields via `Field` (object number, count, brief description, current location, current owner, recorder, recording date). Format `recording_date` with the `formatDate` helper (import it from `flexible-field-value.tsx`, or duplicate the tiny helper — prefer exporting `formatDate` from the value module to keep one copy). They now always show (with "—").
- **Flexible fields grouped + ordered:** replace the `Object.entries(object.fields)` block with iteration over `definitions`:
```tsx
const OTHER = t("fields.other"); // existing key used by the field list; or add objects.otherGroup
const present = (definitions ?? []).filter((d) => object.fields[d.key] != null);
const groups: { group: string; defs: FieldDefinitionView[] }[] = [];
for (const d of present) {
const g = d.group?.trim() ? d.group : OTHER;
const bucket = groups.find((x) => x.group === g) ?? (groups.push({ group: g, defs: [] }), groups[groups.length - 1]);
bucket.defs.push(d);
}
// render: for each group → a subheading + each def as <Field label={labelFor(d.key)} value={<FlexibleFieldValue def={d} value={object.fields[d.key]} lang={lang} />} />
```
Keep the existing `labelFor(key)` helper (active-locale field label). Render a group subheading (reuse the uppercase caption style). Drop the old `JSON.stringify`/`typeof` logic entirely. Keep `PublishControl` below.
- (Confirm `fields.other` exists in i18n — the field-list screen uses it; if not, add `objects.otherGroup` to both locales.)
- [ ] **Step 3: Run tests.** `cd web && pnpm test -- object-detail flexible-field-value && pnpm typecheck && pnpm lint`. Green; the detail test now passes (labels, grouping, placeholder, Edit link).
- [ ] **Step 4: Commit** `feat(web): readable, grouped object detail (labels, placeholders, actions toolbar) (#45)`.
---
## Task 3: Final verification
- [ ] `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` — all green; bundle within 180 KB gz (frontend-only, ~no change).
- [ ] `pnpm test -- i18n` (en/sv parity for `common.yes`/`common.no`/`objects.unknownRef` [+ `objects.otherGroup` if added]); `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`; `git status --short` clean.
- [ ] **Manual smoke (recommended):** with the stack up + a seeded object that has a term/authority/localized field, open `/objects/:id` and confirm labels (not UUIDs/JSON), grouped sections, "—" for empty core fields, and the Edit/Delete toolbar.
---
## Self-Review (completed)
**Spec coverage:** value resolution per type + fallbacks → Task 1 (`FlexibleFieldValue` + sub-components); grouping/order + core placeholders + toolbar + date format → Task 2; story → Task 1 Step 5; tests → Task 1/2; i18n keys + parity + verification → Task 1 Step 1 / Task 3. ✓ Out of scope (export #39, form grouping, backend resolution) not included. ✓
**Placeholder scan:** concrete component + helper code given; the only "confirm X exists" notes (`fields.other`, hook `undefined` handling) are quick verifications against real code, not deferred work.
**Type consistency:** `FlexibleFieldValue({def, value, lang})` defined in Task 1, consumed in Task 2; `formatDate` exported from the value module and reused for `recording_date`; `labelText`/`useTerms`/`useAuthorities`/`buttonVariants` are existing exports.
## Notes
- No backend, no migration, no new dependency → no lockfile churn; bundle effectively unchanged.
- react-query dedups repeated `["terms", vocab]`/`["authorities", kind]` so multiple same-vocabulary term fields cause one fetch; often already warm from the table/combobox.
@@ -0,0 +1,372 @@
# Object Form Robustness 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:** Make the object create/edit form safe for long daily sessions — no double-submit, an unsaved-changes guard, one consistent partial-failure recovery, code-aware validation messages, and batch-entry ergonomics.
**Architecture:** Migrate to a React Router data router (enables `useBlocker`) keeping the route tree verbatim. The form is react-hook-form; `isSubmitting` (made real by returning the async `onSubmit` from `handleSubmit`) drives submit-disable, and `useBlocker(isDirty && !isSubmitting)` drives the dirty guard — so save-driven navigation is never falsely blocked and Cancel flows through the same dialog. `onSubmit` returns a success boolean so the form can reset for "Save & create another".
**Tech Stack:** React 19 + TS + pnpm, react-router-dom 7 (data router), react-hook-form, react-i18next, Base UI (alert-dialog), Vitest + RTL + MSW. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; ui/ no-semicolon, app source double-quote+semicolon; token classes only; guard DOM globals; **run tests exactly once per task.**
**Spec:** `docs/superpowers/specs/2026-06-07-object-form-robustness-design.md`
**Key facts (from the code):**
- `object-form.tsx`: RHF `useForm<FormShape>`; `submit = handleSubmit((data) => { onSubmit(...) })`**does not return the promise** (so `isSubmitting` never tracks it; fix in T2). Props: `mode/defaults/onSubmit/onCancel/formError/fieldErrorKey`. `coreField` renders `errors.core?.[key] && t("form.required")` always. `number_of_objects` registered via `coreField(..., { type: "number", required: true })`.
- `object-new-page.tsx`: `onSubmit` create→setFields; on setFields fail `navigate(\`/objects/${id}/edit\`, { state: { fieldsError, fieldErrorKey } })`; success → `/objects/${id}`.
- `object-edit-form.tsx`: split into `ObjectEditFormLoaded`; reads `location.state` (`fieldsError`/`fieldErrorKey`) to seed the banner; `onSubmit` update→setFields; on `FieldRejection` sets `fieldErrorKey` + banner, stays.
- `FieldRejection` carries `field` + `code`. `useCreateObject/useUpdateObject/useSetFields` expose `.isPending` (unused today).
- Router: `app.tsx` `<BrowserRouter><Routes>` (3 top-level siblings). `renderApp` wraps `ui` in `<MemoryRouter>` with no `<Routes>`.
---
# Task 1: Migrate to a data router (foundation)
**Files:** `web/src/app.tsx`, `web/src/test/render.tsx`. (Possibly `main.tsx` — only if needed; it should NOT need changes since `App` stays the exported component.)
- [ ] **Step 1: `app.tsx` → data router.** Convert the JSX route tree verbatim using `createRoutesFromElements`. Replace the `import { BrowserRouter, Navigate, Route, Routes }` with `import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom";`. Keep all the `lazy`/`Suspense` wrappers and every `<Route>` exactly as-is. New shape:
```tsx
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
{/* ...all the existing nested <Route> elements, verbatim... */}
</Route>
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</>,
),
);
export function App() {
return <RouterProvider router={router} />;
}
```
Do NOT change any path, element, Suspense, or nesting. (The `FormFallback` + lazy imports stay.)
- [ ] **Step 2: `test/render.tsx` → `createMemoryRouter`.** Replace `MemoryRouter` usage:
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import "../i18n";
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] });
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
```
This is behavior-preserving: `ui` renders at `route`; tests that include their own `<Routes>` still nest under the `*` route; now a data-router context exists (so `useBlocker` works later).
- [ ] **Step 3: Full suite must stay green (the migration gate). Run ONCE:**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build
```
Expected: ALL existing tests pass unchanged. If a test fails because it relied on `MemoryRouter`-specific behavior (e.g., asserting a redirect, or a component that rendered without its own `<Routes>` and needs params), investigate and fix the test's setup to the data-router equivalent WITHOUT weakening it. Report any test that needed adjustment and why. If many break, STOP and report (the migration approach may need a tweak) rather than mass-editing.
- [ ] **Step 4: Commit**
```bash
git add web/src/app.tsx web/src/test/render.tsx
git commit -m "refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)"
```
---
# Task 2: Submit-disable + keyboard submit + "Save & create another"
**Files:** `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, tests.
- [ ] **Step 1: i18n** — add to the `form` namespace in BOTH locales (parity):
- en: `"saving": "Saving…"`, `"createAnother": "Save & create another"`
- sv: `"saving": "Sparar…"`, `"createAnother": "Spara & skapa ny"`
- [ ] **Step 2: ObjectForm — make `isSubmitting` real + disable + the new button + Cmd/Ctrl+Enter.**
- Change the `onSubmit` prop type to: `onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise<boolean> | boolean;`
- Destructure `isSubmitting`: `const { register, handleSubmit, formState: { errors, isSubmitting } } = form;`
- Add a ref: `const createAnotherRef = useRef(false);`
- Rewrite `submit` to RETURN/await the promise (so RHF tracks it) and reset on create-another success:
```tsx
const submit = handleSubmit(async (data) => {
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
const values =
mode === "create"
? { core: data.core, visibility: data.visibility, fields }
: { core: data.core, fields };
const createAnother = createAnotherRef.current;
createAnotherRef.current = false;
const ok = await onSubmit(values, { createAnother });
if (ok && createAnother) {
form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} });
document.getElementById("object_number")?.focus();
}
});
```
- Add a keydown handler on the `<form>` for Cmd/Ctrl+Enter:
`onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); void submit(); } }}`
- Footer buttons:
```tsx
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={isSubmitting} onClick={() => (createAnotherRef.current = false)}>
{isSubmitting ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
</Button>
{mode === "create" && (
<Button
type="submit"
variant="secondary"
disabled={isSubmitting}
onClick={() => (createAnotherRef.current = true)}
>
{t("form.createAnother")}
</Button>
)}
<Button type="button" variant="ghost" disabled={isSubmitting} onClick={onCancel}>
{t("form.cancel")}
</Button>
</div>
```
(Add `useRef` to the React import. `variant="secondary"` — confirm it exists in `ui/button.tsx`; if not, use `variant="outline"` or default — check.)
- [ ] **Step 3: Update pages' `onSubmit` to return `boolean` + honor `createAnother`.**
- `object-new-page.tsx`:
```tsx
const onSubmit = async (values: ObjectFormValues, opts?: { createAnother?: boolean }): Promise<boolean> => {
setError(null);
let id: string;
try {
const created = await create.mutateAsync({ ...values.core, visibility: values.visibility ?? "draft" });
id = created.id;
} catch {
setError(t("form.rejected"));
return false;
}
if (Object.keys(values.fields).length > 0) {
try {
await setFields.mutateAsync({ id, fields: values.fields });
} catch (e) {
const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined;
const fieldErrorCode = e instanceof FieldRejection ? e.code : undefined;
navigate(`/objects/${id}/edit`, { state: { created: true, fieldErrorKey, fieldErrorCode } });
return true;
}
}
if (opts?.createAnother) return true; // success; ObjectForm resets, stays on /objects/new
navigate(`/objects/${id}`);
return true;
};
```
- `object-edit-form.tsx` `ObjectEditFormLoaded.onSubmit`: return `false` in the catch, `true` after the success navigate. (Edit mode never passes `createAnother`.)
- [ ] **Step 4: Tests.** Extend `object-form.test.tsx` / `object-new-page.test.tsx`:
- During an in-flight create (MSW delayed handler, or assert the button is `disabled` + shows "Saving…" synchronously after submit), the create mutation is called exactly once on a double-click. (If timing is hard, at minimum assert the button becomes `disabled` while submitting and reads `t("form.saving")`.)
- "Save & create another": click it in create mode with a delayed/immediate success handler → after success the form is reset (e.g., `object_number` input is empty) and the location is still `/objects/new` (no navigation to detail). Use the renderApp data-router harness; assert location via a probe or that the form is still present + cleared.
- Cmd/Ctrl+Enter triggers submit (fireEvent.keyDown with `{ key: "Enter", metaKey: true }` → the create mutation fires).
- [ ] **Step 5: Verify (vitest ONCE).** `cd web && pnpm vitest run src/objects && pnpm typecheck && pnpm lint`. Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/objects/object-form.tsx web/src/objects/object-new-page.tsx web/src/objects/object-edit-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-form.test.tsx web/src/objects/object-new-page.test.tsx
git commit -m "feat(web): disable submit while saving + Save & create another + Cmd/Ctrl+Enter (#46)"
```
---
# Task 3: Validation messages (server code echo, type-specific core errors, min count)
**Files:** `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, tests.
- [ ] **Step 1: i18n** — add to the `form` namespace (both locales, parity):
- en: `"minCount": "Must be at least 1"`, and a nested `"fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }`.
- sv: `"minCount": "Måste vara minst 1"`, `"fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }`.
- [ ] **Step 2: ObjectForm — carry the rejection `code` + type-specific messages.**
- Add prop `fieldErrorCode?: string | null;` (alongside `fieldErrorKey`).
- The highlight effect picks the code-specific message:
```tsx
useEffect(() => {
if (fieldErrorKey) {
const codeKey = fieldErrorCode ? `form.fieldError.${fieldErrorCode}` : "";
const message =
fieldErrorCode && t(codeKey) !== codeKey ? t(codeKey) : t("form.fieldRejected", { field: fieldErrorKey });
form.setError(`fields.${fieldErrorKey}` as never, { type: "server", message });
}
}, [fieldErrorKey, fieldErrorCode, form, t]);
```
- Core error render → message-aware (so `min` shows minCount, required falls back):
```tsx
{errors.core?.[key] && (
<p role="alert" className="text-xs text-destructive">
{errors.core[key]?.message || t("form.required")}
</p>
)}
```
- `number_of_objects` min: in `coreField`, when registering a number with required, also pass `min`. Simplest: special-case the count field by giving `coreField` an optional `min` and rendering. Concretely change the `number_of_objects` registration to include `min: { value: 1, message: t("form.minCount") }`. Implement by extending `coreField`'s `opts` with `min?: number` and, when set, register `{ valueAsNumber: true, required, min: { value: opts.min, message: t("form.minCount") } }`; call `coreField("number_of_objects", "count", { type: "number", required: true, min: 1 })`.
- [ ] **Step 3: Pass the code through the pages.**
- `object-edit-form.tsx`: in the `FieldRejection` catch, also `setFieldErrorCode(e.code)` (add a `fieldErrorCode` state) and pass `fieldErrorCode` to `<ObjectForm>`. Also seed it from `location.state.fieldErrorCode` (set by the create teleport). The banner stays `form.fieldRejected` (or upgrade to code-specific too — optional; the field highlight is the key UX).
- `<ObjectForm ... fieldErrorKey={fieldErrorKey} fieldErrorCode={fieldErrorCode} />`.
- [ ] **Step 4: Tests.** Extend `object-edit-form.test.tsx`:
- A `setFields` 422 with `{ field: "...", code: "type_mismatch" }` → the field shows the `form.fieldError.type_mismatch` message (assert the text).
- `number_of_objects` set to `0` and submit → the `form.minCount` message shows and NO create/update mutation is called (client-side block). (In `object-form.test.tsx` or a page test.)
- [ ] **Step 5: Verify (vitest ONCE).** `cd web && pnpm vitest run src/objects && pnpm typecheck && pnpm lint`. PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/objects/object-form.tsx web/src/objects/object-edit-form.tsx web/src/objects/object-new-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-edit-form.test.tsx web/src/objects/object-form.test.tsx
git commit -m "feat(web): code-aware field errors + min count validation (#46)"
```
---
# Task 4: Unsaved-changes guard
**Files:** `web/src/lib/use-unsaved-changes.tsx` (new), `web/src/objects/object-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/lib/use-unsaved-changes.test.tsx` (new) or extend object-form tests.
- [ ] **Step 1: i18n** — add a `form.unsaved` namespace (both locales, parity):
- en: `"unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" }`
- sv: `"unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" }`
- [ ] **Step 2: The hook + dialog** `web/src/lib/use-unsaved-changes.tsx`:
```tsx
import { useEffect } from "react";
import { useBlocker } from "react-router-dom";
export function useUnsavedChanges(active: boolean) {
const blocker = useBlocker(active);
useEffect(() => {
if (!active) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [active]);
return blocker;
}
```
And an `UnsavedChangesDialog` component (same file or a sibling) using `ui/alert-dialog`, driven by the blocker:
```tsx
import { useTranslation } from "react-i18next";
import type { Blocker } from "react-router-dom";
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from "@/components/ui/alert-dialog";
export function UnsavedChangesDialog({ blocker }: { blocker: Blocker }) {
const { t } = useTranslation();
const open = blocker.state === "blocked";
return (
<AlertDialog open={open} onOpenChange={(o) => { if (!o) blocker.reset?.(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("form.unsaved.title")}</AlertDialogTitle>
<AlertDialogDescription>{t("form.unsaved.body")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => blocker.reset?.()}>{t("form.unsaved.stay")}</AlertDialogCancel>
<AlertDialogAction onClick={() => blocker.proceed?.()}>{t("form.unsaved.leave")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialog>
</AlertDialog>
);
}
```
IMPORTANT: open `web/src/components/ui/alert-dialog.tsx` and match the EXACT exported part names/props (`AlertDialog` may take `open`/`onOpenChange`, or be trigger-driven — adapt to the real API; the delete dialogs in `web/src/objects/delete-object-dialog.tsx` / `components/delete-confirm-dialog.tsx` show the controlled usage to mirror). The dialog must be openable WITHOUT a trigger (controlled by `open`). Validate by running the test.
- [ ] **Step 3: Wire into ObjectForm.**
- `const isDirty = form.formState.isDirty;`
- `const blocker = useUnsavedChanges(isDirty && !isSubmitting);`
- Render `<UnsavedChangesDialog blocker={blocker} />` inside the form's container.
- **Cancel** now just calls `onCancel` (which navigates) — the blocker intercepts it and shows the dialog automatically when dirty. (No separate confirm needed; confirm this in the test.)
- [ ] **Step 4: Tests** `web/src/lib/use-unsaved-changes.test.tsx` (and/or extend object-form):
- Render a small component (or the ObjectForm) under the `renderApp` data-router harness with two routes; with a dirty form, click a `<Link>`/Cancel → the dialog appears; "Keep editing" stays (location unchanged), "Discard" proceeds (location changes).
- `beforeunload`: with `active=true`, a `beforeunload` event is registered (spy on `window.addEventListener`) and not when inactive.
- A clean form navigates without the dialog.
- Saving (isSubmitting true) does NOT block — simulate or assert via the `isDirty && !isSubmitting` condition (e.g., the blocker arg is false during submit).
- [ ] **Step 5: Verify (vitest ONCE).** `cd web && pnpm vitest run src/objects src/lib && pnpm typecheck && pnpm lint`. PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/use-unsaved-changes.tsx web/src/objects/object-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/lib/use-unsaved-changes.test.tsx
git commit -m "feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46)"
```
---
# Task 5: Partial-failure unification + final gate
**Files:** `web/src/objects/object-edit-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, tests.
(Task 2 already changed the create page to pass `state: { created: true, fieldErrorKey, fieldErrorCode }`. This task handles the edit page's reading of it + messaging.)
- [ ] **Step 1: i18n** — add (both locales, parity): en `"createdButFieldRejected": "Object created, but a field was rejected — fix it below."`; sv `"createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan."`.
- [ ] **Step 2: Edit page reads `created`.** In `ObjectEditFormLoaded`, broaden the `locationState` type to `{ created?: boolean; fieldsError?: boolean; fieldErrorKey?: string; fieldErrorCode?: string } | null` and seed the banner:
```tsx
const [error, setError] = useState<string | null>(() => {
if (locationState?.created) return t("form.createdButFieldRejected");
if (locationState?.fieldErrorKey) return t("form.fieldRejected", { field: locationState.fieldErrorKey });
if (locationState?.fieldsError) return t("form.rejected");
return null;
});
const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(locationState?.fieldErrorKey ?? null);
const [fieldErrorCode, setFieldErrorCode] = useState<string | null>(locationState?.fieldErrorCode ?? null);
```
(Keep backward compatibility: the create page from T2 sends `created`; older `fieldsError` branch can remain or be removed since T2 replaced it — remove `fieldsError` seeding if no longer sent.)
- [ ] **Step 3: Tests.** Update `object-new-page.test.tsx`: the create→setFields-422 path now navigates to `/objects/:id/edit` and the edit form shows the `form.createdButFieldRejected` banner + highlights the field. (The existing partial-failure test asserted the old `fieldsError` flow — update it to the new `created` message, not weakened.)
- [ ] **Step 4: 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.
- [ ] **Step 5: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 6: Manual smoke (recommended).** `pnpm dev`: create with a bad field → lands on edit with "Object created, but a field was rejected"; submit disables + "Saving…"; edit a field then try to leave (sidebar/Cancel/reload) → guard prompts; "Save & create another" resets; count 0 blocked client-side; Cmd+Enter submits.
- [ ] **Step 7: Commit**
```bash
git add web/src/objects/object-edit-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-new-page.test.tsx
git commit -m "feat(web): unify create/edit partial-failure recovery with 'created' banner (#46)"
```
---
## Self-Review (completed)
**Spec coverage:** data-router migration + harness, full suite green (T1); submit-disable via real `isSubmitting` + Cmd/Ctrl+Enter + Save-&-create-another (T2); code-aware field errors + type-specific core errors + min count (T3); unsaved-changes guard via `useBlocker(isDirty && !isSubmitting)` + beforeunload + dialog, Cancel through the blocker (T4); partial-failure unified to the edit route with a "created" banner (T5, building on T2's create-side state). All acceptance criteria 17 mapped. ✓
**Placeholder scan:** the alert-dialog wiring says "match the exact exported parts" with the delete dialogs named as the reference — a concrete adapt-to-real-API step, not a TODO. `variant="secondary"` flagged to verify against button.tsx. No vague steps; all code blocks complete. ✓
**Type/flow consistency:** `onSubmit` returns `Promise<boolean>|boolean` (T2) — both pages updated to return booleans; `createAnotherRef` gates the reset; `fieldErrorCode` prop added (T3) and threaded from both the edit catch and the create teleport state (T2/T5); the guard condition `isDirty && !isSubmitting` ensures save/teleport navigation (still submitting) is never blocked — consistent across T2/T4/T5. ✓
## Notes
- The single biggest correctness lever: `handleSubmit` must RETURN/await `onSubmit` so `isSubmitting` is real (T2) — both the submit-disable AND the guard's non-blocking-while-saving depend on it.
- `useBlocker(boolean)` (RR v7) blocks all nav when true; Cancel and sidebar links both flow through the one dialog. Save-driven nav happens while `isSubmitting` → condition false → not blocked.
- No new dependency. New i18n keys: `form.saving/createAnother/minCount/createdButFieldRejected` + `form.fieldError.*` + `form.unsaved.*` (en+sv parity). No keys removed.
@@ -0,0 +1,218 @@
# Toast Notifications + Consistent Mutation Feedback — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Add a Base UI toast system bridged to the out-of-React `QueryClient`, so every mutation gives consistent feedback — a per-mutation success toast (opt-in via `meta.successMessage`) and a catch-all error toast (unless `meta.suppressErrorToast`) — while keeping the existing inline 422/409 UX.
**Architecture:** A module-scope `createToastManager()` is passed to a `<ToastRegion>` (`Toast.Provider` + portaled viewport) mounted app-wide, and `.add()`-ed from a `MutationCache` on the `QueryClient` (`onError`/`onSuccess` read `mutation.meta` + `i18n.t` outside React). The 18 mutation hooks declare `meta`. `meta` is type-checked via a react-query `Register` augmentation.
**Tech Stack:** React 19 + TS + pnpm, `@base-ui/react` toast (already a dep), TanStack Query, react-i18next, Vitest+RTL+MSW, Storybook 10.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity; no codename; portal queries via `within(document.body)`; `check:size` ≤ 180 KB gz.
**Spec:** `docs/superpowers/specs/2026-06-07-toast-notifications-design.md`
**Base UI Toast facts (validated from the d.ts):** `createToastManager()``{ add(opts) => id, close, update, promise }` (works outside React; `add({ title?, description?, type?, timeout?, priority? })`, `type` is a free-form string, re-`add` with same `id` updates in place). `Toast.Provider` accepts `toastManager`. Render: `const { toasts } = Toast.useToastManager(); toasts.map(t => <Toast.Root toast={t}>…)`. Parts: Provider / Viewport / Portal / Positioner / Root(requires `toast` prop) / Title(`<h2>`) / Description(`<p>`) / Close(`<button>`) / Action / Arrow. Title/Description read the toast's title/description from `ToastRootContext` (no children needed — **verify by running**). The wrapper pattern to mirror is `web/src/components/ui/alert-dialog.tsx`.
---
# Task 1: Toast infrastructure (manager, region, MutationCache wiring, meta typing, i18n, story)
**Files:** create `web/src/toast/toast-manager.ts`, `web/src/components/ui/toast.tsx`, `web/src/components/ui/toast.stories.tsx`, `web/src/api/react-query.d.ts`; modify `web/src/main.tsx`, `web/src/i18n/{en,sv}.json`.
- [ ] **Step 1: Module-scope manager** `web/src/toast/toast-manager.ts`:
```ts
import { createToastManager } from "@base-ui/react/toast";
/** A toast manager created outside React so non-React code (the QueryClient
* MutationCache) can add toasts. Passed to <Toast.Provider toastManager=…>. */
export const toastManager = createToastManager();
```
- [ ] **Step 2: `ui/toast.tsx`** — wrap the Base UI Toast parts (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn()`), and export a `<ToastRegion>`:
```tsx
import { Toast as ToastPrimitive } from "@base-ui/react/toast";
import { cn } from "@/lib/utils";
import { toastManager } from "@/toast/toast-manager";
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager();
return toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
toast={toast}
data-slot="toast"
className={cn(
"flex items-start gap-2 rounded-md border bg-white p-3 text-sm shadow-md",
toast.type === "error" && "border-red-300",
)}
>
<div className="flex-1">
{toast.title && <ToastPrimitive.Title data-slot="toast-title" className="font-medium" />}
<ToastPrimitive.Description data-slot="toast-description" className="text-neutral-700" />
</div>
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close"
className="text-neutral-400 hover:text-neutral-700"
>
×
</ToastPrimitive.Close>
</ToastPrimitive.Root>
));
}
/** App-wide toast region: provides the external manager + a portaled viewport. */
export function ToastRegion({ children }: { children: React.ReactNode }) {
return (
<ToastPrimitive.Provider toastManager={toastManager}>
{children}
<ToastPrimitive.Portal>
<ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
<ToastList />
</ToastPrimitive.Viewport>
</ToastPrimitive.Portal>
</ToastPrimitive.Provider>
);
}
```
**Validate by running** (first toast in the repo): confirm `Title`/`Description` auto-render the toast's `title`/`description` from context (if they DON'T, pass `{toast.title}`/`{toast.description}` as children); confirm `Viewport`/`Positioner` nesting (Base UI may require a `Toast.Positioner` inside the viewport per toast — adjust to the real API when the story runs). Keep the styled-by-`type` distinction.
- [ ] **Step 3: meta typing** `web/src/api/react-query.d.ts`:
```ts
import "@tanstack/react-query";
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: {
/** i18n key for a success toast (opt-in). */
successMessage?: string;
/** i18n key overriding the default error toast message. */
errorMessage?: string;
/** Skip the global error toast (the component shows the error inline). */
suppressErrorToast?: boolean;
};
}
}
```
- [ ] **Step 4: Wire the `MutationCache`** in `web/src/main.tsx`. Import the manager, the i18n instance (the configured singleton — confirm the default export of `web/src/i18n`; the app already does `import "./i18n"`), and the typed errors:
```tsx
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "./i18n";
import { toastManager } from "./toast/toast-manager";
import { ToastRegion } from "./components/ui/toast";
import { InUseError, HttpError } from "./api/queries";
import type { MutationMeta } from "@tanstack/react-query"; // for the helper's param type
function mutationErrorMessage(error: unknown, meta: MutationMeta | undefined): string {
if (meta?.errorMessage) return i18n.t(meta.errorMessage);
if (error instanceof InUseError) return i18n.t("actions.inUse", { count: error.count });
if (error instanceof HttpError && error.status === 503) return i18n.t("search.unavailable");
return i18n.t("toast.error");
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
mutationCache: new MutationCache({
onError: (error, _vars, _ctx, mutation) => {
if (mutation.meta?.suppressErrorToast) return;
toastManager.add({ type: "error", description: mutationErrorMessage(error, mutation.meta) });
},
onSuccess: (_data, _vars, _ctx, mutation) => {
if (mutation.meta?.successMessage) {
toastManager.add({ type: "success", description: i18n.t(mutation.meta.successMessage) });
}
},
}),
});
```
And mount the region around `<App/>`:
```tsx
<QueryClientProvider client={queryClient}>
<ConfigProvider>
<ToastRegion>
<App />
</ToastRegion>
</ConfigProvider>
</QueryClientProvider>
```
(If `i18n` has no default export, import the instance it does export, or `import i18n from "i18next"` only if that's the configured instance — use whatever `web/src/i18n` exports; the goal is the *configured* instance so `t` resolves the app's keys/language.)
- [ ] **Step 5: i18n** — add a `toast` namespace to **both** `en.json` + `sv.json`:
`{ "created": "Created"/"Skapat", "saved": "Saved"/"Sparat", "updated": "Updated"/"Uppdaterat", "deleted": "Deleted"/"Borttaget", "renamed": "Renamed"/"Namn ändrat", "published": "Visibility updated"/"Synlighet uppdaterad", "error": "Something went wrong"/"Något gick fel" }`.
- [ ] **Step 6: Story** `web/src/components/ui/toast.stories.tsx` — render `<ToastRegion>` and, in `play`, call `toastManager.add({ type: "success", description: "Saved" })` (and an error one), asserting the toast text appears (portal → `within(document.body)`). Mirror the established story format. This is the **validation** that the Base UI composition is correct — iterate until green.
- [ ] **Step 7:** `cd web && pnpm test -- toast && pnpm typecheck && pnpm lint`. The toast must actually render. **Commit** `feat(web): Base UI toast region + global mutation feedback wiring (#47)`.
---
# Task 2: Declare `meta` on the mutation hooks + integration tests
**Files:** `web/src/api/queries.ts`; a test (e.g. `web/src/objects/publish-control.test.tsx` or a new `web/src/api/mutation-feedback.test.tsx`).
Add a `meta` option to each `useMutation({...})` per the rule:
- **`meta.successMessage`** (a `toast.*` key) on every discrete user action.
- **`meta.suppressErrorToast: true`** on mutations whose consuming component **already renders the error inline** (so no double-report).
| Hook | `successMessage` | `suppressErrorToast` | Why suppress |
|---|---|---|---|
| `useCreateObject` | `toast.created` | yes | object form shows `form.rejected` inline |
| `useUpdateObject` | `toast.saved` | yes | object form inline |
| `useSetFields` | — (the create/update toast covers the save) | yes | 422 field-highlight inline; no own success toast to avoid a double "saved" |
| `useDeleteObject` | `toast.deleted` | yes | `DeleteObjectDialog` shows error inline |
| `useSetVisibility` | `toast.published` | yes | `publish-control` shows error inline |
| `useLogin` | — | yes | login page shows error inline |
| `useLogout` | — | — | fire-and-forget |
| `useCreateVocabulary` | `toast.created` | yes | vocab create form shows `form.rejected` |
| `useRenameVocabulary` | `toast.renamed` | yes | vocab rename shows `form.rejected` |
| `useDeleteVocabulary` | `toast.deleted` | yes | delete dialog inline (409) |
| `useAddTerm` | `toast.created` | yes | add-term form inline |
| `useUpdateTerm` | `toast.saved` | **no** | TermRow has no inline error → let the toast be the feedback |
| `useDeleteTerm` | `toast.deleted` | yes | delete dialog inline |
| `useCreateAuthority` | `toast.created` | yes | authority create form inline |
| `useUpdateAuthority` | `toast.saved` | **no** | AuthorityRow has no inline error |
| `useDeleteAuthority` | `toast.deleted` | yes | delete dialog inline |
| `useCreateFieldDefinition` | `toast.created` | yes | field form inline |
| `useUpdateFieldDefinition` | `toast.saved` | **no** | field-form edit may lack inline error |
| `useDeleteFieldDefinition` | `toast.deleted` | yes | delete dialog inline |
- [ ] **Step 1: VERIFY the suppress column per component.** For each hook, open its consumer and check whether it visibly renders `isError`/catches+shows the error. Set `suppressErrorToast` **iff** it does. (The table is the expected mapping; correct any row that doesn't match the actual component — the principle governs: suppress only when the error is already shown inline. Update the "Why" if you change a row.)
- [ ] **Step 2: Add `meta` to each hook.** E.g.:
```ts
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => { ... },
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
```
Leave `mutationFn`/`onSuccess` unchanged; only add the `meta` line.
- [ ] **Step 3: Integration test** (`mutation-feedback.test.tsx`, RTL + MSW + the `renderApp` harness incl. `<ToastRegion>` — ensure the test render tree wraps with `ToastRegion` + the real `queryClient` MutationCache; if `renderApp` doesn't, add a variant that does, or test via `main`-equivalent providers):
- **Success:** perform a create-vocabulary action (or call a `meta.successMessage` mutation) → a "Created" toast appears (`within(document.body)`).
- **Error (catch-all):** MSW returns 500 for a non-suppressed mutation (e.g. `useUpdateTerm`) → an error toast appears.
- **Suppressed:** a `suppressErrorToast` mutation failing → **no** toast added (and its inline error still shows). Assert the toast region has no error toast.
- (Testing the MutationCache requires the real cache — construct a `QueryClient` with the same `mutationCache` config in the test wrapper, or export a `makeQueryClient()` factory from a shared module and use it in both `main.tsx` and tests. Prefer extracting the cache config into a small `web/src/api/query-client.ts` factory to avoid duplicating it in tests — do this if it keeps the test honest.)
- [ ] **Step 4:** `cd web && pnpm test && pnpm typecheck && pnpm lint`. All green. **Commit** `feat(web): per-mutation success/error toast metadata (#47)`.
---
# Task 3: Final verification
- [ ] `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` — all green; index ≤ 180 KB gz (Base UI toast adds to the always-loaded shell; report the number — if it pushes over, lazy-load is hard for a global region, so flag for a budget decision).
- [ ] `pnpm test -- i18n` (en/sv parity for `toast.*`); `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`; `git status --short` clean.
- [ ] **Manual smoke (recommended):** with the stack up, create a vocabulary → "Created" toast; trigger a failure (e.g. duplicate key) → error toast or the existing inline message (no double); delete a term in use → the dialog's "used by N" (no extra toast).
---
## Self-Review (completed)
**Spec coverage:** Base UI toast region + external manager (T1 S1S2, S6); global MutationCache onError catch-all + onSuccess meta-driven (T1 S4); meta typing (T1 S3); per-mutation meta (T2); inline 422/409 kept (suppress flags, T2); toast i18n + parity (T1 S5, T3); story (T1 S6); verification/bundle (T3). ✓ Out of scope (replace inline UX, undo/queued, read-error toasts) not included. ✓
**Placeholder scan:** concrete code for manager/region/cache/typing; the Base UI Title/Description auto-render + viewport nesting carry an explicit "validate by running" (novel primitive); the suppress mapping is a concrete table with a governing principle + a per-component verification step (not vague).
**Type consistency:** `meta` shape declared once (`react-query.d.ts`) and consumed in the MutationCache (T1) + set on hooks (T2); `mutationErrorMessage` uses the exported `InUseError`/`HttpError`; `toast.*` keys used in both the cache helper and the hook `meta`.
## Notes
- No new dependency (`@base-ui/react` present); bundle grows only by the toast primitive in the always-loaded region — watch `check:size` (budget 180).
- Re-`add` with the same id de-dupes/refreshes a toast — not used now, available if repeated errors get noisy.
- Extracting a `makeQueryClient()` factory (used by `main.tsx` + tests) keeps the toast wiring testable without duplicating the MutationCache config.
@@ -0,0 +1,374 @@
# Typography Hierarchy + Page `<h1>` + Per-Route `document.title` 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 AppShell route a consistent semantic page `<h1>` and a distinct browser-tab title (`"{Page} | {AppName}"`), via a small `PageTitle` component and a `useDocumentTitle` hook, and fix the one misused `<h3>` caption.
**Architecture:** A presentational `PageTitle` (`ui/page-title.tsx`) renders the styled `<h1>`. A `useDocumentTitle(page)` hook (`lib/`) composes `"{page} | {app_name}"` (app_name from `useConfig`), sets `document.title`, and restores the prior title on unmount — which lets a master-detail detail pane override the tab to the object's `object_number` and revert on close. Pages reuse existing i18n keys; no new strings, no new dependency.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-i18next, react-router 7, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; reuse existing i18n keys (en/sv already in parity); source double-quote/semicolon, stories single-quote/no-semicolon; token classes only; **do not restructure layout/columns — only add the heading element + title hook**; guard `document` for jsdom.
**Spec:** `docs/superpowers/specs/2026-06-07-typography-page-titles-design.md`
**File structure:**
- `web/src/components/ui/page-title.tsx` (new) — `<PageTitle>` h1.
- `web/src/components/ui/page-title.stories.tsx` (new) — story.
- `web/src/lib/use-document-title.ts` (new) — the hook.
- `web/src/lib/use-document-title.test.tsx` (new) — hook test.
- Modify pages: `web/src/objects/objects-page.tsx`, `web/src/objects/object-new-page.tsx`,
`web/src/vocab/vocabularies-page.tsx`, `web/src/authorities/authorities-page.tsx`,
`web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
- Modify `web/src/objects/object-detail.tsx` (detail title override).
- Modify `web/src/vocab/vocabulary-terms.tsx` (h3→div caption fix).
- Modify `web/src/auth/login-page.tsx` (document.title = app.name).
> NOTE: exact page file paths — verify with `git ls-files web/src | grep -E 'objects-page|object-new|vocabularies-page|authorities-page|fields-page|search-page|object-detail|vocabulary-terms|login-page'` before editing; the directory names above are from exploration but confirm.
---
# Task 1: `PageTitle` component + story
**Files:**
- Create: `web/src/components/ui/page-title.tsx`
- Create: `web/src/components/ui/page-title.stories.tsx`
- [ ] **Step 1: Implement** `web/src/components/ui/page-title.tsx`:
```tsx
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
export function PageTitle({ className, ...props }: ComponentProps<"h1">) {
return (
<h1
data-slot="page-title"
className={cn("text-2xl font-semibold tracking-tight", className)}
{...props}
/>
);
}
```
Confirm the `cn` import path matches the other `ui/*` files (open `web/src/components/ui/button.tsx`
and copy its exact `cn` import — expected `@/lib/utils`).
- [ ] **Step 2: Write the story** `web/src/components/ui/page-title.stories.tsx`:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { PageTitle } from './page-title'
const meta = {
component: PageTitle,
args: { children: 'Objects' },
tags: ['ai-generated'],
} satisfies Meta<typeof PageTitle>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByRole('heading', { level: 1, name: 'Objects' })).toBeInTheDocument()
},
}
```
(Match the house story style — single quotes, no semicolons — as in `web/src/components/label-editor.stories.tsx`. Adjust the `Meta` import if that file imports it differently.)
- [ ] **Step 3: Run the story-as-test + typecheck + lint**
Run: `cd web && pnpm vitest run src/components/ui/page-title.stories.tsx && pnpm typecheck && pnpm lint`
Expected: PASS, clean.
- [ ] **Step 4: Commit**
```bash
git add web/src/components/ui/page-title.tsx web/src/components/ui/page-title.stories.tsx
git commit -m "feat(web): PageTitle h1 component + story (#57)"
```
---
# Task 2: `useDocumentTitle` hook + test
**Files:**
- Create: `web/src/lib/use-document-title.ts`
- Create: `web/src/lib/use-document-title.test.tsx`
- [ ] **Step 1: Confirm the config hook.** Open `web/src/config/config-context.ts` and confirm the
exported hook name and that it returns `app_name` (expected `useConfig()``{ app_name, ... }`).
Use the exact import path/name in the hook below.
- [ ] **Step 2: Write the failing test** `web/src/lib/use-document-title.test.tsx`:
```tsx
import { afterEach, expect, test } from "vitest";
import { render } from "@testing-library/react";
import "../i18n";
import { ConfigProvider } from "../config/config-provider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useDocumentTitle } from "./use-document-title";
function Titled({ page }: { page: string }) {
useDocumentTitle(page);
return null;
}
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<ConfigProvider>{ui}</ConfigProvider>
</QueryClientProvider>,
);
}
afterEach(() => {
document.title = "";
});
test("sets document.title to '{page} | {app_name}'", () => {
wrap(<Titled page="Objects" />);
expect(document.title).toMatch(/^Objects \| .+/);
});
test("restores the previous title on unmount", () => {
document.title = "Prev";
const { unmount } = wrap(<Titled page="Objects" />);
expect(document.title).toMatch(/^Objects \| /);
unmount();
expect(document.title).toBe("Prev");
});
```
NOTE: this assumes `ConfigProvider` supplies a default `app_name` synchronously (the spec says
`useConfig` defaults to `"Collection Management System"` before `/api/config` resolves). If
`ConfigProvider` needs MSW for `/api/config`, instead import `handlers` from `../test/handlers` and
set up an MSW server in the test (mirror an existing test that uses ConfigProvider). If a simpler
existing wrapper exists (check `web/src/test/render.tsx` — does `renderApp` include ConfigProvider?),
prefer that. Do NOT weaken the assertions; adapt the wrapper to provide config.
- [ ] **Step 3: Run to verify it fails**
Run: `cd web && pnpm vitest run src/lib/use-document-title.test.tsx`
Expected: FAIL — cannot import `useDocumentTitle`.
- [ ] **Step 4: Implement** `web/src/lib/use-document-title.ts`:
```ts
import { useEffect } from "react";
import { useConfig } from "../config/config-context";
export function useDocumentTitle(page: string): void {
const { app_name } = useConfig();
useEffect(() => {
if (typeof document === "undefined") return;
const previous = document.title;
document.title = `${page} | ${app_name}`;
return () => {
document.title = previous;
};
}, [page, app_name]);
}
```
(Adjust the `useConfig` import path/name to match what Step 1 found.)
- [ ] **Step 5: Run to verify it passes**
Run: `cd web && pnpm vitest run src/lib/use-document-title.test.tsx`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/use-document-title.ts web/src/lib/use-document-title.test.tsx
git commit -m "feat(web): useDocumentTitle hook (restores prior title on unmount) (#57)"
```
---
# Task 3: Wire `<PageTitle>` + `useDocumentTitle` into the list/form pages
**Files (modify):** `web/src/objects/objects-page.tsx`, `web/src/objects/object-new-page.tsx`,
`web/src/vocab/vocabularies-page.tsx`, `web/src/authorities/authorities-page.tsx`,
`web/src/fields/fields-page.tsx`, `web/src/search/search-page.tsx`.
For EACH page: read it first, add the imports
`import { PageTitle } from "@/components/ui/page-title";` (match the file's import style) and
`import { useDocumentTitle } from "../lib/use-document-title";` (verify the relative depth), call the
hook near the top of the component, and render `<PageTitle>` at the top of the page's content. Use the
i18n key from the table. **Do not restructure existing layout/columns** — only add the heading
element (and, if the page already has a top action row, place `<PageTitle>` on its left).
| File | i18n key | Notes |
|---|---|---|
| `objects-page.tsx` | `nav.objects` | Page already has a toolbar (filter, New button, pagination). Put `<PageTitle>` at the top-left of that toolbar row, or in a small header row above the table. |
| `object-new-page.tsx` | `objects.new` | Form page; `<PageTitle>` above the form. |
| `vocabularies-page.tsx` | `nav.vocabularies` | Two-column; `<PageTitle>` above the columns (full width). |
| `authorities-page.tsx` | `nav.authorities` | Tabbed; `<PageTitle>` above the tabs. |
| `fields-page.tsx` | `fields.title` | Two-column; `<PageTitle>` above the columns. |
| `search-page.tsx` | `nav.search` | Two-column; `<PageTitle>` above the columns. |
- [ ] **Step 1: objects-page.tsx** — add imports, `const { t } = useTranslation()` (it likely already
has `t`), `useDocumentTitle(t("nav.objects"))`, and render `<PageTitle>{t("nav.objects")}</PageTitle>`
at the top of the content. Keep all existing markup.
- [ ] **Step 2: object-new-page.tsx** — same pattern with `objects.new`.
- [ ] **Step 3: vocabularies-page.tsx** — same with `nav.vocabularies`.
- [ ] **Step 4: authorities-page.tsx** — same with `nav.authorities`.
- [ ] **Step 5: fields-page.tsx** — same with `fields.title`.
- [ ] **Step 6: search-page.tsx** — same with `nav.search`.
- [ ] **Step 7: Add/extend a page test.** In the existing test for objects (or create
`web/src/objects/objects-page.test.tsx` if none — check first), assert the `<h1>` and title:
```tsx
test("renders the page heading and sets the document title", async () => {
renderApp(/* the objects page route, mirroring the existing objects test setup */);
expect(await screen.findByRole("heading", { level: 1, name: /objects/i })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/objects \| /i));
});
```
If an objects page/integration test already exists, ADD this assertion there using the same render
setup rather than duplicating the harness. Do not weaken existing assertions.
- [ ] **Step 8: Run the affected tests + typecheck + lint**
Run: `cd web && pnpm vitest run src/objects src/vocab src/authorities src/fields src/search && pnpm typecheck && pnpm lint`
Expected: PASS (existing tests unaffected by the added heading; the new assertion passes). If any
existing test breaks because a heading query is now ambiguous, investigate — the page `<h1>` text
should be distinct from row/cell content; do NOT weaken the test.
- [ ] **Step 9: Commit**
```bash
git add web/src/objects/objects-page.tsx web/src/objects/object-new-page.tsx web/src/vocab/vocabularies-page.tsx web/src/authorities/authorities-page.tsx web/src/fields/fields-page.tsx web/src/search/search-page.tsx web/src/objects/*.test.tsx
git commit -m "feat(web): page <h1> + document.title on list/form routes (#57)"
```
---
# Task 4: Detail-pane title override, caption fix, login title + final gate
**Files (modify):** `web/src/objects/object-detail.tsx`, `web/src/vocab/vocabulary-terms.tsx`,
`web/src/auth/login-page.tsx`.
- [ ] **Step 1: Object detail title override.** In `web/src/objects/object-detail.tsx`, after the
object has loaded, call `useDocumentTitle(object.object_number)`. The hook must receive a real value
— only call it once `object` is loaded. Because hooks can't be conditional, call it unconditionally
with a guarded value, e.g.:
```tsx
import { useDocumentTitle } from "../lib/use-document-title";
// ... inside the component, AFTER object data is available:
useDocumentTitle(object?.object_number ?? "");
```
But setting `" | App"` (empty page) while loading is undesirable. Prefer: keep the hook call
unconditional but pass the object_number only when loaded, and make the component not render/return
early before the data is present (check the existing structure — `object-detail.tsx` likely already
early-returns a loading/skeleton state). If it early-returns BEFORE the hook, that violates rules-of-
hooks. Resolve by either: (a) calling `useDocumentTitle(object_number)` only in the loaded branch by
splitting the component into an outer (fetch + loading) and an inner `ObjectDetailLoaded({ object })`
that calls the hook — RECOMMENDED; or (b) guarding inside the hook usage so loading sets nothing.
Choose (a): create an inner component that receives the loaded `object` and calls
`useDocumentTitle(object.object_number)`; the outer handles loading. Keep `object_name` as the
existing `<h2>`.
Verify `object_number` is the right field (read the component / the `AdminObjectView`/object type).
- [ ] **Step 2: Caption fix.** In `web/src/vocab/vocabulary-terms.tsx` around line 52, change the
`<h3 className="mb-2 label-caption">…</h3>` to `<div className="mb-2 label-caption">…</div>` (same
className/content; only the element changes). Confirm there is no CSS/test depending on the `h3`.
- [ ] **Step 3: Login title.** In `web/src/auth/login-page.tsx`, add a small effect:
```tsx
import { useEffect } from "react";
// ... inside the component (t from useTranslation already present):
useEffect(() => {
document.title = t("app.name");
}, [t]);
```
(Do not use `useDocumentTitle` here — login is pre-auth/standalone.)
- [ ] **Step 4: Detail-override test.** Add (or extend an existing object-detail test) asserting the
document title reflects the object on the detail route and reverts on unmount. Mirror the existing
object-detail test's render/MSW setup (reuse `web/src/test/handlers.ts` + fixtures):
```tsx
test("object detail sets the tab title to the object number and reverts", async () => {
document.title = "Base";
const { unmount } = renderApp(/* object detail route, per the existing detail test */);
await waitFor(() => expect(document.title).toMatch(/<expected object_number from fixture> \| /));
unmount();
expect(document.title).toBe("Base");
});
```
Use the actual `object_number` from the existing object fixture (read `web/src/test/fixtures.ts`
the `amphora` fixture). If reverting-on-unmount is awkward to assert through the full route, assert
the override (title contains the object_number) at minimum; do not weaken below that.
- [ ] **Step 5: FULL GATE (single test pass — run tests exactly ONCE):**
Run:
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
Expected: all green. Report test totals, largest chunk, check:colors line.
- [ ] **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 codename matches.
- [ ] **Step 7: Manual smoke (recommended).** `pnpm dev`: each route shows a page `<h1>`; the tab title
reads "{Page} | {AppName}"; opening an object changes the tab to the object number and closing it
reverts; exactly one `<h1>` per page.
- [ ] **Step 8: Commit**
```bash
git add web/src/objects/object-detail.tsx web/src/vocab/vocabulary-terms.tsx web/src/auth/login-page.tsx web/src/objects/*.test.tsx
git commit -m "feat(web): object-detail tab title, caption element fix, login title (#57)"
```
---
## Self-Review (completed)
**Spec coverage:** PageTitle h1 component (T1); useDocumentTitle hook with restore-on-unmount (T2);
per-route h1 + title on objects/object-new/vocabularies/authorities/fields/search reusing existing
keys (T3); detail-pane `object_number` override + revert (T4 S1); h3→div caption fix (T4 S2); login
title (T4 S3); one h1 per page preserved (list owns h1, detail keeps h2 — T3/T4); tests for component,
hook, page, and override (T1/T2/T3/T4); gate + no codename + no new dep + no new strings (T4). All
acceptance criteria 16 mapped. ✓
**Placeholder scan:** the only non-literal spots are render-harness reuse ("mirror the existing test
setup") and the object fixture's `object_number` ("read fixtures.ts") — these are deliberate "match
the existing pattern" instructions with concrete files named, not TODOs. The rules-of-hooks resolution
for object-detail (split into inner loaded component) is spelled out. ✓
**Type consistency:** `PageTitle` is `ComponentProps<"h1">`; `useDocumentTitle(page: string)` defined
in T2 and called with `t(key)` (string) in T3 and `object.object_number` (string) in T4;
`useConfig().app_name` is the title's app-name source throughout. ✓
## Notes
- No new dependency, no new i18n strings (all keys exist in en/sv).
- The restore-on-unmount in `useDocumentTitle` is load-bearing for the master-detail override — keep it.
- Verify exact page file paths first (the NOTE under File structure); adjust import depths accordingly.
@@ -0,0 +1,256 @@
# 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.tsx` — `useId()`.** Add `useId` to the React import (`import { useId } from "react";`). Inside the component, add `const inputId = useId();` and change the two lines to:
```tsx
<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:
```tsx
<ComboboxClear aria-label={t("common.clear")} />
<ComboboxTrigger aria-label={t("common.open")} />
```
and
```tsx
<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):
```tsx
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`):
```tsx
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:
```tsx
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:**
```bash
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**
```bash
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:
```tsx
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:
```tsx
<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:
```tsx
<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:
```tsx
<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()`:
```tsx
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:
```tsx
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):**
```bash
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:**
```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 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**
```bash
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.
@@ -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 `<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,S3S7); 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 15 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.
@@ -0,0 +1,211 @@
# Design-Kit Consistency — 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:** Add three shared helpers (`useLang`, `segmentClass`, `rowStateClass`), adopt them across the duplicated sites, and apply behavior-preserving kit one-offs (delete dead Card, sidebar focusRing, login PageTitle, field-list Badge, size-4, icon dismiss buttons).
**Architecture:** Task 1 creates the helpers + deletes Card (additive/safe). Task 2 adopts the 3 helpers across 6 + 3 + 4 sites. Task 3 applies the one-off cleanups + full gate. Behavior-preserving throughout; `check:colors`/`check:size`/existing component tests are the guards.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (token classes + `cn`), react-i18next, Base UI, Vitest 4 + RTL.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon; token classes only (`check:colors`). `tsconfig` has `noUnusedLocals`, so remove any destructure that becomes unused.
**Spec:** `docs/superpowers/specs/2026-06-08-design-kit-consistency-design.md`
**Key facts (verified current):**
- `lib/focus-ring.ts` exports `focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50"`. `cn` is `@/lib/utils`.
- `Button` (`@/components/ui/button`) has sizes incl. `icon-sm`. `Badge` (`@/components/ui/badge`) has a `secondary` variant. `PageTitle` (`@/components/ui/page-title`) is an `<h1>` styled `text-2xl font-semibold tracking-tight`.
- `components/ui/card.tsx` has ZERO importers and no `card.stories`.
- `useLang` sites (each currently `const lang = i18n.language.startsWith("sv") ? "sv" : "en";`): `objects/object-detail.tsx:59`, `objects/field-input.tsx:32`, `vocab/vocabulary-terms.tsx:13`, `vocab/vocabulary-list.tsx:17`, `fields/field-list.tsx:27`, `authorities/authorities-page.tsx:19`.
- `segmentClass` sites: `objects/objects-table.tsx:174` (`` className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`} ``), `search/search-panel.tsx:76` (`className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}`), `authorities/authorities-page.tsx:41` (`cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")`).
- `rowStateClass` sites: `objects/objects-table.tsx:252` (`selected ? "bg-primary/10" : "hover:bg-muted"`), `vocab/vocabulary-list.tsx:113` (`isActive ? "bg-primary/10" : "hover:bg-muted"`), `search/search-result-row.tsx:15` (`isActive ? "bg-primary/10" : "hover:bg-muted"`), `fields/field-list.tsx:86` (`def.key === selectedKey ? "bg-primary/10" : ""` — note the missing idle hover).
---
# Task 1: Create helpers + delete dead Card
**Files:** Create `web/src/lib/use-lang.ts`, `web/src/lib/class-recipes.ts`, `web/src/lib/class-recipes.test.ts`; Delete `web/src/components/ui/card.tsx`.
- [ ] **Step 1: `web/src/lib/use-lang.ts`:**
```ts
import { useTranslation } from "react-i18next";
/** The instance's active UI language, narrowed to the two supported locales. */
export function useLang(): "sv" | "en" {
const { i18n } = useTranslation();
return i18n.language.startsWith("sv") ? "sv" : "en";
}
```
- [ ] **Step 2: `web/src/lib/class-recipes.ts`:**
```ts
import { cn } from "@/lib/utils";
import { focusRing } from "./focus-ring";
/** Segmented-control / filter-pill item. Unifies the active/inactive token recipe +
* focus ring; callers pass their contextual padding/size via `className`. */
export function segmentClass(active: boolean, className?: string): string {
return cn("rounded-md", focusRing, active ? "bg-primary text-primary-foreground" : "border", className);
}
/** Selected vs idle row background for master-detail / list rows. */
export function rowStateClass(active: boolean): string {
return active ? "bg-primary/10" : "hover:bg-muted";
}
```
- [ ] **Step 3: `web/src/lib/class-recipes.test.ts`** (write + run):
```ts
import { expect, test } from "vitest";
import { rowStateClass, segmentClass } from "./class-recipes";
test("segmentClass active uses the primary tokens + focus ring", () => {
const cls = segmentClass(true, "px-2 py-1");
expect(cls).toContain("bg-primary");
expect(cls).toContain("text-primary-foreground");
expect(cls).toContain("focus-visible:ring-ring/50");
expect(cls).toContain("px-2");
});
test("segmentClass inactive uses border, not the primary fill", () => {
const cls = segmentClass(false);
expect(cls).toContain("border");
expect(cls).not.toContain("bg-primary");
});
test("rowStateClass toggles selected vs idle-hover", () => {
expect(rowStateClass(true)).toBe("bg-primary/10");
expect(rowStateClass(false)).toBe("hover:bg-muted");
});
```
Run: `cd web && pnpm vitest run src/lib/class-recipes.test.ts` → 3 passing.
- [ ] **Step 4: Delete the dead Card component:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git rm web/src/components/ui/card.tsx
```
(Confirm no references first: `git grep -n "components/ui/card\"" web/src` returns nothing.)
- [ ] **Step 5: Verify + lint:**
```bash
cd web && pnpm vitest run src/lib/class-recipes.test.ts && pnpm typecheck && pnpm lint
```
Expected: green (Card had no importers, so its deletion can't break typecheck/lint).
- [ ] **Step 6: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/lib/use-lang.ts web/src/lib/class-recipes.ts web/src/lib/class-recipes.test.ts
git rm -q web/src/components/ui/card.tsx 2>/dev/null; git add -A web/src/components/ui
git commit -m "feat(web): useLang + segmentClass/rowStateClass helpers; delete dead Card (#66)"
```
---
# Task 2: Adopt the helpers across the duplicated sites
**Files:** Modify `objects/object-detail.tsx`, `objects/field-input.tsx`, `vocab/vocabulary-terms.tsx`, `vocab/vocabulary-list.tsx`, `fields/field-list.tsx`, `authorities/authorities-page.tsx`, `objects/objects-table.tsx`, `search/search-panel.tsx`, `search/search-result-row.tsx`.
- [ ] **Step 1: Adopt `useLang()` in the 6 components.** In each of `objects/object-detail.tsx`, `objects/field-input.tsx`, `vocab/vocabulary-terms.tsx`, `vocab/vocabulary-list.tsx`, `fields/field-list.tsx`, `authorities/authorities-page.tsx`: add `import { useLang } from "../lib/use-lang";` and replace `const lang = i18n.language.startsWith("sv") ? "sv" : "en";` with `const lang = useLang();`. Then, if `i18n` is no longer referenced anywhere else in that component, change `const { t, i18n } = useTranslation();` to `const { t } = useTranslation();` (the `noUnusedLocals` typecheck will fail otherwise — so this removal is required wherever `i18n` becomes unused). Note `authorities/authorities-page.tsx` also imports `focusRing` and uses `cn` — leave those.
- [ ] **Step 2: Adopt `segmentClass` at the 3 segmented sites.**
- `objects/objects-table.tsx`: add `import { segmentClass } from "../lib/class-recipes";`; change the pill `className` (currently `` `${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}` ``) to `className={segmentClass(active, "px-2 py-1")}`. If `focusRing` is now unused in this file, remove its import. (The object-number `<Link>` also uses `focusRing` — if so, KEEP the import.)
- `search/search-panel.tsx`: add the import; change `className={cn("rounded-md px-2 py-0.5", focusRing, active ? "bg-primary text-primary-foreground" : "border")}` to `className={segmentClass(active, "px-2 py-0.5")}`. Remove now-unused `focusRing`/`cn` imports if they're unused elsewhere in the file.
- `authorities/authorities-page.tsx`: add the import; change the NavLink className callback body `cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")` to `segmentClass(isActive, "px-3 py-1 text-sm")`. Remove now-unused `focusRing`/`cn` imports if unused elsewhere.
- [ ] **Step 3: Adopt `rowStateClass` at the 4 selected-row sites.** Add `import { rowStateClass } from "…/lib/class-recipes";` (or extend the existing class-recipes import) to each:
- `objects/objects-table.tsx`: in the row `className`, change `${selected ? "bg-primary/10" : "hover:bg-muted"}` to `${rowStateClass(selected)}`.
- `vocab/vocabulary-list.tsx`: change `${isActive ? "bg-primary/10" : "hover:bg-muted"}` to `${rowStateClass(isActive)}`.
- `search/search-result-row.tsx`: change `${isActive ? "bg-primary/10" : "hover:bg-muted"}` to `${rowStateClass(isActive)}`.
- `fields/field-list.tsx`: change `${def.key === selectedKey ? "bg-primary/10" : ""}` to `${rowStateClass(def.key === selectedKey)}` (this ADDS the `hover:bg-muted` idle hover the others have — an intended consistency fix).
- [ ] **Step 4: Verify (vitest ONCE for the affected suites), typecheck, lint:**
```bash
cd web && pnpm vitest run src/objects src/vocab src/fields src/authorities src/search && pnpm typecheck && pnpm lint
```
Expected: green. These are class-string-equivalent changes (segmentClass/rowStateClass produce the same token sets; `cn` ordering is irrelevant to Tailwind), so the existing component tests pass unchanged. `field-list`'s row now also carries `hover:bg-muted` (additive). If a test asserted the exact old className string, update it to match the new equivalent (unlikely — tests query by role/text).
- [ ] **Step 5: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/objects/object-detail.tsx web/src/objects/field-input.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/vocabulary-list.tsx web/src/fields/field-list.tsx web/src/authorities/authorities-page.tsx web/src/objects/objects-table.tsx web/src/search/search-panel.tsx web/src/search/search-result-row.tsx
git commit -m "refactor(web): adopt useLang + segmentClass/rowStateClass across sites (#66)"
```
---
# Task 3: One-off kit cleanups + full gate
**Files:** Modify `shell/sidebar.tsx`, `auth/login-page.tsx`, `fields/field-list.tsx`, `shell/theme-switch.tsx`, `shell/user-menu.tsx`, `shell/header-search.tsx`, `objects/objects-page.tsx`, `objects/object-detail-drawer.tsx`.
- [ ] **Step 1: `shell/sidebar.tsx`** — use the `focusRing` constant. Add `import { focusRing } from "../lib/focus-ring";` (if not already imported). At the two `cn(...)` sites (lines ~46 and ~88) replace the literal `"focus-visible:ring-3 focus-visible:ring-ring/50"` entry with `focusRing`. (Both are inside `cn(...)` lists, so just swap the string for the constant.)
- [ ] **Step 2: `auth/login-page.tsx`** — use `PageTitle`. Add `import { PageTitle } from "@/components/ui/page-title";` and change `<h1 className="text-2xl font-semibold">{app_name}</h1>` to `<PageTitle>{app_name}</PageTitle>`.
- [ ] **Step 3: `fields/field-list.tsx`** — type-tag → `Badge`. Add `import { Badge } from "@/components/ui/badge";` and change the type-tag `<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{…}</span>` (line ~97) to `<Badge variant="secondary">{…}</Badge>` (keep the inner expression/children unchanged).
- [ ] **Step 4: Icon sizing → `size-4`** in the 3 app-source sites: `shell/theme-switch.tsx:39` (`<Icon className="h-4 w-4" …>``className="size-4"`), `shell/user-menu.tsx:27` (`<CircleUser className="h-4 w-4" …>``size-4`), `shell/header-search.tsx:23` (the search icon's `… h-4 w-4 …` → replace `h-4 w-4` with `size-4`, keeping the other classes). Do NOT touch `components/ui/select.tsx`.
- [ ] **Step 5: Icon dismiss buttons → kit Button.**
- `objects/objects-page.tsx:54`: add `import { Button } from "@/components/ui/button";` (if absent) and change the `<button type="button" onClick={closeDetail} aria-label={t("actions.closeDetail")} className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"><X className="size-4" aria-hidden="true" /></button>` to:
```tsx
<Button
variant="ghost"
size="icon-sm"
onClick={closeDetail}
aria-label={t("actions.closeDetail")}
>
<X className="size-4" aria-hidden="true" />
</Button>
```
- `objects/object-detail-drawer.tsx:31-36`: add `import { Button } from "@/components/ui/button";` and render the `DrawerClose` AS the kit Button via the render prop:
```tsx
<DrawerClose
aria-label={t("actions.closeDetail")}
render={<Button variant="ghost" size="icon-sm" />}
>
<X className="size-4" aria-hidden="true" />
</DrawerClose>
```
(This mirrors the `AlertDialogTrigger render={<Button … />}` pattern in `components/delete-confirm-dialog.tsx`; the `DrawerClose` keeps its close-on-click behaviour and the `aria-label`.)
- [ ] **Step 6: 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. Report test totals, largest chunk (gz) from check:size (should be ≤ the prior ~216.5 KB — the Card delete only removes dead code), and the `check:colors` line. The existing `user-menu`, `objects-table`, `object-detail`/drawer, `login-page`, sidebar, `field-list`, search tests must pass unchanged (the icon buttons keep their `aria-label`s; the drawer still closes; login still renders an `<h1>` via PageTitle).
- [ ] **Step 7: 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 8: Manual smoke (recommended).** `pnpm dev`: the visibility pills / authority tabs / search facets look unchanged and keep their focus rings; the selected list rows (objects, vocab, search, fields) highlight identically and field rows now have a hover; the object-detail close buttons (wide pane + drawer) work; the login title and field-list type tag look right.
- [ ] **Step 9: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/fields/field-list.tsx web/src/shell/theme-switch.tsx web/src/shell/user-menu.tsx web/src/shell/header-search.tsx web/src/objects/objects-page.tsx web/src/objects/object-detail-drawer.tsx
git commit -m "refactor(web): kit consistency — focusRing, PageTitle, Badge, size-4, icon buttons (#66)"
```
---
## Self-Review (completed)
**Spec coverage:** AC1 `useLang` + 6 sites (T1 S1, T2 S1); AC2 `segmentClass`/`rowStateClass` + adoption + field-list hover fix (T1 S2-S3, T2 S2-S3); AC3 Card deleted (T1 S4); AC4 one-offs — sidebar focusRing, login PageTitle, field-list Badge, size-4, icon buttons (T3 S1-S5); AC5 gate/check:size/codename (T3 S6-S7). ✓
**Placeholder scan:** every edit gives the exact before string + after code; helper bodies are complete; the test has concrete assertions. The "remove `i18n` if unused" instructions are concrete (driven by `noUnusedLocals`). No TBD. ✓
**Type/consistency:** `useLang()` (T1) returns `"sv" | "en"` consumed as `const lang` (T2 S1); `segmentClass(active, className?)` / `rowStateClass(active)` (T1) called with the exact args in T2 S2-S3; `Button size="icon-sm"`, `Badge variant="secondary"`, `PageTitle` all confirmed to exist. ✓
## Notes
- No new dependency, no new i18n keys. `check:colors` stays green — `segmentClass`/`rowStateClass` and all edits use tokens (`bg-primary`, `border`, `ring-ring`, `bg-muted`). Card deletion only removes dead code.
- `cn()` (tailwind-merge) makes class ordering irrelevant, so the helper outputs are visually identical to the prior inline strings (except field-list's intended added hover).
- The `<SegmentedControl>` component and the form-spacing scale are deferred (out of scope).
@@ -0,0 +1,191 @@
# Object-Form Flexible-Field Grouping — 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:** Group the object form's flexible-field inputs by `def.group` (definition order, "Other" last) with subheadings, reusing one shared helper so the form and the detail view group identically.
**Architecture:** Extract the detail view's defs-grouping into `lib/group-fields.ts` (`groupDefinitions`), unit-test it, refactor `object-detail.tsx` to use it (output-preserving), then render the form's flexible block grouped via the same helper.
**Tech Stack:** React 19 + TS + pnpm, react-hook-form, react-i18next, Vitest + RTL. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (no new keys); app source double-quote+semicolon; token classes only; no behavior change to the form (inputs/validation/submission).
**Spec:** `docs/superpowers/specs/2026-06-08-form-field-grouping-design.md`
**Key facts:**
- `object-detail.tsx` builds groups inline (`const other = t("fields.other"); const present = (definitions ?? []).filter(d => object.fields[d.key] != null); const groups: { group, defs }[] = []; for (const def of present) { … } ` + orphan push + `groups.sort((a,b) => Number(a.group===other) - Number(b.group===other));`). Type alias `FieldDefinitionView = components["schemas"]["FieldDefinitionView"]` already imported.
- `object-form.tsx` flexible block: `<fieldset className="space-y-3 border-t pt-3"><legend className="label-caption">{t("form.flexibleHeading")}</legend>{definitions.map((def) => <div key={def.key}><FieldInput definition={def} form={form} />{errors.fields?.[def.key] && <p role="alert" className="text-xs text-destructive">{errors.fields[def.key]?.message ?? t("form.required")}</p>}</div>)}</fieldset>`.
- Field-def fixtures have `group: "Description"` (inscription) and `group: null` (the rest).
- `sr-only` is a valid utility (used in the #52 skip link). `fields.other` + `form.flexibleHeading` are existing i18n keys.
---
# Task 1: Shared `groupDefinitions` helper + unit test + detail refactor
**Files:** `web/src/lib/group-fields.ts` (new), `web/src/lib/group-fields.test.ts` (new), `web/src/objects/object-detail.tsx`.
- [ ] **Step 1: `web/src/lib/group-fields.ts`:**
```ts
import type { components } from "../api/schema";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export type FieldGroup = { group: string; defs: FieldDefinitionView[] };
/** Group field definitions by `def.group` (trimmed), preserving definition order
* within and across groups; ungrouped defs fall into a trailing `otherLabel` bucket. */
export function groupDefinitions(
definitions: FieldDefinitionView[],
otherLabel: string,
): FieldGroup[] {
const groups: FieldGroup[] = [];
for (const def of definitions) {
const group = def.group?.trim() ? def.group : otherLabel;
let bucket = groups.find((g) => g.group === group);
if (!bucket) {
bucket = { group, defs: [] };
groups.push(bucket);
}
bucket.defs.push(def);
}
groups.sort((a, b) => Number(a.group === otherLabel) - Number(b.group === otherLabel));
return groups;
}
```
- [ ] **Step 2: `web/src/lib/group-fields.test.ts`** (write + run, must pass):
```ts
import { expect, test } from "vitest";
import { groupDefinitions } from "./group-fields";
type Def = { key: string; group?: string | null };
const def = (key: string, group: string | null): Def => ({ key, group });
function keysByGroup(defs: Def[]) {
// cast through unknown — the helper only reads key/group
return groupDefinitions(defs as never, "Other").map((g) => ({
group: g.group,
keys: g.defs.map((d) => (d as unknown as Def).key),
}));
}
test("preserves definition order within and across groups; Other is last", () => {
const result = keysByGroup([
def("a", "Description"),
def("b", null),
def("c", "Description"),
def("d", "Provenance"),
def("e", " "),
]);
expect(result).toEqual([
{ group: "Description", keys: ["a", "c"] },
{ group: "Provenance", keys: ["d"] },
{ group: "Other", keys: ["b", "e"] },
]);
});
test("all-ungrouped → a single trailing Other group", () => {
expect(keysByGroup([def("x", null), def("y", null)])).toEqual([
{ group: "Other", keys: ["x", "y"] },
]);
});
```
Run: `cd web && pnpm vitest run src/lib/group-fields.test.ts` → PASS (2 tests). (If the `as never`/`as unknown` casts trip lint, type the test `def` as the real `FieldDefinitionView` partial via `Partial<…> as …` — keep it lint-clean and `any`-free; the helper only reads `key`/`group`.)
- [ ] **Step 3: Refactor `object-detail.tsx`** to use the helper. Add `import { groupDefinitions } from "../lib/group-fields";`. Replace the inline group-building loop + the final `groups.sort(...)` with:
```tsx
const other = t("fields.other");
const present = (definitions ?? []).filter((d) => object.fields[d.key] != null);
const groups = groupDefinitions(present, other);
```
Keep the orphan handling exactly as-is AFTER this (`const definedKeys = …; const orphans = …; if (orphans.length > 0 && !groups.some((g) => g.group === other)) groups.push({ group: other, defs: [] });`). The appended Other bucket remains last (the helper already put any Other last, and appending when absent adds it at the end). Do NOT re-add a `groups.sort(...)` — appending keeps Other last. The render (`groups.map(...)`) is unchanged.
- [ ] **Step 4: Verify (vitest ONCE):** `cd web && pnpm vitest run src/lib/group-fields.test.ts src/objects/object-detail.test.tsx && pnpm typecheck && pnpm lint`. PASS — the object-detail tests must stay green (output-preserving refactor).
- [ ] **Step 5: Commit**
```bash
git add web/src/lib/group-fields.ts web/src/lib/group-fields.test.ts web/src/objects/object-detail.tsx
git commit -m "refactor(web): extract groupDefinitions helper; object-detail uses it (#45)"
```
---
# Task 2: Group the object-form flexible inputs + test + gate
**Files:** `web/src/objects/object-form.tsx`, `web/src/objects/object-form.test.tsx`.
- [ ] **Step 1: Group the flexible block** in `object-form.tsx`. Add `import { groupDefinitions } from "../lib/group-fields";`. Replace the flexible `<fieldset>` body:
```tsx
{definitions && definitions.length > 0 && (
<fieldset className="space-y-3 border-t pt-3">
<legend className="sr-only">{t("form.flexibleHeading")}</legend>
{groupDefinitions(definitions, t("fields.other")).map((g) => (
<div key={g.group} className="space-y-3">
<div className="label-caption">{g.group}</div>
{g.defs.map((def) => (
<div key={def.key}>
<FieldInput definition={def} form={form} />
{errors.fields?.[def.key] && (
<p role="alert" className="text-xs text-destructive">
{errors.fields[def.key]?.message ?? t("form.required")}
</p>
)}
</div>
))}
</div>
))}
</fieldset>
)}
```
Change ONLY this block: the `<legend>` goes from visible `label-caption` to `sr-only`; the flat `definitions.map` becomes grouped. Field inputs + error markup are identical, just nested under group wrappers. No change anywhere else (core fields, visibility, footer, submit logic).
- [ ] **Step 2: Test** — extend `object-form.test.tsx`. The field-def fixtures have `inscription` in group `"Description"` and the rest ungrouped → "Other". Add a test that renders `<ObjectForm mode="create" …>` and asserts the group subheadings + membership:
```tsx
test("groups flexible fields by definition group with subheadings", async () => {
renderApp(<ObjectForm mode="create" onSubmit={() => {}} onCancel={() => {}} />);
// the "Description" group heading and the "Other" group heading both render
expect(await screen.findByText("Description")).toBeInTheDocument();
expect(screen.getByText(/^Other$/)).toBeInTheDocument();
// the Description-grouped field input is present (Inscription) and appears before an ungrouped one (Material)
const inscription = screen.getByLabelText(/inscription/i);
const material = screen.getByLabelText(/material/i);
expect(inscription.compareDocumentPosition(material) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
```
(Adjust field labels to the fixtures' actual rendered labels — read `web/src/test/fixtures.ts` `fieldDefinitions` for the exact `labels`/keys and `web/src/objects/field-input.tsx` for how each renders its label, so the `getByLabelText`/`findByText` queries match. The key assertion: a named group heading + the "Other" heading both appear, and a grouped field precedes an ungrouped one in the DOM.) Keep the existing object-form tests green.
- [ ] **Step 3: 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.
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: open New object / edit — the flexible fields now appear under group subheadings (e.g. "Description", "Other") matching the detail view's grouping; inputs/validation/submit still work.
- [ ] **Step 6: Commit**
```bash
git add web/src/objects/object-form.tsx web/src/objects/object-form.test.tsx
git commit -m "feat(web): group object-form flexible fields by definition group (#45)"
```
---
## Self-Review (completed)
**Spec coverage:** shared `groupDefinitions` + unit test (T1 S1S2); detail refactor output-preserving (T1 S3S4); form grouped via the helper with `sr-only` legend + visible subheadings (T2 S1); form grouping test (T2 S2); gate (T2 S3). Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the form test says "adjust labels to the fixtures' actual rendered labels" with the files named (fixtures.ts, field-input.tsx) — a concrete match-the-data step, not a TODO; the core assertion (named + Other headings, order) is explicit. The helper-test cast note keeps it `any`-free. No vague steps. ✓
**Type/consistency:** `groupDefinitions(defs, otherLabel): FieldGroup[]` defined in T1, consumed by detail (`present`) and form (all `definitions`); detail's orphan-Other append stays last; the form reuses the existing `FieldInput`/error markup unchanged. ✓
## Notes
- No new dependency; no new i18n keys (`fields.other` + `form.flexibleHeading` exist).
- The refactor of object-detail is output-preserving — its tests are the guard.
- Field-list's AZ grouping is intentionally NOT unified (different purpose).
@@ -0,0 +1,247 @@
# Standardize Loading States on Skeleton — 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:** Replace the "…" text and empty `role="status"` divs with shared `Skeleton`-based loading recipes that mirror the loaded layout and announce loading to screen readers.
**Architecture:** A new `ui/skeletons.tsx` exports `ListSkeleton`, `FormSkeleton`, `AppShellSkeleton` (each a `role="status" aria-label={t("common.loading")}` live region built on the existing `Skeleton`). Apply them at every inconsistent loading site; retrofit the two good list-like skeletons to `ListSkeleton`.
**Tech Stack:** React 19 + TS + pnpm, react-i18next, Vitest + RTL + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (one new key); **ui/ files = no-semicolon** (match `ui/skeleton.tsx`); app source = double-quote+semicolon; stories single-quote/no-semicolon; token classes only; never nest `<div>` inside `<ul>`.
**Spec:** `docs/superpowers/specs/2026-06-08-loading-skeletons-design.md`
**Key facts:**
- `ui/skeleton.tsx`: `function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} /> }` (no-semicolon).
- "…" sites render `<li>…</li>` inside a `<ul>``vocabulary-list.tsx` (`<ul className="flex-1 overflow-auto">`, loading `<li className="p-3 text-sm text-muted-foreground">…</li>`), `vocabulary-terms.tsx` (`<ul className="mb-4">`), `authorities-page.tsx` (`<ul className="mb-4">`).
- empty status divs: `require-auth.tsx` `if (isLoading) return <div role="status" aria-label="loading" />;` (pre-shell); `object-edit-form.tsx` `if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;`.
- `app.tsx`: `function FormFallback() { return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div> }` used in 3 Suspense fallbacks (ObjectNewPage, ObjectEditForm, FieldsPage).
- retrofits: `field-list.tsx` (`space-y-2 p-3` + 6 × `<Skeleton className="h-9 w-full" />`), `search-panel.tsx` (`space-y-2 p-3` + 5 × `<Skeleton className="h-12 w-full" />`).
- i18n `common` namespace exists in both locales: `{ "yes", "no", "close" }` — add `"loading"`.
---
# Task 1: Shared skeleton recipes + i18n + story
**Files:** `web/src/components/ui/skeletons.tsx` (new), `web/src/components/ui/skeletons.stories.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`.
- [ ] **Step 1: i18n** — add `"loading"` to the `common` namespace in BOTH locales (keep parity):
- en: `"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" },`
- sv: `"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" },`
- [ ] **Step 2: Implement `web/src/components/ui/skeletons.tsx`** (no-semicolon, ui/* style):
```tsx
import { useTranslation } from "react-i18next"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
function ListSkeleton({
rows = 6,
rowClassName = "h-9 w-full",
className,
}: {
rows?: number
rowClassName?: string
className?: string
}) {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-2 p-3", className)}>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className={rowClassName} />
))}
</div>
)
}
function FormSkeleton({ fields = 5, className }: { fields?: number; className?: string }) {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-4 p-4", className)}>
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-8 w-full" />
</div>
))}
<Skeleton className="h-8 w-28" />
</div>
)
}
function AppShellSkeleton() {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t("common.loading")} className="flex min-h-screen">
<aside className="w-44 space-y-2 border-r p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</aside>
<div className="flex flex-1 flex-col">
<div className="flex items-center border-b px-4 py-2">
<Skeleton className="h-6 w-40" />
</div>
<div className="flex-1 space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
</div>
</div>
)
}
export { ListSkeleton, FormSkeleton, AppShellSkeleton }
```
(`AppShellSkeleton` inlines its content rows rather than nesting `ListSkeleton`, so there's ONE `role="status"` for the whole boot screen. Token classes only.)
- [ ] **Step 3: Story `web/src/components/ui/skeletons.stories.tsx`** (single-quote, no-semicolon; match an existing story, e.g. `menu.stories.tsx`/`page-title.stories.tsx`). Export three stories (`List`, `Form`, `AppShellLoading`) each rendering the respective recipe; one `play` test (on `List`) asserting a `role="status"` region is present:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { AppShellSkeleton, FormSkeleton, ListSkeleton } from './skeletons'
const meta = { title: 'ui/Skeletons', tags: ['ai-generated'] } satisfies Meta
export default meta
type Story = StoryObj
export const List: Story = {
render: () => <ListSkeleton rows={4} />,
play: async ({ canvas }) => {
await expect(canvas.getByRole('status')).toBeInTheDocument()
},
}
export const Form: Story = { render: () => <FormSkeleton /> }
export const AppShellLoading: Story = { render: () => <AppShellSkeleton /> }
```
(Adjust the `Meta`/`StoryObj` typing to the house pattern if `satisfies Meta` without a component arg complains — these are render-only stories; mirror how an existing component-less story is typed, or pass `component: ListSkeleton`.)
- [ ] **Step 4: Verify (vitest ONCE):**
`cd web && pnpm vitest run src/components/ui/skeletons.stories.tsx && pnpm typecheck && pnpm lint`
Expected: PASS, clean. (If a storybook cache flake appears — `Cannot read properties of null (reading 'useEffect')``rm -rf node_modules/.cache/storybook node_modules/.vite` and re-run ONCE.)
- [ ] **Step 5: Commit**
```bash
git add web/src/components/ui/skeletons.tsx web/src/components/ui/skeletons.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): shared loading skeleton recipes (List/Form/AppShell) + common.loading (#53)"
```
---
# Task 2: Apply skeletons across all loading sites + gate
**Files (modify):** `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/auth/require-auth.tsx`, `web/src/app.tsx`, `web/src/fields/field-list.tsx`, `web/src/search/search-panel.tsx`.
Add `import { ListSkeleton } from "@/components/ui/skeletons";` (and `FormSkeleton`/`AppShellSkeleton` where used) to each. NEVER nest a `<div>` (the recipe) inside a `<ul>` — render the skeleton in place of the `<ul>`.
- [ ] **Step 1: vocabulary-list.tsx.** Read the loading region. The list is `<ul className="flex-1 overflow-auto">` with a loading `<li>…</li>`. Render the skeleton in place of the `<ul>` during load:
```tsx
{isLoading ? (
<ListSkeleton className="flex-1 overflow-auto" />
) : (
<ul className="flex-1 overflow-auto">
{isError && (<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>)}
{data?.length === 0 && (/* keep existing empty state */)}
{data?.map(/* keep existing rows */)}
</ul>
)}
```
Keep the `isError`/empty/rows branches exactly as they are now (just move them under the `!isLoading` `<ul>`; remove the loading `<li>`). Preserve the `flex-1 overflow-auto` layout via the skeleton's `className`.
- [ ] **Step 2: vocabulary-terms.tsx.** The `<ul className="mb-4">` has a loading `<li>`. Same approach:
```tsx
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isError && (/* keep */)}
{terms?.length === 0 && (/* keep */)}
{terms?.map(/* keep TermRow */)}
</ul>
)}
```
- [ ] **Step 3: authorities-page.tsx.** The `<ul className="mb-4">` has a loading `<li>`. Same:
```tsx
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isError && (/* keep */)}
{authorities?.length === 0 && (/* keep */)}
{authorities?.map(/* keep AuthorityRow */)}
</ul>
)}
```
- [ ] **Step 4: object-edit-form.tsx.** Replace the outer loading branch:
`if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;`
`if (isLoading) return <FormSkeleton />;`
(Import `FormSkeleton`. The not-found branch stays unchanged.)
- [ ] **Step 5: require-auth.tsx.** Replace:
`if (isLoading) return <div role="status" aria-label="loading" />;`
`if (isLoading) return <AppShellSkeleton />;`
(Import `AppShellSkeleton`. It uses only `useTranslation` — safe pre-shell.)
- [ ] **Step 6: app.tsx lazy fallbacks.** Remove the `FormFallback` function. Import `FormSkeleton` and `ListSkeleton` from `@/components/ui/skeletons`. Replace the three Suspense fallbacks:
- ObjectNewPage: `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
- ObjectEditForm: `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
- FieldsPage: `fallback={<ListSkeleton />}`
Keep the `<Suspense>` wrappers + lazy imports; only the `fallback` prop changes (and `FormFallback` is deleted).
- [ ] **Step 7: field-list.tsx (retrofit).** Replace the inline `isLoading` block:
`return (<div className="space-y-2 p-3">{Array.from({length:6}).map(... <Skeleton h-9 w-full/>)}</div>)`
`return <ListSkeleton rows={6} />;`
Remove the now-unused `Skeleton` import if nothing else uses it (check).
- [ ] **Step 8: search-panel.tsx (retrofit).** Replace the `hasQuery && search.isLoading` block's inline skeleton:
`<div className="space-y-2 p-3">{Array.from({length:5}).map(... <Skeleton h-12 w-full/>)}</div>`
`<ListSkeleton rows={5} rowClassName="h-12 w-full" />`
Remove the now-unused `Skeleton` import if nothing else uses it (check).
- [ ] **Step 9: 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. The existing suite must stay green (no test asserts the old "…"/empty-div markup; tests `findBy` content). If a test fails because it queried `getByRole("status")` and now finds a labelled region (or multiple), update it minimally without weakening. Report test totals, largest chunk (KB gz), check:colors line.
- [ ] **Step 10: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 11: Manual smoke (recommended).** `pnpm dev`: first load shows the app-shell skeleton (no blank flash); navigating to /vocabularies, /authorities, /search, /fields shows list skeletons (no "…"); opening /objects/:id/edit and /objects/new shows a form skeleton (no full-pane "Loading…"); all transition into content without an obvious jump.
- [ ] **Step 12: Commit**
```bash
git add web/src/vocab/vocabulary-list.tsx web/src/vocab/vocabulary-terms.tsx web/src/authorities/authorities-page.tsx web/src/objects/object-edit-form.tsx web/src/auth/require-auth.tsx web/src/app.tsx web/src/fields/field-list.tsx web/src/search/search-panel.tsx
git commit -m "feat(web): standardize loading on shared skeleton recipes; retire '…' + empty status divs (#53)"
```
---
## Self-Review (completed)
**Spec coverage:** recipes List/Form/AppShell as `role="status"` live regions + `common.loading` + story (T1); three "…" → ListSkeleton, object-edit-form → FormSkeleton, require-auth → AppShellSkeleton, per-route lazy fallbacks replacing FormFallback (T2 S1S6); field-list + search-panel retrofit (T2 S7S8); gate (T2 S9). Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the per-site replacements show the exact before→after and say "keep the existing error/empty/row branches" with the files/lines named — concrete, not vague. The story typing has an explicit fallback note. No TODOs. ✓
**Type/consistency:** `ListSkeleton({rows,rowClassName,className})`, `FormSkeleton({fields,className})`, `AppShellSkeleton()` defined in T1, consumed with matching props in T2; import path `@/components/ui/skeletons` uniform. ✓
## Notes
- No new dependency; one new i18n key (`common.loading`, en+sv).
- HTML validity: list skeletons replace the `<ul>` during load (never `<div>`-in-`<ul>`).
- `check:size` ≈ unchanged (small components) — report it.
- Retrofitting field-list/search-panel may leave an unused `Skeleton` import — remove it (lint will catch).
@@ -0,0 +1,421 @@
# Consistent, Status-Aware Mutation Error Feedback — 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:** Replace the generic, inconsistent mutation-error feedback with one status-aware mapping rendered consistently inline across every create/edit/delete surface.
**Architecture:** A shared `errorMessageKey(error)` maps `InUseError`/`HttpError(status)` → i18n keys (single source of truth). A shared `<MutationError error>` renders the inline alert. Mutations throw `HttpError(status)` so the status reaches the helper. All inline sites adopt the helper/component; the two non-suppressed update mutations suppress the toast and show inline like their siblings.
**Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, openapi-fetch, react-i18next, Vitest 4 (jsdom) + RTL + MSW. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; app source double-quote+semicolon; `ui/` files untouched; token classes only. Run a single test pass.
**Spec:** `docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md`
**Key facts:**
- `HttpError`/`InUseError`/`FieldRejection` are exported from `web/src/api/queries.ts` (lines 6, 13, 20). `HttpError(status)` carries `.status`; `InUseError(count)` carries `.count`.
- `web/src/api/query-client.ts:11-23` `mutationErrorMessage(error, meta)` currently special-cases `InUseError`, `HttpError 503 → search.unavailable`, `meta.errorMessage`, else `toast.error`. The `MutationCache.onError` skips when `meta.suppressErrorToast`.
- **16 of 18 mutations already `suppressErrorToast`.** Only `useUpdateTerm` (`queries.ts:444`) and `useUpdateAuthority` (`:521`) do not — they have `meta: { successMessage: "toast.saved" }`.
- The 16 **mutation** `throw new Error("…")` sites: `queries.ts:184,203,230,248,279,306,331,382,441,458,475,492,518,535,562,579`. `useCreateObject` (`:184`) and `useCreateVocabulary` (`:279`) destructure `{ data, error }` (no `response`) — add `response`. Lines `38,73,90,105,152,169,264` are **query** fetch errors and `:121` is the login map — **do not change these**. `useSearch` (`:360`) already throws `HttpError`.
- Inline generic-error sites (all render `{X.isError && <p role="alert" className="text-xs text-destructive">{t("form.rejected")}</p>}`): `authorities-page.tsx:144-148` (`create`), `vocabulary-terms.tsx:119-123` (`addTerm`), `vocabulary-list.tsx:57-61` (`create`) + `:109-113` (`renameVocabulary`), `field-form.tsx:201-205` (`failed = isEdit ? update.isError : create.isError`, line 98). `delete-confirm-dialog.tsx:40` sets `err instanceof InUseError ? actions.inUse : form.rejected`. `object-new-page.tsx:36-39` and `object-edit-form.tsx:86-88` set `t("form.rejected")` in a catch else.
- `objects.loadError` ("Could not load objects") already exists in both locales. `term-row.tsx`/`authority-row.tsx` Edit-button `onClick` currently does `setLabels(...); setUri(...); setEditing(true);`.
- Test env: jsdom project, MSW `onUnhandledRequest:"error"`. `renderApp` (`src/test/render.tsx`) mounts `ui` at `path:"*"` in a memory router. `src/api/mutation-feedback.test.tsx` exists (tests toast wiring via `makeQueryClient` + `ToastRegion` + `renderHook`).
---
# Task 1: Shared helper + `<MutationError>` + i18n + query-client rewire
**Files:** Create `web/src/api/error-message.ts`, `web/src/api/error-message.test.ts`, `web/src/components/mutation-error.tsx`, `web/src/components/mutation-error.test.tsx`; Modify `web/src/api/query-client.ts`, `web/src/api/mutation-feedback.test.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`.
- [ ] **Step 1: Add the 5 i18n keys (both locales, parity).** Add a new top-level `"errors"` object to `web/src/i18n/en.json`:
```json
"errors": {
"forbidden": "You don't have permission to do that.",
"notFound": "That item no longer exists.",
"conflict": "That conflicts with existing data.",
"validation": "Some values weren't accepted.",
"server": "The server had a problem. Please try again."
},
```
and to `web/src/i18n/sv.json`:
```json
"errors": {
"forbidden": "Du har inte behörighet att göra det.",
"notFound": "Objektet finns inte längre.",
"conflict": "Det står i konflikt med befintliga data.",
"validation": "Vissa värden godtogs inte.",
"server": "Servern hade ett problem. Försök igen."
},
```
(Valid JSON — mind commas. Place it consistently in both files, e.g. before `toast`.)
- [ ] **Step 2: Create `web/src/api/error-message.ts`:**
```ts
import { HttpError, InUseError } from "./queries";
/** Maps a caught mutation error to an i18n key (+ interpolation opts). The single
* source of truth shared by the global toast fallback and every inline display. */
export function errorMessageKey(error: unknown): { key: string; opts?: Record<string, unknown> } {
if (error instanceof InUseError) return { key: "actions.inUse", opts: { count: error.count } };
if (error instanceof HttpError) return { key: statusKey(error.status) };
return { key: "toast.error" };
}
function statusKey(status: number): string {
if (status === 403) return "errors.forbidden";
if (status === 404) return "errors.notFound";
if (status === 409) return "errors.conflict";
if (status === 422) return "errors.validation";
if (status >= 500) return "errors.server";
return "toast.error";
}
```
- [ ] **Step 3: Create `web/src/api/error-message.test.ts`** (write + run):
```ts
import { expect, test } from "vitest";
import { errorMessageKey } from "./error-message";
import { HttpError, InUseError } from "./queries";
test("maps HTTP statuses to specific keys", () => {
expect(errorMessageKey(new HttpError(403))).toEqual({ key: "errors.forbidden" });
expect(errorMessageKey(new HttpError(404))).toEqual({ key: "errors.notFound" });
expect(errorMessageKey(new HttpError(409))).toEqual({ key: "errors.conflict" });
expect(errorMessageKey(new HttpError(422))).toEqual({ key: "errors.validation" });
expect(errorMessageKey(new HttpError(500))).toEqual({ key: "errors.server" });
expect(errorMessageKey(new HttpError(502))).toEqual({ key: "errors.server" });
});
test("an unmapped status falls back to the generic key", () => {
expect(errorMessageKey(new HttpError(418))).toEqual({ key: "toast.error" });
});
test("InUseError carries the count", () => {
expect(errorMessageKey(new InUseError(3))).toEqual({ key: "actions.inUse", opts: { count: 3 } });
});
test("a bare Error or unknown maps to the generic key", () => {
expect(errorMessageKey(new Error("boom"))).toEqual({ key: "toast.error" });
expect(errorMessageKey(null)).toEqual({ key: "toast.error" });
});
```
Run: `cd web && pnpm vitest run src/api/error-message.test.ts` → 4 passing.
- [ ] **Step 4: Create `web/src/components/mutation-error.tsx`:**
```tsx
import { useTranslation } from "react-i18next";
import { errorMessageKey } from "../api/error-message";
/** Renders a caught mutation error as an inline alert, or nothing when error is falsy.
* Replaces the duplicated `<p role="alert" className="text-xs text-destructive">` markup. */
export function MutationError({ error }: { error: unknown }) {
const { t } = useTranslation();
if (!error) return null;
const { key, opts } = errorMessageKey(error);
return (
<p role="alert" className="text-xs text-destructive">
{t(key, opts)}
</p>
);
}
```
- [ ] **Step 5: Create `web/src/components/mutation-error.test.tsx`** (write + run):
```tsx
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import "../i18n";
import { MutationError } from "./mutation-error";
import { HttpError, InUseError } from "../api/queries";
test("renders the status-specific message for an HttpError", () => {
render(<MutationError error={new HttpError(403)} />);
expect(screen.getByRole("alert")).toHaveTextContent(/permission/i);
});
test("renders the in-use count for an InUseError", () => {
render(<MutationError error={new InUseError(2)} />);
expect(screen.getByRole("alert")).toHaveTextContent(/2/);
});
test("renders nothing when there is no error", () => {
const { container } = render(<MutationError error={null} />);
expect(container).toBeEmptyDOMElement();
});
```
Run: `cd web && pnpm vitest run src/components/mutation-error.test.tsx` → 3 passing.
- [ ] **Step 6: Rewire `web/src/api/query-client.ts`.** Change the import line `import { HttpError, InUseError } from "./queries";` to `import { errorMessageKey } from "./error-message";`, and replace the whole `mutationErrorMessage` function body with:
```ts
function mutationErrorMessage(
error: unknown,
meta: MutationMeta | undefined,
): string {
if (meta?.errorMessage) return i18n.t(meta.errorMessage);
const { key, opts } = errorMessageKey(error);
return i18n.t(key, opts);
}
```
(Leave the `MutationCache` `onError`/`onSuccess` wiring unchanged.)
- [ ] **Step 7: Rework the obsolete toast test in `web/src/api/mutation-feedback.test.tsx`.** The "a non-suppressed mutation failing shows the catch-all error toast" test uses `useUpdateTerm`, which will suppress in Task 3 — decouple it now by driving the `MutationCache` fallback with a synthetic non-suppressed mutation. Add `useMutation` to the `@tanstack/react-query` import and `HttpError` to the queries import, then replace that single test with:
```tsx
test("a non-suppressed mutation failing shows the status-mapped error toast", async () => {
const { result, unmount } = renderHook(
() =>
useMutation({
mutationFn: async () => {
throw new HttpError(500);
},
}),
{ wrapper: makeWrapper() },
);
await expect(result.current.mutateAsync()).rejects.toThrow();
await waitFor(() => {
expect(
within(document.body).getByText(i18n.t("errors.server")),
).toBeInTheDocument();
});
unmount();
});
```
(Keep the other two tests — success toast via `useUpdateTerm`, and suppressed `useDeleteVocabulary` adds no toast — unchanged. Imports: add `useMutation`; add `HttpError` from `./queries`.)
- [ ] **Step 8: Verify (vitest ONCE for the touched files), then typecheck + lint:**
```bash
cd web && pnpm vitest run src/api/error-message.test.ts src/components/mutation-error.test.tsx src/api/mutation-feedback.test.tsx src/i18n && pnpm typecheck && pnpm lint
```
Expected: all green (helper 4, component 3, feedback 3, i18n parity covering the 5 new keys).
- [ ] **Step 9: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/error-message.ts web/src/api/error-message.test.ts web/src/components/mutation-error.tsx web/src/components/mutation-error.test.tsx web/src/api/query-client.ts web/src/api/mutation-feedback.test.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): shared status-aware error-message helper + MutationError component (#63)"
```
---
# Task 2: Make mutation errors load-bearing (throw `HttpError(status)`)
**Files:** Modify `web/src/api/queries.ts`.
- [ ] **Step 1: Convert the 16 mutation throws to `HttpError`.** In `web/src/api/queries.ts`, at each of these lines replace `throw new Error("…")` with `throw new HttpError(response.status)` — keeping every surrounding `InUseError` (409) and `FieldRejection` (422) branch exactly as-is:
- `:203` `useUpdateObject``if (response.status !== 204) throw new HttpError(response.status);`
- `:230` `useSetFields` — the final fallback after the 204/422 checks: `throw new HttpError(response.status);`
- `:248` `useDeleteObject``throw new HttpError(response.status);`
- `:306` `useAddTerm``if (response.status !== 201) throw new HttpError(response.status);`
- `:331` `useCreateAuthority``throw new HttpError(response.status);`
- `:382` `useCreateFieldDefinition``if (response.status !== 201 || !data) throw new HttpError(response.status);`
- `:441` `useUpdateTerm``throw new HttpError(response.status);`
- `:458` `useDeleteTerm` — the post-409 fallback: `if (response.status !== 204) throw new HttpError(response.status);`
- `:475` `useRenameVocabulary``throw new HttpError(response.status);`
- `:492` `useDeleteVocabulary` — post-409 fallback: `throw new HttpError(response.status);`
- `:518` `useUpdateAuthority``throw new HttpError(response.status);`
- `:535` `useDeleteAuthority` — post-409 fallback: `throw new HttpError(response.status);`
- `:562` `useUpdateFieldDefinition``throw new HttpError(response.status);`
- `:579` `useDeleteFieldDefinition` — post-409 fallback: `throw new HttpError(response.status);`
- [ ] **Step 2: The two POSTs that don't destructure `response`.** For `useCreateObject` (`:184`) and `useCreateVocabulary` (`:279`), change `const { data, error } = await api.POST(...)` to `const { data, error, response } = await api.POST(...)` and replace `if (error || !data) throw new Error("…")` with `if (error || !data) throw new HttpError(response.status);`.
- [ ] **Step 3: Do NOT touch** the query fetch errors (`:38,73,90,105,152,169,264`) or the login map (`:121`) — they keep `throw new Error(...)` (consumed by component `isError`/login mapping, not the toast). `HttpError` is already imported in this file (it's defined here), so no import change.
- [ ] **Step 4: Verify (vitest ONCE), typecheck, lint:**
```bash
cd web && pnpm vitest run src/api/queries.test.ts src/api/mutation-feedback.test.tsx && pnpm typecheck && pnpm lint
```
Expected: green. `HttpError` extends `Error`, so any `.rejects.toThrow()` assertions still pass (no test asserts the old message strings). If `queries.test.ts` asserts a specific thrown type/message that now differs, update it to assert `HttpError`/status (do not weaken).
- [ ] **Step 5: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/queries.ts
git commit -m "feat(web): mutations throw HttpError(status) so failures are status-aware (#63)"
```
---
# Task 3: Edit-row consistency + delete-dialog adoption
**Files:** Modify `web/src/api/queries.ts`, `web/src/vocab/term-row.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/components/delete-confirm-dialog.tsx`, and their tests / `web/src/api/mutation-feedback.test.tsx` consumers.
- [ ] **Step 1: Suppress the two update toasts.** In `web/src/api/queries.ts`, change `useUpdateTerm`'s meta (`:444`) from `meta: { successMessage: "toast.saved" }` to `meta: { successMessage: "toast.saved", suppressErrorToast: true }`, and the same for `useUpdateAuthority` (`:521`).
- [ ] **Step 2: Inline error + reset in `web/src/vocab/term-row.tsx`.** Add the import `import { MutationError } from "../components/mutation-error";`. In the editing view, add `<MutationError error={updateTerm.error} />` immediately after the save/cancel `<div className="flex gap-2">…</div>` (still inside the `<li>`). In the non-editing view's Edit `<Button>` `onClick`, add `updateTerm.reset();` as the first statement (before `setLabels(...)`):
```tsx
onClick={() => {
updateTerm.reset();
setLabels(term.labels as LabelInput[]);
setUri(term.external_uri ?? "");
setEditing(true);
}}
```
- [ ] **Step 2b: Same for `web/src/authorities/authority-row.tsx`.** Import `MutationError`; add `<MutationError error={updateAuthority.error} />` after the save/cancel `<div className="flex gap-2">…</div>`; add `updateAuthority.reset();` as the first statement in the Edit button's `onClick`.
- [ ] **Step 3: Status-aware delete dialog (`web/src/components/delete-confirm-dialog.tsx`).** Add `import { errorMessageKey } from "../api/error-message";`, remove the now-unused `import { InUseError } from "../api/queries";`, and change the `catch` block (lines ~37-41) from the `err instanceof InUseError ? … : t("form.rejected")` line to:
```tsx
} catch (err) {
// Keep the dialog open; show the blocking reason. Never let the rejected
// mutation escape as an unhandled rejection.
const { key, opts } = errorMessageKey(err);
setMessage(t(key, opts));
return;
}
```
(`errorMessageKey` already maps `InUseError``actions.inUse` with the count, so the in-use message is preserved and a 403/404 delete now shows a specific message.)
- [ ] **Step 4: Tests.** Add a failing-update test to `web/src/vocab/term-row.tsx`'s area — create `web/src/vocab/term-row.test.tsx` if absent (check first), else extend. Render a `TermRow` inside `renderApp` with the `makeQueryClient` toast wrapper is overkill — use the existing `renderApp` and MSW. Concretely:
```tsx
import { expect, test } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { TermRow } from "./term-row";
const term = { id: "t1", external_uri: null, labels: [{ lang: "en", label: "Bronze" }] };
test("a failed term update shows an inline error and keeps the row editable", async () => {
server.use(
http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
);
renderApp(<ul><TermRow vocabularyId="v1" term={term as never} lang="en" /></ul>);
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/permission/i);
// still editable: the save button is still present (editor did not close)
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});
test("re-entering edit after a failure clears the stale error", async () => {
server.use(
http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
);
renderApp(<ul><TermRow vocabularyId="v1" term={term as never} lang="en" /></ul>);
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(screen.queryByRole("alert")).toBeNull();
});
```
(Adjust the `lang`/`term` cast to match `TermView`. If a `term-row.test.tsx` already exists, append these and keep existing tests green. The `mutation-feedback.test.tsx` test #1`useUpdateTerm` success → `toast.saved` — still passes since suppress only affects errors; confirm it stays green.)
- [ ] **Step 5: Verify (vitest ONCE):**
```bash
cd web && pnpm vitest run src/vocab/term-row.test.tsx src/authorities src/components/delete-confirm-dialog.test.tsx src/api/mutation-feedback.test.tsx && pnpm typecheck && pnpm lint
```
Expected: green (new row tests pass; delete-dialog story/test still green; authority tests green). If `delete-confirm-dialog.test.tsx` doesn't exist, drop it from the command.
- [ ] **Step 6: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/queries.ts web/src/vocab/term-row.tsx web/src/authorities/authority-row.tsx web/src/components/delete-confirm-dialog.tsx web/src/vocab/term-row.test.tsx
git commit -m "feat(web): inline status-aware errors on term/authority edit rows + delete dialog (#63)"
```
---
# Task 4: Create/rename/object form adoption + fetch fix + full gate
**Files:** Modify `web/src/authorities/authorities-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/vocabulary-list.tsx`, `web/src/fields/field-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx` (+ a test).
- [ ] **Step 1: Adopt `<MutationError>` at the create/rename inline sites.** In each file, add `import { MutationError } from "../components/mutation-error";` (path `../components/mutation-error` from these dirs) and replace the `{X.isError && <p role="alert" className="text-xs text-destructive">{t("form.rejected")}</p>}` block with `<MutationError error={X.error} />`:
- `authorities-page.tsx:144-148``<MutationError error={create.error} />`
- `vocabulary-terms.tsx:119-123``<MutationError error={addTerm.error} />`
- `vocabulary-list.tsx:57-61``<MutationError error={create.error} />`; and `:109-113``<MutationError error={renameVocabulary.error} />`
- `field-form.tsx:201-205``<MutationError error={isEdit ? update.error : create.error} />`; then delete the now-unused `const failed = isEdit ? update.isError : create.isError;` (`:98`). Keep `const pending = …` and the `{error && …form.required}` validation block.
- [ ] **Step 2: Object create/edit catch-else via the helper.** In `web/src/objects/object-new-page.tsx`, add `import { errorMessageKey } from "../api/error-message";` and change the create catch (`:36-39`) from `} catch { setError(t("form.rejected")); return false; }` to:
```tsx
} catch (e) {
const { key, opts } = errorMessageKey(e);
setError(t(key, opts));
return false;
}
```
In `web/src/objects/object-edit-form.tsx`, add the same import and change the non-`FieldRejection` else (`:86-88`) from `setError(t("form.rejected"));` to:
```tsx
} else {
const { key, opts } = errorMessageKey(e);
setError(t(key, opts));
}
```
- [ ] **Step 3: Fetch-error fix in `web/src/objects/object-edit-form.tsx`.** Change `const { data: object, isLoading } = useObject(id!);` to `const { data: object, isLoading, isError } = useObject(id!);` and insert, between the `isLoading` and `!object` guards:
```tsx
if (isError) return <p className="p-4 text-sm text-destructive">{t("objects.loadError")}</p>;
```
- [ ] **Step 4: Test the fetch fix + one create-form adoption.** Add to `web/src/objects/object-edit-form.test.tsx` (create if absent):
```tsx
test("renders a load error (not 'not found') when the object fetch fails", async () => {
server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 500 })));
renderApp(<Routes><Route path="/objects/:id/edit" element={<ObjectEditForm />} /></Routes>, {
route: "/objects/abc/edit",
});
expect(await screen.findByText(/could not load/i)).toBeInTheDocument();
expect(screen.queryByText(/not found/i)).toBeNull();
});
```
(Use the file's existing imports/harness; add `http`/`HttpResponse` from `msw`, `Routes`/`Route`, `server`, `renderApp`, `ObjectEditForm` as needed. If the file exists, append and keep its tests green.) Also add to `web/src/authorities/authorities-page.test.tsx` (or the nearest existing authorities test) a case asserting a failed create shows the status message:
```tsx
test("a failed create shows a status-aware inline error", async () => {
server.use(http.post("/api/admin/authorities", () => new HttpResponse(null, { status: 403 })));
// …render the authorities page, fill a label, submit…
expect(await screen.findByText(/permission/i)).toBeInTheDocument();
});
```
(Wire the render/fill/submit to match the existing authorities test harness; if that harness isn't readily reusable, cover the create-error path through `field-form` or skip this second assertion — the `MutationError` component test already proves the rendering, and `error-message.test.ts` proves the mapping. Do NOT add a brittle test just to hit a number.)
- [ ] **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. Report test totals, largest chunk (gz), and the `check:colors` line.
- [ ] **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).** With the stack + server + `pnpm dev`: cause a failing save (e.g. stop the server, or a 409 on a duplicate) on a term/authority edit row → inline status message appears at the row, row stays editable; a failed create shows the inline message; loading an edit form whose object 500s shows "Could not load objects" not "not found."
- [ ] **Step 8: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/authorities/authorities-page.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/vocabulary-list.tsx web/src/fields/field-form.tsx web/src/objects/object-new-page.tsx web/src/objects/object-edit-form.tsx web/src/objects/object-edit-form.test.tsx web/src/authorities/authorities-page.test.tsx
git commit -m "feat(web): adopt MutationError across create/object forms; distinguish edit-form fetch error (#63)"
```
---
## Self-Review (completed)
**Spec coverage:** AC1 `errorMessageKey` + unit test (T1 S2-S3); AC2 `<MutationError>` + adoption at delete-dialog (T3 S3), create/rename forms (T4 S1), object-form `formError` via helper (T4 S2), edit rows (T3 S2); AC3 16 throws → `HttpError` + query-client rewire (T2, T1 S6); AC4 update suppress + inline + reset (T3 S1-S2); AC5 edit-form fetch error (T4 S3); AC6 gate + parity + codename (T4 S5-S6, T1 S1). ✓
**Placeholder scan:** every code step shows complete code; tests have concrete assertions and exact statuses (403/500); the one soft spot (T4 S4 second assertion) is explicitly bounded with "don't add a brittle test to hit a number," and the mapping/rendering are already proven by T1's two test files. No TODO/TBD. ✓
**Type/consistency:** `errorMessageKey(error: unknown): { key: string; opts? }` defined in T1, consumed identically by `query-client.ts`, `MutationError`, `delete-confirm-dialog`, `object-new-page`, `object-edit-form`. `HttpError(status)` (T2) feeds `errorMessageKey`'s `instanceof HttpError` branch. `suppressErrorToast` added (T3 S1) matches the sibling mutations' meta shape. `objects.loadError` pre-exists. ✓
## Notes
- No new dependency. `ui/*` untouched. en/sv parity preserved (5 new `errors.*` keys, guarded by the #60 parity test).
- After Task 2 (before Task 3), `useUpdateTerm`/`useUpdateAuthority` are still non-suppressed and now throw `HttpError`, so a failed update shows a status-mapped **toast** — a transient mid-milestone state; Task 3 moves it inline. Each commit is independently green.
- Error classes stay in `queries.ts`; relocating them to `api/errors.ts` is tracked in #65.
@@ -0,0 +1,295 @@
# Reference-Data Scannability + Parity — 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:** Make the three reference-data lists scannable (locale-aware sort + filter), show `external_uri` in read rows, add field-group count badges, and close the small parity/validation gaps — no layout/edit-modality change, no backend change.
**Architecture:** A shared `lib/sort.ts` (memoized `Intl.Collator` comparators) + a tiny `ExternalUriLink` are consumed by the vocab/authorities/fields list components. Each list gets a client-side filter `useState` + `<Input>`; rows are filtered then sorted before render.
**Tech Stack:** React 19 + TS + pnpm, react-i18next, Vitest + RTL + MSW. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (3 new keys); app source double-quote+semicolon; token classes only; **don't mutate query-cache arrays — sort a copy** (`[...list].sort(...)`).
**Spec:** `docs/superpowers/specs/2026-06-08-refdata-scannability-design.md`
**Key facts (from the code):**
- `labelText(labels, lang)` in `lib/labels.ts`. `lang = i18n.language.startsWith("sv") ? "sv" : "en"`.
- term/authority view = `{ id, labels, external_uri?: string|null }` (authority also `kind`); vocabulary = `{ id, key }`; field-def = `{ key, labels, data_type, group?, required, … }`.
- `term-row.tsx`/`authority-row.tsx`: read mode `<span className="flex-1">{labelText(...)}</span>` + Edit + DeleteConfirmDialog; edit mode has the `external_uri` `<Input>` (no `type`/placeholder).
- `vocabulary-list.tsx`: create form (has empty-guard) + list; rename `<form onSubmit>` calls `renameVocabulary.mutate({ id, key: draftKey.trim() })` with NO empty-guard.
- `authorities-page.tsx`: tabs + list + create form; create calls `create.mutate({ kind, external_uri: null, labels })` — hardcoded null, no URI field.
- `field-list.tsx`: groups built into a `Map`; sorted with `t("fields.other")` last (no AZ); group header `<div className="… label-caption">{group}</div>`; rows show label+key+type+required.
- `common` i18n: `{ yes, no, close, loading }`. `labels`: `{ label, externalUri, otherLanguages }`.
---
# Task 1: Shared `sort.ts` + `ExternalUriLink` + i18n + unit test
**Files:** `web/src/lib/sort.ts` (new), `web/src/lib/sort.test.ts` (new), `web/src/components/external-uri-link.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`.
- [ ] **Step 1: i18n (both locales, parity).**
- `common`: add `"filter"` (en "Filter…" / sv "Filtrera…") and `"noMatches"` (en "No matches" / sv "Inga träffar").
- `labels`: add `"uriPlaceholder": "https://…"` (same string in both).
- [ ] **Step 2: `web/src/lib/sort.ts`:**
```ts
import type { components } from "../api/schema";
import { labelText } from "./labels";
type LabelView = components["schemas"]["LabelView"];
const collators = new Map<string, Intl.Collator>();
function collatorFor(lang: string): Intl.Collator {
let c = collators.get(lang);
if (!c) {
c = new Intl.Collator(lang, { sensitivity: "base", numeric: true });
collators.set(lang, c);
}
return c;
}
export function compareStrings(lang: string, a: string, b: string): number {
return collatorFor(lang).compare(a, b);
}
export function byLabel(lang: string) {
return (a: { labels: LabelView[] }, b: { labels: LabelView[] }) =>
compareStrings(lang, labelText(a.labels, lang), labelText(b.labels, lang));
}
export function byKey(lang: string) {
return (a: { key: string }, b: { key: string }) => compareStrings(lang, a.key, b.key);
}
```
- [ ] **Step 3: `web/src/lib/sort.test.ts`** (write failing first, then it passes with Step 2):
```ts
import { expect, test } from "vitest";
import { byKey, byLabel, compareStrings } from "./sort";
const L = (label: string) => ({ labels: [{ lang: "en", label }] });
test("byLabel sorts case-insensitively and locale-aware", () => {
const sorted = [L("Iron"), L("bronze"), L("Amber")].sort(byLabel("en")).map((x) => x.labels[0].label);
expect(sorted).toEqual(["Amber", "bronze", "Iron"]);
});
test("byKey sorts keys with numeric awareness", () => {
const sorted = [{ key: "item10" }, { key: "item2" }, { key: "item1" }].sort(byKey("en")).map((x) => x.key);
expect(sorted).toEqual(["item1", "item2", "item10"]);
});
test("compareStrings is case-insensitive", () => {
expect(compareStrings("en", "bronze", "BRONZE")).toBe(0);
});
```
Run: `cd web && pnpm vitest run src/lib/sort.test.ts` → PASS (3 tests).
- [ ] **Step 4: `web/src/components/external-uri-link.tsx`** (app-source style):
```tsx
export function ExternalUriLink({ uri }: { uri: string }) {
return (
<a
href={uri}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-xs text-muted-foreground hover:text-foreground"
>
{uri}
</a>
);
}
```
- [ ] **Step 5: Verify (vitest ONCE):** `cd web && pnpm vitest run src/lib/sort.test.ts && pnpm typecheck && pnpm lint`. PASS, clean.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/sort.ts web/src/lib/sort.test.ts web/src/components/external-uri-link.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): collator sort helpers + ExternalUriLink + filter/uri i18n (#50)"
```
---
# Task 2: Vocabularies — filter + sort + URI + rename guard
**Files:** `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/term-row.tsx`, `web/src/vocab/vocabularies.test.tsx`.
- [ ] **Step 1: `vocabulary-list.tsx`** — add `i18n` to `useTranslation` (`const { t, i18n } = …`) and `const lang = i18n.language.startsWith("sv") ? "sv" : "en";`; import `byKey` from `../lib/sort` and `Input` is already imported. Add `const [filter, setFilter] = useState("");`.
- Add a filter `<Input>` between the create `<form>` and the list block:
```tsx
<div className="border-b p-2">
<Input
aria-label={t("common.filter")}
placeholder={t("common.filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
```
- In the loaded `<ul>`, replace `{data?.map((v) => (…` with a filtered+sorted list:
```tsx
{(() => {
const q = filter.trim().toLowerCase();
const rows = [...(data ?? [])]
.filter((v) => !q || v.key.toLowerCase().includes(q))
.sort(byKey(lang));
if (data && data.length > 0 && rows.length === 0)
return <li className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</li>;
return rows.map((v) => (/* the EXISTING <li> row markup, unchanged */));
})()}
```
Keep the `isError`/empty(`data?.length === 0`) branches as-is. (Prefer a clean inline: compute `rows` before the `return`, but the IIFE keeps it local — implementer may hoist `const rows = …` above the JSX instead, whichever is cleaner and lint-clean.)
- **Rename empty-guard:** in the rename `<form onSubmit>`, add a guard before mutate:
```tsx
onSubmit={(e) => {
e.preventDefault();
if (!draftKey.trim()) return;
renameVocabulary.mutate({ id: v.id, key: draftKey.trim() }, { onSuccess: () => setEditingId(null) });
}}
```
- [ ] **Step 2: `vocabulary-terms.tsx`** — add `const [filter, setFilter] = useState("");`; import `byLabel` from `../lib/sort`. Add a filter `<Input>` above the terms list (the component already has `Input` imported; place it above the `{isLoading ? … : <ul>}` block). In the `<ul>`, replace `{terms?.map((term) => …}` with filtered (`labelText(term.labels, lang).toLowerCase().includes(q)`) + `.sort(byLabel(lang))`; filtered-empty (terms exist) → `common.noMatches` `<li>`. Add `type="url"` + `placeholder={t("labels.uriPlaceholder")}` to the add-term `external_uri` `<Input id="term-uri">`.
- [ ] **Step 3: `term-row.tsx`** — import `ExternalUriLink` from `../components/external-uri-link`. In read mode, wrap the label + URI in a flex-1 column so the URI shows under the label:
```tsx
<div className="flex-1">
<div>{labelText(term.labels, lang)}</div>
{term.external_uri && <ExternalUriLink uri={term.external_uri} />}
</div>
```
(Replace the existing `<span className="flex-1">{labelText(...)}</span>`; keep the Edit + DeleteConfirmDialog.) Add `type="url"` + `placeholder={t("labels.uriPlaceholder")}` to the edit-mode `external_uri` `<Input>`.
- [ ] **Step 4: Tests** (`vocabularies.test.tsx`) — extend (mirror the existing MSW/`tree()` setup; check the term/vocab fixtures for labels to assert order):
- vocabularies render **sorted by key** (seed/confirm the fixture has out-of-order keys; assert DOM order).
- typing in the vocab **filter** narrows the list (type a substring → only matching vocab shows).
- **rename with an empty key** does NOT call the rename endpoint (override the PATCH/PUT handler with a spy; clear the rename input and submit; assert no request).
- a **term read row shows its `external_uri`** as a link (seed a term fixture with `external_uri`; assert `getByRole("link", { name: /<uri>/ })`). (If the term fixture lacks a URI, add one — check `materialTerms` in `web/src/test/fixtures.ts`.)
Keep existing vocab tests green. Don't weaken.
- [ ] **Step 5: Verify (vitest ONCE):** `cd web && pnpm vitest run src/vocab && pnpm typecheck && pnpm lint`. PASS.
- [ ] **Step 6: Commit**
```bash
git add web/src/vocab/vocabulary-list.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/term-row.tsx web/src/vocab/vocabularies.test.tsx
git commit -m "feat(web): vocab list/terms sort+filter, external_uri in rows, rename guard, url input (#50)"
```
---
# Task 3: Authorities — filter + sort + create URI + read URI
**Files:** `web/src/authorities/authorities-page.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/authorities/authorities.test.tsx`.
- [ ] **Step 1: `authorities-page.tsx`** — add `const [filter, setFilter] = useState("");` and `const [uri, setUri] = useState("");`; import `byLabel` from `../lib/sort` and `Input`/`Label` (`Input` may need importing — check; `Button` already imported).
- **Filter `<Input>`** above the list block (below the tablist):
```tsx
<div className="mb-3">
<Input aria-label={t("common.filter")} placeholder={t("common.filter")} value={filter} onChange={(e) => setFilter(e.target.value)} />
</div>
```
- In the loaded `<ul>`, replace `{authorities?.map((a) => …}` with filtered (`labelText(a.labels, lang).toLowerCase().includes(q)`) + `.sort(byLabel(lang))`; filtered-empty (authorities exist) → `common.noMatches` `<li>`.
- **Create form gains an `external_uri` field** (after the `<LabelEditor>`):
```tsx
<div className="space-y-1">
<Label htmlFor="auth-create-uri">{t("labels.externalUri")}</Label>
<Input id="auth-create-uri" type="url" placeholder={t("labels.uriPlaceholder")} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
```
- In `onCreate`, send the URI and reset it:
```tsx
create.mutate(
{ kind: kind as string, external_uri: uri.trim() || null, labels },
{ onSuccess: () => { setLabels([]); setUri(""); } },
);
```
- [ ] **Step 2: `authority-row.tsx`** — same as term-row: import `ExternalUriLink`; read mode shows label + `ExternalUriLink` (when `authority.external_uri`) in a `flex-1` column; edit `external_uri` `<Input>` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`.
- [ ] **Step 3: Tests** (`authorities.test.tsx`) — extend (mirror existing setup; check `personAuthorities` fixture):
- list **sorted by label** (assert DOM order; ensure fixture has out-of-order labels or seed it).
- **filter** narrows the list.
- the **create form has an `external_uri` field** and a created authority **posts the entered URI** (spy the POST body; type a URI; assert `body.external_uri`).
- a **read row shows the `external_uri`** link (seed a fixture authority with a URI).
Keep existing authorities tests green (tabs, aria-selected, create-without-label alert, 500, redirect).
- [ ] **Step 4: Verify (vitest ONCE):** `cd web && pnpm vitest run src/authorities && pnpm typecheck && pnpm lint`. PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/authorities/authorities-page.tsx web/src/authorities/authority-row.tsx web/src/authorities/authorities.test.tsx
git commit -m "feat(web): authorities sort+filter, create external_uri, external_uri in rows, url input (#50)"
```
---
# Task 4: Fields — filter + within-group sort + group order + count badges + gate
**Files:** `web/src/fields/field-list.tsx`, `web/src/fields/fields.test.tsx`.
- [ ] **Step 1: `field-list.tsx`** — import `byLabel` and `compareStrings` from `../lib/sort` and `Badge` from `@/components/ui/badge`. Add `const [filter, setFilter] = useState("");` (add `useState` to the React import). After the early returns, before grouping:
- Filter `data` by label/key: `const q = filter.trim().toLowerCase(); const filtered = (data ?? []).filter((d) => !q || labelText(d.labels, lang).toLowerCase().includes(q) || d.key.toLowerCase().includes(q));`
- Build the `groups` Map from `filtered` (not `data`).
- **Sort group entries** named AZ (collator), `t("fields.other")` last:
```tsx
const otherLabel = t("fields.other");
const entries = [...groups.entries()].sort((a, b) => {
if (a[0] === otherLabel) return 1;
if (b[0] === otherLabel) return -1;
return compareStrings(lang, a[0], b[0]);
});
```
- **Sort each group's defs** by `byLabel(lang)`: when rendering `defs.map`, use `[...defs].sort(byLabel(lang)).map(...)`.
- **Count badge** in each group header:
```tsx
<div className="flex items-center justify-between border-b bg-muted px-3 py-1 label-caption">
<span>{group}</span>
<Badge variant="secondary">{defs.length}</Badge>
</div>
```
- Render a **filter `<Input>`** above the `<ul>` (wrap the return in a fragment/column): a `<div className="border-b p-2"><Input aria-label={t("common.filter")} placeholder={t("common.filter")} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>` then the `<ul>`. If `filtered.length === 0` (data exists), show `<p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p>` instead of the `<ul>`. (Import `Input` from `@/components/ui/input`.)
Note: the filter Input must remain visible during the empty-filter state so the user can clear it.
- [ ] **Step 2: Tests** (`fields.test.tsx`) — extend (mirror existing setup; `fieldDefinitions` fixture):
- fields render **sorted within their group by label** (assert relative DOM order of two fields in the same group).
- a **group header shows a count badge** (assert the count number is present near a group label).
- typing in the **filter** narrows the visible fields (and/or shows `common.noMatches` when nothing matches).
Keep the existing fields tests green (grouped list, create text field, reveal pickers).
- [ ] **Step 3: 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 (KB gz), check:colors line.
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: each reference list is alphabetized and has a working filter; term/authority rows show their external_uri as a muted link; field groups show counts; the authority create form has a URI field; renaming a vocab to empty does nothing; URI inputs are `type=url` with a placeholder.
- [ ] **Step 6: Commit**
```bash
git add web/src/fields/field-list.tsx web/src/fields/fields.test.tsx
git commit -m "feat(web): field-list filter, within-group label sort, group order, count badges (#50)"
```
---
## Self-Review (completed)
**Spec coverage:** sort.ts collator + byLabel/byKey + unit test (T1); ExternalUriLink + i18n (T1); vocab list/terms sort+filter + rename guard + read URI + url input (T2); authorities sort+filter + create URI + read URI + url input (T3); fields filter + within-group sort + group AZ + count badges (T4); gate (T4). Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the list-rewrite steps say "the EXISTING row markup, unchanged" with the files quoted, and tests say "check the fixture / seed a URI" with the fixture files named — concrete, not vague. The IIFE-vs-hoist choice is explicitly the implementer's (both lint-clean). No TODOs. ✓
**Type/consistency:** `byLabel(lang)`/`byKey(lang)`/`compareStrings(lang,a,b)` defined in T1, consumed in T2/T3/T4; `ExternalUriLink({uri})` used in term-row + authority-row; the filter `useState`/predicate pattern is uniform across the four lists; `[...arr].sort(...)` (copy, never mutate cache) everywhere. ✓
## Notes
- No new dependency; 3 new i18n keys (`common.filter`, `common.noMatches`, `labels.uriPlaceholder`), en+sv.
- Counts: only field-group counts (client-side). Per-vocab term & per-kind authority counts need backend → follow-up.
- Always sort a COPY of the react-query data (never mutate the cached array).
@@ -0,0 +1,140 @@
# Search-Result Date Meta + Estimated-Count Copy — 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:** Thread `recording_date` through the search index/hit/view (Rust), show it on the result row, and soften the estimated-count copy.
**Architecture:** Backend adds `recording_date: Option<String>` (`YYYY-MM-DD`) to `SearchDocument` (projected in `build_document`), `SearchHit` (mapped in `search_objects`), and `SearchHitView` (API). The frontend type comes from the regenerated/edited `schema.d.ts`; the row renders the date and the count copy gains a `~`.
**Tech Stack:** Rust (search + api crates, Meilisearch, utoipa), React + TS + pnpm. Tests need the docker stack (Postgres :5442, Meilisearch :7700 — already up). Rust test runner: `cargo nextest`. Frontend: `pnpm test`.
**Conventions:** `cargo +nightly fmt` + `cargo clippy --workspace --all-targets -- -D warnings` for Rust; pnpm + no `any`/`eslint-disable` + en/sv parity for web; no codename; manage deps via cargo-mcp if any (none here). Run a single test pass.
**Spec:** `docs/superpowers/specs/2026-06-08-search-row-date-design.md`
**Key facts:**
- `crates/search/src/lib.rs`: `SearchDocument` (`:30`), `SearchHit` (`:52`), `build_document` (`:302`, returns the `SearchDocument { … }` literal around `:363`), `search_objects` hit map (`~:220`). `domain::Date` Display = ISO `YYYY-MM-DD`; `CatalogueObject.recording_date: Option<Date>`.
- `crates/api/src/admin_search.rs`: `SearchHitView` (`:26`) + map closure (`:100`).
- `crates/search/tests/search.rs`: a `doc(...)` helper builds a `SearchDocument` literal; `search_objects_returns_hits_…` (`:55`) seeds 3 docs + asserts. `tests/sync.rs`/`tests/reindex.rs` construct `CatalogueObject` (already has `recording_date` — unaffected) but may also build `SearchDocument` — check.
- `web/src/api/schema.d.ts` `SearchHitView` block (`:601`): keys are alphabetized — insert `recording_date?: string | null;` between `object_number` and `snippet`.
- `web/src/search/search-result-row.tsx`: meta line `<span>{hit.object_number}</span> <VisibilityBadge/>`.
- i18n `search.resultCount_one/_other`; `web/src/test/fixtures.ts` `searchHits`; `web/src/search/search.test.tsx` asserts `/25 results/i`.
---
# Task 1: Backend — `recording_date` through index → hit → view (+ schema)
**Files:** `crates/search/src/lib.rs`, `crates/api/src/admin_search.rs`, `crates/search/tests/search.rs` (+ any test with a `SearchDocument`/`SearchHit` literal), `web/src/api/schema.d.ts`.
- [ ] **Step 1: `SearchDocument` + `build_document`** (`crates/search/src/lib.rs`):
- Add `pub recording_date: Option<String>,` to `SearchDocument` (after `recorder`, before `visibility` — keep a sensible order).
- In the `Ok(SearchDocument { … })` literal at the end of `build_document`, add `recording_date: object.recording_date.map(|d| d.to_string()),`.
- [ ] **Step 2: `SearchHit` + `search_objects` mapping** (`crates/search/src/lib.rs`):
- Add `pub recording_date: Option<String>,` to `SearchHit` (after `visibility`, before `snippet` or near it).
- In `search_objects`, the `SearchHit { id, object_number, object_name, brief_description, visibility, snippet }` literal: add `recording_date: doc.recording_date,`.
- [ ] **Step 3: `SearchHitView`** (`crates/api/src/admin_search.rs`):
- Add `pub recording_date: Option<String>,` to `SearchHitView`.
- In the `.map(|h| SearchHitView { … })` closure: add `recording_date: h.recording_date,`.
- [ ] **Step 4: Fix test literals + extend the search test** (`crates/search/tests/search.rs`):
- The `doc(...)` helper builds a `SearchDocument` literal — add `recording_date: None,` to it (compile fix). Grep the search + api test dirs for other `SearchDocument {`/`SearchHit {` literals and add `recording_date: None` where needed (object fixtures that build `CatalogueObject` are unaffected — they already have `recording_date`).
- In `search_objects_returns_hits_with_highlight_filter_and_paging`, set a date on one seeded doc before indexing: `bronze_a.recording_date = Some("1962-04-03".to_string());` and after the existing `hit` lookup assert: `assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));`.
- [ ] **Step 5: Build + lint + test (stack is up).**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
cargo build --workspace
cargo +nightly fmt
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run -p search -p api
```
Expected: compiles, fmt clean, clippy clean, tests pass (incl. the new `recording_date` assertion). Run the test suite ONCE (single Postgres — no concurrent cargo test runs). If clippy/fmt change files, include them.
- [ ] **Step 6: Update `web/src/api/schema.d.ts`** — add the field to the `SearchHitView` block (alphabetical, between `object_number` and `snippet`):
```ts
SearchHitView: {
brief_description?: string | null;
id: string;
object_name: string;
object_number: string;
recording_date?: string | null;
snippet?: string | null;
visibility: components["schemas"]["Visibility"];
};
```
(This mirrors what `pnpm gen:api` would emit now that the backend returns the field; a later regen reproduces it identically. Optionally verify with `pnpm gen:api` if a server is running — not required.)
- [ ] **Step 7: Commit**
```bash
git add crates/search/src/lib.rs crates/api/src/admin_search.rs crates/search/tests/search.rs web/src/api/schema.d.ts
# (+ any other test files you fixed)
git commit -m "feat(search): index + return recording_date on search hits (#61)"
```
---
# Task 2: Frontend — row date + softened count + tests + gate
**Files:** `web/src/search/search-result-row.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/test/fixtures.ts`, `web/src/search/search.test.tsx`.
- [ ] **Step 1: `search-result-row.tsx`** — show the date on the meta line when present:
```tsx
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>{hit.object_number}</span>
{hit.recording_date && <span>· {hit.recording_date}</span>}
<VisibilityBadge visibility={hit.visibility} />
</div>
```
(Render the `YYYY-MM-DD` string verbatim — it's a recording date, not a UTC timestamp.)
- [ ] **Step 2: i18n — soften the count** (both locales, parity; the `#60` parity test guards this):
- en.json `search`: `"resultCount_one": "~{{count}} result"`, `"resultCount_other": "~{{count}} results"`.
- sv.json `search`: `"resultCount_one": "~{{count}} träff"`, `"resultCount_other": "~{{count}} träffar"`.
(No code change in `search-panel.tsx` — the key already interpolates `count`.)
- [ ] **Step 3: Fixtures** (`web/src/test/fixtures.ts`) — add `recording_date` to the first `searchHits` entry:
`recording_date: "1962-04-03",` (the generated rest can be `recording_date: null` or omit it — it's optional; set `null` on the mapped `Array.from(...)` items to satisfy the type cleanly).
- [ ] **Step 4: Tests** (`web/src/search/search.test.tsx`):
- Assert the first result row shows the date: after results render, `expect(screen.getByText("1962-04-03")).toBeInTheDocument();` (or scope within the row).
- Tighten the count assertion to require the `~`: change `getByText(/25 results/i)``getByText(/~\s*25 results/i)` (or assert `getByText(/~25 results/i)`).
Keep the existing load-more + visibility-filter tests green.
- [ ] **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. Report test totals, largest chunk, check:colors line.
- [ ] **Step 6: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src crates; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 7: Manual smoke (recommended).** With the stack + server + web dev up: search the collection — result rows show the recording date (for objects that have one and have been (re)indexed); the count reads "~N results".
- [ ] **Step 8: Commit**
```bash
git add web/src/search/search-result-row.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/test/fixtures.ts web/src/search/search.test.tsx
git commit -m "feat(web): show recording_date on search rows; flag estimated count as approximate (#61)"
```
---
## Self-Review (completed)
**Spec coverage:** recording_date in SearchDocument/build_document (T1 S1), SearchHit/search_objects (T1 S2), SearchHitView (T1 S3), schema.d.ts (T1 S6); Rust test literal fixes + flow assertion (T1 S4); row date (T2 S1); softened count i18n (T2 S2); fixtures + tests (T2 S3S4); Rust + frontend gates (T1 S5, T2 S5). Acceptance criteria 14 mapped. ✓
**Placeholder scan:** the schema.d.ts edit is given verbatim; the test changes name exact assertions; "grep for other SearchDocument/SearchHit literals" is a concrete compile-driven step (the compiler names them if missed). No vague steps. ✓
**Type/consistency:** `recording_date: Option<String>` (Rust) ↔ `recording_date?: string | null` (TS) consistent; `object.recording_date.map(|d| d.to_string())` (YYYY-MM-DD) matches `AdminObjectView`; the frontend reads `hit.recording_date` from the regenerated type. ✓
## Notes
- No new dependency. en/sv parity preserved (only `resultCount_*` values change; guarded by #60).
- Backfill: already-indexed objects show no date until `reindex_all`; new/edited objects index it via `sync_object`. A `reindex` CLI is a follow-up.
- Rust tests need the docker stack (up); run cargo tests ONCE (shared Postgres).
- `cargo nextest run -p search -p api` covers the touched crates; a full `cargo build --workspace` ensures nothing else broke from the struct change.
@@ -0,0 +1,448 @@
# Session-Expiry Soft Redirect + Auth Feedback — 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:** Replace the full-page-reload 401 redirect with a router soft-redirect that preserves SPA state, carries a "session expired" reason + return-to path, and add the missing login/logout feedback.
**Architecture:** A non-React navigate-bridge module (`auth-redirect.ts`) holds a settable `navigate` ref; a one-line `NavigationBridge` component (mounted at the router root) registers React Router's navigate into it. The openapi-fetch 401 middleware calls `redirectToLogin()`, which soft-navigates to `/login?reason=expired&from=<path>`. The login page reads `reason`/`from` (validated against open redirects), `RequireAuth` captures the attempted path, and the user menu shows a logout-pending state.
**Tech Stack:** React 19 + TS + pnpm, React Router 7 (data router), TanStack Query v5, react-i18next, Base UI menu, Vitest 4 (jsdom project) + RTL + MSW. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; app source double-quote+semicolon; token classes only; `ui/` files use no-semicolon style (do not touch `ui/menu.tsx`). Run a single test pass.
**Spec:** `docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md`
**Key facts:**
- `web/src/api/auth-redirect.ts` currently: `redirectToLogin()``window.location.assign("/login")` (guarded by `pathname !== "/login"`). Called from `web/src/api/client.ts` middleware on `response.status === 401`. Do **not** change `client.ts`.
- `web/src/app.tsx` builds a data router via `createBrowserRouter(createRoutesFromElements(<>…</>))`. Top-level children: `<Route path="/login">`, `<Route element={<RequireAuth/>}>…</Route>`, `<Route path="*">`. Imports `Navigate`, `Route`, `Outlet` is **not** yet imported (add it).
- `web/src/test/render.tsx` `renderApp(ui, {route})` mounts `ui` at `path:"*"` in a `createMemoryRouter` — tests pass their own `<Routes>` tree. jsdom project url is `http://localhost`.
- `vi.stubGlobal("location", {...})` is the established way to stub `window.location` here (see `theme-switch.test.tsx` stubbing `matchMedia`); restore with `vi.unstubAllGlobals()`.
- `login-page.tsx`: `useLogin()` mutation; success currently `navigate("/objects", {replace:true})`; submit `disabled={login.isPending}`; existing error alert uses `role="alert"`. Existing tests: `login-page.test.tsx` (`tree()` with `/login` + `/objects` routes).
- `require-auth.tsx`: `useMe()`; `isLoading``<AppShellSkeleton/>`; `!user``<Navigate to="/login" replace/>`. Test: `require-auth.test.tsx`.
- `user-menu.tsx`: `useLogout()`, `onSignOut` navigates to `/login` on success; `<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>`; returns `null` when `!me`. Base UI `MenuItem` supports `closeOnClick` + `disabled`. Test: `user-menu.test.tsx`.
- i18n `auth` block keys: `email/password/signIn/signOut/invalid/networkError` in both `en.json` and `sv.json`.
---
# Task 1: Navigate bridge + soft redirect
**Files:**
- Modify: `web/src/api/auth-redirect.ts`
- Create: `web/src/api/auth-redirect.test.ts`
- Create: `web/src/shell/navigation-bridge.tsx`
- Modify: `web/src/app.tsx`
- [ ] **Step 1: Rewrite `web/src/api/auth-redirect.ts`:**
```ts
type NavigateFn = (to: string, opts?: { replace?: boolean }) => void;
let navigateFn: NavigateFn | null = null;
/** Register (or clear) the router's navigate fn. Called by NavigationBridge. */
export function setNavigate(fn: NavigateFn | null): void {
navigateFn = fn;
}
/** Soft-redirect to login on a 401, preserving SPA state and the return path.
* Falls back to a hard navigation when no router navigate is registered yet
* (e.g. a 401 during the very first load). No-op when already on /login. */
export function redirectToLogin(): void {
const { pathname, search } = window.location;
if (pathname === "/login") return;
const from = encodeURIComponent(pathname + search);
const target = `/login?reason=expired&from=${from}`;
if (navigateFn) {
navigateFn(target, { replace: true });
} else {
window.location.assign(target);
}
}
```
- [ ] **Step 2: Write the failing test `web/src/api/auth-redirect.test.ts`:**
```ts
import { afterEach, expect, test, vi } from "vitest";
import { redirectToLogin, setNavigate } from "./auth-redirect";
function stubLocation(pathname: string, search = "") {
const assign = vi.fn();
vi.stubGlobal("location", { pathname, search, assign });
return assign;
}
afterEach(() => {
setNavigate(null);
vi.unstubAllGlobals();
});
test("uses the registered navigate to soft-redirect with reason + from", () => {
const assign = stubLocation("/objects/abc", "?x=1");
const navigate = vi.fn();
setNavigate(navigate);
redirectToLogin();
expect(navigate).toHaveBeenCalledWith(
"/login?reason=expired&from=%2Fobjects%2Fabc%3Fx%3D1",
{ replace: true },
);
expect(assign).not.toHaveBeenCalled();
});
test("falls back to a hard navigation when no navigate is registered", () => {
const assign = stubLocation("/objects/abc");
redirectToLogin();
expect(assign).toHaveBeenCalledWith("/login?reason=expired&from=%2Fobjects%2Fabc");
});
test("does nothing when already on /login", () => {
stubLocation("/login");
const navigate = vi.fn();
setNavigate(navigate);
redirectToLogin();
expect(navigate).not.toHaveBeenCalled();
});
```
- [ ] **Step 3: Run the test — expect PASS** (the implementation in Step 1 already satisfies it):
```bash
cd web && pnpm vitest run src/api/auth-redirect.test.ts
```
Expected: 3 passing. (If you wrote the test before the impl, it would fail on the missing `setNavigate` export — either order is fine; end state is green.)
- [ ] **Step 4: Create `web/src/shell/navigation-bridge.tsx`:**
```tsx
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { setNavigate } from "../api/auth-redirect";
/** Bridges React Router's navigate to the non-React 401 handler. Renders nothing. */
export function NavigationBridge() {
const navigate = useNavigate();
useEffect(() => {
setNavigate(navigate);
return () => setNavigate(null);
}, [navigate]);
return null;
}
```
- [ ] **Step 5: Wrap the routes in `web/src/app.tsx`** so the bridge is always mounted. Add `Outlet` to the `react-router-dom` import and import the bridge:
```tsx
import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route, RouterProvider } from "react-router-dom";
```
```tsx
import { NavigationBridge } from "./shell/navigation-bridge";
```
Add this component above `const router = …`:
```tsx
function RootLayout() {
return (
<>
<NavigationBridge />
<Outlet />
</>
);
}
```
Wrap the existing top-level fragment children in a pathless `<Route element={<RootLayout />}>` — i.e. change `createRoutesFromElements(<> … </>)` so the outer element is `<Route element={<RootLayout />}> … </Route>` containing the existing `/login`, `RequireAuth`, and `*` routes unchanged:
```tsx
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
{/* …AppShell subtree exactly as before… */}
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</Route>,
),
);
```
- [ ] **Step 6: Verify build + lint + existing app test (vitest ONCE for the touched files):**
```bash
cd web && pnpm vitest run src/api/auth-redirect.test.ts src/app.test.tsx && pnpm typecheck && pnpm lint
```
Expected: PASS. `app.test.tsx` must stay green (the pathless wrapper is transparent — same rendered routes).
- [ ] **Step 7: Commit**
```bash
git add web/src/api/auth-redirect.ts web/src/api/auth-redirect.test.ts web/src/shell/navigation-bridge.tsx web/src/app.tsx
git commit -m "feat(web): soft-redirect to login on 401 via a navigate bridge (#48)"
```
---
# Task 2: Login page — reason banner, return-to, empty-field guard
**Files:**
- Modify: `web/src/auth/login-page.tsx`
- Modify: `web/src/auth/login-page.test.tsx`
- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json`
- [ ] **Step 1: Add i18n keys** (both locales, parity). In `web/src/i18n/en.json` `auth` block add:
```json
"sessionExpired": "Your session expired — please sign in again.",
"signingOut": "Signing out…"
```
In `web/src/i18n/sv.json` `auth` block add:
```json
"sessionExpired": "Din session har gått ut — logga in igen.",
"signingOut": "Loggar ut…"
```
(Place them after the existing `networkError` entry; mind trailing commas. `signingOut` is consumed in Task 3 — add both now so the parity test stays green.)
- [ ] **Step 2: Update `web/src/auth/login-page.tsx`.** Add `useSearchParams` to the import and a `safeFrom` helper; show the reason banner; route to `safeFrom` on success; guard the submit. Full changes:
Import line:
```tsx
import { useNavigate, useSearchParams } from "react-router-dom";
```
Add a module-level helper (above `export function LoginPage`):
```tsx
/** Accept only a single-leading-slash local path; reject protocol-relative
* ("//host") and absolute URLs to avoid an open redirect. */
function safeFrom(raw: string | null): string {
if (!raw) return "/objects";
return /^\/(?!\/)/.test(raw) ? raw : "/objects";
}
```
Inside the component, after `const navigate = useNavigate();`:
```tsx
const [params] = useSearchParams();
const sessionExpired = params.get("reason") === "expired";
```
Change the success navigation:
```tsx
{ onSuccess: () => navigate(safeFrom(params.get("from")), { replace: true }) },
```
Add the banner just inside the `<form>`, above the email field (after the `<h1>`):
```tsx
{sessionExpired && (
<p className="text-sm text-muted-foreground">{t("auth.sessionExpired")}</p>
)}
```
Change the submit button disabled condition:
```tsx
<Button type="submit" className="w-full" disabled={login.isPending || !email.trim() || !password}>
```
- [ ] **Step 3: Update `web/src/auth/login-page.test.tsx`.** Extend `tree()` with an `:id` destination and add tests. Replace the file's `tree()` and append the new tests:
```tsx
function tree() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/objects" element={<div>objects landing</div>} />
<Route path="/objects/:id" element={<div>object detail</div>} />
</Routes>
);
}
```
Add these tests (keep the two existing ones):
```tsx
test("shows the session-expired notice when reason=expired", async () => {
renderApp(tree(), { route: "/login?reason=expired" });
expect(await screen.findByText(/session expired/i)).toBeInTheDocument();
});
test("returns to the from path on success", async () => {
renderApp(tree(), { route: "/login?from=%2Fobjects%2F123" });
await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com");
await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText("object detail")).toBeInTheDocument();
});
test("rejects an off-site from and falls back to /objects", async () => {
renderApp(tree(), { route: "/login?from=%2F%2Fevil.com" });
await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com");
await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText("objects landing")).toBeInTheDocument();
});
test("disables submit until both fields are filled", async () => {
renderApp(tree(), { route: "/login" });
const button = screen.getByRole("button", { name: /sign in/i });
expect(button).toBeDisabled();
await userEvent.type(screen.getByLabelText(/email/i), "a@b.se");
expect(button).toBeDisabled();
await userEvent.type(screen.getByLabelText(/password/i), "pw");
expect(button).toBeEnabled();
});
```
(The existing "successful login navigates to /objects" test has no `from`, so `safeFrom(null)``/objects` keeps it green. The default MSW `/api/admin/login` handler returns 204 for `editor@example.com` / `pw-editor-123`.)
- [ ] **Step 4: Run the login + i18n tests (vitest ONCE):**
```bash
cd web && pnpm vitest run src/auth/login-page.test.tsx src/i18n
```
Expected: all green (login-page tests + the i18n parity test covering the 2 new keys).
- [ ] **Step 5: Commit**
```bash
git add web/src/auth/login-page.tsx web/src/auth/login-page.test.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): login reason banner + return-to + empty-field guard (#48)"
```
---
# Task 3: RequireAuth return-to + logout pending + full gate
**Files:**
- Modify: `web/src/auth/require-auth.tsx`
- Modify: `web/src/auth/require-auth.test.tsx`
- Modify: `web/src/shell/user-menu.tsx`
- Modify: `web/src/shell/user-menu.test.tsx`
- [ ] **Step 1: Update `web/src/auth/require-auth.tsx`** to capture the attempted path:
```tsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useMe } from "../api/queries";
import { AppShellSkeleton } from "@/components/ui/skeletons";
export function RequireAuth() {
const { data: user, isLoading } = useMe();
const location = useLocation();
if (isLoading) return <AppShellSkeleton />;
if (!user) {
const from = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?from=${from}`} replace />;
}
return <Outlet />;
}
```
- [ ] **Step 2: Update `web/src/auth/require-auth.test.tsx`** so the login stub echoes the search string, and assert `from` is carried. Replace the `tree()` and the redirect test:
```tsx
import { screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { Route, Routes, useLocation } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { RequireAuth } from "./require-auth";
function LoginStub() {
const location = useLocation();
return <div>login page {location.search}</div>;
}
function tree() {
return (
<Routes>
<Route path="/login" element={<LoginStub />} />
<Route element={<RequireAuth />}>
<Route path="/objects" element={<div>secret objects</div>} />
</Route>
</Routes>
);
}
test("renders children when authenticated", async () => {
renderApp(tree(), { route: "/objects" });
expect(await screen.findByText("secret objects")).toBeInTheDocument();
});
test("redirects unauthenticated users to /login carrying the attempted path", async () => {
server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 })));
renderApp(tree(), { route: "/objects" });
await waitFor(() => expect(screen.getByText(/from=%2Fobjects/)).toBeInTheDocument());
});
```
- [ ] **Step 3: Update `web/src/shell/user-menu.tsx`** to show a logout-pending state. Change only the `<MenuItem>`:
```tsx
<MenuItem closeOnClick={false} disabled={logout.isPending} onClick={onSignOut}>
{logout.isPending ? t("auth.signingOut") : t("auth.signOut")}
</MenuItem>
```
(Everything else in the file is unchanged. `onSignOut` already navigates to `/login` on success, which unmounts the menu since `useMe` becomes null.)
- [ ] **Step 4: Add a pending-state test to `web/src/shell/user-menu.test.tsx`.** Add `delay` to the msw import and append a test:
```tsx
import { delay, http, HttpResponse } from "msw";
```
```tsx
test("shows a pending state on Sign out while logging out", async () => {
server.use(
http.post("/api/admin/logout", async () => {
await delay(50);
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(<UserMenu />);
const trigger = await screen.findByRole("button", { name: /editor@example.com/ });
await userEvent.click(trigger);
const menu = within(document.body);
await userEvent.click(await menu.findByText("Sign out"));
expect(await menu.findByText(/signing out/i)).toBeInTheDocument();
});
```
(The existing "signs out" test still passes: `closeOnClick={false}` keeps the item, the POST still fires, `loggedOut` flips true.)
- [ ] **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. Report test totals, largest chunk (gz), and the `check:colors` line.
- [ ] **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 codename matches (`codename-exit=1`).
- [ ] **Step 7: Manual smoke (recommended).** With the stack + server + `pnpm dev` up: open an object edit, let the session expire (or delete the session cookie) and trigger a save → you land on `/login` **without a full reload**, see "Your session expired…", and after re-login return to the same record. Deep-link to a route while logged out → login → returns there. Empty login form → Sign in disabled. Click Sign out → brief "Signing out…".
- [ ] **Step 8: Commit**
```bash
git add web/src/auth/require-auth.tsx web/src/auth/require-auth.test.tsx web/src/shell/user-menu.tsx web/src/shell/user-menu.test.tsx
git commit -m "feat(web): return-to-destination on auth redirect; logout pending state (#48)"
```
---
## Self-Review (completed)
**Spec coverage:** navigate bridge + soft redirect with reason/from (T1 — AC1); login reason banner +
validated return-to + empty-field guard (T2 — AC2, AC4 first half); `RequireAuth` return-to (T3 S1S2 —
AC3); logout pending state (T3 S3S4 — AC4 second half); i18n 2 keys + parity (T2 S1); full gate +
codename (T3 S5S6 — AC5). All 5 acceptance criteria mapped. ✓
**Placeholder scan:** every code step shows complete code; tests have concrete assertions and exact
encoded URLs (`%2Fobjects%2Fabc%3Fx%3D1`, `%2F%2Fevil.com`); `vi.stubGlobal("location", …)` is the
established stub. No TODO/TBD. ✓
**Type consistency:** `setNavigate(fn: NavigateFn | null)` defined in T1 and called with React Router's
`navigate` (compatible signature `(to, {replace})`) in `NavigationBridge`, and with `null` in cleanup;
`redirectToLogin()` signature unchanged so `client.ts` needs no edit; `safeFrom(raw: string | null)`
consumed only in `login-page.tsx`. i18n keys `auth.sessionExpired` / `auth.signingOut` added in T2 and
`auth.signingOut` consumed in T3. ✓
## Notes
- No new dependency. `ui/menu.tsx` is **not** modified (Base UI `MenuItem` already exposes `closeOnClick`
+ `disabled`). en/sv parity preserved (2 new keys, guarded by the #60 parity test).
- `client.ts` is intentionally untouched — only `redirectToLogin`'s behaviour changes.
- The in-place re-auth modal (full field-level preservation) is a deferred follow-up per the spec.
@@ -0,0 +1,344 @@
# Split queries.ts — 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:** Extract the 4 error classes to `api/errors.ts`, add a `keys` query-key factory in `api/query-keys.ts` (and invalidate `["search"]` on object writes), then split `queries.ts` into `api/queries/{auth,objects,field-defs,vocab,authorities,search}.ts` behind a stable `api/queries/index.ts` barrel — behavior-preserving except the search invalidation.
**Architecture:** Three ordered, individually-green tasks. Task 1 extracts errors (queries.ts re-exports them). Task 2 adds the key factory + search invalidation (still monolithic). Task 3 moves the now-final hook bodies into domain modules behind a barrel that keeps `../api/queries` stable for all ~30 consumers.
**Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, openapi-fetch, Vitest 4 (jsdom) + RTL + MSW.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon. Run a single test pass per task. Behavior-preserving except the one search-invalidation change.
**Spec:** `docs/superpowers/specs/2026-06-08-split-queries-design.md`
**Key facts:**
- `web/src/api/queries.ts` (584 lines) currently defines 4 error classes (`HttpError` :6, `FieldRejection` :13, `InUseError` :20, `VisibilityError` :394) + `type ObjectListParams` (:46) + all hooks.
- Error-class importers (non-test): `api/error-message.ts` (`HttpError, InUseError`), `objects/object-edit-form.tsx` + `objects/object-new-page.tsx` (`FieldRejection`), `objects/publish-control.tsx` (`VisibilityError`), `search/search-panel.tsx` (`HttpError`) — all via `../api/queries`. Tests: `mutation-error.test.tsx`, `labelled-record-row.test.tsx`, `error-message.test.ts` import error classes from `../api/queries`.
- ~30 files import hooks from `../api/queries`. Query-layer test suites: `queries.test.ts`, `queries.authoring.test.tsx`, `queries.fields.test.tsx`, `queries.search.test.tsx`, `queries.visibility.test.tsx`, `queries.vocab.test.tsx`, `mutation-feedback.test.tsx`.
- Key literals: `["me"]`, `["config"]` (in `config/config-provider.tsx:10`), `["objects", params]`, `["objects"]`, `["object", id]`, `["field-definitions"]`, `["terms", vocabularyId]`, `["authorities", kind]`, `["vocabularies"]`, `["search", term, visibility]`.
- `useTerms`/`useAuthorities` key on a `string | null | undefined` arg (enabled-gated), so `keys.terms`/`keys.authorities` must accept that union.
---
# Task 1: Extract error classes → `api/errors.ts`
**Files:** Create `web/src/api/errors.ts`; Modify `web/src/api/queries.ts`, `web/src/api/error-message.ts`.
- [ ] **Step 1: Create `web/src/api/errors.ts`** (move the 4 classes verbatim):
```ts
export class HttpError extends Error {
constructor(public readonly status: number) {
super(`HTTP ${status}`);
this.name = "HttpError";
}
}
export class FieldRejection extends Error {
constructor(public readonly field: string, public readonly code: string) {
super(`field rejected: ${field}`);
this.name = "FieldRejection";
}
}
export class InUseError extends Error {
constructor(public readonly count: number) {
super(`in use: ${count}`);
this.name = "InUseError";
}
}
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
export class VisibilityError extends Error {
constructor(public status: number) {
super(`visibility change failed (${status})`);
this.name = "VisibilityError";
}
}
```
- [ ] **Step 2: Update `web/src/api/queries.ts`.** DELETE the 4 class definitions (lines ~6-25 `HttpError`/`FieldRejection`/`InUseError`, and ~393-399 `VisibilityError`). At the top of the file (after the existing `import` lines), add an import for use + a re-export for compatibility:
```ts
import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors";
export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors";
```
(The `import` binds them for the throw sites in this file; the `export … from` re-exports them so every consumer importing from `../api/queries` keeps working. Everything else in `queries.ts` is unchanged.)
- [ ] **Step 3: Repoint `web/src/api/error-message.ts`.** Change `import { HttpError, InUseError } from "./queries";` to `import { HttpError, InUseError } from "./errors";`. (This is the decoupling: the toast path no longer transitively loads the hook module.)
- [ ] **Step 4: Verify (vitest ONCE for the affected suites), typecheck, lint:**
```bash
cd web && pnpm vitest run src/api/error-message.test.ts src/api/mutation-feedback.test.tsx src/api/queries.test.ts src/components/mutation-error.test.tsx src/components/labelled-record-row.test.tsx && pnpm typecheck && pnpm lint
```
Expected: green. The error classes are now sourced from `errors.ts` but re-exported, so all importers resolve. If typecheck flags an unused import in `queries.ts`, ensure each of the 4 classes is actually thrown somewhere in the file (they are: `HttpError` many sites, `FieldRejection` in `useSetFields`, `InUseError` in the delete mutations, `VisibilityError` in `useSetVisibility`) — keep all four in the import.
- [ ] **Step 5: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/errors.ts web/src/api/queries.ts web/src/api/error-message.ts
git commit -m "refactor(web): extract API error classes to api/errors.ts (#65)"
```
---
# Task 2: Query-key factory + search invalidation
**Files:** Create `web/src/api/query-keys.ts`, `web/src/api/query-keys.test.ts`, `web/src/api/search-invalidation.test.tsx`; Modify `web/src/api/queries.ts`, `web/src/config/config-provider.tsx`.
- [ ] **Step 1: Create `web/src/api/query-keys.ts`:**
```ts
export type ObjectListParams = {
limit: number;
offset: number;
sort?: string;
order?: "asc" | "desc";
visibility?: string;
q?: string;
};
/** Central query-key factory — the single source of truth for cache keys, so
* query/invalidate/setQueryData sites can't drift. */
export const keys = {
me: () => ["me"] as const,
config: () => ["config"] as const,
objects: () => ["objects"] as const,
objectsPage: (params: ObjectListParams) => ["objects", params] as const,
object: (id: string) => ["object", id] as const,
fieldDefinitions: () => ["field-definitions"] as const,
vocabularies: () => ["vocabularies"] as const,
terms: (vocabularyId: string | null | undefined) => ["terms", vocabularyId] as const,
authorities: (kind: string | null | undefined) => ["authorities", kind] as const,
search: () => ["search"] as const,
searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const,
};
```
- [ ] **Step 2: Create `web/src/api/query-keys.test.ts`** (write + run):
```ts
import { expect, test } from "vitest";
import { keys } from "./query-keys";
test("the key factory produces the expected arrays", () => {
expect(keys.me()).toEqual(["me"]);
expect(keys.config()).toEqual(["config"]);
expect(keys.objects()).toEqual(["objects"]);
const p = { limit: 50, offset: 0 };
expect(keys.objectsPage(p)).toEqual(["objects", p]);
expect(keys.object("x")).toEqual(["object", "x"]);
expect(keys.fieldDefinitions()).toEqual(["field-definitions"]);
expect(keys.vocabularies()).toEqual(["vocabularies"]);
expect(keys.terms("v1")).toEqual(["terms", "v1"]);
expect(keys.authorities("person")).toEqual(["authorities", "person"]);
expect(keys.search()).toEqual(["search"]);
expect(keys.searchResults("q", null)).toEqual(["search", "q", null]);
});
test("objects() is a prefix of objectsPage() so invalidation matches", () => {
const prefix = keys.objects();
const full = keys.objectsPage({ limit: 50, offset: 0 });
expect(full.slice(0, prefix.length)).toEqual(prefix);
});
```
Run: `cd web && pnpm vitest run src/api/query-keys.test.ts`.
- [ ] **Step 3: Replace every key literal in `web/src/api/queries.ts` with `keys.*`.** Add `import { keys, type ObjectListParams } from "./query-keys";` and DELETE the local `export type ObjectListParams = {…};` block (now imported). Substitutions (every occurrence):
- `queryKey: ["me"]``queryKey: keys.me()`; `qc.invalidateQueries({ queryKey: ["me"] })``keys.me()`; `qc.setQueryData(["me"], null)``qc.setQueryData(keys.me(), null)`
- `queryKey: ["objects", params]``keys.objectsPage(params)`
- `["objects"]` (invalidations) → `keys.objects()`
- `["object", id]``keys.object(id)`
- `["field-definitions"]``keys.fieldDefinitions()`
- `["terms", vocabularyId]``keys.terms(vocabularyId)`
- `["authorities", kind]``keys.authorities(kind)`
- `["vocabularies"]``keys.vocabularies()`
- `queryKey: ["search", term, visibility]``keys.searchResults(term, visibility)`
Re-export the type so consumers importing `ObjectListParams` from `../api/queries` keep working: add `export type { ObjectListParams } from "./query-keys";` near the top.
- [ ] **Step 4: Add search invalidation (`web/src/api/queries.ts`).** In each of these `onSuccess` handlers add `void qc.invalidateQueries({ queryKey: keys.search() });`:
- `useUpdateObject` onSuccess (after the `objects`/`object` invalidations)
- `useDeleteObject` onSuccess (alongside the `objects` invalidation — convert it to a block: `onSuccess: () => { void qc.invalidateQueries({ queryKey: keys.objects() }); void qc.invalidateQueries({ queryKey: keys.search() }); }`)
- `useSetVisibility` onSuccess (after the `object`/`objects` invalidations)
- [ ] **Step 5: Update `web/src/config/config-provider.tsx`.** Add `import { keys } from "../api/query-keys";` and change `queryKey: ["config"]``queryKey: keys.config()`.
- [ ] **Step 6: Create `web/src/api/search-invalidation.test.tsx`** (write + run) — proves the new behavior:
```tsx
import { expect, test } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "../test/server";
import { useSetVisibility } from "./queries";
import { keys } from "./query-keys";
test("changing an object's visibility invalidates the active search query", async () => {
server.use(
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })),
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(keys.searchResults("amphora", null), { pages: [], pageParams: [] });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useSetVisibility(), { wrapper });
await result.current.mutateAsync({ id: "o1", visibility: "public" });
await waitFor(() =>
expect(qc.getQueryState(keys.searchResults("amphora", null))?.isInvalidated).toBe(true),
);
});
```
Run: `cd web && pnpm vitest run src/api/search-invalidation.test.tsx`. (If `isInvalidated` is flaky, assert `qc.getQueryState(keys.searchResults("amphora", null))` exists and was marked stale via `isInvalidated`; the mutation's `onSuccess` runs the invalidation synchronously after the 204.)
- [ ] **Step 7: Verify (vitest ONCE for the query suites), typecheck, lint:**
```bash
cd web && pnpm vitest run src/api/query-keys.test.ts src/api/search-invalidation.test.tsx src/api/queries.test.ts src/api/queries.authoring.test.tsx src/api/queries.fields.test.tsx src/api/queries.search.test.tsx src/api/queries.visibility.test.tsx src/api/queries.vocab.test.tsx src/config && pnpm typecheck && pnpm lint
```
Expected: green. The key arrays are identical to before, so all existing query tests pass unchanged.
- [ ] **Step 8: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/query-keys.ts web/src/api/query-keys.test.ts web/src/api/search-invalidation.test.tsx web/src/api/queries.ts web/src/config/config-provider.tsx
git commit -m "refactor(web): central query-key factory + invalidate search on object writes (#65)"
```
---
# Task 3: Split queries.ts into `api/queries/` domain modules
**Files:** Create `web/src/api/queries/{index,auth,objects,field-defs,vocab,authorities,search}.ts`; Delete `web/src/api/queries.ts`.
**Approach:** Move each hook (and its local `type X = components[...]` aliases) VERBATIM from the current `queries.ts` into its domain module — the bodies already use `keys.*` and the `errors.ts` classes after Tasks 1-2. Only the relative import paths change (`./client``../client`, `./schema``../schema`, `./errors`, `./query-keys`). Then add the barrel and delete `queries.ts`.
- [ ] **Step 1: `web/src/api/queries/auth.ts`** — header + move `useMe`, `useLogin`, `useLogout`:
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client";
import type { components } from "../schema";
import { keys } from "../query-keys";
type UserView = components["schemas"]["UserView"];
type LoginRequest = components["schemas"]["LoginRequest"];
```
(These three throw only plain `Error` — no `errors.ts` import needed here.)
- [ ] **Step 2: `web/src/api/queries/objects.ts`** — header + move `useObjectsPage`, `useObject`, `useCreateObject`, `useUpdateObject`, `useSetFields`, `useDeleteObject`, `useSetVisibility`:
```ts
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client";
import type { components } from "../schema";
import { HttpError, FieldRejection, VisibilityError } from "../errors";
import { keys, type ObjectListParams } from "../query-keys";
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
type Visibility = "draft" | "internal" | "public";
```
(`ObjectListParams` now comes from `query-keys`. `useObjectsPage`/`useObject` query fns throw plain `Error`; the mutations use the imported error classes.)
- [ ] **Step 3: `web/src/api/queries/field-defs.ts`** — header + move `useFieldDefinitions`, `useCreateFieldDefinition`, `useUpdateFieldDefinition`, `useDeleteFieldDefinition`:
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client";
import type { components } from "../schema";
import { HttpError, InUseError } from "../errors";
import { keys } from "../query-keys";
type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"];
type LabelInput = components["schemas"]["LabelInput"];
```
- [ ] **Step 4: `web/src/api/queries/vocab.ts`** — header + move `useVocabularies`, `useCreateVocabulary`, `useRenameVocabulary`, `useDeleteVocabulary`, `useTerms`, `useAddTerm`, `useUpdateTerm`, `useDeleteTerm`:
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client";
import type { components } from "../schema";
import { HttpError, InUseError } from "../errors";
import { keys } from "../query-keys";
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
type LabelInput = components["schemas"]["LabelInput"];
```
- [ ] **Step 5: `web/src/api/queries/authorities.ts`** — header + move `useAuthorities`, `useCreateAuthority`, `useUpdateAuthority`, `useDeleteAuthority`:
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client";
import type { components } from "../schema";
import { HttpError, InUseError } from "../errors";
import { keys } from "../query-keys";
type LabelInput = components["schemas"]["LabelInput"];
```
- [ ] **Step 6: `web/src/api/queries/search.ts`** — header + move the `SEARCH_PAGE` const and `useSearch`:
```ts
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { api } from "../client";
import { HttpError } from "../errors";
import { keys } from "../query-keys";
const SEARCH_PAGE = 20;
```
- [ ] **Step 7: `web/src/api/queries/index.ts`** (barrel):
```ts
export * from "./auth";
export * from "./objects";
export * from "./field-defs";
export * from "./vocab";
export * from "./authorities";
export * from "./search";
export * from "../errors";
export type { ObjectListParams } from "../query-keys";
```
- [ ] **Step 8: Delete the old monolith:** `git rm web/src/api/queries.ts` (every hook has been moved; the barrel + modules now provide the same exports). Confirm no hook/type was dropped: each of the 24 hooks + `ObjectListParams` + the 4 error classes is exported via the barrel.
- [ ] **Step 9: 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 `../api/queries` import path now resolves to `api/queries/index.ts`, so all ~30 consumers + the query-layer test suites resolve unchanged. If typecheck reports a missing export, a hook landed in the wrong module or an import path is off — fix the module, do NOT edit consumers/tests. Report test totals, largest chunk (gz), and the `check:colors` line.
- [ ] **Step 10: 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`); `web/src/api/queries.ts` shows as deleted, the 7 new files added.
- [ ] **Step 11: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/queries/ && git rm -q web/src/api/queries.ts 2>/dev/null; git add -A web/src/api
git commit -m "refactor(web): split queries.ts into api/queries/ domain modules behind a barrel (#65)"
```
---
## Self-Review (completed)
**Spec coverage:** AC1 errors extracted + error-message repointed + barrel re-export (T1, T3 S7); AC2 directory split + queries.ts deleted + stable path (T3); AC3 key factory used everywhere incl. config-provider (T2 S3/S5); AC4 search invalidation on the 3 object mutations (T2 S4); AC5 existing tests unchanged + gate (T1 S4, T2 S7, T3 S9). ✓
**Placeholder scan:** every new file shown in full or as a precise header + verbatim-move instruction; the move tasks name the exact hook list per module; tests have concrete assertions. No TBD. ✓
**Type/consistency:** `keys` (T2) is the same object consumed in T3's modules; `ObjectListParams` defined in `query-keys.ts` (T2), imported by `objects.ts` (T3 S2) and re-exported by the barrel (T3 S7); error classes from `errors.ts` (T1) imported by `objects/field-defs/vocab/authorities/search` modules (T3) and re-exported by the barrel; `keys.terms`/`keys.authorities` accept `string | null | undefined` to match the enabled-gated query usage. ✓
## Notes
- No new dependency, no new i18n keys, `components/ui/*` untouched. `check:size` should be unchanged (pure reorg + one invalidate call). Barrel keeps `../api/queries` stable → zero consumer churn.
- The error classes are intentionally importable from both `../api/errors` (canonical) and `../api/queries` (compat re-export). Repointing the 4 component importers to `../api/errors` is a deferred cosmetic follow-up.
- `auth.ts` needs no `errors.ts` import (its throws are plain `Error`); every other module imports the error classes it throws.
@@ -0,0 +1,288 @@
# Token-Styled Select 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:** Replace the four raw `<select>` elements with a token-styled `ui/Select` (Base UI Select) that matches `ui/Input` and has a visible focus ring.
**Architecture:** A new `ui/select.tsx` wraps Base UI Select (Portal+Positioner+Popup) styled like Input. `object-form` visibility moves to a react-hook-form `Controller`; `field-form`'s three selects use `value`/`onValueChange` directly. Base UI Select is not a native `<select>`, so the affected tests are rewritten from `userEvent.selectOptions` to open-trigger + click-option.
**Tech Stack:** React 19 + TS + pnpm, Base UI (`@base-ui/react/select`, namespace `Select`), react-hook-form (object-form), lucide-react (Chevron/Check), Vitest + RTL + Storybook. Test runner: `pnpm test` (single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (no new keys expected); **ui/ files = no-semicolon** (match `ui/combobox.tsx`/`ui/menu.tsx`); app source = double-quote+semicolon; stories single-quote/no-semicolon; token classes only; this repo enforces `react-hooks/refs` + `react-refresh/only-export-components` — refactor cleanly, never disable.
**Spec:** `docs/superpowers/specs/2026-06-08-token-select-design.md`
**Key facts:**
- Base UI: `import { Select as SelectPrimitive } from "@base-ui/react/select"` — parts `Root, Trigger, Value, Icon, Portal, Positioner, Popup, List, Item, ItemIndicator, ItemText`. Mirror `ui/combobox.tsx`/`ui/menu.tsx` wrapper style. **Novel → validate by running.**
- Input className to match (`ui/input.tsx`): `h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 … focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:… aria-invalid:border-destructive dark:bg-input/30`.
- `object-form.tsx`: visibility `<select {...register("visibility")}>` (create-mode only), `<Label htmlFor="visibility">`, options draft/internal, default "draft".
- `field-form.tsx`: useState selects — `dataType` (`id="field-type"`, TYPES, disabled on edit), `vocabularyId` (`id="field-vocab"`, when term, placeholder + `useVocabularies()` `{id,key}`, disabled on edit, required), `authorityKind` (`id="field-kind"`, when authority, "Any"=`""` + KINDS, disabled on edit).
- Tests: `object-form.test.tsx` (visibility options + edit-mode-absent) and `fields.test.tsx` (selectOptions for type/kind/vocab) — must be rewritten.
---
# Task 1: `ui/select.tsx` + story (validate by running)
**Files:** `web/src/components/ui/select.tsx` (new), `web/src/components/ui/select.stories.tsx` (new).
- [ ] **Step 1: Study the references.** Read `web/src/components/ui/combobox.tsx` and `web/src/components/ui/menu.tsx` for the exact house pattern (namespace import, `data-slot`, `cn()`, NO semicolons, Portal+Positioner+Popup, token classes). Read `web/src/components/ui/input.tsx` for the trigger className to match. **Confirm the real Base UI Select part names/props by inspecting `web/node_modules/@base-ui/react/select/` types** — especially `Trigger`, `Value` (placeholder prop), `Positioner` (sideOffset/anchor), `Item` (`value` prop), `ItemIndicator`, `ItemText`.
- [ ] **Step 2: Implement `web/src/components/ui/select.tsx`** (no-semicolon). Export `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`. Skeleton — adapt every prop/part to what the installed Base UI actually exposes (validate in Step 4):
```tsx
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
function Select<Value>(props: SelectPrimitive.Root.Props<Value>) {
return <SelectPrimitive.Root {...props} />
}
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
"dark:bg-input/30 data-[popup-open]:border-ring",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon className="text-muted-foreground">
<ChevronDown className="h-4 w-4" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return <SelectPrimitive.Value data-slot="select-value" className={cn("truncate", className)} {...props} />
}
function SelectContent({ className, sideOffset = 6, ...props }: SelectPrimitive.Popup.Props & { sideOffset?: number }) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner sideOffset={sideOffset} className="z-50" alignItemWithTrigger={false}>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
"max-h-[min(24rem,var(--available-height))] min-w-[var(--anchor-width)] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
className
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }
```
IMPORTANT: `--anchor-width`/`--available-height`, `alignItemWithTrigger`, `data-[popup-open]`, the `Value` placeholder API, and whether `Trigger` is generic — all MUST be confirmed against the installed types/runtime. If a class/prop doesn't exist, drop/replace it. Token classes only (no raw palette). The STORY passing is the proof.
- [ ] **Step 3: Story `web/src/components/ui/select.stories.tsx`** (single-quote, no-semicolon; match `menu.stories.tsx`). A controlled `Select` with a `SelectTrigger`(+`SelectValue placeholder`) and a `SelectContent` of 3 `SelectItem`s; a `play` test that clicks the trigger, clicks an option (queried via `within(document.body)` — Base UI Select options render in a portal with role `option`), and asserts the trigger now shows the chosen label.
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { expect, within } from 'storybook/test'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
function Controlled() {
const [value, setValue] = useState('')
return (
<Select value={value} onValueChange={setValue}>
<SelectTrigger aria-label="Fruit"><SelectValue placeholder="Pick one" /></SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="pear">Pear</SelectItem>
<SelectItem value="plum">Plum</SelectItem>
</SelectContent>
</Select>
)
}
const meta = { component: Select, tags: ['ai-generated'], render: () => <Controlled /> } satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('combobox', { name: 'Fruit' }))
await userEvent.click(await within(document.body).findByRole('option', { name: 'Pear' }))
await expect(canvas.getByRole('combobox', { name: 'Fruit' })).toHaveTextContent('Pear')
},
}
```
(If the Base UI Select trigger isn't role `combobox`, adjust the query to what it actually is — discover by running. If `onValueChange`/`value` prop names differ, fix. The story passing IS the validation; report the final working API.)
- [ ] **Step 4: Validate by running (vitest ONCE):**
`cd web && pnpm vitest run src/components/ui/select.stories.tsx && pnpm typecheck && pnpm lint`
Iterate the wrapper until the story play test passes. Report the FINAL working API (exports, the trigger role/accessible-name mechanism, value/onValueChange names, placeholder mechanism) — Tasks 2/3 depend on it.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/ui/select.tsx web/src/components/ui/select.stories.tsx
git commit -m "feat(web): ui/select Base UI Select wrapper matching Input + story (#51)"
```
---
# Task 2: object-form visibility → ui/Select
**Files:** `web/src/objects/object-form.tsx`, `web/src/objects/object-form.test.tsx`.
- [ ] **Step 1: Replace the visibility `<select>`** (create-mode block). Add imports: `import { Controller } from "react-hook-form";` (if not present) and `import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";`. Replace:
```tsx
{mode === "create" && (
<div className="space-y-1">
<Label htmlFor="visibility">{t("form.visibility")}</Label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">{t("form.draft")}</SelectItem>
<SelectItem value="internal">{t("form.internal")}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
)}
```
(Use the exact value/onValueChange/trigger-id API confirmed in Task 1. `form.control` is available from `useForm`; destructure `control` or use `form.control`. The default value stays `"draft"` from `defaultValues`.) Keep `<Label htmlFor="visibility">` so the trigger is labelled (the trigger carries `id="visibility"`).
- [ ] **Step 2: Rewrite the visibility test** in `object-form.test.tsx` (the first test, lines 826). Replace the `HTMLSelectElement.options` inspection with open-and-assert:
```tsx
// after typing object number/name/inscription:
await userEvent.click(screen.getByLabelText(/visibility/i)); // opens the Select
expect(await within(document.body).findByRole("option", { name: /draft/i })).toBeInTheDocument();
expect(within(document.body).getByRole("option", { name: /internal/i })).toBeInTheDocument();
expect(within(document.body).queryByRole("option", { name: /public/i })).not.toBeInTheDocument();
// close without changing (Escape) so default "draft" stays, then submit:
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
const values = onSubmit.mock.calls[0][0];
expect(values.core.object_number).toBe("A-9");
expect(values.visibility).toBe("draft");
expect(values.fields.inscription).toBe("To the gods");
```
Add `within` to the testing-library import. NOTE: `screen.getByLabelText(/visibility/i)` must resolve the Select trigger — if the Label→trigger association doesn't make `getByLabelText` work (Task 1 should ensure it via `id`), fall back to `screen.getByRole("combobox", { name: /visibility/i })`; use whichever the Task-1 validation showed works, consistently. Do NOT weaken the draft/internal/not-public assertions.
- [ ] **Step 3: Confirm the "edit mode: no visibility control" test (lines 6882) still passes** — it queries `queryByLabelText(/visibility/i)` which returns null in edit mode (the block isn't rendered). Should pass unchanged. If the query mechanism changed in Step 2, mirror it here (e.g. `queryByRole("combobox", { name: /visibility/i })`).
- [ ] **Step 4: Verify (vitest ONCE):** `cd web && pnpm vitest run src/objects/object-form.test.tsx && pnpm typecheck && pnpm lint`. PASS (the other object-form tests — Cmd+Enter, required, minCount, edit, pruneFields — must stay green).
- [ ] **Step 5: Commit**
```bash
git add web/src/objects/object-form.tsx web/src/objects/object-form.test.tsx
git commit -m "feat(web): object-form visibility uses ui/Select (#51)"
```
---
# Task 3: field-form's three selects → ui/Select + gate
**Files:** `web/src/fields/field-form.tsx`, `web/src/fields/fields.test.tsx`.
- [ ] **Step 1: Replace the three `<select>`s** in `field-form.tsx`. Add `import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";`.
- **data_type** (lines 118133):
```tsx
<div className="space-y-1">
<Label htmlFor="field-type">{t("fields.type")}</Label>
<Select value={dataType} onValueChange={setDataType} disabled={isEdit}>
<SelectTrigger id="field-type"><SelectValue /></SelectTrigger>
<SelectContent>
{TYPES.map((type) => (
<SelectItem key={type} value={type}>{t(`fields.types.${type}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
```
- **vocabulary** (the `dataType === "term"` block): `<Select value={vocabularyId} onValueChange={setVocabularyId} disabled={isEdit}>` with `<SelectTrigger id="field-vocab"><SelectValue placeholder={t("form.selectPlaceholder")} /></SelectTrigger>` and `SelectItem`s from `vocabularies?.map((v) => <SelectItem key={v.id} value={v.id}>{v.key}</SelectItem>)`. (No empty `""` item — the placeholder shows when `vocabularyId===""`; the required check `!vocabularyId` still blocks submit.)
- **authority_kind** (the `dataType === "authority"` block): `<Select value={authorityKind} onValueChange={setAuthorityKind} disabled={isEdit}>` with `<SelectTrigger id="field-kind"><SelectValue /></SelectTrigger>`, an `<SelectItem value="">{t("fields.anyKind")}</SelectItem>` then `KINDS.map((k) => <SelectItem key={k} value={k}>{t(`authorities.${k}`)}</SelectItem>)`.
Keep every `<Label htmlFor>` and the surrounding `div.space-y-1`. No change to submit/validation/reset logic.
- NOTE on Base UI Select value `""`: confirm the authority_kind `""` "Any" item selects/displays correctly, and that vocabulary's `""` shows the placeholder (Task 1 validated the placeholder). If Base UI Select disallows an empty-string item value, use a sentinel (e.g. `"__any__"`) mapped to `""`/`null` at submit — but prefer `""` if it works; verify by running the test.
- [ ] **Step 2: Rewrite `fields.test.tsx`** select interactions (a small helper keeps it readable). Add `within` to the import. Replace `userEvent.selectOptions(...)` with open+click:
- A helper:
```tsx
async function choose(triggerName: RegExp, optionName: RegExp) {
await userEvent.click(screen.getByLabelText(triggerName));
await userEvent.click(await within(document.body).findByRole("option", { name: optionName }));
}
```
- "selecting Authority…": `await choose(/^type$/i, /authority/i);` then `const kind = await screen.findByLabelText(/authority kind/i);` (now a Select trigger) → `await choose(/authority kind/i, /^person$/i);` → create → assert `body.authority_kind === "person"`.
- "selecting Term…": `await choose(/^type$/i, /term/i);` then assert the vocabulary trigger appears (`await screen.findByLabelText(/^vocabulary$/i)`); click create → expect the `role="alert"` (blocked, `posted===false`); then `await choose(/^vocabulary$/i, /material/i)` (the seeded vocab `v-material` shows its `key` — match its label; check the MSW vocab fixture for the displayed `key` text and match it) → create → `posted===true`.
- "creates a text field" + "lists field definitions…" — default type is text; the type Select isn't opened, so these should pass unchanged (the create posts `data_type: "text"`). Verify.
- If `getByLabelText(triggerName)` doesn't resolve the Select trigger, use `getByRole("combobox", { name: triggerName })` (whatever Task 1 validated) — consistently. Keep all `body`/`posted` assertions identical.
- IMPORTANT: confirm the vocab option label — the test selected `"v-material"` (the option *value*); with Select the user clicks the visible **label** (the vocab `key`). Read the vocab MSW fixture (`web/src/test/handlers.ts`) to find the `key` displayed for id `v-material` and match the option name to that text.
- [ ] **Step 3: 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 (KB gz; Select adds to the bundle — report and flag if >250), check:colors line.
- [ ] **Step 4: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: each select now matches Input (height/radius/border) and shows a focus ring on keyboard focus; data_type/vocab/kind disabled on edit; visibility still defaults to draft and submits correctly; term/authority conditional pickers work.
- [ ] **Step 6: Commit**
```bash
git add web/src/fields/field-form.tsx web/src/fields/fields.test.tsx
git commit -m "feat(web): field-form selects use ui/Select; rewrite select tests (#51)"
```
---
## Self-Review (completed)
**Spec coverage:** ui/Select matching Input + story-validated (T1); visibility via Controller (T2); data_type/vocabulary/authority_kind via value/onValueChange + disabled-on-edit (T3); behavior preserved (placeholder, "Any"=`""`, required-vocab check, reset); tests rewritten to click interaction with identical payload assertions (T2/T3); gate + check:size report (T3). Acceptance criteria 15 mapped. ✓
**Placeholder scan:** the Base UI Select part tree/props are "confirm by running" (novel primitive) with a concrete skeleton + the validation step — not a TODO. The `""`-value caveat (authority "Any") has an explicit fallback (sentinel). The vocab option label is "read the fixture and match" with the file named. No vague steps. ✓
**Type/consistency:** exports `Select/SelectTrigger/SelectValue/SelectContent/SelectItem` defined in T1, consumed identically in T2/T3; the trigger query mechanism (getByLabelText vs getByRole combobox) is chosen once in T1 and used consistently. value/onValueChange contract uniform. ✓
## Notes
- No new dependency (Base UI + lucide already present); no new i18n keys.
- `check:size` is the one budget risk (Select in the form chunks) — measured + flagged in T3, not silently bumped.
- Validate-by-running (T1) is mandatory for the novel Base UI Select, per the repo pattern (combobox/menu/toast).
- Deferred: searchable vocabulary combobox; a raw-`<select>` lint guard.
@@ -0,0 +1,726 @@
# 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.
@@ -0,0 +1,196 @@
# Fields Management — Design
**Date:** 2026-06-04
**Status:** Approved (brainstorming) — ready for implementation planning.
## Context
Milestones 15 (merged to `main` at `2d0b76a`) delivered the SPA foundation, object
authoring, publishing, vocabulary/authority management, and search. The app shell has
**one disabled nav stub left: Fields.** This milestone enables it — managing the
*flexible field definitions* that form the catalogue's extensible schema (the fields the
M2 object authoring form renders beyond the fixed core columns).
Like Search (M5), this is **not** a pure-frontend milestone. `GET /api/admin/field-definitions`
exists (read-only), and the db layer has `db::fields::create_field_definition`, but there
is **no HTTP write route**. So Fields is a combined backend + frontend slice.
After this milestone, **every nav item is live** — zero stubs.
## Decisions (settled during brainstorming)
- **Combined backend + frontend, create + list only.** Expose `POST /api/admin/field-definitions`
over the existing `db::fields::create_field_definition`; build the list + create UI.
New field definitions are purely additive (they only add optional schema), so create is
safe and useful standalone. **Edit/delete is deferred** — it needs new db functions and a
referential-integrity policy (a field definition drives existing object data; the same
concern as issue #30). File a follow-up when this lands.
- **Two-pane layout** (consistent with Objects/Vocabularies): grouped list of existing
definitions on the left, a persistent create form on the right. No per-item detail view
or `:id` route (definitions are create+list only and identified by `key`).
- **Create endpoint capability = `EditCatalogue`** (matches other catalogue writes; the
existing GET uses `ViewInternal`).
- **Ungrouped fields** render under a localized "Other" heading.
## Backend contract (to build)
### Domain (already present — reuse, no change)
`FieldType` (`crates/domain/src/field_definition.rs`) is a discriminated union:
`Text | LocalizedText | Integer | Date | Boolean | Term { vocabulary_id } | Authority { kind: Option<AuthorityKind> }`.
- `FieldType::from_parts(data_type: &str, vocabulary_id: Option<VocabularyId>, authority_kind: Option<AuthorityKind>) -> Option<FieldType>`
reconstructs the type from the three stored columns and **returns `None` for any
inconsistent combination** — a scalar carrying a binding, a `term` without a vocabulary
(or with an authority kind), an `authority` carrying a vocabulary. This is the single
validation chokepoint the handler reuses.
- `NewFieldDefinition { key, field_type, required, group_key: Option<String>, labels: Vec<LocalizedLabel> }`.
- `db::fields::create_field_definition(conn: &mut PgConnection, new: &NewFieldDefinition) -> Result<FieldDefinitionId, sqlx::Error>`
(multi-statement — must be passed a transaction connection `&mut *tx`).
### `api` crate — new write handler
`POST /api/admin/field-definitions`, capability **`EditCatalogue`**. Request body:
```
NewFieldDefinitionRequest {
key: String,
data_type: String, // text|localized_text|integer|date|boolean|term|authority
vocabulary_id: Option<String>, // required iff data_type == "term"
authority_kind: Option<String>, // person|organisation|place; only for "authority" (optional)
required: bool,
group: Option<String>,
labels: Vec<LabelInput>, // { lang, label } — reuse the existing LabelInput schema
}
```
Handler logic:
1. Parse `vocabulary_id` (if present) to `VocabularyId` (→ 400 on malformed UUID) and
`authority_kind` (if present) to `AuthorityKind` by matching `person|organisation|place`
(→ 400 otherwise).
2. `FieldType::from_parts(&data_type, vocabulary_id, authority_kind)``None` ⇒ **422**
(covers "term without vocabulary", "authority with vocabulary", unknown type, stray
binding on a scalar — all in one check).
3. Build `NewFieldDefinition`, run `create_field_definition` inside a transaction, commit.
4. On a unique-violation (duplicate `key`, Postgres SQLSTATE 23505) ⇒ **409**; other db
errors ⇒ 500.
5. Return **`201 { key }`** (a small `CreatedField { key }` view).
Register in `crates/api/src/openapi.rs` (path + the `NewFieldDefinitionRequest` and
`CreatedField` schemas). The route is added to the admin router (likely a new
`crates/api/src/admin_fields.rs`, or alongside the existing field-definition GET in
`admin_objects.rs` — implementer's call, following the module that already owns
`list_field_definitions`).
### Typed client
Regenerate `web/src/api/schema.d.ts` (run the server against the test infra +
`pnpm gen:api`) so the path, `NewFieldDefinitionRequest`, and `CreatedField` appear.
## Frontend architecture
### Routes & navigation
```
/fields → FieldsPage (FieldList left, FieldForm right) — no nested :id route
```
Added under the protected `AppShell`. In `app-shell.tsx`, **Fields** becomes the last
active `NavLink`; `DISABLED_NAV` becomes empty (the disabled-button block renders nothing
/ is removed). All nav items are now live.
### Components / files
```
web/src/fields/
fields-page.tsx two-pane grid (FieldList left, FieldForm right)
field-list.tsx useFieldDefinitions, grouped by `group` (ungrouped → "Other"); rows
show localized label + key + data_type badge + required marker; with
loading / empty / error states
field-form.tsx the create form (key, LabelEditor sv/en, type Select, conditional
Vocabulary/authority-kind, group, required) → useCreateFieldDefinition
web/src/api/queries.ts + useCreateFieldDefinition (POST; invalidates ["field-definitions"])
web/src/app.tsx + the /fields route
web/src/shell/app-shell.tsx enable Fields NavLink; DISABLED_NAV = []
web/src/i18n/{en,sv}.json + fields.* namespace
```
### `FieldForm` — the discriminated-union create form
- `key` — text Input (machine identifier).
- Labels — the **shared `LabelEditor`** (sv/en; EN required), reused from M4.
- `type` — Select over the 7 data types.
- **Conditional:** when type=`term`, reveal a **Vocabulary** Select populated from
`useVocabularies` (reused); when type=`authority`, reveal an **authority-kind** Select
(Any / Person / Organisation / Place). All other types show no extra config.
- `group` — optional text Input.
- `required` — Checkbox.
- Submit → `useCreateFieldDefinition` → on success invalidate `["field-definitions"]` and
clear the form.
### Data layer
`useCreateFieldDefinition()``POST /api/admin/field-definitions` with the request body;
invalidates `["field-definitions"]`. `useFieldDefinitions()` and `useVocabularies()`
already exist and are reused.
## Data flow
`useFieldDefinitions` is the **same cached query the M2 object authoring form consumes**.
Creating a field definition and invalidating `["field-definitions"]` makes the new field
appear both in the Fields list **and** immediately as an available field in the object
editor. That shared-cache effect is the milestone's main payoff.
## Validation & error handling
- **Client:** `key` non-empty (trimmed); EN label required (the `LabelEditor` guard);
if type=`term`, a vocabulary must be chosen — all block submit with inline messages.
- **Backend (source of truth):** key uniqueness (409) and type/binding consistency (422),
surfaced as a form-level `form.rejected` alert.
- The list has loading / empty / error states (reuse the M1 list-state patterns).
## Testing
### Backend (`crates/api/tests/`)
- POST creates a scalar field (e.g. `integer`) → 201; a `term` field with a valid
`vocabulary_id` → 201; `term` without `vocabulary_id` → 422; duplicate `key` → 409;
unauthenticated → 401. (Mirror the existing admin test harness — seed an editor, login,
oneshot requests.)
### Frontend (Vitest + RTL + MSW, `onUnhandledRequest:"error"`)
- New MSW handler `POST /api/admin/field-definitions` (+ a `fieldDefinitions` GET fixture
with at least one grouped and one ungrouped entry).
- Tests: list renders, grouped (incl. the "Other" group); creating a `text` field posts
the expected body and clears the form; selecting **Term** reveals the vocabulary picker
and blocks submit until one is chosen; the EN-required guard blocks submit; create
invalidates `["field-definitions"]`; the Fields nav item is an enabled link (and no
disabled nav buttons remain).
### Project constraints
- en/sv i18n key parity (authority-kind labels reuse existing `authorities.*`).
- No `any` / `eslint-disable` / `@ts-ignore`. Codename ban.
- Bundle ≤150 KB gz (current headroom ~5 KB; lazy-load `/fields` with `React.lazy` +
`Suspense` — as M2 did for the object forms — if it pushes over, then re-verify).
## Acceptance criteria
1. `POST /api/admin/field-definitions` creates definitions of all representative types,
reuses `FieldType::from_parts` for consistency validation (term/authority), is
`EditCatalogue`-gated, returns 409 on duplicate key and 422 on inconsistent type/config.
2. The Fields nav item is enabled and routes to `/fields`; the grouped list renders; the
create form shows the conditional type config (vocabulary for term, kind for authority).
3. A newly created field appears in the Fields list **and** in the M2 object authoring
form (shared `["field-definitions"]` invalidation).
4. EN-required and term-needs-vocabulary client validation; backend 409/422 surfaced as a
form-level error.
5. Web + backend CI green (cargo test; web typecheck/lint/test/build, bundle ≤150 KB gz);
en/sv parity.
6. After this milestone, the app shell has **no disabled nav stubs**.
## Out of scope / follow-ups
- **Edit/delete field definitions** — needs new `db::fields` update/delete functions and a
referential-integrity policy (block/handle deleting a field that objects reference, or
that is `required`). File a backend follow-up when this milestone lands.
- Per-field validation rules (min/max, length, regex) — already tracked as **#11**.
- Field reordering and group management (renaming/reordering groups).
- Changing a field's `key` or `type` after creation (immutable for now).
@@ -0,0 +1,176 @@
# Instance Locale (env-driven) + Single-Language Content Authoring — Design
**Date:** 2026-06-05
**Status:** Approved (brainstorming) — ready for implementation planning.
## Context
The app is **Sweden-first** but the UI must stay translatable (English now; Danish/
Norwegian conceivable later). A question was raised: do we need the **content-level
multilingual machinery** (the `lang`-keyed label tables + `LocalizedText` field type), or
should we remove it to simplify the codebase?
### The content-translation decision (keep the schema; simplify only the inputs)
Two different things are both called "translation":
- **UI translation** — react-i18next app chrome (sv/en JSON, `LangSwitch`, localStorage
persistence). Stays and grows.
- **Content multilingualism** — the *data*: `domain::LocalizedLabel { lang, label }`, the
`FieldType::LocalizedText` flexible-field type, and three DB tables keyed by
`(parent_id, lang)`: `term_label`, `authority_label`, `field_definition_label`.
**Decision: keep the content schema; simplify only the authoring UI** (brainstorm option
A + the migration-risk analysis). Rationale:
- The expensive, hard-to-reverse part is the **database schema**. Removing it and adding it
back later means new migrations, backfilling every row with a language tag, rewriting
every db read/write (`vocab`/`authority`/`fields`), changing API DTOs, swapping the
frontend editor, and regenerating the typed client — a domain→db→api→web cross-cutting
refactor. That is the exact pain to avoid.
- The cheap, reversible part is the **UI** (one input vs two). Keeping the schema is nearly
free: a Sweden-only instance just stores `lang = <default>` rows; re-enabling multilingual
authoring later is "show the second input again" — **zero migration**.
- The machinery is already built, tested, and merged (M2/M4). Removing it is *also* work +
risk. Museum cataloguing commonly needs bilingual descriptive metadata
(Spectrum/Europeana/loans), so "Sweden only" is the assumption most likely to change.
So this milestone **keeps all multilingual capacity dormant in the schema** and **collapses
the authoring inputs to a single language** (the instance default).
### Instance locale via environment variables (no settings table/page)
The app is **single-tenant** (no org/tenant table today — tables: object, audit_log,
field_definition[+label], vocabulary, term[+label], authority[+label], app_user). The
instance language and timezone are set-and-forget per deployment and never change at
runtime, so they are **environment variables**, not a database settings table or an admin
settings page. (If multi-org ever lands, this becomes per-org via a future migration — out
of scope now.)
### Timezone: always store UTC
Timestamps are already `TIMESTAMPTZ` (UTC); `recording_date` is a plain `DATE`. **Storage and
transmission stay UTC** — the API never localizes timestamps. The instance timezone is a
**display/formatting** concern only:
- **Interactive UI** → the frontend formats UTC → instance tz via `Intl.DateTimeFormat`.
- **Server-rendered artifacts** (PDF export #39, future reports/CLI) → the backend will need
the tz to format without a browser. That server-side formatting is **owned by #39**; this
milestone only *stores and exposes* the tz value.
## Scope
**In:**
1. Two backend config knobs: `default_language` (`DEFAULT_LANGUAGE`, default `sv`) and
`default_timezone` (`DEFAULT_TIMEZONE`, default `Europe/Stockholm`), in `server::Config`
and `AppState`.
2. A public `GET /api/config` endpoint exposing `{ app_name, default_language,
default_timezone }`.
3. Frontend config provider: fetch `/api/config` on boot; apply `default_language` to
i18n (when no stored preference); expose the values via context.
4. Single-language content authoring: collapse `LabelEditor` and the `LocalizedText` field
input to one input writing at `default_language`. Schema/DTOs unchanged.
**Out (deferred / owned elsewhere):**
- **Per-account UI language** (cross-device persistence: a `language` column on `app_user`,
returned at login, SPA inits from it) → file as a follow-up issue. (Per-*browser*
persistence already exists via `LangSwitch` + localStorage.)
- Danish/Norwegian UI translations (just more i18n JSON when wanted).
- Server-side timezone formatting → PDF export (#39).
- A real org-settings page → only if multi-org lands.
## Backend
### Config (`crates/server/src/config.rs`)
Add to `Config` (clap derive, matching `app_name`):
```rust
/// Default UI + content-authoring language for this instance (BCP-47 / i18n key, e.g. "sv").
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
pub default_language: String,
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
/// this is a display hint surfaced to clients and (later) server-side renderers.
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
pub default_timezone: String,
```
The timezone is treated as an opaque string (no backend tz library; validated client-side
by `Intl`). Thread both into `AppState` (alongside `app_name`).
### `AppState` (`crates/api/src/lib.rs`)
Add `pub default_language: String` and `pub default_timezone: String`. `server::run` populates
them from `Config`.
### Config endpoint (`crates/api/src/` — e.g. a small `config.rs` or in `health`/`public`)
```
GET /api/config (unauthenticated)
→ 200 { "app_name": String, "default_language": String, "default_timezone": String }
```
`#[derive(Serialize, ToSchema)]` `ConfigView`, registered in `openapi.rs`. Unauthenticated
because the SPA needs it before login (so the login page renders in the instance language).
No secrets are exposed. Regenerate `web/src/api/schema.d.ts`.
## Frontend
### Config provider (`web/src/`)
- A `useConfig` query/hook fetching `GET /api/config` once on boot (TanStack Query, long
`staleTime`), exposing `{ app_name, default_language, default_timezone }` via context.
- **Language:** i18n still inits synchronously with a safe fallback. After config loads, if
there is **no** `localStorage[LOCALE_KEY]` preference, call
`i18n.changeLanguage(config.default_language)`. The existing `LangSwitch` + localStorage
override path is unchanged. (Net effect: a fresh browser defaults to the instance language;
a user who has switched keeps their choice.)
- **Timezone:** add a small `formatTimestamp(utc, config.default_timezone, locale)` helper
using `Intl.DateTimeFormat(locale, { timeZone })`. Use it wherever timestamps are rendered.
(During planning, audit the current UI for timestamp surfaces; if there are none yet, the
helper + config value are forward-ready for PDF #39 / a future audit screen — do not
invent displays.)
### Single-language content authoring (`web/src/components/label-editor.tsx`, the `LocalizedText` field input in `web/src/objects/field-input.tsx`)
- **`LabelEditor`:** replace the EN+SV pair with a **single** labelled text input. `onChange`
emits `[{ lang: config.default_language, label }]` (omit empty). The "EN required / SV
optional" rule collapses to "the single label is required."
- **`LocalizedText` field input:** replace the per-language inputs with a single textbox;
the value written is `{ [config.default_language]: text }`.
- **Reading:** `labelText` / `domain::pick_label` already pick a language with fallback; set
the preferred lang to `config.default_language` (then English, then first) so existing
multilingual data (e.g. seeded) still displays.
- **Unchanged:** `LabelInput`/`LabelView` DTOs, the three `*_label` tables, `LocalizedLabel`,
`FieldType::LocalizedText`, and all db read/write paths. Only the input components change.
## Data flow
Boot → SPA fetches `/api/config` → context holds `{ app_name, default_language,
default_timezone }` → i18n language set to `default_language` (unless overridden in
localStorage) → content editors author one label at `default_language` (stored as a
single-entry `[{lang,label}]`) → timestamps render via `Intl` in `default_timezone`.
## Error handling
- `/api/config` is static, env-derived — it cannot fail at the DB level (no query). If the
fetch fails (network), the SPA falls back to the synchronous i18n default and a sensible
built-in timezone (`"Europe/Stockholm"`), and retries via TanStack Query.
- Empty/invalid timezone: the frontend `Intl` call throws for an invalid IANA name; the
helper catches and falls back to UTC display rather than crashing.
## Testing
- **Backend:** `GET /api/config` returns the configured values; defaults applied when env
unset; endpoint is reachable unauthenticated. `Config` parses `DEFAULT_LANGUAGE` /
`DEFAULT_TIMEZONE` (+ `--default-language` / `--default-timezone`).
- **Frontend (Vitest + RTL + MSW):** with a config of `{ default_language: "sv" }` and no
stored locale, i18n switches to `sv`; with a stored `en`, it stays `en`. `LabelEditor`
renders one input and emits `[{ lang: "sv", label }]`. A `LocalizedText` field input
emits `{ sv: text }`. Existing screens that read labels still render.
- en/sv i18n key parity; no `any`/`eslint-disable`; codename ban; bundle ≤150 KB gz.
## Acceptance criteria
1. `DEFAULT_LANGUAGE` / `DEFAULT_TIMEZONE` env vars (with CLI flags + defaults `sv` /
`Europe/Stockholm`) drive instance locale; no settings table or admin page.
2. `GET /api/config` (public) returns `app_name` + `default_language` + `default_timezone`;
in `schema.d.ts`.
3. The SPA defaults its UI language to the instance default (overridable per-browser via the
existing `LangSwitch`/localStorage).
4. Content authoring is single-language: `LabelEditor` and `LocalizedText` inputs collapse to
one field writing at the default language; **the content schema/DTOs/tables are unchanged**
(multilingual capacity remains dormant, re-enabled by UI alone).
5. Timestamps render in the instance timezone via `Intl` where displayed; storage stays UTC.
6. CI green (cargo + web typecheck/lint/test/build, bundle ≤150 KB); en/sv parity.
## Out of scope / follow-ups
- Per-account UI language (cross-device) — separate issue (filed with this milestone).
- Danish/Norwegian UI locales; server-side tz formatting (#39); org-level settings page.
@@ -0,0 +1,220 @@
# Reference-Data Edit/Delete Lifecycle — Design
**Date:** 2026-06-05
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issues:** #30 (vocabularies/terms/authorities edit+delete), #36 (field-definitions edit+delete).
## Context
The admin reference-data surface currently exposes **create + list only** for all four
entity kinds:
- Vocabularies & terms — `GET/POST /api/admin/vocabularies`,
`GET/POST /api/admin/vocabularies/{id}/terms`
- Authorities — `GET/POST /api/admin/authorities?kind=`
- Field definitions — `GET/POST /api/admin/field-definitions`
There is no way to rename a vocabulary, correct a term's labels/URI, fix a misspelled
authority, edit a field definition's labels/group/required flag, or delete any of them.
In a cataloguing system this is a real operational gap: reference data accrues mistakes
that today can only be fixed by direct DB edits. This milestone completes the CRUD
lifecycle — backend endpoints **and** frontend UI — for all four kinds.
### The integrity constraint that shapes everything
Object flexible-field values live in a **JSONB column on `object`** (migration
`0005_object_fields.sql`: `ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT
'{}'`), keyed by field-definition `key`, with `term`/`authority` references stored as
**UUID strings inside that JSON**. There is **no foreign key** from object data to
terms/authorities/field-definitions. Consequences:
- Deleting a **term, authority, or field-definition** is *not* blocked or cascaded by
the database — it would silently leave dangling UUIDs / orphaned keys in objects. Any
"is it referenced?" guard must be an explicit JSONB scan we write.
- Deleting a **vocabulary** *is* already protected: `term.vocabulary_id` and
`field_definition.vocabulary_id` are `REFERENCES vocabulary (id) ON DELETE RESTRICT`
(migrations `0002`, `0004`). So a non-empty/bound vocabulary delete fails at the FK
level — we catch that and surface a clean 409, rather than invent a check.
- `term_label`, `authority_label`, `field_definition_label` are all `ON DELETE CASCADE`
from their parent, so deleting a term/authority/field-def cleans up its own labels.
### Decisions settled in brainstorming
1. **Scope:** cohesive — backend **and** frontend edit/delete for all four kinds in one
milestone (no backend endpoints shipping without UI).
2. **Delete policy for referenced entities:** **block with 409 + count.** Never silently
alter catalogue data. The curator must clear/reassign the referencing object fields
first. (Cascade-scrub was rejected as too destructive / no undo.)
3. **Field-definition immutability:** `key`, `data_type`, and binding
(`vocabulary_id`/`authority_kind`) are **immutable**; `labels`, `group_key`,
`required` are editable.
4. **`required` toggled on:** allowed; governs validation on *future* object writes only
— no retroactive scan or block of existing objects.
5. **Vocabulary `key` rename:** allowed (cosmetic — terms and field-definitions bind by
vocabulary UUID, not key).
6. **Frontend affordance:** **in-place edit + `AlertDialog` delete** (Option A) — reuse
the existing two-pane form panes and the installed AlertDialog; no new shadcn deps.
7. **Storybook:** add co-located stories for components created or meaningfully changed
where it makes sense (forms, editable rows, dialogs) — not every trivial change.
## Backend
All endpoints are gated by `EditCatalogue` and audited: every mutation runs the
insert/update/delete **and** `audit::record(&mut *conn, &NewAuditEvent { actor, action,
entity_type, entity_id, .. })` in **one transaction**, mirroring the #21 create-audit
pattern (`AuditActor::User(auth.user.id.to_uuid())`). Audit actions: `Updated` /
`Deleted`.
### Endpoints
**Vocabularies / terms** (`crates/api/src/admin_vocab.rs`)
- `PATCH /api/admin/vocabularies/{id}` — rename `key` only.
- `DELETE /api/admin/vocabularies/{id}` — allowed only when the vocabulary has no terms
and is bound by no field-definition. Enforced by the existing FK `RESTRICT`; the
resulting `sqlx` FK error is mapped to **409** with a reason, not a 500.
- `PATCH /api/admin/vocabularies/{id}/terms/{term_id}` — edit labels + `external_uri`.
- `DELETE /api/admin/vocabularies/{id}/terms/{term_id}`**409 + count** if any object
references the term; otherwise delete (its `term_label` rows cascade).
**Authorities** (`crates/api/src/admin_authorities.rs`)
- `PATCH /api/admin/authorities/{id}` — edit labels + `external_uri`. `kind` is
**immutable** (field bindings filter by kind).
- `DELETE /api/admin/authorities/{id}`**409 + count** if referenced; otherwise delete
(`authority_label` cascades).
**Field definitions** (`crates/api/src/` field-definition handler)
- `PATCH /api/admin/field-definitions/{key}` — edit labels, `group_key`, `required`. The
PATCH body exposes **only** those three fields, so `key`/`data_type`/binding are
**structurally immutable** (they cannot be sent — no runtime reject needed). Invalid
values (empty label, empty `group_key` — both have `CHECK <> ''` constraints) return
**422**, consistent with the create path.
- `DELETE /api/admin/field-definitions/{key}`**409 + count** if any object stores that
field key; otherwise delete (label cascades).
### Referenced-checks (JSONB scans)
- **Term / authority referenced:** count objects whose `fields` JSONB contains the UUID
as a value. Use a jsonpath value-match, scoped to the field keys whose definition is
`term`/`authority`-typed for that vocabulary/kind, to avoid false positives. Returns a
count (and the implementation may also collect a small sample of object numbers for the
UI message).
- **Field-definition referenced:** count objects where `fields ? '<key>'` (JSONB
key-exists operator) — simple and indexable.
### DB layer (`crates/db/src/{vocab,authority,fields}.rs`)
New functions, each taking `actor: AuditActor` and a `&mut PgConnection` and writing its
audit entry atomically (mirroring the existing create functions):
- `vocab::rename_vocabulary`, `vocab::delete_vocabulary`, `vocab::update_term`,
`vocab::delete_term`, `vocab::count_objects_referencing_term`
- `authority::update_authority`, `authority::delete_authority`,
`authority::count_objects_referencing_authority`
- `fields::update_field_definition`, `fields::delete_field_definition`,
`fields::count_objects_using_field`
`delete_vocabulary` maps the FK-restrict error to a typed "in use" error so the handler
can return 409. The three `count_*` helpers are read-only (no audit, no actor).
### API error shape
Reuse the existing typed-error conventions:
- **409 (referenced / in-use):** JSON body with the blocking count (e.g.
`{ "code": "in_use", "count": N }`) so the UI can render "used by N objects".
- **422 (invalid value):** the existing field-error shape
(`{ field, code }`, e.g. empty label / empty `group_key`), consistent with
`set_fields`' `FieldErrorView` and the create path. (Immutability is enforced
structurally by the PATCH DTO, not by a runtime 422.)
- **404:** entity not found.
OpenAPI is regenerated so `web/src/api/schema.d.ts` picks up the new endpoints and DTOs.
## Frontend
**Affordance: in-place edit + `AlertDialog` delete**, per screen layout. Deletes
everywhere use the installed `AlertDialog`, mirroring the existing `DeleteObjectDialog`.
### Per-screen
**Fields** (`web/src/fields/`, two-pane `/fields`)
- Selecting a row in the left `FieldList` turns the right pane (`FieldForm`) into an
**edit form**: `labels` / `group_key` / `required` editable; `key` / `data_type` /
binding shown **disabled**. A "New field" button resets the pane to create mode.
- Each list row gets a delete affordance → `AlertDialog` confirm.
**Vocabularies** (`web/src/vocab/`, two-pane `/vocabularies/:id`)
- Left `VocabularyList` rows: rename-`key` (inline edit) + delete.
- Right-pane (`VocabularyTerms`) term rows: edit (labels/URI) + delete.
**Authorities** (`web/src/authorities/`, single-pane `/authorities/:kind`)
- Each authority row: inline-expand edit (labels/URI) + delete.
### Hooks (`web/src/api/queries.ts`)
Add, mirroring `useUpdateObject`/`useDeleteObject` and invalidating the correct list
query keys: `useRenameVocabulary`, `useDeleteVocabulary`, `useUpdateTerm`,
`useDeleteTerm`, `useUpdateAuthority`, `useDeleteAuthority`, `useUpdateFieldDefinition`,
`useDeleteFieldDefinition`. The **409 (referenced)** and **422 (immutable)** responses
parse into the existing `HttpError`/`FieldRejection` style.
### Delete-blocked UX
On a 409 the `AlertDialog` **stays open** and shows the blocking reason —
`t("actions.inUse", { count })` → e.g. *"Used by 7 objects — clear those fields first"*
instead of closing.
### i18n (`web/src/i18n/{en,sv}.json`)
New action keys under `vocab.*` / `authorities.*` / `fields.*` plus a shared `actions.*`
namespace where it makes sense: `edit`, `delete`, `rename`, `save`, `cancel`, `inUse`
(count-interpolated). **en/sv parity** required.
### Storybook
Add co-located `*.stories.tsx` for the components that gain meaningful states: the
field-definition edit form, the editable term/authority row, and the delete-confirm
dialog wrapper. (Skip trivially-changed components.)
## Testing
**Backend** (existing infra: compose Postgres on host 5442, Meili on 7700;
`#[sqlx::test]` provisions its own DB; mirror `crates/api/tests/admin_catalog.rs`):
- **db:** update/delete per entity; each `count_objects_referencing_*` returns the right
count; delete blocked when referenced; `delete_vocabulary` FK-restrict → typed in-use
error; an audit row (`Updated`/`Deleted`, correct actor) per mutation.
- **api:** each `PATCH`/`DELETE``EditCatalogue` required (401/403 without), happy path
(200/204), **409 + count** when referenced, **422** on an invalid value (empty
`group_key`/label), **404** when missing. (Immutables are absent from the PATCH DTO, so
there is no "immutable changed" path to test.)
- OpenAPI regenerated.
**Frontend** (Vitest + RTL + MSW, `onUnhandledRequest: "error"`):
- Mutation hooks invalidate the correct keys; a 409 parses into the typed error.
- Per screen: edit form populates and saves; delete confirms via `AlertDialog`; a 409
keeps the dialog open showing "used by N"; field-def edit shows key/type/binding
disabled.
- en/sv key-parity check (existing test).
- Storybook stories for the meaningfully-changed components run green under the
addon-vitest project.
## Acceptance criteria
1. Update + delete endpoints for vocabulary (rename), term, authority, field-definition —
all `EditCatalogue`, all audited.
2. Referenced term/authority/field-def delete → **409 + count**; vocabulary delete →
**409** when it has terms or is bound.
3. Field-def `key`/`data_type`/binding immutable (absent from the PATCH DTO — cannot be
changed); `labels`/`group`/`required` editable; `required` not retroactively enforced.
4. In-place edit + `AlertDialog` delete on all three screens; a blocked delete shows
"used by N" without closing.
5. Storybook stories added for the meaningfully-changed components.
6. en/sv parity; no `any`/`eslint-disable`/`@ts-ignore`; codename ban; bundle ≤150 KB
gz; cargo + web typecheck/lint/test/build green; OpenAPI regenerated.
## Out of scope / follow-ups
- A "find/replace a reference across objects" bulk-reassign tool (would let a curator
clear references blocking a delete in one action) — file if the 409 friction is felt.
- Surfacing the referencing objects as clickable links in the 409 message (v1 shows a
count + optional sample, not a live list).
- Audit-entry coalescing for multi-step edits (#13, separate).
@@ -0,0 +1,146 @@
# Wire the Spectrum Seed into Runtime — Design
**Date:** 2026-06-05
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #14.
## Context
`db::seed::seed_spectrum_cataloguing(conn)` already exists, is **idempotent** (each
vocabulary/field-definition is created only if its key is absent), and is tested at the
db layer (`crates/db/tests/seed.rs` covers content + re-seed idempotency). Nothing
invokes it yet — #14 is purely about **wiring**.
The server binary already has the pattern to extend. `crates/server/src/main.rs` defines
a clap `Command` enum with one variant (`CreateUser`); `main` dispatches `None → run`,
`Some(sub) → one-shot`. `server::create_user(database_url, …)` (`crates/server/src/lib.rs`)
is the one-shot template: connect with a tiny pool (`Db::connect(url, 2)`), open a
transaction, do the work with `AuditActor::System`, commit, print/log, exit.
The app is **single-tenant** (env-driven config, no control plane / provisioning service).
So #14's suggested "per-org provisioning" home does not exist yet; the realistic wiring
now is a manual one-shot, mirroring `create-user`.
### Decision (from brainstorming)
A **`server seed` CLI subcommand** — explicit, idempotent, safe to re-run, no coupling to
the serve path. (Rejected: a `--seed` startup flag — couples seeding to serving;
auto-seed-on-first-boot — silently mutates data on boot, needs first-boot detection; the
provisioning path — no control plane exists.) The operator runs `server seed` once when
setting up an instance, alongside `server create-user`.
## Components
### 1. `Command::Seed` variant (`crates/server/src/main.rs`)
Add to the `Command` enum:
```rust
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
Seed,
```
And a dispatch arm in `main`:
```rust
Some(Command::Seed) => seed(&cli.config.database_url).await,
```
`Seed` takes no args of its own — it uses the flattened `Config`'s `database_url` (which
already reads `DATABASE_URL` from env / `--database-url`), exactly like `CreateUser` reads
`cli.config.database_url`.
### 2. `server::seed` one-shot (`crates/server/src/lib.rs`)
A new public function modeled on `create_user`:
```rust
/// One-shot: apply migrations (idempotent) then seed the baseline Spectrum cataloguing
/// vocabularies + field definitions. Safe to re-run.
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
let db = Db::connect(database_url, 2)
.await
.context("connecting to the database")?;
// Apply migrations first so `server seed` works on a fresh DB without first
// starting the server. Migrations are idempotent. (This is a deliberate, robust
// step beyond create_user, which assumes a migrated DB.)
db.migrate().await.context("running database migrations")?;
let mut tx = db.pool().begin().await?;
db::seed::seed_spectrum_cataloguing(&mut tx)
.await
.context("seeding Spectrum cataloguing baseline")?;
tx.commit().await?;
println!("seeded Spectrum cataloguing baseline (idempotent)");
Ok(())
}
```
Notes:
- The seed function uses `AuditActor::System` internally for the vocabulary creates, so no
actor plumbing is needed at the server layer.
- It returns `()`; the printed line is a generic confirmation (re-running a fully-seeded DB
prints the same line — correct, since the operation is idempotent).
- `Db::migrate()` is the same method `run` calls on startup (`lib.rs:22`).
### 3. Convenience: `just seed` recipe + README note
- `justfile`: add
```
# Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent)
seed:
cargo run -p server -- seed
```
(`set dotenv-load` already supplies `DATABASE_URL`.)
- `README.md` "Running locally": add one line to the setup steps, e.g. after creating the
admin user — "Seed the baseline cataloguing fields: `just seed` (or
`cargo run -p server -- seed`)."
## Data flow
`server seed``server::seed(database_url)``Db::connect``db.migrate()`
`tx = begin()``db::seed::seed_spectrum_cataloguing(&mut tx)` (idempotent ensure-by-key)
`tx.commit()` → confirmation line → exit 0.
## Error handling
- Connection / migration failures → `anyhow` error with context, non-zero exit (matches
`create_user`).
- A partial seed cannot persist: all inserts run inside the single transaction, so any
error rolls the whole seed back (the seed fn already takes `&mut *tx`).
- Re-running on an already-seeded DB is a no-op (ensure-by-key) and exits 0.
## Testing
- **Existing (db layer):** `crates/db/tests/seed.rs` already asserts the seed creates the
expected vocabularies + field definitions and that re-seeding is idempotent. No change.
- **New (server layer):** add a test mirroring `crates/server/tests/create_user.rs`'s
harness (read it to match how it bridges a `#[sqlx::test]` `PgPool` to the
`database_url`-taking one-shot). The test exercises the wiring end-to-end: invoke the
seed one-shot **twice** against a fresh test DB and assert it succeeds both times and
that a known seeded row (e.g. vocabulary `material`, field definition `title`) is
present. This proves the `seed` glue + migrate path, complementing the db-layer content
tests.
- If `create_user.rs` tests the db layer directly rather than `server::create_user`
(because the one-shot takes a URL, not a pool), mirror that: call
`db::seed::seed_spectrum_cataloguing` twice via the pool and assert idempotent success.
The thin `server::seed` glue (connect + migrate + commit) is then covered by manual
verification (below).
- **Manual verification:** `docker compose up -d`, then `just seed` twice — both exit 0;
the second is a no-op; the seeded vocabularies/fields appear in the `/vocabularies` and
`/fields` admin screens.
## Acceptance criteria
1. `server seed` (and `just seed`) applies the idempotent Spectrum cataloguing seed and
exits 0; re-running is a safe no-op.
2. It works on a fresh (but reachable) database — migrations are applied first.
3. The wiring mirrors the existing `create-user` one-shot (pool of 2, tx, `AuditActor::System`
via the seed fn, `anyhow` context on failure).
4. `cargo nextest run --workspace` green; `cargo +nightly fmt` + `clippy -D warnings` clean;
no codename.
5. README "Running locally" documents the seed step; `just seed` recipe present.
## Out of scope
- `--seed` startup flag; auto seed-on-first-boot.
- Per-org provisioning / control-plane seeding (no control plane exists; revisit if it lands).
- Seeding vocabulary **terms** (the seed deliberately creates vocabularies empty; terms are
populated by the organisation or a later import).
- Making `create-user` migrate (out of scope; only `seed` gains the migrate step here).
@@ -0,0 +1,187 @@
# Objects Data-Overview Table + Responsive Shell — Design
**Date:** 2026-06-06
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issues:** #44 (object list → table); subsumes #58 (responsive layout) for the shell.
## Context
The Objects screen is where curators triage hundreds of records daily, but today
`web/src/objects/object-list.tsx` renders a **thin 20rem list** (object_number + name +
visibility badge) inside a master/detail grid, with no columns, sort, or filter. The
backend `GET /api/admin/objects` (`list_objects_paged`) takes only `limit`/`offset` and
orders by `object_number`. A *separate* `search-panel.tsx` (Meilisearch full-text, infinite
scroll, visibility filter) is a parallel browse UI with different ergonomics. Goal: a real,
scannable, sortable, filterable **data-overview table** plus a shell that adapts to viewport
width and gives every object a shareable URL.
### Facts established during exploration
- **Timestamps already exist.** The `object` table has `created_at` + `updated_at`
(`migrations/0003_object.sql`); `updated_at` is set to `now()` on every write; the db layer
already reads them into `CatalogueObject`. They are simply **not exposed in
`AdminObjectView`** — so adding an "Updated" column needs *no migration*, just two fields on
`AdminObjectView::from_object`.
- **Search is best-effort/optional** (`AppState.search: None` → the search endpoint 503s). So
the **Postgres-backed list must remain the always-available browse surface**; full-text
search is a layer on top, not a replacement.
- **No new dependencies needed:** `lucide-react` is already installed (nav icons); Base UI
ships `drawer`, `collapsible`, and `tooltip` primitives (the slide-in detail + sidebar).
### Decisions (from brainstorming)
1. **Layout:** a Linear/email-style shell — collapsible **icon sidebar**; a **full-width
objects table** as the overview; selecting a row opens detail as a **right-hand pane on
wide viewports / a slide-in drawer when narrow**; **`/objects/:id` is a canonical,
shareable URL**.
2. **Search:** **table-first.** The table gets Postgres-backed sort + visibility filter + a
quick text filter (object number/name). The dedicated Meilisearch Search screen stays as-is;
folding full-text into the table's search box is a **deferred follow-up**.
3. One milestone, **built in phases** (backend → table → shell/responsive/detail).
4. **Storybook** stories for meaningful new components (per the standing preference).
## 1. Shell: collapsible icon sidebar + responsive frame
`web/src/shell/app-shell.tsx`:
- The sidebar gains a **collapse toggle**; expanded = `w-44` (icon + label), collapsed = an
icon rail (`~w-14`, icon-only). State persisted in `localStorage` (e.g. `sidebar-collapsed`).
- Each nav item (`objects`, `vocabularies`, `authorities`, `search`, `fields`) gets a
`lucide-react` icon. When collapsed, the label is shown via a Base UI **`Tooltip`** on hover
and as the `aria-label`/`title` for AT.
- Below a width breakpoint the sidebar **auto-collapses** to the rail (the user can still
toggle). Nav `NavLink` active state + focus-visible rings preserved/added.
- This resolves #58 at the shell level (the per-screen master/detail responsiveness is handled
in §3).
## 2. Objects table (`/objects`)
Replace the narrow list with a **full-width table** filling the main content area.
**Columns (default):** Object № (sortable) · Name (sortable) · Visibility (badge; filterable)
· Current location · # objects · Updated (sortable). Real `<table>` semantics with
`scope="col"` headers and `aria-sort` on the active sort column.
**Toolbar (above the table):**
- A debounced **quick text filter** (`q`) — Postgres `ILIKE` on `object_number` + `object_name`
(always available; distinct from the Meili Search screen which searches descriptions/fields).
- **Visibility filter chips** (`all` / `draft` / `internal` / `public`), mirroring the search
panel's pattern (honest `<button aria-pressed>`).
- The **New** button (right-aligned).
**Sorting:** clicking a sortable header toggles sort column + direction (server-side); default
`object_number asc` (today's order). Reflected in `aria-sort`.
**"Updated" rendering:** relative ("2d", "1w") with an absolute tooltip, formatted in the
instance timezone/locale via `Intl` (`useConfig().default_timezone` + active language).
**Pagination (footer):** `fromto of total`, prev/next, and a **page-size selector**
(25/50/100/200 — backend caps at 200). Keep the **offset** model (it supports sort + a true
total cleanly; infinite scroll does not).
**URL-synced state:** `q`, `visibility`, `sort`, `order`, and the page offset live in the URL
query string (the search panel already does this for `q`/`visibility`). This makes the table
shareable, back-button-friendly, and preserves position across the row→detail→back round-trip.
**Row interaction:** click navigates to `/objects/:id` (canonical); the selected row is
highlighted; keyboard-navigable.
## 3. Detail presentation + canonical URL (`/objects/:id`)
- `/objects/:id` is the **canonical, shareable** address — opening the link loads the table and
reveals that object's detail.
- **Wide viewport:** detail renders as a **right-hand pane** beside the (compressed) table.
**Narrow viewport:** detail **slides in from the right as a Base UI `Drawer`** over the table,
with a backdrop. A close affordance returns to `/objects`, table state preserved via the URL.
- Implementation: nested routing — the `/objects` route renders the table; an `:id` child
controls the pane/drawer (presence of `:id` opens it). The pane-vs-drawer switch is by
viewport width (CSS breakpoint / a `matchMedia` hook); the `Drawer` is used only at narrow
widths.
- **Reuses the existing `ObjectDetail`.** Its *content* improvements (resolving
term/authority/`localized_text` to labels, grouping by field group) are **issue #45** and
explicitly out of scope here — this milestone changes where/how detail is presented, not its
internals.
## 4. Backend contract (`crates/api/src/admin_objects.rs`, `crates/db/src/catalog.rs`)
- **Query params on `GET /api/admin/objects`:** `sort` (enum: `object_number` |
`object_name` | `updated_at` | `created_at` | `visibility`; default `object_number`),
`order` (`asc` | `desc`; default `asc`), `visibility` (optional filter: draft|internal|public),
`q` (optional text). All optional; absent → today's behavior.
- **`list_objects_paged`** extended to accept the sort column + direction + filters. Build
`ORDER BY` from the **whitelisted enum** (never interpolate a raw client string — SQL-injection
safe) and `WHERE` clauses for `visibility = $` and/or `(object_number ILIKE $q OR object_name
ILIKE $q)`. **`count_objects`** takes the same filters so the total reflects the filtered set.
- **Expose timestamps:** add `created_at` + `updated_at` (RFC3339 strings) to `AdminObjectView`
and `AdminObjectView::from_object` (values already present on the domain object). No migration.
- Gated by `ViewInternal` as today. Regenerate `web/src/api/schema.d.ts`.
## 5. Frontend data layer (`web/src/api/queries.ts`)
- `useObjectsPage` gains `{ sort, order, visibility, q, limit, offset }`; the query key includes
them; use `placeholderData: keepPreviousData` so sorting/paging/filtering doesn't flash empty.
- A small `use-media-query`/`matchMedia` hook for the pane-vs-drawer breakpoint (if one doesn't
already exist).
## Data flow
`/objects?sort=…&order=…&visibility=…&q=…&offset=…``useObjectsPage(params)`
`GET /api/admin/objects?…` (Postgres, sorted/filtered, with filtered total) → table renders
columns + `aria-sort` + pagination. Row click → `/objects/:id` (URL carries the table state) →
detail pane (wide) or `Drawer` (narrow) over the table → close → `/objects?…` restored.
## Error handling / edges
- List load error / empty: reuse the existing error + empty states (standardized on `Skeleton`
loading per #53 if convenient, else keep current).
- Invalid `sort`/`order`/`visibility` from a hand-edited URL: backend rejects unknown enum
values (422) or the handler falls back to defaults; the frontend clamps to known values.
- Quick filter with no matches: empty-state message; pagination shows `0 of 0`.
- Deep-linking `/objects/:id` for a missing/deleted object: existing 404 handling
(`useObject``objects.notFound`); the table still renders behind/beside.
- Narrow→wide resize while detail open: the pane/drawer swaps presentation without losing the
selected `:id`.
## Testing
**Backend** (`#[sqlx::test]`, mirror `crates/api/tests/admin_catalog.rs`):
- `list_objects` honors `sort`+`order` (e.g. by `object_name desc`, by `updated_at`),
`visibility` filter, and `q` ILIKE; the `total` reflects the filter; default (no params)
matches today's `object_number asc`; an unknown `sort` value is rejected/falls back.
- `AdminObjectView` includes `created_at`/`updated_at`.
- OpenAPI regenerated.
**Frontend** (Vitest + RTL + MSW):
- Table renders the columns from a mocked page; a sortable header click updates the URL
(`sort`/`order`) and re-queries with `aria-sort`; visibility chips + quick filter update the
URL and query (debounced); pagination + page-size update offset/limit; row click navigates to
`/objects/:id` and the table state (URL) is preserved on back.
- Sidebar collapse toggles + persists to `localStorage`; collapsed rail shows tooltips/labels.
- Detail presents as a pane vs `Drawer` per a mocked `matchMedia` width.
- en/sv parity for new keys; no `any`/`eslint-disable`; no codename.
**Storybook** (per the standing preference — meaningful interactive components):
- The table **row** (default / selected / various visibility), the **sortable column header**
(idle / asc / desc), the **pagination control**, and the **collapsible sidebar** (expanded /
collapsed). Mirror the established story format.
**Bundle:** `pnpm check:size` — index chunk ≤ **165 KB gz** (lucide icons + any newly-used Base
UI primitives land in the always-loaded shell; tree-shaken lucide imports keep this small —
verify).
## Acceptance criteria
1. `/objects` is a full-width, scannable table (№, name, visibility, location, count, updated)
with server-side sort, a visibility filter, and a quick text filter — all state in the URL.
2. Pagination has prev/next + a page-size selector + a true (filtered) total.
3. The sidebar collapses to an icon rail (persisted) and auto-collapses on narrow viewports.
4. Selecting a row opens detail as a right pane (wide) or slide-in drawer (narrow);
`/objects/:id` is a canonical shareable URL that opens that object directly.
5. Backend exposes `created_at`/`updated_at` and supports `sort`/`order`/`visibility`/`q`
(injection-safe, filtered total); OpenAPI regenerated.
6. Storybook stories for the row/header/pagination/sidebar; cargo + web typecheck/lint/test/build
green; index ≤ 165 KB gz; en/sv parity; no codename.
## Phasing (for the plan)
1. **Backend:** `sort`/`order`/`visibility`/`q` params + filtered count + expose timestamps +
OpenAPI regen.
2. **Table:** full-width table, columns, sortable headers, filters, pagination + page-size,
URL-synced state, `useObjectsPage` params (+ stories).
3. **Shell & detail:** collapsible icon sidebar (lucide + tooltip + persistence + auto-collapse),
responsive detail pane/drawer, canonical `/objects/:id` routing (+ stories).
## Out of scope → follow-ups
- **Meilisearch full-text unified into the table's search box** (graceful fallback when search
disabled) — deferred; the dedicated Search screen stays for now.
- **Object detail *content*** (term/authority/localized_text → labels, group-by-group) — **#45**.
- Multi-select / bulk actions (e.g. bulk visibility change); saved views/filters.
- Per-screen responsive work beyond the Objects shell (other master/detail screens) — remainder
of #58.
@@ -0,0 +1,128 @@
# Searchable Term/Authority Picker — Design
**Date:** 2026-06-06
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #27.
## Context
The object authoring form renders **term** and **authority** flexible fields as native
`<select>`s (`web/src/objects/field-input.tsx``OptionsSelect`, used by `TermField` /
`AuthorityField`). Each field fetches the *full* option set for its vocabulary /
authority-kind (`useTerms(vocabulary_id)` / `useAuthorities(kind)`, cached 5 min) and lists
it client-side. The value contract is **`option value = term/authority id`**, and labels
render in the active locale via `labelIn(labels, lang)` (sv → en → first). This is lean but
has no type-to-filter, which gets unwieldy as a vocabulary grows.
### Decisions (from brainstorming)
1. **Search strategy: client-side filtering now.** The combobox filters the already-loaded
option list as you type. No backend change. Server-side `?q=` search (for genuinely large
vocabularies, plus resolving a selected id→label that isn't in the filtered set) is
**deferred to a follow-up issue** — current vocabularies are small, and YAGNI.
2. **Primitive: Base UI Combobox.** `@base-ui/react` (v1.5.0) is already a dependency and
ships a native `combobox` primitive. Using it is consistent with the existing Base UI
wrappers (e.g. `ui/alert-dialog.tsx` wraps `@base-ui/react/alert-dialog`) and adds **zero
new packages** (no Radix, no cmdk). **Combobox** (not Autocomplete) is correct: the
committed value is the **id**, distinct from the typed filter text.
3. **Bundle: non-issue.** The object form is already lazy-loaded (`app.tsx`:
`ObjectNewPage`/`ObjectEditForm` are `lazy(() => import(...))`), so the combobox weight
lands in the object-form route chunk, **not** the ~147 KB gz index bundle the 150 KB
budget guards.
## Components
### 1. `web/src/components/ui/combobox.tsx` (new)
A thin wrapper over `@base-ui/react/combobox`, in the same style as the other `ui/*` Base UI
wrappers (`data-slot` attributes, `cn()` class composition, exporting the composed parts).
The plan must **read `@base-ui/react/combobox` (its `index.d.ts` / Base UI docs) to pin the
exact part names and value props** (the namespace exposes Root / Input / Trigger / Positioner
/ Popup / List / Item-style parts and a `selectionMode`; single-select value is controlled).
The wrapper exposes whatever composed parts the `OptionsCombobox` below needs (root, input,
popup/list, item) with the project's Tailwind classes matching the existing inputs/menus.
### 2. `OptionsCombobox` in `web/src/objects/field-input.tsx`
Replaces `OptionsSelect` with the **same prop contract** so it is a drop-in:
```ts
function OptionsCombobox({
id: string,
value: string, // selected id ("" = none)
onChange: (v: string) => void, // emits the selected id (or "")
options: { id: string; labels: LabelView[] }[],
lang: string,
placeholder: string,
}): JSX.Element
```
Behavior:
- Renders a text input that **filters `options` client-side** by each option's active-locale
label (`labelIn(o.labels, lang)`), case-insensitively, as the user types. Base UI
Combobox's built-in filtering drives this (the items carry an id value + a label string for
matching/display).
- Selecting an item calls `onChange(option.id)`; the closed trigger/input shows the selected
item's label (resolved from `options` by id).
- **Clearable**: when nothing matches / the user clears the input, the value becomes `""`
(valid only when the field is not `required``required` is enforced by the existing
react-hook-form `Controller` `rules`, unchanged).
- Accessible (label association via the existing `<Label htmlFor>`, keyboard nav from the
primitive).
`TermField` and `AuthorityField` keep their `Controller` (value = id, `onChange =
field.onChange`, `rules={{ required }}`); only the rendered child changes from `OptionsSelect`
to `OptionsCombobox`. `useTerms` / `useAuthorities` are unchanged.
## Data flow
`TermField`/`AuthorityField``useTerms`/`useAuthorities` (full list, cached) →
`OptionsCombobox` filters by active-locale label as the user types → on select, emits the
chosen **id** to the rhf `Controller` → stored in `fields.<key>` exactly as before. Editing an
object: the stored id is matched against the loaded options to show its label (same as the
current `<select>`).
## Error handling / edges
- **Options still loading** (`useTerms`/`useAuthorities` pending): the combobox shows the
placeholder and is effectively empty/disabled until options arrive — same as the current
`<select>` rendering `options ?? []`.
- **Unknown stored id** (e.g. the referenced term was later deleted): no matching option, so
the combobox shows empty/placeholder (or the raw id) — acceptable and no worse than the
current `<select>`, which also can't render a missing option.
- **Empty vocabulary**: the combobox shows the placeholder and an empty list.
## Testing
- **`web/src/objects/field-input.test.tsx`** (update): drive the combobox instead of the
native select — open it, type to filter, assert only matching options show, select one and
assert the rhf value is the option **id**; clear and assert `""`. The popup renders in a
**portal**, so query it via `within(document.body)` (the established pattern from the
dialog work). Keep the existing non-term/authority field-type tests (text/integer/date/
boolean/localized_text) untouched.
- **Storybook** (`web/src/objects/field-input.stories.tsx` or a focused
`combobox.stories.tsx`): stories for the combobox — default/placeholder, filter-as-you-type,
a selected value. Mirror the established story format (`@storybook/react-vite`,
`storybook/test`, `tags: ['ai-generated']`, single quotes). (Per the standing rule: stories
for meaningful interactive components.)
- **Bundle:** `pnpm check:size` — the **index** chunk stays ≤ 150 KB gz (combobox weight is in
the lazy object-form chunk; confirm the index didn't grow materially).
## Acceptance criteria
1. Term and authority fields render a **searchable combobox** that filters the loaded options
by active-locale label as you type; selecting commits the term/authority **id** (value
contract unchanged); the field is clearable when not required.
2. Built on **Base UI's `combobox`** primitive via a `ui/combobox.tsx` wrapper — no new npm
dependency.
3. `useTerms`/`useAuthorities` unchanged (client-side filtering; no backend change).
4. `field-input.test.tsx` covers open/filter/select/clear; a Storybook story exists; other
field types unchanged.
5. `pnpm typecheck` + `pnpm lint` (no `any`/`eslint-disable`/`@ts-ignore`) + `pnpm test` +
`pnpm build` green; index bundle ≤ 150 KB gz; en/sv parity for any new i18n keys; no codename.
## Out of scope → follow-up issue
- **Server-side term/authority search** (`GET /api/admin/vocabularies/{id}/terms?q=`,
`authorities?q=`, debounced, top-N) for genuinely large vocabularies, **and** resolving a
selected id→label when the item isn't in the filtered/loaded set (needs a by-id lookup).
File this as a new issue when a vocabulary actually grows large.
- Multi-select term/authority fields (the schema is single-value today).
@@ -0,0 +1,149 @@
# Dark-Mode Theme Toggle — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #59.
## Context
`web/src/index.css` defines a complete `.dark` token set (24 tokens — background/foreground,
card, popover, primary, secondary, muted, accent, destructive, success, warning, highlight,
border, input, ring + their `-foreground` variants), and the `ui/*` components carry `dark:`
variants. After #49, the feature screens route through the semantic tokens, so the app now
*adapts* to `.dark`. But nothing ever applies the `.dark` class and there is **no theme toggle**,
so dark mode can't activate. This milestone ships the toggle (the issue's "ship it" path).
Theme is **client-only**, mirroring the locale mechanism: `localStorage` persistence, read at
startup, applied to the DOM. `/api/config` (`ConfigView`) carries no theme field; a per-instance
server default is out of scope (could add `default_theme` later, like `default_language`).
### Decisions (from brainstorming)
1. **Tri-state model:** `"light" | "dark" | "system"`. Default (unset) is `"system"` — follows the
OS via `prefers-color-scheme` and keeps re-tracking live until the user pins light or dark.
2. **Icon segmented control:** three icon buttons (lucide `Sun`/`Moon`/`Monitor`), active one
highlighted, mirroring `LangSwitch` styling, mounted in the header next to `LangSwitch`.
3. **FOUC prevention:** a synchronous pre-React init applies the class before first paint.
4. **Dark `--primary` contrast tweak** (parked from #49) folded in here.
## Architecture
Plain client-side theming over CSS custom properties (no `next-themes` dependency). The `.dark`
class on `<html>` activates the existing dark token block; Tailwind utilities reference the tokens,
so no per-component work is needed beyond what #49 already did.
```
localStorage["theme"] ──read──▶ resolve(theme) ──▶ <html class="dark"?> ──▶ tokens ──▶ UI
▲ ▲
setTheme() (toggle) matchMedia listener (when theme === "system")
```
## Components
### `web/src/theme/theme.ts` (new) — core, framework-free
- `export const THEME_KEY = "theme";`
- `export type Theme = "light" | "dark" | "system";`
- `export function resolveTheme(theme: Theme): "light" | "dark"` — returns `theme` unless
`"system"`, in which case `matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"`.
Guards `typeof window`/`matchMedia` for non-DOM (test/SSR) safety → falls back to `"light"`.
- `export function readTheme(): Theme` — reads `localStorage[THEME_KEY]`; returns `"system"` if
absent/invalid. Guards `typeof localStorage`.
- `export function applyTheme(theme: Theme): void` — `document.documentElement.classList.toggle("dark",
resolveTheme(theme) === "dark")`. Guards `typeof document`.
### `web/src/theme/use-theme.ts` (new) — React hook (sibling of `i18n/use-locale.ts`)
- `useTheme(): { theme: Theme; setTheme: (t: Theme) => void }`.
- Holds `theme` in `useState(readTheme)`.
- `setTheme(t)`: `localStorage.setItem(THEME_KEY, t)`, `setThemeState(t)`, `applyTheme(t)`.
- `useEffect`: when `theme === "system"`, subscribe to `matchMedia("(prefers-color-scheme: dark)")`
`change``applyTheme("system")`; clean up on change/unmount. (No-op subscription when pinned.)
- On mount it also calls `applyTheme(theme)` once (covers the case where the hook mounts without the
pre-React init, e.g. tests) — idempotent with the inline script.
### Pre-React init (FOUC prevention) — `web/index.html`
A tiny inline `<script>` in `<head>`, before the module script, that synchronously sets the class
so a dark reload never flashes light:
```html
<script>
try {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch (e) {}
</script>
```
(Kept inline + defensive; it must run before paint, so it cannot be a module import.)
### `web/src/shell/theme-switch.tsx` (new) — the UI
- Renders three `<button>`s in a row (`flex gap-1`), one per `Theme`, each with a lucide icon
(`Sun` → light, `Moon` → dark, `Monitor` → system), sized `size-4`/`h-4 w-4`.
- Active button highlighted; inactive `text-muted-foreground` (mirror `LangSwitch`). Each carries
`aria-pressed={theme === value}` and `aria-label={t("theme.<value>")}`.
- `onClick``setTheme(value)`.
- No raw color utilities (token classes only — passes `check:colors`).
### Mount — `web/src/shell/app-shell.tsx`
Insert `<ThemeSwitch />` in the header immediately before `<LangSwitch />`:
```tsx
<header className="flex items-center gap-4 border-b px-4 py-2">
<div className="flex-1" />
<ThemeSwitch />
<LangSwitch />
<Button variant="ghost" size="sm" onClick={onSignOut}>{t("auth.signOut")}</Button>
</header>
```
### i18n — `web/src/i18n/{en,sv}.json`
Add a `theme` namespace (en/sv parity):
- en: `{ "light": "Light", "dark": "Dark", "system": "System" }`
- sv: `{ "light": "Ljust", "dark": "Mörkt", "system": "System" }`
### Dark `--primary` contrast tweak — `web/src/index.css`
Current dark `--primary: oklch(0.673 0.182 276.935)` with near-black `--primary-foreground:
oklch(0.205 0 0)` yields ~3.21:1 for button-label text. Lower the dark `--primary` lightness so the
near-black foreground reaches **≥4.5:1** (e.g. around `oklch(0.62 0.20 277)` — implementer computes
the exact value with a WCAG contrast check against `--primary-foreground`, keeping it a recognizable
indigo and `--ring` in sync). Light mode is unchanged (already 8.3:1).
## Data flow
First load: inline script applies class from `localStorage`/system before paint → React mounts →
`useTheme` seeds from `localStorage` and (when `system`) attaches the media listener. Toggle click →
`setTheme` persists + re-applies. OS theme change while in `system` → listener re-applies. Other
tabs are not synced (out of scope; a `storage` listener could be a later nicety).
## Error handling / edges
- `localStorage`/`matchMedia`/`document` all guarded → safe in tests/SSR (fall back to light, no throw).
- Invalid stored value → treated as `"system"`.
- The inline script is wrapped in `try/catch` so a storage exception never blocks render.
- Pinning light/dark removes the system listener so it stops following the OS.
## Testing
- **`web/src/theme/theme.test.ts`** (unit): `resolveTheme` maps light/dark verbatim and resolves
`system` via a mocked `matchMedia`; `readTheme` returns `system` when unset and the stored value
otherwise; `applyTheme` toggles the `dark` class on `documentElement`.
- **`web/src/shell/theme-switch.test.tsx`** (renderApp): clicking **Dark** adds `.dark` to
`document.documentElement` and sets `localStorage.theme === "dark"`; clicking **Light** removes it;
clicking **System** with `matchMedia` mocked dark → class present, `localStorage.theme === "system"`;
`aria-pressed` reflects the active mode. (Mock `window.matchMedia` in the test as jsdom lacks it.)
- **Storybook:** `theme-switch.stories.tsx` rendering the three-state control (a play test asserting
the three buttons + aria-pressed). Note: toggling theme in a story mutates `<html>` globally —
keep the story render-only or reset the class in `play`/`beforeEach`.
- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`. en/sv parity; no
codename. `check:size` within 250 KB gz (three small lucide icons; `Sun`/`Moon`/`Monitor`
negligible, but confirm).
## Acceptance criteria
1. A tri-state theme toggle (Light/Dark/System) appears in the header; default is System.
2. Choosing Dark applies `.dark` to `<html>` and persists; Light removes it; System follows the OS
and live-updates via `prefers-color-scheme` until the user pins a value.
3. No light flash on a dark reload (synchronous pre-React init).
4. Dark `--primary` button-label contrast ≥ 4.5:1 (`--ring` kept in sync); light unchanged.
5. en/sv parity for `theme.*`; `aria-pressed` + `aria-label` on the controls.
6. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no codename; no new npm dep.
## Out of scope → follow-ups
- Per-account / server-synced theme preference (add `default_theme` to `ConfigView` later, mirroring
`default_language`).
- Cross-tab sync via a `storage` event listener.
- Header redesign / wayfinding (#54); a full dark-mode visual QA pass across every screen (the tokens
make it adapt, but a dedicated screenshot review is a separate effort).
@@ -0,0 +1,128 @@
# Design-Token Adoption Across Feature Screens — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #49.
## Context
`web/src/index.css` defines a full semantic OKLCH token set (the shadcn defaults:
`--foreground`, `--muted-foreground`, `--primary`, `--destructive`, `--accent`, `--border`,
`--radius`, light + `.dark`) and `ui/*` components use it. But the **feature screens bypass it**:
~120 hardcoded Tailwind color utilities outside `components/ui/``text-red-600` ×27 (not
`--destructive`, a *different* red), `text-neutral-400/500/600` ×47 (three shades for "muted
text"), `bg-neutral-50/100`, plus **two competing accents** (`bg-indigo-50`/`text-indigo-600`/
`bg-indigo-600` for selection/links/chips vs the near-black `--primary` for buttons and
`bg-neutral-800` for the publish stepper). Status colors (visibility badge `amber`/`green`,
search highlight `yellow`) aren't tokens; `rounded` (0.25rem, ×23) ignores the `--radius` token;
`ui/Card` has zero usages; the uppercase caption label appears with 4 different recipes.
(Recent table/detail/toast work *did* introduce token usage — `bg-primary` ×6, `bg-destructive`,
`text-foreground` — so the codebase is now mixed; this finishes the job.)
### Decisions (from brainstorming)
1. **One brand accent: indigo `--primary`.** Set `--primary`/`--ring` to an indigo so primary
buttons, selected rows, links, and chips share one recognizable accent (the existing indigo
usages map straight onto `bg-primary`/`text-primary`).
2. **Add status tokens** (`--success`/`--warning`/`--highlight`) and route the visibility badge +
search highlight through them (via Badge variants).
3. **Enforce** with a CI/lint guard banning raw color utilities outside `components/ui/`.
4. **Dark-mode toggle stays #59.** This migration makes the `.dark` token set *work* (semantic
tokens adapt), unblocking #59; the toggle/persistence is not in scope.
5. **One milestone** (the guard can only pass once the migration is complete, so it lands last).
## Token changes (`web/src/index.css`)
- **Indigo primary** in `:root`: `--primary: oklch(0.511 0.262 276.966)` (≈ indigo-600),
`--primary-foreground: oklch(0.985 0 0)`, `--ring: oklch(0.511 0.262 276.966)`. In `.dark`: a
lighter indigo that reads on dark (≈ indigo-400, `oklch(0.673 0.182 276.966)`) +
`--primary-foreground: oklch(0.205 0 0)`, matching `--ring`. (Implementer may fine-tune to a
Tailwind indigo shade; keep `--primary-foreground` contrast AA.)
- **Status tokens** in `:root` and `.dark`, exposed in `@theme inline` as `--color-success`,
`--color-success-foreground`, `--color-warning`, `--color-warning-foreground`,
`--color-highlight`, `--color-highlight-foreground`. Light values ≈ success
`oklch(0.6 0.13 160)` / warning `oklch(0.72 0.15 75)` / highlight `oklch(0.905 0.16 100)` with
readable foregrounds; dark variants adjusted for contrast. `--destructive` unchanged.
- `--accent`/`--muted` stay the neutral shadcn grays (hover/subtle surfaces). Selected/active
states use `bg-primary/10` (a light indigo tint) rather than repurposing `--accent`.
## Component updates
- **`ui/badge.tsx`:** add `success` and `warning` variants (token-based, with dark variants);
**`VisibilityBadge`** (`web/src/objects/visibility-badge.tsx`) selects a variant — `public`
`success`, `internal``warning`, `draft` → the default/secondary — instead of patching
`bg-amber-100`/`bg-green-100`.
- **`search/highlight.tsx`:** `bg-yellow-200``bg-highlight` (token).
- **Shared caption:** add a single `.label-caption` utility (in `index.css` `@layer
components`, or a tiny `ui` helper) = `text-xs font-medium uppercase tracking-wide
text-muted-foreground`; replace the 4 ad-hoc recipes (object-detail, object-form,
publish-control, field-list, vocabulary-terms).
- **`ui/Card`:** adopt for clearly hand-rolled bordered panels where it's a clean swap (e.g. the
object-detail container). Not forced onto every bordered div — low priority within scope.
## Migration map (mechanical, ~120 sites, outside `components/ui/`)
| From | To |
|---|---|
| `text-red-600` | `text-destructive` |
| `text-neutral-400` / `-500` / `-600` | `text-muted-foreground` |
| `text-neutral-700` / `-900` | `text-foreground` |
| `bg-neutral-50` / `-100` | `bg-muted` |
| `bg-neutral-200` (active nav) | `bg-accent` |
| `bg-indigo-50` (selected row) | `bg-primary/10` |
| `bg-indigo-600` / `text-indigo-600` | `bg-primary` / `text-primary` |
| `bg-neutral-800` (publish stepper / tabs) | `bg-primary` (with `text-primary-foreground`) |
| bare `rounded` (×23) | `rounded-md` |
| `bg-amber-*`/`bg-green-*`/`bg-yellow-*` (badge/highlight) | via Badge variants / `--highlight` |
Apply judgment on a few one-offs (e.g. `border-red-300`/`border-green-300` on the combobox/drawer
`border-destructive`/`border-success` or keep neutral `border`). The goal: **zero raw color
utilities outside `components/ui/`** after the migration.
## Enforcement (`web/scripts/check-no-raw-colors.mjs`)
A grep-style Node script (mirroring `check-bundle-size.mjs`) that scans `src/**/*.{ts,tsx}`,
**excluding `src/components/ui/`**, and fails (exit 1) if it finds a raw palette utility matching
`(text|bg|border|ring|fill|stroke|from|to|via)-(neutral|gray|slate|zinc|stone|red|orange|amber|
yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}`.
Print each offending file:line. Add a `check:colors` script and wire it into the test gate (e.g.
the `check` flow / CI). A short inline allowlist (path or `eslint-disable`-style comment) only if
a genuine exception arises. **Added last**, after the migration, so the gate is green.
## Data flow / architecture
Pure styling refactor — no behavior, routing, API, or data changes. Tokens in `index.css`
Tailwind utility classes (`bg-primary`, `text-muted-foreground`, …) in feature components →
resolved at build. `ui/*` components already consume tokens and gain the new Badge variants.
## Error handling / edges
- The migration must not change layout/spacing — only color/radius utilities (and Badge variant
selection). Don't restructure markup.
- `bg-primary/10` opacity modifier on a token works in Tailwind v4 — verify it resolves.
- A few utilities are genuinely semantic-ambiguous (e.g. `text-neutral-900` for an emphasized
value vs body) — map to `text-foreground`; muted captions → `text-muted-foreground`.
- The enforcement regex must not flag token utilities (`text-foreground`, `bg-primary`) or
non-color numerics (`gap-2`, `w-44`) — scope it to the palette names above.
## Testing
- **Existing `CssCheck` story** (`visibility-badge.stories.tsx`) asserts the public badge's
resolved `bg-green-100` oklch — it **must be updated** to the new `--success` token's resolved
value (run the story to read the actual computed color, as the original did). Add Badge
`success`/`warning` stories.
- `pnpm typecheck` / `lint` / `test` / `build` green; the **new `check:colors` passes** (proves
the migration is complete — zero raw utilities outside `ui/`); `check:size` ≈ unchanged (CSS
token churn only); no codename; en/sv untouched (no strings).
- Manual smoke: the app still renders (now indigo-accented); selected rows/links/buttons share
the indigo; visibility badges + search highlight use the status tokens; nothing relies on a
removed color. (No automated visual-regression in the repo — the guard + updated stories +
smoke are the safety net.)
## Acceptance criteria
1. `--primary`/`--ring` are indigo; primary buttons, selected rows, links, chips use it (one
accent). Status tokens (`--success`/`--warning`/`--highlight`) exist (+ `@theme` + `.dark`).
2. `VisibilityBadge` and the search highlight use token-based Badge variants / `--highlight`
(no hardcoded amber/green/yellow); one shared caption utility.
3. **No raw color utilities outside `components/ui/`**; bare `rounded` standardized to the radius
token; `check:colors` guard added to the gate and passing.
4. No behavior/layout change; `CssCheck` story updated; `typecheck`/`lint`/`test`/`build`/
`check:size` green; no codename.
5. Dark-mode tokens are coherent (light + `.dark`) so #59 (toggle) is unblocked — but the toggle
is not added here.
## Out of scope → follow-ups
- The **dark-mode toggle** + persistence (#59) — this only makes the tokens dark-ready.
- Per-screen **layout/spacing** redesign, density changes, typography scale (#57).
- Forcing `ui/Card` onto every panel (adopt only the clean swaps).
@@ -0,0 +1,169 @@
# App Header Wayfinding — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #54.
## Context
The `<header>` in `web/src/shell/app-shell.tsx` carries only a flex spacer + ThemeSwitch +
LangSwitch + a Sign out button — no "where am I", no user identity, no search entry. Three further
gaps: deep-linking to nested/create routes gives no header cue; the configured `app_name`
(`useConfig()`, default "Collection Management System") is never shown — the sidebar brand
(`sidebar.tsx:76`) and login heading (`login-page.tsx:38`) render the hardcoded `t("app.name")` =
"Collection"; and global search requires a full route change via the sidebar.
Facts established: routes are JSX `<Route>` elements (no `useMatches`/`handle`), so a breadcrumb can't
use react-router's match data — and the object routes carry a UUID `:id`, not the `object_number` the
breadcrumb wants. `useMe()` (`api/queries.ts:30`) returns `UserView | null` =
`{ email, id, role }`. `useLogout()` (`queries.ts:129`) POSTs `/api/admin/logout`. Base UI ships a
Menu primitive — `import { Menu } from "@base-ui/react/menu"` (namespace export) — no new dependency.
`#57` added `useDocumentTitle(page)` per page; this milestone adds a parallel page-driven breadcrumb.
The full ⌘K command palette / in-place search modal is **#33** (out of scope here).
### Decisions (from brainstorming)
1. **Page-driven breadcrumb context** (`useBreadcrumb(trail)`), parallel to `useDocumentTitle` — the
header stays dumb; detail pages supply exact dynamic crumbs (`object_number`, vocab name).
2. **User-menu dropdown** using a new `ui/menu.tsx` Base UI Menu wrapper; Sign out moves into it.
3. **Compact header search** that navigates to `/search?q=…` (light entry; the palette is #33).
4. **Brand + login use `useConfig().app_name`**; the hardcoded `app.name` i18n key is removed.
## Components
### 1. `app_name` everywhere (kill "Collection")
- `web/src/shell/sidebar.tsx:76`: `t("app.name")``useConfig().app_name` (still hidden when the
sidebar is collapsed).
- `web/src/auth/login-page.tsx`: the `<h1>` (line 38) and the `document.title` effect (line 18,
added in #57) → `useConfig().app_name`. Login is rendered inside `ConfigProvider` (mounted in
`main.tsx` around everything), so `useConfig()` works there; if `/api/config` is auth-gated
pre-login, it returns the default "Collection Management System" — still the correct product name,
and better than "Collection".
- Remove the now-unused `app.name` key from `en.json` and `sv.json` (grep confirms only the three
usages above; all are migrated). `useDocumentTitle` already reads `app_name` from `useConfig`, not
i18n, so nothing else depends on the key.
### 2. Breadcrumb (page-driven context)
- **`web/src/shell/breadcrumb-context.ts`** — `export type BreadcrumbItem = { label: string; to?: string }`
and a context `{ trail: BreadcrumbItem[]; setTrail: (t: BreadcrumbItem[]) => void }` (default
`{ trail: [], setTrail: () => {} }`).
- **`web/src/shell/breadcrumb-provider.tsx`** — holds `useState<BreadcrumbItem[]>([])`, provides
`{ trail, setTrail }`. Mounted in `AppShell` wrapping the header + main.
- **`web/src/shell/use-breadcrumb.ts`** — `useBreadcrumb(trail: BreadcrumbItem[])`: in a `useEffect`
calls `setTrail(trail)`, keyed on a **serialized** trail string
(`trail.map(i => \`${i.label}${i.to ?? ""}\`).join("")`) to avoid re-running on every
render from a fresh array literal. (No clear-on-unmount: every AppShell route sets its own trail,
so the next page overwrites — avoids an empty-then-refill flash.)
- **Header rendering:** a `Breadcrumb` element on the header's left renders the trail as
`crumb / crumb / crumb`; non-terminal crumbs with a `to` are `<Link>`s (`text-muted-foreground
hover:text-foreground`), the terminal crumb is `text-foreground`. Separator `/` (muted). Uses
`text-sm`. Truncates with `truncate`/`min-w-0` so a long `object_number` doesn't push the right side
off. (No `aria` nav landmark needed beyond a simple `<nav aria-label="Breadcrumb">`.)
**Per-page trails** (reusing existing i18n keys; dynamic labels from already-loaded data):
| Route / component | Trail |
|---|---|
| `objects-page` | `[{label: t("nav.objects")}]` |
| `object-new-page` | `[{label: t("nav.objects"), to: "/objects"}, {label: t("objects.new")}]` |
| `object-detail` (`ObjectDetailLoaded`) | `[{label: t("nav.objects"), to: "/objects"}, {label: object.object_number}]` |
| `object-edit-form` (loaded) | `[{nav.objects→/objects}, {object_number→/objects/:id}, {t("actions.edit")}]` |
| `vocabularies-page` | `[{label: t("nav.vocabularies")}]` |
| `vocabulary-terms` (loaded) | `[{nav.vocabularies→/vocabularies}, {label: <vocab name>}]` |
| `authorities-page` | `[{label: t("nav.authorities")}]` |
| `fields-page` | `[{label: t("nav.fields")}]` |
| `search-page` | `[{label: t("nav.search")}]` |
`/search/:id` reuses `ObjectDetail`, so it shows the object's canonical `Objects / {number}` trail
(acceptable — it identifies the record; refining to a search-relative trail is a later nicety).
### 3. `web/src/components/ui/menu.tsx` (new — Base UI Menu wrapper)
Wrap the Base UI Menu parts in the established `ui/*` style (`data-slot`, `cn()`, `render` prop where
a part should be a `Button`). Minimum surface needed: `Menu.Root``MenuTrigger`, and a
`MenuContent` composing `Menu.Portal` + `Menu.Positioner` + `Menu.Popup`, plus `MenuItem` and
`MenuSeparator`. Style the popup as a card (`bg-popover text-popover-foreground border rounded-md
shadow-md p-1`), items as `data-[highlighted]` rows (mirror the combobox/alert-dialog token classes).
**Base UI Menu is novel in this repo → the exact part tree + props (`render`, positioner side/align,
portal) must be validated by running** (a story), as combobox/drawer/tooltip/toast were. A
`menu.stories.tsx` renders a trigger + a few items and (play test) opens it and asserts an item.
### 4. `web/src/shell/user-menu.tsx`
- `const { data: me } = useMe();` and the `useLogout()` flow (moved out of the header bar).
- Trigger: a `Button variant="ghost" size="sm"` with a lucide `User`/`CircleUser` icon + the email
(truncated; icon-only below `sm` if needed). Opens `MenuContent`:
- email (a non-interactive header row, `label-caption` or muted),
- role (secondary muted text — raw `me.role`),
- `MenuSeparator`,
- `MenuItem` **Sign out** (`t("auth.signOut")`) → triggers logout + navigate to `/login` (the same
logic currently in `app-shell.tsx`).
- If `me` is null (shouldn't happen inside `AppShell`/RequireAuth), render nothing or just the trigger.
### 5. `web/src/shell/header-search.tsx`
- A small `<form>` with an `<Input>` (lucide `Search` icon, placeholder `t("search.headerPlaceholder")`).
- `onSubmit`: `navigate("/search?q=" + encodeURIComponent(query.trim()))` when non-empty; clears or
keeps the field (keep). The search page already reads `?q=` (`search-panel.tsx`), so it pre-fills +
executes. Width compact (`w-48 lg:w-64`); **hidden below `sm`** (`hidden sm:block`) to keep the
narrow header uncluttered (full responsive header = #58).
- New i18n key `search.headerPlaceholder` (en: "Search…", sv: "Sök…") in both locales (parity).
### 6. Header assembly (`app-shell.tsx`)
```tsx
<header className="flex items-center gap-4 border-b px-4 py-2">
<Breadcrumb /> {/* left, truncates */}
<div className="flex-1" />
<HeaderSearch />
<ThemeSwitch />
<LangSwitch />
<UserMenu />
</header>
```
The standalone Sign out `<Button>` is removed (now in `UserMenu`); `onSignOut`/logout logic moves to
`UserMenu`. Wrap header+main in `BreadcrumbProvider` so both the pages (setters) and the header
(reader) share it.
## Data flow
Route mounts → page calls `useBreadcrumb(trail)` → provider state updates → header `Breadcrumb`
renders it. `UserMenu` reads `useMe()` (cached `["me"]` query). `HeaderSearch` submit → router
navigate to `/search?q=`. Brand/login read `useConfig().app_name`.
## Error handling / edges
- `useBreadcrumb` effect keyed on serialized trail → no render loop, no stale array identity churn.
- A page that forgets to set a trail would show the previous page's crumbs; all AppShell routes set
one (acceptance check). Login is outside AppShell (no breadcrumb there).
- Long `object_number`/email truncate (`min-w-0 truncate`) so the header never overflows.
- Base UI Menu requires Portal + Positioner (like the other primitives) — validate by running.
- `me` null inside AppShell is not expected (RequireAuth guards), but `UserMenu` guards it.
## Testing
- **Breadcrumb:** a page sets a trail → the header renders the crumbs; a non-leaf crumb is a working
`<Link>` (click navigates). Test via `renderApp` at a nested route (e.g. `/objects/new` shows
"Objects / New" with "Objects" linking to `/objects`).
- **`ui/menu` story** (validate-by-running): open the menu, assert an item is visible.
- **UserMenu:** renders the email from a mocked `useMe`; opening it shows Sign out; clicking Sign out
triggers the logout request (MSW) and navigates to `/login`. (Mirror the existing app-shell signout
expectations — move them here.)
- **HeaderSearch:** typing a query + submit navigates to `/search?q=<encoded>` (assert the resulting
route/`?q=`).
- **app_name:** sidebar brand + login render `useConfig().app_name` (the default in tests).
- **app-shell.test.tsx:** update — the Sign out button moved into UserMenu; keep/upgrade the signout
assertion to go through the menu. Don't weaken.
- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`; en/sv parity (one new
key `search.headerPlaceholder`; `app.name` removed from both); no codename. **`check:size`:** Base UI
Menu is added to the always-loaded shell — it may nudge the largest chunk; report the value (budget
250 KB gz; raise only if it genuinely exceeds, and flag to the user rather than silently bumping).
## Acceptance criteria
1. The header shows a route-driven breadcrumb on the left for every AppShell route (section for list
pages; `Section / New|Edit` and `Section / {object_number|vocab name}` for nested), via a
page-driven `useBreadcrumb`/context; non-leaf crumbs link.
2. A user-menu dropdown on the right shows the signed-in email + role and a Sign out item (which logs
out); the standalone Sign out button is gone. Built on a reusable `ui/menu` Base UI wrapper.
3. A compact header search field navigates to `/search?q=…` (hidden below `sm`).
4. The sidebar brand and the login heading + tab title render `useConfig().app_name`; the hardcoded
`app.name` i18n key is removed.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (within budget or
flagged); en/sv parity; no codename; no new npm dependency.
## Out of scope → follow-ups
- Full ⌘K command palette / in-place search-results modal (#33).
- Broader responsive header behavior (#58) — this only hides the search field below `sm`.
- User avatar images, a user/account settings page, role-name i18n mapping.
- Refining `/search/:id` to a search-relative breadcrumb (currently shows the canonical object trail).
@@ -0,0 +1,119 @@
# Object Detail Readability — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #45.
## Context
`web/src/objects/object-detail.tsx` renders flexible field values with
`typeof value === "object" ? JSON.stringify(value) : String(value)`. Since term/authority
values are stored as **UUID strings** and `localized_text` as a `{lang: text}` **object**,
the result is: term/authority fields show a **raw UUID**, and `localized_text` shows
**`{"sv":"…"}`** JSON. Flexible fields also render in `Object.entries` (insertion) order,
ungrouped, and empty core fields vanish (layout shifts per object). This is the worst
readability defect in the screen curators read most — now more visible since the detail moved
into the new pane/drawer (#44).
The resolution machinery already exists: `useTerms(vocabulary_id)` / `useAuthorities(kind)`
and `labelText(labels, lang)` (`web/src/lib/labels.ts`) — the object form/combobox use them.
`FieldDefinitionView` carries `data_type`, `vocabulary_id`, `authority_kind`, `group`, and
`labels`.
### Decisions (from brainstorming)
1. **Client-side resolution**, reusing `useTerms`/`useAuthorities` + `labelText` (no backend
change; react-query dedups repeated vocabularies/kinds). Backend-resolved labels were
rejected for now (more work; the client machinery exists). PDF export (#39) may revisit
server-side resolution later.
2. **Scope = detail readability bundle:** value resolution + grouping/order + small polish
(date formatting, empty-core placeholders, an actions toolbar). **Excluded:** export/PDF
(#39), the object form's grouping (left to the form work / #46), backend resolution.
## Components
### `FlexibleFieldValue` (new, in `object-detail.tsx` or a sibling file)
Rendered once per flexible field — so the term/authority hooks satisfy the rules of hooks
(one hook call per component instance, not a loop). Props: `{ def: FieldDefinitionView,
value: unknown, lang: string }`. Switches on `def.data_type`:
- **`term`**: `const { data: terms } = useTerms(def.vocabulary_id)`; find `terms?.find(t => t.id === value)`; show `labelText(term.labels, lang)`.
- **`authority`**: `const { data: authorities } = useAuthorities(def.authority_kind)`; same.
- **`localized_text`**: `value` is `Record<string, string>`; show `value[lang] ?? value.en ?? Object.values(value)[0] ?? "—"`.
- **`date`**: `Intl.DateTimeFormat(i18n.language, { dateStyle: "medium" }).format(new Date(value))` — a bare date, **no `timeZone`** (would shift a date-only value).
- **`boolean`**: `value ? t("common.yes") : t("common.no")`.
- **`integer` / `text` / default**: `String(value)`.
**Fallbacks:**
- Term/authority value present but not found in the loaded set (deleted ref) → render the raw
id in muted text with a `t("objects.unknownRef")` ("(unknown)") suffix.
- Terms/authorities still loading → a muted placeholder (e.g. the id faintly, or "…").
- Empty/absent value → "—".
- `def` missing for a stored key (field definition deleted) → fall back to `String(value)` (or
the raw value) muted.
react-query keys: `["terms", vocabularyId]` / `["authorities", kind]` are shared, so N term
fields in one vocabulary cause **one** fetch (often already cached from the table/combobox).
### Detail layout changes (`object-detail.tsx`)
- **Header → actions toolbar:** left = `object_name` (`<h2>`) + `VisibilityBadge`; right =
a toolbar with **Edit** (a real `Button` linking to `/objects/:id/edit` — use `Link` +
`buttonVariants` like the table's New button, since the Base UI `Button` has no `asChild`)
and **Delete** (the existing `DeleteObjectDialog`). `PublishControl` stays below the fields.
- **Core fields render always** with a muted "—" when empty (object number, count, brief
description, current location, current owner, recorder, recording date) — stable layout.
Update the local `Field` component to show "—" instead of returning `null`. (`recording_date`
formatted as a locale date.)
- **Flexible fields grouped + ordered:** iterate `definitions` (stable API order); for each
definition whose key exists in `object.fields` with a non-null value, render
`<FlexibleFieldValue>` under a subheading for its `def.group` (null group → an "Other" /
ungrouped section rendered last). No more `Object.entries`/`JSON.stringify`.
## Data flow
`useObject(id)` + `useFieldDefinitions()` (already loaded) → group definitions by
`group` (stable order) → for each present flexible field, `FlexibleFieldValue` resolves the
display via the type-appropriate hook (`useTerms`/`useAuthorities`, react-query-cached) or
inline (localized/date/boolean/text). Core fields render directly with placeholders.
## Error handling / edges
- Deleted term/authority (id not resolvable) → muted id + "(unknown)"; never a crash or raw
JSON.
- `localized_text` with no active-language entry → English → first → "—".
- Invalid date string → fall back to the raw string (guard `Number.isNaN(date.getTime())`).
- Object with no flexible values → no flexible section (only core fields).
- A stored key with no matching definition → render the raw value muted (don't drop silently
into `JSON.stringify`).
## Testing
**Vitest + RTL + MSW** (extend `web/src/test/fixtures.ts` — it already has `fieldDefinitions`
with term/authority/localized_text/date/boolean defs, `materialTerms`, `personAuthorities`):
- A `term` field renders the term's active-locale label (not the UUID); `authority` likewise;
`localized_text` renders the active-language string (not JSON); `date` is locale-formatted;
`boolean` → yes/no.
- A term value whose id isn't in the vocabulary → muted id + "(unknown)" fallback.
- Flexible fields render **grouped** with group subheadings, in definition order.
- An empty core field shows "—".
- The Edit action links to `/objects/:id/edit`; Delete dialog present.
- Component test for `FlexibleFieldValue` covering each `data_type` + the unknown-ref fallback.
**Storybook** (per the standing preference): `FlexibleFieldValue.stories.tsx` — Term /
Authority / LocalizedText / Date / Boolean / UnknownRef variants (MSW from the preview, or
pass pre-seeded query data). Mirror the established story format.
## Acceptance criteria
1. Term and authority flexible fields display their active-locale **label** (not a UUID);
`localized_text` shows the active-language string (not JSON); date/boolean/integer/text
render readably.
2. A deleted/unknown term/authority ref degrades to a muted id + "(unknown)", never raw JSON
or a crash.
3. Flexible fields are **grouped by `def.group`** in stable definition order; empty core fields
show "—".
4. The detail header is an actions toolbar (Edit as a Button + Delete); `recording_date` is
locale-formatted.
5. A `FlexibleFieldValue` Storybook story exists; en/sv parity for new keys (`common.yes`,
`common.no`, `objects.unknownRef`); `pnpm typecheck`/`lint`/`test`/`build` green;
`check:size` within budget; no codename; no `any`/`eslint-disable`.
## Out of scope → follow-ups
- **Export / PDF** of a record (#39).
- The **object form's** flexible-field grouping/order (the form still renders flat) — left to
the form work (#46) or a small follow-up.
- **Backend-resolved labels** (would help PDF export #39 and avoid client N+1 at large scale).
@@ -0,0 +1,164 @@
# Object Form Robustness — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #46.
## Context
The object create/edit form has data-integrity gaps that compound in long daily cataloguing sessions
(High severity):
- **Submit never disabled during save.** `object-form.tsx` submit button has no `disabled`/pending.
Both create (`object-new-page.tsx`) and edit (`object-edit-form.tsx`) do **two sequential awaited
mutations** (create/update, then `setFields`) with no feedback → a second click can duplicate-create
or race the two-step write. `publish-control.tsx` is the model (disables on `isPending`).
- **No unsaved-changes guard.** No `isDirty`/`useBlocker`/`beforeunload`; Cancel/nav loses work
silently.
- **Two-phase write recovers inconsistently.** Create teleports to the edit page on `setFields`
failure; edit stays put with a banner. The partial write (core saved, field rejected) reads as
nothing happened.
- **Thin validation.** Core errors always show `form.required`; the server `code`
(`type_mismatch`/`unresolved`/`unknown`) on `FieldRejection` is discarded; no `number_of_objects >= 1`.
- No "Save & create another" / keyboard submit for batch entry.
**Facts established:** the form uses **react-hook-form** (so `formState.isDirty` + `isSubmitting` are
available; `isSubmitting` stays true across both awaited mutations — the unified pending signal).
`ObjectForm` props: `mode`, `defaults`, `onSubmit`, `onCancel`, `formError`, `fieldErrorKey`; the
422→highlight path uses `form.setError`. `FieldRejection` (`queries.ts`) carries `field` + `code`.
The router is `<BrowserRouter>` + `<Routes>` (component router) in `app.tsx`; **`useBlocker` requires
a data router** (`createBrowserRouter` + `RouterProvider`). `renderApp` wraps `ui` in `<MemoryRouter>`
with no `<Routes>` (tests supply their own when they need params).
### Decisions (from brainstorming)
1. **Migrate to a data router** (the long-term-correct foundation; unblocks `useBlocker` and React
Router v7 data APIs). Done via `createRoutesFromElements` to keep the exact route tree (minimal
diff), isolated as the first task, with the test harness moved to `createMemoryRouter`.
2. **Unify create partial-failure** to the edit route with a distinct "Object created, but a field was
rejected" banner (single recovery surface).
3. **Include batch entry** ("Save & create another" + Cmd/Ctrl+Enter).
## Components
### 0. Data-router migration (foundation — isolated)
- **`app.tsx`:** replace `<BrowserRouter><Routes>…</Routes></BrowserRouter>` with a module-level
`const router = createBrowserRouter(createRoutesFromElements(<>…the existing <Route> tree…</>))`
and `export function App() { return <RouterProvider router={router} />; }`. The route JSX (login,
`RequireAuth``AppShell` nest with all nested routes, lazy `Suspense` wrappers, splat) moves
**verbatim** into `createRoutesFromElements`. No behavior/path change.
- **`main.tsx`:** unchanged provider stack (QueryClient → Config → Toast) now wraps `<App/>` which is
`<RouterProvider/>`.
- **`test/render.tsx`:** `renderApp(ui, { route })` switches to
`createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] })` rendered via
`<RouterProvider/>` (inside `QueryClientProvider`). Behavior-preserving: `ui` renders at `route`,
and tests that supply their own `<Routes>` still nest correctly; now a data-router context exists so
`useBlocker` works. **Acceptance: the entire existing suite stays green** before any feature is built
on top.
### 1. Submit pending + keyboard + batch (`object-form.tsx`)
- Submit `<Button type="submit" disabled={form.formState.isSubmitting}>` showing
`isSubmitting ? t("form.saving") : (mode === "create" ? t("form.create") : t("form.save"))`. Disable
Cancel while submitting. (`isSubmitting` is true across both awaited mutations because RHF awaits the
async `onSubmit`.)
- **Cmd/Ctrl+Enter** submits: a keydown handler on the form (`(e.metaKey||e.ctrlKey) && e.key==="Enter"`
`form.handleSubmit(...)`), or rely on the native submit + a small handler. Implementer picks the
clean form; must not double-submit.
- **"Save & create another"** (create mode only): a secondary submit button. `ObjectForm` gains an
optional `onSubmitAndNew?: (values) => Promise<void>` (or a `submitAction` flag passed to `onSubmit`)
so the create page knows which button fired. On success it `form.reset(blankDefaults)` and focuses the
first field instead of navigating. Edit mode does not show this button.
### 2. Validation messages
- **Server `code` echo:** when a `FieldRejection` is shown, pick the message by `code`
`form.fieldError.type_mismatch` / `form.fieldError.unresolved` / `form.fieldError.unknown` (fallback
to the existing `form.fieldRejected`). The create/edit pages already extract `e.field`; also pass
`e.code` through to `ObjectForm` (extend `fieldErrorKey` handling to carry a code, e.g. a
`fieldError?: { key: string; code?: string }` prop, or a second `fieldErrorCode?` prop). The
highlight `form.setError` message uses the code-specific string.
- **Core-field messages:** render the RHF error by `type``errors.core[key]?.type === "min"`
`form.min` (with the limit), else `form.required`. (Today it's always `form.required`.)
- **`number_of_objects >= 1`:** register with `min: { value: 1, message: t("form.minCount") }` (RHF,
client-side, before the round-trip). Keep `required`.
### 3. Unsaved-changes guard (`lib/use-unsaved-changes.tsx`)
- `useUnsavedChanges(isDirty: boolean)`:
- adds a `beforeunload` listener while `isDirty` (covers reload / tab-close / browser back),
- calls `useBlocker(({currentLocation,nextLocation}) => isDirty && currentLocation.pathname !== nextLocation.pathname)` to block in-app navigation while dirty,
- returns the `blocker` so the caller renders an `UnsavedChangesDialog` when `blocker.state === "blocked"` (Stay → `blocker.reset()`, Leave → `blocker.proceed()`).
- **`UnsavedChangesDialog`** built on `ui/alert-dialog` (title/body/Stay/Leave). New i18n
`form.unsaved.{title,body,stay,leave}`.
- **Wire into `ObjectForm`:** `const isDirty = form.formState.isDirty;``useUnsavedChanges(isDirty)`;
render the dialog. The **Cancel** button: if dirty, open the same confirm dialog (Leave → `onCancel`);
if clean, `onCancel` directly.
- **Suppress on successful save:** after a successful submit the form navigates; `isDirty` must be false
by then (RHF `reset` on success, or the blocker condition excludes the programmatic post-save nav).
The create→edit partial-failure nav is programmatic and must not be blocked — reset dirty (or set a
"saving" ref that bypasses the blocker) around the save so the post-save/teleport navigation isn't
caught. Implementer ensures save-driven navigation never triggers the guard.
### 4. Partial-failure unification
- **Create** (`object-new-page.tsx`): on `setFields` failure after the core create succeeds, navigate
to `/objects/${id}/edit` with `state: { created: true, fieldErrorKey: e.field, fieldErrorCode: e.code }`
(today it passes `fieldsError`/`fieldErrorKey`; add `created` + `code`). The core-create already
succeeded, so this is correct (the object exists; editing it is the right URL).
- **Edit** (`object-edit-form.tsx`): read `location.state`. If `created`, show the banner
`form.createdButFieldRejected` ("Object created, but a field was rejected — fix it below"); otherwise
the existing rejection banner. Both highlight the field (via `fieldErrorKey` + code). Edit's own
in-place partial-failure (update ok, setFields fails) keeps staying-put with its banner.
- Result: a single recovery surface (the edit form) for both flows, with create messaged as "created."
## Data flow
RHF manages form state → `isSubmitting` disables submit across both mutations → `isDirty` drives the
guard (beforeunload + useBlocker + dialog) → on save success the form is clean and navigates → on
create partial-failure it navigates to the edit route with `created` state → the edit form shows the
"created" banner + field highlight (code-specific message).
## Error handling / edges
- `isSubmitting` covers the whole two-phase write (RHF awaits `onSubmit`); a second click is a no-op
(button disabled).
- The guard must not fire on save-driven navigation (post-success or create→edit teleport) — reset
dirty / bypass ref around saves.
- `beforeunload` only prompts while dirty (don't attach unconditionally).
- `useBlocker` now works (data router). Tests render under `createMemoryRouter` (harness change) so the
blocker is exercisable.
- A `FieldRejection` with an unknown `code` falls back to the generic `form.fieldRejected`.
- `number_of_objects` min is client-side UX; the server remains source of truth.
## Testing
- **Migration:** the full existing suite passes under the new `createMemoryRouter` harness (no
behavior change) — this is the gate for Task 1.
- **Submit disable:** during an in-flight save the submit button is `disabled` and reads "Saving…";
a double-click fires the create mutation once (assert call count).
- **Keyboard:** Cmd/Ctrl+Enter submits.
- **Save & create another:** create mode → success resets the form (fields cleared, stays on /new),
does not navigate to detail.
- **Validation:** a `FieldRejection` with `code: "type_mismatch"` shows the code-specific message;
`number_of_objects = 0` shows `form.minCount` without hitting the server.
- **Dirty guard:** with a dirty form, attempting in-app nav shows the dialog (Stay keeps you, Leave
proceeds); Cancel-when-dirty confirms; a clean form navigates freely; `beforeunload` listener added
only when dirty (assert via the registered handler / a spy).
- **Partial-failure:** create with a `setFields` 422 → lands on `/objects/:id/edit` with the "Object
created, but a field was rejected" banner + field highlight; edit's own setFields 422 → stays with
its banner. (Extend `object-new-page.test.tsx` / `object-edit-form.test.tsx`.)
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity for all new keys;
no codename.
## Acceptance criteria
1. The app uses a data router (`createBrowserRouter` + `RouterProvider`) with the route tree unchanged;
the test harness uses `createMemoryRouter`; the full suite is green.
2. The submit button is disabled and shows "Saving…" while either mutation is pending; no double-submit
/ duplicate-create is possible from the UI.
3. A dirty form guards navigation: `useBlocker` dialog on in-app nav, `beforeunload` on reload/close,
and Cancel confirms when dirty; saving navigates without a false prompt.
4. Create and edit share one partial-failure recovery surface (the edit form); the create case is
messaged "Object created, but a field was rejected" and highlights the field.
5. Field-rejection messages reflect the server `code`; core errors show type-specific messages;
`number_of_objects >= 1` is validated client-side.
6. Create mode offers "Save & create another" (resets the form) and Cmd/Ctrl+Enter submit.
7. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity; no codename; no
new npm dependency.
## Out of scope → follow-ups
- Flexible-field grouping/ordering (#45 / detail), per-field server validation rules (#11).
- Turning core-field 422s into per-field `FieldRejection`s server-side (the core mutations still throw
generic errors; only `setFields` yields field-level codes).
- Autosave / draft persistence.
@@ -0,0 +1,123 @@
# Toast Notifications + Consistent Mutation Feedback — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #47.
## Context
Mutations across the app communicate **success only** by side effects (navigation, a dialog
closing) — a curator doing bulk cataloguing can't tell whether a save landed, which invites
double-submits and re-checking. **Errors** are inconsistent: inline `<p role="alert">` in the
forms, in-dialog for deletes, three message kinds in `publish-control`, and some mutation
failures surface nowhere. There is no toast/notification system (no `toast`/`sonner` dep).
### Facts established during exploration
- The `QueryClient` is created at **module scope** in `web/src/main.tsx` (outside React), with
`defaultOptions.queries` only — no mutation defaults yet.
- **Base UI ships a `toast` primitive** (`@base-ui/react/toast`) — no new dependency. It exports
`createToastManager()`, an **out-of-React manager** you pass to `<Toast.Provider>` and can
`.add()` from anywhere — the clean bridge to the module-scope queryClient handlers.
- The `i18n` singleton (`web/src/i18n`) exposes `i18n.t(...)`, callable **outside React**.
- Existing typed mutation errors (`web/src/api/queries.ts`): `InUseError` (409 + count),
`FieldRejection` (422 field), `HttpError` (status), `VisibilityError`. The object form shows
422 as a field highlight; the delete dialogs catch errors and show them inline.
### Decisions (from brainstorming)
1. **Base UI Toast**, bridged via a module-scope `createToastManager()` (no new dep, consistent
with the combobox/tooltip/drawer wrappers).
2. **Wiring: global `MutationCache` + per-mutation `meta`.** A global `onError` is a catch-all
safety net (no silent failures); a global `onSuccess` shows a toast only when a mutation
declares `meta.successMessage`. Mutations that report errors inline opt out via
`meta.suppressErrorToast`.
3. **Keep inline field/dialog errors** (422 highlight, 409 "in use") — toasts add success
confirmations + a catch-all for otherwise-silent failures, not a replacement.
## Components
### `ui/toast.tsx` (new) + the manager
- Module scope (alongside the QueryClient — `main.tsx` or a small `web/src/toast/` module):
`export const toastManager = Toast.createToastManager();`
- `web/src/components/ui/toast.tsx` — wrap the Base UI Toast parts (Provider / Viewport /
Portal / Positioner / Root / Title / Description / Close) in the established `ui/*` style
(`data-slot`, `cn()`, mirror `ui/alert-dialog.tsx`). Provide a `<ToastRegion>` that renders
`Toast.Provider` (with `toastManager`) + a `Toast.Viewport` listing the active toasts (mapped
from `useToastManager().toasts`), styled as stacked cards (success vs error variant via the
toast's `type`/`data`). The viewport must be an `aria-live` region (Base UI handles the live
semantics; confirm). **Base UI Toast is novel in this repo → the exact part tree + manager API
(`createToastManager`, `manager.add({ title?, description, type })`, `useToastManager`) must be
validated by running** (as the combobox/drawer/tooltip were).
- Mount `<ToastRegion>` in `main.tsx` inside the provider tree (so it's on every screen).
### Global mutation feedback (`main.tsx` / queryClient)
```ts
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
mutationCache: new MutationCache({
onError: (error, _vars, _ctx, mutation) => {
if (mutation.meta?.suppressErrorToast) return;
toastManager.add({ type: "error", description: errorMessage(error, mutation) });
},
onSuccess: (_data, _vars, _ctx, mutation) => {
const key = mutation.meta?.successMessage;
if (key) toastManager.add({ type: "success", description: i18n.t(key) });
},
}),
});
```
- `errorMessage(error, mutation)`: `mutation.meta?.errorMessage ? i18n.t(...)` else a type-aware
default — `InUseError``i18n.t("actions.inUse", { count })`, `HttpError` 503 →
`i18n.t("search.unavailable")`, otherwise `i18n.t("toast.error")`.
- `meta` is typed via a module augmentation of `@tanstack/react-query`'s `Register` interface so
`meta.successMessage`/`meta.errorMessage`/`meta.suppressErrorToast` are type-checked (no
`any`).
### Per-mutation meta (`web/src/api/queries.ts`)
- **`meta.successMessage`** (a `toast.*` key) on the discrete actions: object create/update/
delete, set-visibility/publish, vocabulary create/rename/delete, term create/update/delete,
authority create/update/delete, field-definition create/update/delete.
- **`meta.suppressErrorToast: true`** on mutations that surface errors inline: the object form's
create/update + `useSetFields` (422 field highlight), `useLogin`, and the delete hooks (the
`DeleteConfirmDialog`/`DeleteObjectDialog` show the 409/rejection inline). These keep success
toasts.
## Data flow
A mutation runs → react-query's `MutationCache` fires the global `onSuccess`/`onError` → reads
`mutation.meta``toastManager.add(...)` (message via `i18n.t`) → `<ToastRegion>` (subscribed
to the manager) renders the toast in its portal/viewport → auto-dismisses (Base UI default) or
on Close. Inline field/dialog errors render as before (unaffected).
## Error handling / edges
- A mutation with neither `meta` nor inline handling → still gets the catch-all error toast on
failure (the safety net); silent success (no `successMessage`) is intentional where navigation
already signals it.
- No double-report: inline-handling mutations set `suppressErrorToast`.
- `i18n.t` outside React uses the singleton's current language (already synced via the locale
hook); a missing key falls back to the generic `toast.error`.
- Toasts stack + auto-dismiss; an error toast may stay longer / be dismissible (Base UI config).
## Testing
- **Vitest + RTL + MSW:** a mutation declaring `meta.successMessage` shows a success toast
(query `within(document.body)`); a failing mutation (MSW 500) shows an error toast; a
`suppressErrorToast` mutation does **not** add a toast on error (no double-report) while its
inline error still renders; the toast region is present and `aria-live`. Drive these through a
real screen action (e.g. create a vocabulary → "Created" toast; a 500 → error toast).
- **Storybook:** a `ui/toast` story rendering a success + an error toast (via the manager), per
the standing preference; verify the Base UI composition by running.
- Reuse existing screens' MSW handlers (`web/src/test/handlers.ts`).
## Acceptance criteria
1. A Base UI toast region is mounted app-wide; `toastManager` (out-of-React) is `.add`-able from
the queryClient handlers. No new npm dependency.
2. Global `MutationCache`: `onError` toasts a catch-all (type-aware) message unless
`meta.suppressErrorToast`; `onSuccess` toasts `meta.successMessage` when present.
3. The discrete CRUD/publish mutations declare `meta.successMessage`; inline-error mutations
(object form, login, deletes) set `meta.suppressErrorToast` and keep their inline UX.
4. `meta` is type-checked (react-query `Register` augmentation); no `any`/`eslint-disable`.
5. en/sv parity for the `toast.*` keys; a toast Storybook story; `pnpm typecheck`/`lint`/`test`/
`build` green; `check:size` within 180 KB gz; no codename.
## Out of scope → follow-ups
- Replacing the inline 422 field-highlight / 409 "in use" UX with toasts.
- Undo/action toasts, queued/grouped toasts, per-account toast preferences.
- Toasting query (read) errors — this milestone covers mutations (the silent-write problem).
@@ -0,0 +1,167 @@
# Typography Hierarchy + Page `<h1>` + Per-Route `document.title` — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #57.
## Context
The app has a flat type scale (almost everything `text-sm`/`text-xs`) and only 3 ad-hoc headings:
`login-page.tsx` `<h1 text-2xl>`, `object-detail.tsx` `<h2 text-xl>` (object name), and
`vocabulary-terms.tsx:52` an `<h3 class="label-caption">` **misused as a caption**. The list routes
(objects, vocabularies, authorities, fields, search) have **no page `<h1>`** and no visible title.
Separately, `web/index.html` hardcodes `<title>Collection</title>` and nothing ever sets
`document.title` — every tab/bookmark reads "Collection", so a curator with many object tabs can't
tell them apart.
All authenticated routes render inside `AppShell` (login is standalone). The header has no
title/breadcrumb slot (that's #54 — out of scope here). `.label-caption` exists (from #49).
`app_name` comes from `useConfig()` (`ConfigView.app_name`). i18n already has `nav.{objects,
vocabularies,authorities,fields,search}` plus `objects.title`/`fields.title`/`objects.new`.
### Decisions (from brainstorming)
1. **`PageTitle` component** (`ui/page-title.tsx`) rendering a semantic `<h1>` with consistent
classes — over utility classes or inline copy-paste. No `SectionTitle` (YAGNI; one h2 exists).
2. **Tab title format `"{Page} | {AppName}"`** — page-first so truncated tabs stay distinguishable.
3. Reuse existing i18n keys for page titles — no new strings.
4. Master-detail: the list page owns the single `<h1>`; the detail pane overrides `document.title`
to the object identifier while mounted.
## Type scale
| Level | Element | Classes |
|---|---|---|
| Page title | `<h1>` | `text-2xl font-semibold tracking-tight` |
| Section title | `<h2>` | `text-xl font-semibold` (object-detail `object_name`, already this) |
| Body | — | `text-sm` (unchanged) |
| Caption | — | `.label-caption` (exists) |
Only the page-title level is new; the rest already exist in the codebase. No global CSS scale change
beyond the `PageTitle` component.
## Components
### `web/src/components/ui/page-title.tsx` (new)
Mirrors the `ui/*` style (`data-slot`, `cn()`):
```tsx
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
export function PageTitle({ className, ...props }: ComponentProps<"h1">) {
return (
<h1
data-slot="page-title"
className={cn("text-2xl font-semibold tracking-tight", className)}
{...props}
/>
);
}
```
A Storybook story renders it with sample text and asserts the `<h1>` role/level.
### `web/src/lib/use-document-title.ts` (new)
```ts
import { useEffect } from "react";
import { useConfig } from "../config/config-context";
export function useDocumentTitle(page: string): void {
const { app_name } = useConfig();
useEffect(() => {
if (typeof document === "undefined") return;
const previous = document.title;
document.title = `${page} | ${app_name}`;
return () => {
document.title = previous;
};
}, [page, app_name]);
}
```
- Sets `"{page} | {app_name}"`; re-runs when `page` or `app_name` changes (so it updates when the
async config resolves, or when a detail pane swaps the `page` value).
- **Restores the prior title on cleanup.** This is what makes the master-detail override revert: the
detail pane captures the list page's title (`"Objects | …"`), sets the object title, and restores
`"Objects | …"` when it unmounts.
- `useConfig()` exposes `app_name` (defaults to `"Collection Management System"` until `/api/config`
resolves); the dep on `app_name` means the title corrects itself once config loads.
## Per-route wiring
Each `AppShell` page renders one `<PageTitle>` and calls `useDocumentTitle` (reusing existing keys):
| Route | Component | `<h1>` / title key |
|---|---|---|
| `/objects` | `ObjectsPage` | `nav.objects` |
| `/objects/new` | `ObjectNewPage` | `objects.new` |
| `/vocabularies` | `VocabulariesPage` | `nav.vocabularies` |
| `/authorities/:kind` | `AuthoritiesPage` | `nav.authorities` |
| `/fields` | `FieldsPage` | `fields.title` |
| `/search` | `SearchPage` | `nav.search` |
Placement: the `<PageTitle>` goes at the top of each page's content region (above the table/columns),
in a consistent wrapper (e.g. a small header row with existing actions). **Only color/typography +
the added heading element — do not restructure the existing layout/columns.** Where a page already
has a top action row (e.g. "New object" button), put the `<PageTitle>` on the left of that row.
### Master-detail `document.title` override
- `/objects/:id`: `ObjectsPage` still renders the visible `<h1>` ("Objects") and sets the base title.
`ObjectDetail` (right pane) keeps `object_name` as its `<h2>` and calls
`useDocumentTitle(object.object_number)` once the object is loaded — overriding the tab to e.g.
`"1024.1 | Collection"`, reverting to `"Objects | Collection"` on unmount (hook restore).
- `/search/:id`: `ObjectDetail` is reused in `SearchPage`'s pane — same override over the "Search"
base, automatically.
- Result: exactly **one `<h1>` per page** (a11y), while each open object tab is distinguishable.
- Edge: `ObjectDetail` must only call the hook with a real value once loaded (guard the loading
state — don't set `"undefined | …"`). While loading, leave the base title.
### Semantic caption fix
`web/src/vocabulary/vocabulary-terms.tsx:52`: `<h3 className="mb-2 label-caption">`
`<div className="mb-2 label-caption">` (it's a caption, not a section heading). No style change
(class identical), just the element.
### Login (standalone)
`login-page.tsx` keeps its `<h1>` (app name) and sets `document.title = t("app.name")` via a small
inline `useEffect` (deps `[t]`) — **not** `useDocumentTitle`, since `/api/config` may be
unauthenticated pre-login and `useConfig`/`ConfigProvider` may not apply there. This is deterministic
(no `"{page} | {app}"` composition on the login screen), keeping login self-contained.
## Data flow
Route mounts → page calls `useDocumentTitle(t(key))` → effect sets `"{page} | {app_name}"`. Config
resolves → `app_name` dep changes → title corrected. Detail pane mounts → overrides with
`object_number` → unmount restores. `<PageTitle>` is purely presentational.
## Error handling / edges
- `document` guarded (test/SSR safety).
- Detail pane must not set a title until the object is loaded (no `"undefined | …"`).
- Two `useDocumentTitle` instances active at once (list + detail) on a detail route: the detail's
effect runs after the list's (child mounts after parent), so the object title wins; restore on the
detail's unmount returns to the list title. The list's effect does not re-fire on detail
open/close (its `page`/`app_name` deps are unchanged), so it won't clobber the override.
- One `<h1>` per page is preserved on master-detail routes (list owns the h1; detail uses h2).
## Testing
- **`page-title` story/test:** renders `<PageTitle>Objects</PageTitle>`, assert
`getByRole("heading", { level: 1 })` has the text.
- **`use-document-title.test.tsx`:** render a component using the hook inside `renderApp` (which
provides config) or a small ConfigProvider wrapper; assert `document.title === "X | <app_name>"`;
unmount → assert it reverts to the previous value. (Mock/confirm the config `app_name` used.)
- **Page-level:** assert `ObjectsPage` renders an `<h1>` with the localized "Objects" and sets
`document.title`. A detail test: rendering the object route sets `document.title` to the
`object_number` and reverts when navigating away (reuse existing object MSW handlers/fixtures).
- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`; en/sv parity
(reused keys); no codename; `check:size` within 250 KB gz.
## Acceptance criteria
1. A `PageTitle` (`<h1>`) component exists and is rendered once per `AppShell` route (objects,
object-new, vocabularies, authorities, fields, search) using existing i18n keys.
2. `document.title` is set per route as `"{Page} | {AppName}"`; object detail routes
(`/objects/:id`, `/search/:id`) show the object's `object_number` in the tab and revert on close.
3. The misused `<h3>` caption in `vocabulary-terms` becomes a non-heading element (`.label-caption`).
4. Exactly one `<h1>` per page (master-detail: list owns the `<h1>`, detail keeps `<h2>`).
5. No layout/spacing restructure beyond adding the heading element; no new i18n strings; no new dep.
6. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no codename.
## Out of scope → follow-ups
- Header breadcrumb / wayfinding / global search entry (#54).
- Broader density/spacing/typography redesign per screen (the type scale here is intentionally
minimal: page title + the existing section/body/caption levels).
- A `<title>` template via a router data API or a `<DocumentTitle>` provider (the hook is sufficient).
@@ -0,0 +1,163 @@
# Accessibility Defect Bundle — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #62 (label-id collision, invalid table row semantics, unnamed drawer, unannounced table states, last untranslated strings).
## Context
A frontend deep audit (post the #52 a11y pass, which is verified correct) found five remaining
accessibility gaps. They are independent, low-risk fixes; no new dependency. The #52 work (focus ring,
skip link, route focus, authority nav links, lang group, `<html lang>` sync) stays untouched.
## Components
### 1. `components/label-editor.tsx` — id collision → `useId()`
`LabelEditor` hardcodes `<Label htmlFor="label">` + `<Input id="label">` (`:35-36`). It renders
simultaneously in the create form **and** any inline-edit row (authorities + vocab), so two `id="label"`
inputs coexist and every `<label for="label">` resolves to the first — the second editor's label points
at the wrong field (WCAG 1.3.1 / 4.1.1 / 4.1.2). Fix with React's `useId()` (zero call-site changes):
```tsx
import { useId } from "react";
// …
const inputId = useId();
<Label htmlFor={inputId}>{t("labels.label")}</Label>
<Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />
```
### 2. `objects/objects-table.tsx` — rows: real link + `aria-current`
The data rows are `<tr role="link" tabIndex={0} aria-selected={selected} onClick onKeyDown>` (`:247-259`).
`aria-selected` is invalid on `role="link"` (AT ignores it). Decision (brainstorming): make the
object-**number** cell a real React Router `<Link>` and keep the whole row clickable. The `<tr>` becomes a
plain row:
```tsx
<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>
{/* …other cells unchanged… */}
</tr>
```
- Native anchor ⇒ keyboard focus + Enter activation + SR "link" + middle-click / open-in-new-tab, all free.
- `event.stopPropagation()` on the link prevents the row `onClick` from double-navigating when the link
itself is clicked.
- The whole-row `onClick` stays, so clicking any non-link cell still opens the object (preserves current
UX; the existing "clicking a row …" test clicks the object-**name** cell and still passes).
- Selection is conveyed by `aria-current="page"` on the link (announced when focused); the visual
highlight stays on the `<tr>` via the className.
- Drop `role="link"`, `tabIndex`, `onKeyDown`, and `aria-selected` from the `<tr>`.
### 2b. `objects/objects-table.tsx` — filter pills: restore `focusRing`
The visibility pills (`:168-177`) lack the keyboard focus ring their siblings (`search-panel`,
`authorities-page`) have. Import `focusRing` from `../lib/focus-ring` and append it to the pill className:
```tsx
className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
```
### 3. `objects/objects-table.tsx` — announce loading / error
Loading renders bare `<Skeleton>` rows and the error a plain `<td>` — neither is announced (WCAG 4.1.3).
- Add `aria-busy={isLoading || undefined}` to the `<table>`.
- Add an `sr-only` live `<caption>` that announces loading and settles to the table name (also gives the
table an accessible name):
```tsx
<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>
```
- Add `role="alert"` to the error `<td>` (`:223`) so a load failure is announced assertively:
```tsx
<td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
{t("objects.loadError")}
</td>
```
### 4. `objects/object-detail-drawer.tsx` — name the drawer dialog
The Base UI drawer (a modal dialog) has no accessible name. `DrawerContent` spreads props onto the Popup,
so pass an `aria-label`:
```tsx
<DrawerContent aria-label={t("objects.detailTitle")}>
```
(No change to `components/ui/drawer.tsx`.)
### 5. Last untranslated strings
- `objects/options-combobox.tsx` (`:47-52`): add `import { useTranslation } from "react-i18next";` +
`const { t } = useTranslation();`, then `aria-label={t("common.clear")}` on `ComboboxClear`,
`aria-label={t("common.open")}` on `ComboboxTrigger`, and `<ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty>`
(`common.noMatches` already exists).
- `shell/breadcrumb.tsx` (`:10`): add `useTranslation`; `<nav aria-label={t("nav.breadcrumb")}>`.
### i18n (en + sv parity — 5 new keys)
`common.noMatches` and `common.loading` already exist. New keys:
| key | en | sv |
|-----|----|----|
| `common.clear` | Clear | Rensa |
| `common.open` | Open | Öppna |
| `nav.breadcrumb` | Breadcrumb | Brödsmulor |
| `objects.detailTitle` | Object detail | Objektdetalj |
| `objects.tableLabel` | Objects | Objekt |
## Data flow / accessibility
No data-flow changes. Tab order in the objects table becomes: filter input → visibility pills (now with
ring) → New button → each row's object-number link → pagination. The selected row's link carries
`aria-current="page"`. Loading politely announces via the caption; a load error asserts via the alert
cell. The drawer and breadcrumb gain accessible names; every combobox control is named in the active
locale.
## Error handling / edges
- `aria-busy={isLoading || undefined}` omits the attribute when not loading (no `aria-busy="false"` noise).
- `event.stopPropagation()` on the row link means a plain click on the number navigates once (link), not
twice; modifier/middle clicks open a new tab natively (React Router `<Link>` respects them).
- `aria-current` is omitted (not `"false"`) when the row isn't selected.
- The `<caption>` is `sr-only` — no visual change to the table.
- `useId()` ids are SSR/StrictMode stable and unique per instance.
## Testing
- **`label-editor`**: render two `LabelEditor`s together; assert their inputs have **different** ids and
each `<label>` is associated with its own input (e.g. `getAllByLabelText` returns two distinct nodes).
- **`objects-table`**: (a) a data row exposes a `link` named by the object number
(`getByRole("link", { name: <object_number> })`); (b) the row matching the selected `:id` has the link
with `aria-current="page"`; (c) clicking a row still deep-links (existing test stays green); (d) the
loading state sets `aria-busy` on the table; (e) an errored fetch renders `role="alert"`. Update any
existing assertion that referenced `aria-selected`/the row as a link.
- **`options-combobox`**: the clear/open controls are named via `t()` and the empty text is translated
(assert the English defaults render; the parity test guards sv).
- **`breadcrumb`**: the `<nav>` accessible name comes from `t("nav.breadcrumb")`.
- **`object-detail-drawer`**: the open drawer dialog has an accessible name (`getByRole("dialog", { name })`
or equivalent for the Base UI popup).
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (5 new keys,
guarded by the #60 parity test); no codename; no new dependency. `focusRing` is token-based so
`check:colors` stays green.
## Acceptance criteria
1. `LabelEditor` uses `useId()`; two instances never share an input id.
2. Objects-table data rows expose a real `<Link>` (object-number cell) with `aria-current="page"` on the
selected row; no `role="link"`/`aria-selected` on the `<tr>`; whole-row click still navigates; the
filter pills show the keyboard focus ring.
3. The table sets `aria-busy` while loading and exposes a polite live caption; a load error renders
`role="alert"`.
4. The object-detail drawer dialog and the breadcrumb `<nav>` have accessible names; the combobox
clear/open/empty strings are translated.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity (5 new
keys); no codename; no new dependency.
## Out of scope → follow-ups
- The combobox wrapper's raw palette utilities (`text-neutral-*`, `bg-indigo-50`, `bg-white`) inside
`components/ui/combobox.tsx`, and the segmented-control extraction → design-kit issue **#66**.
- A full ARIA grid for the objects table (sortable/selectable grid semantics) — the navigation-table +
real-link pattern is the correct, simpler choice here.
- Automated axe/CI a11y scanning.
@@ -0,0 +1,135 @@
# Accessibility — Focus, Route Management, Honest Semantics — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #52.
## Context
Keyboard-driven fast data entry is a stated goal and this is a public-institution tool, but several
custom controls drop the `ui/*` kit's focus ring, route changes leave focus stranded, there's no skip
link, the authority "tabs" announce tab semantics they don't fulfill, the lang switch lacks a group
label, and `<html lang>` never updates.
**Current state (post #49/#51/#54/#59):** the four raw `<select>`s are now `ui/Select` (have rings);
sidebar NavLinks + collapse button have rings; reference-data Edit/Delete are `ui/Button` (rings). The
**remaining bare controls without a focus ring**: `lang-switch.tsx` buttons, `theme-switch.tsx`
buttons, `search-panel.tsx` visibility facet chips, `field-list.tsx` row button, and the
`authorities-page.tsx` tab NavLinks. `ui/Button`'s ring = `focus-visible:border-ring
focus-visible:ring-3 focus-visible:ring-ring/50`. `app-shell.tsx`: `<main className="flex-1
overflow-hidden">` (no id/tabIndex), no skip link, no route-focus effect. Authority tabs are router
`NavLink`s with `role="tablist"`/`role="tab"`/`aria-selected` but no roving tabindex/arrow/aria-controls.
Lang switch: two `aria-pressed` buttons, no `role="group"`/`aria-label`. `index.html` hardcodes
`lang="en"`; no `documentElement.lang` sync (i18n init in `i18n/index.ts`, changes via
`use-locale.ts` `setLocale` + `config-provider` default-language effect).
### Decisions (from brainstorming)
1. Shared `focusRing` class on the 5 bare controls.
2. Authority tabs → honest navigation (`NavLink` + `aria-current`, drop tab roles); lang switch →
`role="group"` + `aria-label`.
3. Skip link + focus `<main>` on route change.
4. Sync `document.documentElement.lang` on every language change.
## Components
### `web/src/lib/focus-ring.ts` (new)
`export const focusRing = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";` — applied
via `cn(...)`/template to bare controls (they already have `rounded-md` so the ring shape is correct).
### A. Focus rings (5 controls)
- `lang-switch.tsx`: each `<button>` gets `type="button"` + `focusRing` (+ `rounded-sm px-1` so the
ring has shape).
- `theme-switch.tsx`: each `<button>` className gains `focusRing` (already `rounded-md`).
- `search-panel.tsx`: each facet `<button>` className gains `focusRing` (already `rounded-md`).
- `field-list.tsx`: the row `<button className="flex flex-1 …">` gains `focusRing` + `rounded-sm`.
- `authorities-page.tsx`: the tab `NavLink`s gain `focusRing` (with the semantics change below).
### B. Honest semantics
- **Authority tabs** (`authorities-page.tsx`): replace the `<div role="tablist">` + `role="tab"` +
`aria-selected` with a `<nav aria-label={t("nav.authorities")}>` containing the `NavLink`s; rely on
`NavLink`'s native **`aria-current="page"`** for the active state (drop `role`/`aria-selected`). Keep
the segmented styling (active `bg-primary text-primary-foreground`, else `border`) + add `focusRing`.
- **Lang switch** (`lang-switch.tsx`): wrap the buttons in `<div role="group" aria-label={t("common.language")}>`;
keep `aria-pressed`; inactive stays `text-muted-foreground` (token) — the ring + `font-bold` active +
group label make state/affordance clear.
### C. Route focus + skip link (`app-shell.tsx`)
- **Skip link** as the FIRST focusable element (before `<Sidebar/>`): a visually-hidden-until-focused
anchor —
`<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:bg-background focus:px-3 focus:py-2 focus:ring-3 focus:ring-ring/50">{t("common.skipToContent")}</a>`.
(If `sr-only`/`not-sr-only` utilities aren't available in this Tailwind v4 setup, use an explicit
visually-hidden pattern; verify by running.)
- `<main id="main-content" tabIndex={-1} className="flex-1 overflow-hidden …">`.
- A focus effect: `const location = useLocation();` + `const mainRef = useRef<HTMLElement>(null);` +
`useEffect(() => { mainRef.current?.focus(); }, [location.pathname]);` — but **skip the initial
mount** (so a deep-link load doesn't yank focus): track a `didMount` ref, focus only on subsequent
`pathname` changes. `<main ref={mainRef} …>`. (`tabIndex={-1}` makes `<main>` programmatically
focusable without adding it to the tab order; `outline-none` to avoid an outline on the container.)
### D. `<html lang>` sync (`i18n/index.ts`)
After `i18n.init`, register once:
```ts
function syncHtmlLang(lng: string) {
if (typeof document !== "undefined") {
document.documentElement.lang = lng.startsWith("sv") ? "sv" : "en";
}
}
i18n.on("languageChanged", syncHtmlLang);
syncHtmlLang(i18n.language);
```
This covers the switcher (`setLocale``changeLanguage`), the config default-language effect, and
startup — a single source of truth.
### i18n (en + sv parity)
- `common.language` = "Language" / "Språk"
- `common.skipToContent` = "Skip to content" / "Hoppa till innehåll"
## Data flow / accessibility
Tab order: skip link → sidebar → header → main content. Activating the skip link (or any route change)
moves focus into `<main>` (which contains the route's `<h1>` from #57). `aria-current="page"` marks the
active authority kind + nav route. `documentElement.lang` reflects the active language for SR
pronunciation / browser translation.
## Error handling / edges
- `focusRing` uses only `focus-visible:` (keyboard focus), not `:focus`, so mouse clicks don't show the
ring.
- Route-focus effect skips the initial mount; only fires on `pathname` change (covers nav + master-detail
open; query-param-only changes like the objects filter/pagination don't change `pathname`, so no
refocus there).
- `<main tabIndex={-1}>` is not in the tab order (negative) — only programmatically focusable.
- `documentElement.lang` guarded for non-DOM (tests/SSR).
- The authority tab role change: `aria-current="page"` is the honest active indicator; tests that
queried `role="tab"`/`aria-selected` are updated to `role="link"` + `aria-current`.
## Testing
- **lang-switch:** the buttons are within a `role="group"` named by `common.language`; each is a
keyboard-focusable button with `aria-pressed`.
- **authority tabs:** the kind links are `role="link"` (not `tab`); the active one has
`aria-current="page"`; navigating still works. Update the existing `authorities.test.tsx` assertions
(tab → link, aria-selected → aria-current) — don't weaken (still assert the active kind + href).
- **skip link:** an anchor to `#main-content` is the first focusable element; `<main>` has
`id="main-content"` + `tabIndex={-1}`.
- **route focus:** navigating to a new route focuses `<main>` (assert `document.activeElement` is the
main element after a nav, or that main received focus). Mirror the app-shell test harness.
- **html lang sync:** switching the locale sets `document.documentElement.lang` to "sv"/"en" (drive via
the lang switch or `i18n.changeLanguage`; assert `document.documentElement.lang`).
- The i18n **parity test** (#60) covers the 2 new keys automatically.
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity; no codename; no new
dependency. **`check:colors`:** `focusRing` uses token utilities (`ring-ring`), not raw palette — OK.
## Acceptance criteria
1. All five bare controls (lang-switch, theme-switch, search facet chips, field-list row, authority
kind links) show a keyboard `focus-visible` ring matching the kit.
2. A skip-to-content link is the first focusable element; `<main id="main-content" tabIndex={-1}>`
receives focus on route change (not on initial mount).
3. Authority kind links no longer claim `role="tab"`/`aria-selected`; they use `aria-current="page"` in
a labelled `<nav>`. The lang switch is a labelled `role="group"`.
4. `document.documentElement.lang` updates to the active language on every language change.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity (2 new
keys); no codename; no new dependency.
## Out of scope → follow-ups
- A full ARIA tab-widget (roving tabindex/arrows/tabpanel) for authorities — they're navigation, not a
tab panel, so honest links are correct.
- Automated axe/a11y scanning in CI; a broader sweep beyond the listed controls.
- An `aria-live` route-announcement region (focusing `<main>`/the `<h1>` covers the core need).
@@ -0,0 +1,122 @@
# Design-Kit Consistency — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #66 (dead Card, duplicated segmented-control + selected-row recipes, `useLang`/`focusRing` drift, misc kit one-offs).
## Context
A frontend deep audit found subtler design-system inconsistencies the `check:colors` guard doesn't catch
(the app is already hex-free + token-based + dark-mode-clean). These are duplicated class recipes and
non-adoption of the `ui/*` kit. All fixes are behavior-preserving; `check:colors`/`check:size`/the existing
component tests are the guards. State re-verified against the current code (post #62/#64).
## Components
### New shared helpers
**`lib/use-lang.ts`** — `useLang(): "sv" | "en"`
```ts
import { useTranslation } from "react-i18next";
/** The instance's active UI language, narrowed to the two supported locales. */
export function useLang(): "sv" | "en" {
const { i18n } = useTranslation();
return i18n.language.startsWith("sv") ? "sv" : "en";
}
```
Replaces the inline `const lang = i18n.language.startsWith("sv") ? "sv" : "en";` in **6** components:
`objects/object-detail.tsx`, `objects/field-input.tsx`, `vocab/vocabulary-terms.tsx`,
`vocab/vocabulary-list.tsx`, `fields/field-list.tsx`, `authorities/authorities-page.tsx`. Each switches to
`const lang = useLang();` and drops the now-unused `i18n` from its `useTranslation()` destructure where
`i18n` is otherwise unused. (Left untouched: `shell/lang-switch.tsx` — derives from a different `locale`
var; `i18n/index.ts` — the infra `languageChanged` handler.)
**`lib/class-recipes.ts`** — two shared class helpers
```ts
import { cn } from "@/lib/utils";
import { focusRing } from "./focus-ring";
/** Segmented-control / filter-pill item. Unifies the active/inactive token recipe +
* focus ring; callers pass their contextual padding/size via `className`. */
export function segmentClass(active: boolean, className?: string): string {
return cn("rounded-md", focusRing, active ? "bg-primary text-primary-foreground" : "border", className);
}
/** Selected vs idle row background for master-detail / list rows. */
export function rowStateClass(active: boolean): string {
return active ? "bg-primary/10" : "hover:bg-muted";
}
```
- **`segmentClass`** is adopted at the 3 segmented sites, each keeping its contextual padding:
- `objects/objects-table.tsx:174``segmentClass(active, "px-2 py-1")`
- `search/search-panel.tsx:76``segmentClass(active, "px-2 py-0.5")`
- `authorities/authorities-page.tsx:41` (NavLink) → `segmentClass(isActive, "px-3 py-1 text-sm")`
This DRYs the bug-prone recipe (the active/inactive token pair + `focusRing` — the part that drifted and
caused the #62 missing-ring bug); contextual sizing is intentionally preserved per site.
- **`rowStateClass`** is adopted at the 4 selected-row sites:
- `objects/objects-table.tsx:252``rowStateClass(selected)`
- `vocab/vocabulary-list.tsx:113``rowStateClass(isActive)`
- `search/search-result-row.tsx:15``rowStateClass(isActive)`
- `fields/field-list.tsx:86``rowStateClass(def.key === selectedKey)`**fixes** this site, which
currently uses `… ? "bg-primary/10" : ""` (dropping the `hover:bg-muted` idle hover the others have).
### One-off cleanups
- **Delete `components/ui/card.tsx`** — zero importers (no app/test/story references; no `card.stories`).
- **`shell/sidebar.tsx:46,88`** — replace the raw `focus-visible:ring-3 focus-visible:ring-ring/50`
string (inside the existing `cn(...)`) with the imported `focusRing` constant (adds `outline-none`,
matching every other call site).
- **`auth/login-page.tsx:49`** — `<h1 className="text-2xl font-semibold">{app_name}</h1>`
`<PageTitle>{app_name}</PageTitle>` (`PageTitle` is `text-2xl font-semibold tracking-tight`, restoring
the missing `tracking-tight`). Import `PageTitle` from `@/components/ui/page-title`.
- **`fields/field-list.tsx:97`** — the hand-rolled type-tag `<span className="rounded-md bg-muted px-1.5
py-0.5 text-xs text-muted-foreground">` → `<Badge variant="secondary">` (from `@/components/ui/badge`).
- **Icon sizing**`h-4 w-4``size-4` in the 3 **app-source** sites: `shell/theme-switch.tsx:39`,
`shell/user-menu.tsx:27`, `shell/header-search.tsx:23`. (Leave `components/ui/select.tsx` — kit-internal.)
- **Icon dismiss buttons** → kit `Button variant="ghost" size="icon-sm"`:
- `objects/objects-page.tsx:54` (plain `<button onClick={closeDetail} aria-label={…}>`) → `<Button
variant="ghost" size="icon-sm" onClick={closeDetail} aria-label={t("actions.closeDetail")}><X
className="size-4" aria-hidden="true" /></Button>` (import `Button`).
- `objects/object-detail-drawer.tsx:33` (Base UI `<DrawerClose>`) → keep `DrawerClose` for its
close-on-click semantics, render it AS the kit Button via the render prop:
`<DrawerClose aria-label={t("actions.closeDetail")} render={<Button variant="ghost" size="icon-sm" />}><X
className="size-4" aria-hidden="true" /></DrawerClose>` (mirrors the `AlertDialogTrigger render={<Button/>}`
pattern in `delete-confirm-dialog.tsx`; import `Button`).
## Error handling / edges
- `segmentClass`/`rowStateClass` are pure string builders — no runtime concerns. `cn()` (tailwind-merge)
resolves any padding/utility overlap predictably.
- `<Badge variant="secondary">` shifts the type-tag from `bg-muted`/`text-muted-foreground` to the
`secondary` token pair — a deliberate, minor visual adoption of the kit; still token-based (check:colors
clean).
- The drawer `DrawerClose render={<Button/>}` keeps Base UI's close behaviour (Base UI merges its props
onto the rendered Button); the `aria-label` stays on `DrawerClose`.
- `useLang` returns the same `"sv" | "en"` the inline code produced — no behaviour change.
## Testing
- **`lib/class-recipes.test.ts`** (new): `segmentClass(true)` contains `bg-primary` + `text-primary-foreground`
and the focus-ring utility; `segmentClass(false)` contains `border` (not `bg-primary`); both contain a
passed `className`; `rowStateClass(true)` === `"bg-primary/10"`, `rowStateClass(false)` === `"hover:bg-muted"`.
- **Behavior guard:** the existing component tests must stay green unchanged — `objects-table.test.tsx`,
`authorities.test.tsx`, `vocabularies.test.tsx`, `field-list` / `search` tests, `login-page.test.tsx`,
`breadcrumb`/sidebar, `object-detail`/drawer, `user-menu.test.tsx`. (The login `PageTitle` still renders an
`<h1>`; the icon buttons keep their `aria-label`s; the drawer close still closes.)
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new dependency; no new
i18n keys; no codename. `check:size` unchanged-or-smaller (Card deletion removes dead code).
## Acceptance criteria
1. `useLang()` exists and replaces the inline lang derivation in the 6 listed components; `lang-switch` and
`i18n/index.ts` are untouched.
2. `segmentClass`/`rowStateClass` exist (unit-tested) and are adopted at the 3 segmented + 4 selected-row
sites; `field-list`'s selected row gains the `hover:bg-muted` idle hover.
3. `components/ui/card.tsx` is deleted (no remaining references).
4. The one-offs are applied: sidebar uses `focusRing`; login uses `PageTitle`; field-list type-tag uses
`Badge`; the 3 app-source icons use `size-4`; both icon dismiss buttons use `Button variant="ghost"
size="icon-sm"`.
5. All existing tests pass unchanged; `typecheck`/`lint`/`build`/`check:colors` green; `check:size`
unchanged-or-smaller; no new dependency; no new i18n keys; no codename.
## Out of scope → follow-ups
- A full `<SegmentedControl>`/`<ToggleGroup>` component (the button-vs-NavLink interaction split makes a
class helper the better fit); the form `space-y-*` scale (too subjective — churn risk).
- Standardizing icon sizing inside `components/ui/*` (kit-internal style, separate from app-source).
@@ -0,0 +1,136 @@
# Object-Form Flexible-Field Grouping — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #45 (deferred half — the object **form**'s flexible-field grouping; the detail view shipped in `e2ae093`).
## Context
#45 fixed the object **detail** view: flexible fields are now grouped by `FieldDefinitionView.group`
in definition order with a trailing "Other" bucket (`object-detail.tsx`). The issue's problem #2
explicitly says "detail **and** form", but the **form** still renders flexible-field inputs flat — one
`<fieldset>` with a single "Catalogue fields" legend and `definitions.map(...)` (definition order, no
group subheadings). This milestone brings the form to parity by reusing the *same* grouping the detail
view uses (extracted into a shared helper so they can't drift).
**Facts:** `object-detail.tsx:71-94` builds `{ group, defs }[]` inline: iterate the definitions
(filtered to those with a value), bucket by `def.group?.trim()` else `t("fields.other")`, sort so the
"Other" group is last, plus orphan-key handling. `object-form.tsx` renders the flexible block as
`<fieldset className="space-y-3 border-t pt-3"><legend className="label-caption">{form.flexibleHeading}</legend>{definitions.map((def) => <div key={def.key}><FieldInput .../>{error}</div>)}</fieldset>`.
Field-definition fixtures carry `group` (e.g. `"Description"` / `null`). `field-list.tsx` also groups,
but AZ (a management list, from #50) — out of scope to unify with it.
### Decisions (from brainstorming)
1. Extract the definition-grouping into a shared `lib/group-fields.ts` and use it in **both** detail
and form (one source of truth).
2. The form keeps its `<fieldset>` for a11y but with an `sr-only` legend; visible per-group
`label-caption` subheadings (matching the detail view) — no redundant double-heading.
3. Definition order within groups (no re-sort); ungrouped → trailing "Other".
## Components
### `web/src/lib/group-fields.ts` (new)
```ts
import type { components } from "../api/schema";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export type FieldGroup = { group: string; defs: FieldDefinitionView[] };
/** Group field definitions by `def.group` (trimmed), preserving definition order
* within and across groups; ungrouped defs fall into a trailing `otherLabel` bucket. */
export function groupDefinitions(
definitions: FieldDefinitionView[],
otherLabel: string,
): FieldGroup[] {
const groups: FieldGroup[] = [];
for (const def of definitions) {
const group = def.group?.trim() ? def.group : otherLabel;
let bucket = groups.find((g) => g.group === group);
if (!bucket) {
bucket = { group, defs: [] };
groups.push(bucket);
}
bucket.defs.push(def);
}
// Keep definition order among real groups; the "Other" bucket sorts last.
groups.sort((a, b) => Number(a.group === otherLabel) - Number(b.group === otherLabel));
return groups;
}
```
A unit test asserts: definition order preserved; two defs in the same group bucket together; ungrouped
defs land in the `otherLabel` bucket which is **last** even when defined before a grouped def.
### `object-detail.tsx` (refactor to the shared helper)
Replace the inline `for (const def of present) { … }` group-building + final `groups.sort(...)` with
`const groups = groupDefinitions(present, other);`. Keep the existing orphan handling appended after
(`if (orphans.length > 0 && !groups.some(g => g.group === other)) groups.push({ group: other, defs: [] });`
— appending the Other bucket still leaves it last). Render is unchanged. Net: identical output, now via
the shared helper. (Verify the existing object-detail tests stay green.)
### `object-form.tsx` (group the flexible inputs)
Replace the flat flexible block with grouped rendering, reusing the FieldInput + per-field error markup
inside each group:
```tsx
{definitions && definitions.length > 0 && (
<fieldset className="space-y-3 border-t pt-3">
<legend className="sr-only">{t("form.flexibleHeading")}</legend>
{groupDefinitions(definitions, t("fields.other")).map((g) => (
<div key={g.group} className="space-y-3">
<div className="label-caption">{g.group}</div>
{g.defs.map((def) => (
<div key={def.key}>
<FieldInput definition={def} form={form} />
{errors.fields?.[def.key] && (
<p role="alert" className="text-xs text-destructive">
{errors.fields[def.key]?.message ?? t("form.required")}
</p>
)}
</div>
))}
</div>
))}
</fieldset>
)}
```
- The `<legend>` becomes `sr-only` (a11y-correct fieldset name) while the visible structure is the
per-group `label-caption` subheadings — mirroring detail, no double-heading.
- Field inputs and their error rendering are unchanged (just moved under group wrappers); the submit
payload / validation / pruning are untouched (the same `definitions` drive the same inputs).
- `import { groupDefinitions } from "../lib/group-fields";` (and `fields.other` is an existing key).
## Data flow
`useFieldDefinitions()``groupDefinitions(defs, t("fields.other"))` → grouped subheadings + the same
`FieldInput`s. No change to form state, RHF registration, pruning, or submission.
## Error handling / edges
- Definition order preserved; "Other" last (even if an ungrouped def precedes a grouped one).
- All-ungrouped case → a single "Other" group with a visible "Other" subheading (consistent with the
detail view's behavior).
- The shared helper takes the caller's def list: detail passes `present` (defs with values); the form
passes all `definitions`. Orphan handling stays in detail only.
- `sr-only` legend keeps the fieldset accessible without a visible redundant heading.
## Testing
- **`group-fields.test.ts`** (unit): order preserved; same-group bucketing; ungrouped → trailing Other.
- **object-form test:** with the field-definition fixtures (which include `group` values + ungrouped),
rendering the form shows the grouped subheadings (e.g. "Description" then "Other") and the fields
under them in definition order. (Extend `object-form.test.tsx`; reuse the existing MSW field-def
handler / fixtures — add a `group` to one fixture def if needed to exercise a real named group.)
- **object-detail tests** stay green (the refactor is output-preserving).
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; no new i18n keys (reuse
`fields.other`, `form.flexibleHeading`); en/sv parity unaffected; no codename; no new dependency.
## Acceptance criteria
1. `groupDefinitions` exists (shared, unit-tested) and is used by **both** object-detail and object-form.
2. The object form renders flexible-field inputs grouped by `def.group` (definition order, "Other" last)
with `label-caption` subheadings; the fieldset keeps an `sr-only` legend.
3. The detail view's grouping is unchanged in output (now via the shared helper).
4. Form behavior is otherwise unchanged (inputs, validation, errors, submission, pruning).
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no
codename; no new dependency.
## Out of scope → follow-ups
- Export/PDF (#39); backend label resolution.
- Reordering fields within a group beyond definition order; a field-definition "position"/sort concept.
- Unifying `field-list.tsx`'s (AZ) grouping with this (different purpose — a management list).

Some files were not shown because too many files have changed in this diff Show More