Compare commits

...

154 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
215 changed files with 17078 additions and 2078 deletions
+6 -2
View File
@@ -7,7 +7,9 @@ on:
jobs:
web:
runs-on: ubuntu-latest
runs-on: aceofba-cluster
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
defaults:
run:
working-directory: web
@@ -18,12 +20,14 @@ jobs:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test
- run: pnpm build
- run: pnpm check:size
- run: pnpm check:colors
+1
View File
@@ -28,3 +28,4 @@ cargo clippy --workspace --all-targets -- -D warnings # lint before committing
- **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.
- **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
+1
View File
@@ -2077,6 +2077,7 @@ dependencies = [
"clap",
"db",
"domain",
"dotenvy",
"http-body-util",
"memory-serve",
"reqwest",
+1
View File
@@ -28,4 +28,5 @@ argon2 = "0.5"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
rpassword = "7"
dotenvy = "0.15"
memory-serve = "2.1"
+86 -6
View File
@@ -17,7 +17,7 @@ use domain::{
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{AppState, admin_vocab::LabelInput, pagination::Pagination, reindex};
use crate::{AppState, admin_vocab::LabelInput, reindex};
/// A localized label `{ lang, label }` (shared across admin views).
#[derive(Serialize, ToSchema)]
@@ -45,6 +45,10 @@ pub(crate) struct AdminObjectView {
/// Flexible field values (key -> value).
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
pub fields: serde_json::Value,
/// RFC3339 UTC timestamp.
pub created_at: String,
/// RFC3339 UTC timestamp.
pub updated_at: String,
}
impl AdminObjectView {
@@ -61,6 +65,14 @@ impl AdminObjectView {
recording_date: o.recording_date.map(format_date),
visibility: o.visibility.as_str().to_owned(),
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(),
}
}
}
@@ -88,12 +100,73 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
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`.
#[utoipa::path(
get, path = "/api/admin/objects",
params(
("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(
(status = 200, body = AdminObjectPage),
@@ -104,15 +177,22 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
pub(crate) async fn list_objects(
_auth: Authorized<ViewInternal>,
State(state): State<AppState>,
Query(page): Query<Pagination>,
Query(params): Query<ObjectListParams>,
) -> 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
.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
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+2
View File
@@ -30,6 +30,7 @@ pub(crate) struct SearchHitView {
pub brief_description: Option<String>,
#[schema(value_type = domain::Visibility)]
pub visibility: String,
pub recording_date: Option<String>,
pub snippet: Option<String>,
}
@@ -103,6 +104,7 @@ pub(crate) async fn search_objects(
object_name: h.object_name,
brief_description: h.brief_description,
visibility: h.visibility,
recording_date: h.recording_date,
snippet: h.snippet,
})
.collect(),
+105
View File
@@ -843,6 +843,111 @@ async fn delete_field_definition_referenced_is_409(pool: PgPool) {
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()))
+107 -23
View File
@@ -96,37 +96,121 @@ where
rows.into_iter().map(map_object).collect()
}
/// List objects (all visibility levels) ordered by object number, with paging.
pub async fn list_objects_paged<'e, E>(
executor: E,
/// Whitelisted, injection-safe sort columns for the object list. The client never
/// supplies a column name directly — the API layer maps an opaque token onto a variant,
/// 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,
offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql =
format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2");
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
let (where_sql, binds) = where_clause(query.visibility, query.q);
let rows = sqlx::query(&sql)
.bind(limit)
.bind(offset)
.fetch_all(executor)
.await?;
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 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()
}
/// Count all objects (for pagination totals).
pub async fn count_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let row = sqlx::query("SELECT count(*) AS n FROM object")
.fetch_one(executor)
.await?;
/// Count objects matching the optional visibility/quick filters (for pagination totals).
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);
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**
+136
View File
@@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) {
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]
async fn object_by_id_missing_is_none(pool: PgPool) {
let db = Db::from_pool(pool);
+4
View File
@@ -34,6 +34,7 @@ pub struct SearchDocument {
pub brief_description: Option<String>,
pub current_owner: Option<String>,
pub recorder: Option<String>,
pub recording_date: Option<String>,
/// Filterable: "draft" | "internal" | "public".
pub visibility: String,
/// Flexible field values flattened to searchable text.
@@ -55,6 +56,7 @@ pub struct SearchHit {
pub object_name: String,
pub brief_description: Option<String>,
pub visibility: String,
pub recording_date: Option<String>,
pub snippet: Option<String>,
}
@@ -233,6 +235,7 @@ impl SearchClient {
object_name: doc.object_name,
brief_description: doc.brief_description,
visibility: doc.visibility,
recording_date: doc.recording_date,
snippet,
}
})
@@ -367,6 +370,7 @@ pub async fn build_document(
brief_description: object.brief_description.clone(),
current_owner: object.current_owner.clone(),
recorder: object.recorder.clone(),
recording_date: object.recording_date.map(|d| d.to_string()),
visibility: object.visibility.as_str().to_owned(),
fields_text,
})
+3
View File
@@ -19,6 +19,7 @@ fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
brief_description: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: "draft".to_string(),
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"],
);
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"]);
bronze_b.visibility = "public".to_string();
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"
);
assert!(snippet.contains(search::HL_POST));
assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));
let public = client
.search_objects("bronze", Some("public"), 0, 20)
+1
View File
@@ -27,6 +27,7 @@ db = { path = "../db" }
domain = { path = "../domain" }
search = { path = "../search" }
rpassword.workspace = true
dotenvy.workspace = true
memory-serve = { workspace = true, optional = true }
[build-dependencies]
+4
View File
@@ -41,6 +41,10 @@ impl From<RoleArg> for Role {
#[tokio::main]
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()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
@@ -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,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,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).
@@ -0,0 +1,115 @@
# Standardize Loading States on Skeleton — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #53.
## Context
Loading is rendered three incompatible ways: good `Skeleton` (objects-table, object-detail,
search-panel, field-list); bare "…" text in a `<li>` (vocabulary-list, vocabulary-terms,
authorities-page) — looks half-rendered; and empty `role="status"` divs (require-auth → blank app on
first load; object-edit-form → blank form pane). Plus the lazy-route `FormFallback` renders full-pane
"Loading…" text → flash + layout shift on first nav to `/objects/new`, `/objects/:id/edit`, `/fields`.
`Skeleton` (`ui/skeleton.tsx`) is a simple `animate-pulse rounded-md bg-muted` div. No shared
skeleton recipes or Spinner exist. No tests assert loading markup (they `findBy` content), so retiring
the placeholders won't break tests. The three "…" sites render a `<li>` inside a `<ul>` (so a
`<div>`-based recipe must replace the `<ul>` when loading, not nest inside it). `AppShell` layout:
`<div flex min-h-screen><aside w-44 border-r>…nav…</aside><div flex-1 flex-col><header border-b px-4
py-2/><main flex-1><Outlet/></main></div></div>`.
### Decisions (from brainstorming)
1. **Shared recipes** `ListSkeleton` + `FormSkeleton` (+ `AppShellSkeleton` for boot), built on
`Skeleton`, each an `aria` live region.
2. **require-auth → an app-shell-shaped skeleton** (no blank flash, no shift when the real shell mounts).
3. Skeleton-only (no Spinner primitive).
## Components
### `web/src/components/ui/skeletons.tsx` (new — `ui/*` no-semicolon style)
All recipes wrap content in a status region: `<div role="status" aria-label={t("common.loading")}
aria-busy="true" className={…}>`. New i18n `common.loading` (en "Loading", sv "Laddar").
- **`ListSkeleton({ rows = 6, rowClassName = "h-9 w-full", className })`** — `space-y-2 p-3` (+ `className`)
with `rows` × `<Skeleton className={rowClassName} />`.
- **`FormSkeleton({ fields = 5, className })`** — `space-y-4 p-4` (+ `className`); `fields` × a
`space-y-1` group (`<Skeleton className="h-3 w-24" />` label + `<Skeleton className="h-8 w-full" />`
input) + a trailing `<Skeleton className="h-8 w-28" />` (button). Mirrors the object form to avoid shift.
- **`AppShellSkeleton`** — mirrors `AppShell`: `<div className="flex min-h-screen">` → an `aside
w-44 border-r p-3 space-y-2` with ~5 `<Skeleton className="h-8 w-full" />` nav rows, and a
`flex-1 flex-col` with a `header border-b px-4 py-2` containing a `<Skeleton className="h-6 w-40" />`
and a `<main className="flex-1">` containing `<ListSkeleton />`. Single top-level `role="status"`
on the outer div (one live region for the whole boot screen).
A `skeletons.stories.tsx` renders the three (visual check + a smoke `play` asserting a `status` region).
### Apply across sites
**Retire "…" (render `ListSkeleton` in place of the `<ul>` when loading — valid HTML):**
- `vocabulary-list.tsx`: replace the loading `<li>…</li>`. The list is `<ul className="flex-1
overflow-auto">`; render `{isLoading ? <ListSkeleton className="flex-1" /> : null}` and keep the
`isError`/empty/data branches in the `<ul>` (rendered when not loading). (Keep the column layout —
the skeleton takes the `<ul>`'s place during load.)
- `vocabulary-terms.tsx`: replace the loading `<li>` — render `<ListSkeleton />` when `isLoading`
instead of the loading `<li>` (keep the `<ul>` for the loaded/empty/error branches).
- `authorities-page.tsx`: same — `<ListSkeleton />` when `isLoading`.
(Concretely: `{isLoading ? <ListSkeleton/> : <ul>…error/empty/rows…</ul>}`, or render the skeleton as a
sibling and gate the `<ul>` on `!isLoading`. Implementer picks the cleaner of the two; do not nest a
`<div>` inside `<ul>`.)
**Retire empty `role="status"` divs:**
- `object-edit-form.tsx` (outer `ObjectEditForm`, `isLoading` branch): `return <FormSkeleton />` (wrap to
match the edit form's container if needed — it renders inside the objects edit pane).
- `require-auth.tsx` (`isLoading`): `return <AppShellSkeleton />`.
**`app.tsx` lazy fallbacks** — remove `FormFallback`; give each `Suspense` a tailored fallback:
- `ObjectNewPage`, `ObjectEditForm``fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
(mirrors the new/edit page container `mx-auto max-w-2xl`).
- `FieldsPage``fallback={<ListSkeleton />}` (a simple page skeleton; FieldsPage is a 2-col page, but a
list skeleton in the pane is a fine, shift-light placeholder for the brief lazy load).
**Retrofit the good list-like skeletons to `ListSkeleton`** (consistency):
- `field-list.tsx`: `isLoading``<ListSkeleton rows={6} />` (was `space-y-2 p-3` + 6 × `h-9`). Identical output.
- `search-panel.tsx`: the `hasQuery && search.isLoading` block → `<ListSkeleton rows={5} rowClassName="h-12 w-full" />`.
- **Keep inline:** `objects-table.tsx` (a `<tbody>` of `<tr>`/`<td>` skeleton rows — table-specific) and
`object-detail.tsx` (single `h-40` block) — both already fitting; not worth forcing into a recipe.
## Data flow / accessibility
Each recipe is a `role="status" aria-busy` live region labelled `common.loading` → screen readers
announce "Loading" (the empty `role="status"` divs announced nothing). Visual skeletons mirror the
loaded layout so there's no jump when content arrives.
## Error handling / edges
- Don't nest `<div>` inside `<ul>` — render the list skeleton in place of the `<ul>`.
- `AppShellSkeleton` is pre-shell (require-auth) — it must not import anything that assumes the shell/
router context beyond `t()` (it only needs `useTranslation`).
- Multiple `role="status"` regions on one screen are acceptable; `AppShellSkeleton` uses ONE outer
region (not one per nested skeleton) to avoid SR noise.
- `FormSkeleton` in the lazy fallback vs the object-edit-form loading branch: both render the same
recipe, so the Suspense fallback → loaded-but-fetching → loaded transition stays visually stable.
## Testing
- `skeletons.stories.tsx`: render `ListSkeleton`/`FormSkeleton`/`AppShellSkeleton`; a `play` asserts a
`role="status"` is present (smoke).
- Existing suite stays green: no test asserts the old "…"/empty-div markup; tests `findBy` content,
which still resolves after loading. (If any test was implicitly relying on the empty `role="status"`
via `getByRole("status")` — none found — update it.)
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (one new key
`common.loading`); no codename; no new dependency.
## Acceptance criteria
1. `ListSkeleton`, `FormSkeleton`, `AppShellSkeleton` exist (built on `Skeleton`, each a
`role="status" aria-label={t("common.loading")}` live region) with a story.
2. The three "…" placeholders are replaced by `ListSkeleton`; the two empty `role="status"` divs are
replaced (object-edit-form → `FormSkeleton`, require-auth → `AppShellSkeleton`); the lazy
`FormFallback` is replaced by per-route skeleton fallbacks (no full-pane "Loading…").
3. `field-list` + `search-panel` use the shared `ListSkeleton`; objects-table/object-detail keep their
fitting inline skeletons.
4. Loading visuals mirror the loaded layout (no obvious shift); screen readers announce loading.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no
codename; no new dependency.
## Out of scope → follow-ups
- A `Spinner` primitive (Skeleton-only here).
- Reworking objects-table / object-detail inline skeletons.
- Additional route-level Suspense boundaries or data-router `HydrateFallback`s.
@@ -0,0 +1,213 @@
# Consistent, Status-Aware Mutation Error Feedback — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #63 (silent update failures + dead/unreachable mutation error strings).
## Context
A frontend deep audit flagged the TanStack Query mutation layer for inconsistent error feedback.
Grounding in the code refined the picture:
- **The update mutations are not silent — they're inconsistent.** `useUpdateTerm` (`queries.ts:444`) and
`useUpdateAuthority` (`:521`) are the **only 2 of 18 mutations** without `meta.suppressErrorToast`, so on
failure they fire the global `MutationCache` error toast while the row stays in edit mode. The matching
**create** and **delete** actions on the same screens suppress the toast and show an **inline** message
instead. So within one screen, create-failure → inline, delete-failure → inline, edit-failure → a
disconnected global toast.
- **Once the 2 update mutations also suppress (the consistency fix), all 18 mutations suppress** → the
global error toast becomes vestigial. Therefore status differentiation only delivers value at the
**inline** error sites, which today all render a generic `t("form.rejected")` ("Could not be saved").
- **The generic error strings are dead.** 16 mutations `throw new Error("update failed" | …)`, but
`mutationErrorMessage` (`query-client.ts:11-23`) never reads `.message` — it falls back to
`t("toast.error")`. The bespoke strings are unreachable.
- **`object-edit-form` mislabels a fetch failure.** `objects/object-edit-form.tsx:17` destructures only
`{ data, isLoading }` from `useObject`; a non-404 fetch error (network/500) falls through to the
"object not found" branch. (The **submit** path already handles `FieldRejection` and a generic else
correctly.)
### Decisions (from brainstorming)
1. Differentiate errors **by HTTP status**, surfaced via one shared mapping.
2. Because all mutations suppress the toast, apply status-aware messages at **all inline error sites**
(via one shared `<MutationError>` component), not the (now-vestigial) toast. Half-applying would
create a new inline inconsistency.
3. Make `useUpdateTerm`/`useUpdateAuthority` consistent with their create/delete siblings (suppress +
inline).
4. Fix the `object-edit-form` fetch mislabel.
## Components
### `web/src/api/error-message.ts` (new) — single source of truth
```ts
import { HttpError, InUseError } from "./queries";
/** Maps a caught mutation error to an i18n key (+ interpolation opts). Used by
* both the global toast fallback and every inline error 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";
}
```
Returns a key+opts (not a resolved string) so callers render with their own `t` (reactive to language).
`InUseError` is checked first so 409-with-count keeps its richer message. (No circular import:
`queries.ts` does not import this module; `query-client.ts` imports both.)
### `web/src/components/mutation-error.tsx` (new) — shared inline display
```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>
);
}
```
### `web/src/api/query-client.ts` (rewire)
`mutationErrorMessage` becomes:
```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);
}
```
This keeps the toast path as a one-source-of-truth fallback (for any future non-suppressed mutation) and
drops the old special-cases (`InUseError` and `503 → search.unavailable` now flow through
`errorMessageKey`; no mutation throws 503, and the search **query**'s own inline 503 handling in
`search-panel.tsx` is untouched). Import moves from `{ HttpError, InUseError }` to `{ errorMessageKey }`.
### `web/src/api/queries.ts` (load-bearing errors)
Replace the 16 **mutation** `throw new Error("…")` with `throw new HttpError(response.status)`, preserving
the existing `InUseError` (409) and `FieldRejection` (422 on `setFields`) branches. Two POSTs
(`useCreateObject` `:184`, `useCreateVocabulary` `:279`) destructure only `{ data, error }` — add
`response` so the status is available. **Query** fetch errors (`:38,73,90,105,152,169,264`) and the login
`"invalid"/"network"` mapping (`:121`) are NOT changed (they're consumed by component `isError`/login
mapping, not the mutation toast). `useUpdateObject`'s 422 maps to the generic `errors.validation`;
surfacing a *structured* core-field rejection there is uncertain backend behavior → out of scope.
### Inline-site adoption — replace generic `form.rejected` with `<MutationError>`
- `web/src/components/delete-confirm-dialog.tsx:33-42` — the `confirm` catch currently sets
`err instanceof InUseError ? t("actions.inUse", {count}) : t("form.rejected")`. Replace with
`const { key, opts } = errorMessageKey(err); setMessage(t(key, opts));``errorMessageKey` already
handles `InUseError`, so the dialog simplifies and a 403/404 delete now reads a specific message. (The
dialog keeps its own `message` state because it must stay open on failure; it does not render
`<MutationError>` directly.)
- `web/src/authorities/authorities-page.tsx:144-148` — replace the `{create.isError && <p…>form.rejected}`
with `<MutationError error={create.error} />`.
- `web/src/vocab/vocabulary-terms.tsx:119-123``<MutationError error={addTerm.error} />`.
- `web/src/vocab/vocabulary-list.tsx:57-61` (create) and `:109-113` (rename) — `<MutationError error={create.error} />`
and `<MutationError error={renameVocabulary.error} />`.
- `web/src/fields/field-form.tsx:202-204` — replace the `{failed && <p…>form.rejected}` with
`<MutationError error={isEdit ? update.error : create.error} />`.
- **Object create/edit:** `object-new-page.tsx` and `object-edit-form.tsx` keep the `FieldRejection`
field-specific branch; in the non-`FieldRejection` else, replace `setError(t("form.rejected"))` with
`const { key, opts } = errorMessageKey(e); setError(t(key, opts));` (these set a string passed to
`ObjectForm`'s `formError` prop, so they use `errorMessageKey` directly, not `<MutationError>`).
### Edit-row consistency — `term-row.tsx`, `authority-row.tsx`, `queries.ts`
- Add `suppressErrorToast: true` to `useUpdateTerm` (`:444`) and `useUpdateAuthority` (`:521`) meta.
- In each row's edit view, render `<MutationError error={updateTerm.error} />` (resp. `updateAuthority.error`)
below the save/cancel buttons.
- Call `updateTerm.reset()` (resp. `updateAuthority.reset()`) inside the Edit button's `onClick` (alongside
the existing `setLabels`/`setUri`/`setEditing`) so a stale error from a prior failed save doesn't linger
when re-entering edit mode. On a failed save the row already stays editable (the `onSuccess`
`setEditing(false)` doesn't fire), preserving the user's input.
### `web/src/objects/object-edit-form.tsx` (fetch fix)
```tsx
const { data: object, isLoading, isError } = useObject(id!);
if (isLoading) return <FormSkeleton />;
if (isError) return <p className="p-4 text-sm text-destructive">{t("objects.loadError")}</p>;
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
```
`objects.loadError` ("Could not load objects") already exists in both locales.
### i18n (en + sv parity — 5 new keys under `errors`)
| key | en | sv |
|-----|----|----|
| `errors.forbidden` | You don't have permission to do that. | Du har inte behörighet att göra det. |
| `errors.notFound` | That item no longer exists. | Objektet finns inte längre. |
| `errors.conflict` | That conflicts with existing data. | Det står i konflikt med befintliga data. |
| `errors.validation` | Some values weren't accepted. | Vissa värden godtogs inte. |
| `errors.server` | The server had a problem. Please try again. | Servern hade ett problem. Försök igen. |
## Data flow
mutation rejects → `HttpError(status)` (or `InUseError`/`FieldRejection`) → caught by the component (or
the `MutationCache` fallback) → `errorMessageKey(error)``{key, opts}``t(key, opts)` rendered inline
via `<MutationError>` (or as a `formError` string / dialog message). One mapping, one component, every
surface.
## Error handling / edges
- **All mutations suppress** after this change → the `MutationCache.onError` toast path is a dormant
fallback; kept (not deleted) so a future non-suppressed mutation still gets a sensible message.
- **401** isn't in the status map → falls to `toast.error`; the `client.ts` middleware still redirects to
login (unchanged). A brief generic toast before redirect is pre-existing, not worsened.
- **Network error with no response:** openapi-fetch may reject before a `response` exists; those paths
keep throwing a generic `Error``errorMessageKey` returns `toast.error`. (Only status-bearing
failures get differentiated.)
- **Language reactivity:** `<MutationError>` and the object-form `formError` re-render with the active
locale because they call `t` at render (the `formError` string is recomputed on the next failed submit;
acceptable — error strings are transient).
- **Stale row error:** cleared via `mutation.reset()` on re-edit.
## Testing
- **`web/src/api/error-message.test.ts`** (unit): each status → expected key (403/404/409/422/500/502);
`InUseError(3)``{ key: "actions.inUse", opts: { count: 3 } }`; a bare `Error`/unknown →
`{ key: "toast.error" }`.
- **`web/src/components/mutation-error.test.tsx`**: renders the mapped text for an `HttpError(403)`;
renders nothing for `null`/`undefined`; renders the in-use count for `InUseError`.
- **`web/src/api/mutation-feedback.test.tsx`** (rework): the "non-suppressed → catch-all toast" test is
obsolete (all mutations now suppress). Replace it with a test that a suppressed mutation failing adds
**no** toast (keep the existing delete case), plus assert the success-toast case still works. The
status→message behavior is covered by `error-message.test.ts`.
- **`term-row` / `authority-row`** (extend existing or via `mutation-feedback`): a failed update renders
the inline `MutationError` text and the row stays editable; a successful update closes the editor;
re-entering edit after a failure shows no stale error.
- **`object-edit-form`**: a `useObject` fetch error (mock `/api/admin/objects/{id}` → 500) renders
`objects.loadError`, not `objects.notFound`.
- **Inline adoption**: at least one create-form test (e.g. authorities) asserts a failed create shows the
status-aware message via `MutationError` (e.g. 403 → `errors.forbidden`).
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (the #60
parity test guards the 5 new keys); no codename; no new dependency.
## Acceptance criteria
1. A shared `errorMessageKey(error)` maps `InUseError` and `HttpError` (by status: 403/404/409/422/≥500)
to i18n keys, with a `toast.error` fallback; unit-tested.
2. A shared `<MutationError error>` component renders the mapped inline alert (or nothing) and replaces
the duplicated `form.rejected` markup at every inline mutation-error site (delete dialog via the helper
directly, create/rename forms, edit rows, object-form `formError` via the helper).
3. The 16 mutation throws use `HttpError(status)` (queries unchanged); `query-client.ts` routes the toast
fallback through `errorMessageKey`.
4. `useUpdateTerm`/`useUpdateAuthority` suppress the toast and show an inline error at the row, staying
editable on failure and clearing stale errors on re-edit.
5. `object-edit-form` distinguishes a fetch error (`objects.loadError`) from "not found."
6. `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
- Structured field-level rejection on **core-object** update 422 (uncertain backend shape).
- The broader `queries.ts` split + relocating the error classes to `api/errors.ts` (tracked in **#65** —
`error-message.ts` imports the classes from `queries.ts` for now).
- Toast-based error surfacing (deliberately superseded by inline; the toast path remains only as a
fallback).
- Retry affordances / optimistic updates.
@@ -0,0 +1,100 @@
# Reference-Data Scannability + Parity — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #50 (scoped: scannability + parity; layout/edit-modality unification + API-backed counts are follow-ups).
## Context
The three reference-data screens (vocabularies, authorities, fields) render lists in API
**creation order** with **no client sort and no filter** — finding "Bronze" in a 200-term vocabulary
is effectively impossible. Read-mode rows show **only the label**; `external_uri` (the point of
authority control — distinguishing two "Mercury"s) is visible only in edit mode (`term-row.tsx`,
`authority-row.tsx`). There are **no counts** anywhere. And there are small parity/validation gaps:
authority **create** has no `external_uri` field (hardcoded `null`) though edit does; vocab **rename**
lacks the empty-guard the create form has; URI inputs have no `type="url"`/placeholder.
This milestone fixes the daily-pain "can't find / can't disambiguate" problems + the parity gaps,
**without** changing layout/edit modality (a separate redesign) and **without** backend changes.
**Facts:** `labelText(labels, lang)` exists (`lib/labels.ts`). No `Intl.Collator` anywhere. List
shapes: term/authority = `{ id, labels, external_uri?: string|null }` (authority also `kind`);
vocabulary = `{ id, key }` (no labels); field-definition = `{ key, labels, data_type, group?, required, … }`.
No count field on any view (so per-vocab term counts + per-kind authority counts need backend →
deferred; **field-group counts are client-side** and in scope). `Badge`/`Input` exist. Mutations:
`useCreateAuthority` accepts `external_uri` but the page passes `null`; `useRenameVocabulary` has no
empty-guard; `useAddTerm`/`useUpdateTerm`/`useUpdateAuthority` already accept `external_uri`.
### Decisions (from brainstorming)
1. Sort every list by label/key with a locale-aware `Intl.Collator`; add a client-side filter `Input`.
2. Show `external_uri` (linkified, muted) in term/authority read rows; field-group count `Badge`s.
3. Close the parity/validation gaps (authority-create URI, rename empty-guard, `type="url"` + placeholder).
## Components
### `web/src/lib/sort.ts` (new)
- A memoized collator per lang: `Intl.Collator(lang, { sensitivity: "base", numeric: true })` (cache in a `Map<string, Intl.Collator>`).
- `export function byLabel(lang: string)``(a: { labels: LabelView[] }, b) => number` comparing `labelText(a.labels, lang)` vs `labelText(b.labels, lang)` via the collator.
- `export function byKey(lang: string)``(a: { key: string }, b) => number` comparing `a.key` vs `b.key`.
- (Comparators take the minimal structural type so they work for terms/authorities/fields/vocabs.)
### `web/src/components/external-uri-link.tsx` (new)
A tiny shared read-mode link: `function ExternalUriLink({ uri }: { uri: string })`
`<a href={uri} target="_blank" rel="noopener noreferrer" className="block truncate text-xs text-muted-foreground hover:text-foreground">{uri}</a>`. Used in term-row + authority-row read mode (render only when `external_uri` is truthy).
### i18n (en + sv parity)
- `common.filter` = "Filter…" / "Filtrera…"
- `common.noMatches` = "No matches" / "Inga träffar"
- `labels.uriPlaceholder` = "https://…" (same in both)
### Per-screen changes
**Vocabularies**
- `vocabulary-list.tsx`: a filter `<Input>` (placeholder `common.filter`) above the list; filter vocabularies by `key` (case-insensitive `includes`) then **sort by `byKey(lang)`**; if filtered-empty but data exists, show muted `common.noMatches`. Add the **rename empty-guard**: `if (!draftKey.trim()) return;` before the rename mutate.
- `vocabulary-terms.tsx`: a filter `<Input>` above the terms list; filter terms by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The add-term `external_uri` `<Input>` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`.
- `term-row.tsx`: read mode renders `<ExternalUriLink uri={term.external_uri} />` under the label when present; the edit-mode `external_uri` `<Input>` gets `type="url"` + the placeholder.
**Authorities**
- `authorities-page.tsx`: a filter `<Input>` above the list; filter by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The **create form gains an `external_uri` `<Input>`** (`type="url"`, placeholder) mirroring the edit row, stored in a `useState`, sent via `useCreateAuthority` (replace the hardcoded `external_uri: null`).
- `authority-row.tsx`: read mode renders `<ExternalUriLink uri={authority.external_uri} />` under the label when present; edit `external_uri` `<Input>` gets `type="url"` + placeholder.
**Fields**
- `field-list.tsx`: a filter `<Input>` above the list; filter by `labelText`/`key`; **sort groups alphabetically with "Other"/ungrouped last, and sort fields within each group by `byLabel(lang)`**; each group header shows a `<Badge variant="secondary">{count}</Badge>` (count of fields in that group, after filtering); filtered-empty → `common.noMatches`.
## Data flow
Loaded list → client filter (by label/key substring) → collator sort → render. `external_uri` read
from the existing view field. Field-group counts from the grouped array length. No new queries.
## Error handling / edges
- Filter is case-insensitive substring on the localized label (or key). Empty filter = show all.
- Collator memoized per lang; lang from `i18n.language` (sv/en) as elsewhere.
- `ExternalUriLink` only renders for truthy `external_uri`; `rel="noopener noreferrer"` + `target="_blank"`.
- `type="url"` gives the browser's basic URL hint (not strict validation — full validation is a follow-up); it must not block submitting an empty optional URI (the field stays optional → send `null`/omit when blank, exactly as today).
- Sorting must not mutate the query cache array — sort a copy (`[...list].sort(...)`).
- Field group ordering: a stable rule (named groups AZ by collator, then the ungrouped "Other" bucket last).
## Testing
- **`sort.test.ts`** (unit): `byLabel`/`byKey` order case-insensitively + locale-aware (e.g. "ä" sorts sensibly in sv; "bronze" before "Iron").
- **Vocabularies** (`vocabularies.test.tsx`): vocabularies render sorted by key; typing in the filter narrows the list; rename with an empty key does not fire the mutation.
- **Authorities** (`authorities.test.tsx`): list sorted by label; filter narrows; the create form has an `external_uri` field and a created authority posts the entered URI; a read row shows the `external_uri` link.
- **Vocabulary terms / term-row:** terms sorted by label; a term read row shows its `external_uri` link.
- **Fields** (`fields.test.tsx`): fields sorted within group; a group header shows a count badge; filter narrows. Keep the existing create/reveal-picker assertions green.
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (3 new keys); no codename; no new dependency.
## Acceptance criteria
1. Every reference list is sorted by label (terms/authorities/fields) or key (vocabularies) via a
locale-aware `Intl.Collator`, and has a client-side filter `Input` (with a `common.noMatches`
empty state).
2. `external_uri` is shown (linkified, muted, truncated) in term + authority read rows when present.
3. Field-group headers show a count `Badge`.
4. Parity/validation gaps closed: authority **create** has an `external_uri` field and sends it; vocab
**rename** has an empty-guard; all `external_uri` inputs use `type="url"` + a placeholder.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no
codename; no new dependency; no layout/edit-modality change; no backend change.
## Out of scope → follow-ups
- The layout + edit-modality **unification** (authorities → two-pane pane-edit; vocab-rename/term/
authority → pane-edit; one create location) — a separate redesign.
- **API-backed counts**: per-vocabulary term counts and per-kind authority-tab counts (need view/count
fields or endpoints).
- Strict URL validation (beyond `type="url"`); linkifying `external_uri` elsewhere (e.g. object detail).
@@ -0,0 +1,128 @@
# Search-Result Date Meta + Estimated-Count Copy — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #61 (scoped: add a date to result rows + soften the estimated count; object type/facet is
declined — no such field exists in the domain).
## Context
Search result rows show name + object_number + visibility badge + snippet, but no date — power users
disambiguate hits by period. The count copy reads "{{count}} results" though it's Meilisearch's
`estimated_total`. **There is no object type/category/classification field anywhere in the domain**
(`CatalogueObject` = object_number/object_name/number_of_objects/brief_description/current_location/
current_owner/recorder/**recording_date**/visibility + flexible `fields` + timestamps), so a "type"
meta/facet is impossible without a new domain concept (out of scope). The only disambiguating date
available is `recording_date` (`YYYY-MM-DD`) — but it is **not indexed** into the search document or
returned in the hit, so surfacing it needs a backend change.
**Facts:** `SearchDocument` (`crates/search/src/lib.rs:30`) and `SearchHit` (`:52`) carry
`id/object_number/object_name/brief_description/[current_owner/recorder]/visibility/[snippet]` — no
date. `build_document` (`:302`) projects a `CatalogueObject``SearchDocument` (does not copy
`recording_date`). `search_objects` (`:185`) maps Meili results → `SearchHit`. `SearchHitView`
(`crates/api/src/admin_search.rs:26`) mirrors `SearchHit`. The frontend type is
`components["schemas"]["SearchHitView"]`; `schema.d.ts` is generated by `pnpm gen:api`. `AdminObjectView`
already serializes `recording_date` as `Option<String>` (`YYYY-MM-DD`, via `format_date`). On-write
`sync_object` re-projects an object after each catalogue write; `reindex_all` is the full rebuild path.
### Decisions (from brainstorming)
1. Thread `recording_date` (`YYYY-MM-DD`) through `SearchDocument``SearchHit``SearchHitView`;
show it on the row when present.
2. Soften the count to `~{{count}}` (it's an estimate).
3. Decline object type/facet (no domain field).
## Backend (`crates/search`, `crates/api`)
### `SearchDocument` + `build_document` (`crates/search/src/lib.rs`)
- Add field: `pub recording_date: Option<String>,` to `SearchDocument`.
- In `build_document`'s returned struct: `recording_date: object.recording_date.map(|d| d.to_string()),`
(`domain::Date`'s `Display` is ISO `YYYY-MM-DD`, matching `AdminObjectView`). Index it as a plain
string (Meili stores/returns it; it need not be filterable).
### `SearchHit` + `search_objects` mapping (`crates/search/src/lib.rs`)
- Add `pub recording_date: Option<String>,` to `SearchHit`.
- In `search_objects`'s hit map: `recording_date: doc.recording_date,` (the executed
`SearchDocument` deserialization carries it; old index docs missing the field deserialize to `None`
because it's `Option` — graceful).
### `SearchHitView` (`crates/api/src/admin_search.rs`)
- Add `pub recording_date: Option<String>,` to `SearchHitView`.
- In the map closure (`:100`): `recording_date: h.recording_date,`.
### Indexing / backfill
- New & edited objects get `recording_date` automatically via the existing on-write `sync_object`.
- Already-indexed objects return `recording_date: None` until a `reindex_all` (the existing rebuild
path) runs — a graceful, opt-in backfill; **a `reindex` CLI command is a follow-up**, not in scope.
### Tests (Rust — need the docker stack: Postgres :5442, Meilisearch :7700)
- Update any direct `SearchDocument`/`SearchHit` struct literals in `crates/search/tests/*` /
`crates/api/tests/*` to include `recording_date: None` (compile fix). (Object fixtures construct
`CatalogueObject`, which already has `recording_date` — unaffected.)
- Extend `crates/search/tests/search.rs` (the `search_objects_returns_hits_…` test): give one seeded
object a `recording_date` and assert the returned hit's `recording_date` is the `YYYY-MM-DD` string
(proves it flows index → hit).
## Frontend (`web`)
### `web/src/api/schema.d.ts` (generated)
After the backend change, regenerate via `pnpm gen:api` (stack + server up) **or** hand-add
`recording_date?: string | null;` to the `SearchHitView` block (mirroring `brief_description?: string |
null` / `snippet?: string | null`) — a later `gen:api` reproduces it identically. Either is acceptable;
the manual edit avoids running the server purely for codegen.
### `search-result-row.tsx`
Add `recording_date` to the meta line (the `flex items-center gap-2 text-xs text-muted-foreground`
row), after `object_number`, when present:
```tsx
<span>{hit.object_number}</span>
{hit.recording_date && <span>· {hit.recording_date}</span>}
<VisibilityBadge visibility={hit.visibility} />
```
(Render the `YYYY-MM-DD` string as-is — it's a plain recording date, not a UTC timestamp, so no
timezone formatting. A separator dot before it keeps the row scannable.)
### `search-panel.tsx` + i18n — soften the count
The count uses `t("search.resultCount", { count: total })` with `resultCount_one`/`resultCount_other`.
Change the i18n values to flag the estimate (en + sv, parity preserved):
- en: `"resultCount_one": "~{{count}} result"`, `"resultCount_other": "~{{count}} results"`
- sv: `"resultCount_one": "~{{count}} träff"`, `"resultCount_other": "~{{count}} träffar"`
No code change in `search-panel.tsx` (the key already interpolates `count`).
### Fixtures + tests
- `web/src/test/fixtures.ts` `searchHits`: add `recording_date` to the first hit (e.g. `"1962-04-03"`);
the rest can stay `recording_date: null` (or omit — it's optional).
- `web/src/search/search.test.tsx`: assert the first result row shows the date (`getByText("1962-04-03")`
or within the row); assert the count copy includes the `~` (`getByText(/~\s*25 results/i)` — note the
existing `/25 results/i` assertion still matches the new copy; tighten it to require the `~`).
## Data flow
DB object → `build_document` (now copies `recording_date`) → Meili doc → `search_objects` hit → API
`SearchHitView``useSearch``SearchResultRow` renders the date. The count is the same
`estimated_total`, now labelled `~`.
## Error handling / edges
- `recording_date` is `Option` end-to-end → absent on objects without a recording date and on
not-yet-reindexed docs; the row simply omits it.
- `Date::to_string()` is ISO `YYYY-MM-DD` (matches `AdminObjectView`); no locale/timezone formatting.
- The `~` is cosmetic; pluralization (`_one`/`_other`) is unchanged.
## Testing
- **Rust:** `cargo build --workspace`; `cargo nextest run -p search -p api` (stack up) green, incl. the
new recording_date assertion + any literal compile-fixes.
- **Frontend:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; the new row-date +
count tests pass; en/sv parity (the #60 parity test guards the changed values); no codename.
- No new dependency.
## Acceptance criteria
1. `recording_date` is projected into the search index (`SearchDocument`/`build_document`) and returned
through `SearchHit``SearchHitView``schema.d.ts` (`SearchHitView.recording_date?: string|null`).
2. The search result row shows the recording date (when present) on its meta line.
3. The result-count copy reads `~{{count}} …` to flag the Meilisearch estimate (en + sv).
4. Rust (`cargo build` + search/api tests) green; frontend gate green; en/sv parity; no codename; no new
dependency.
## Out of scope → follow-ups
- An object **type/classification** domain concept + a **type facet** (no such field exists today).
- A `reindex` CLI command to backfill `recording_date` onto already-indexed objects (new/edited objects
index it automatically; `reindex_all` is the existing rebuild path).
- Richer faceting; making `recording_date` filterable/sortable in search.
@@ -0,0 +1,205 @@
# Session-Expiry Soft Redirect + Auth Feedback — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #48 (Frontend UX: session-expiry handling loses in-progress work; auth feedback gaps).
## Context
The session-expiry path is the most damaging daily failure mode for a long-session data-entry tool.
Today a 401 from any API call runs `redirectToLogin()` (`web/src/api/auth-redirect.ts`) →
`window.location.assign("/login")`, a **full page reload**: it tears down the React app and query
cache, gives the login page no reason ("session expired"), and after re-login drops the user on
`/objects` rather than where they were. Login also hardcodes `navigate("/objects")` and ignores the
attempted destination, and the Sign out control has no pending state.
**Already fixed since the audit (verified):** `require-auth.tsx` now renders `<AppShellSkeleton />`
during the `useMe` fetch (not the blank `<div role="status">` the audit cited), so the "blank screen on
load" problem is gone and is **out of scope**. Logout **error** feedback also already exists: `useLogout`
carries no `meta.suppressErrorToast`, so the global `MutationCache.onError` (`api/query-client.ts`)
already shows an error toast on logout failure — the only remaining logout gap is a pending/disabled
state.
**The crux:** the 401 handler lives in an openapi-fetch `Middleware` (`api/client.ts`), which runs
**outside** React and therefore cannot call `useNavigate`. The app uses a React Router 7 **data router**
(`createBrowserRouter`, `app.tsx`). The test harness `renderApp` (`src/test/render.tsx`) mounts the
component under test at `path:"*"` in a **fresh `createMemoryRouter`** — it does not use the app's real
`router` singleton.
### Decisions (from brainstorming)
1. **Soft redirect + return** (chosen over an in-place re-auth modal): router-navigate (no full reload)
to `/login?reason=expired&from=<path>`; login shows the reason and returns the user to `from`. The
React app + query cache survive; an unsaved edit form's typed values are still lost, but the user
lands back on the same record. (Full field-level preservation via an in-place modal is a deferred
follow-up.)
2. **Navigate-bridge module** (chosen over exporting the `router` singleton or a custom DOM event): a
module holds a settable `navigate` ref that a one-line component registers. This is testable with the
memory-router harness (tests call `setNavigate(mock)` directly); importing the singleton `router`
would be untestable because `renderApp` never mounts it.
## Components
### `api/auth-redirect.ts` (rewrite)
```ts
type NavigateFn = (to: string, opts?: { replace?: boolean }) => void;
let navigateFn: NavigateFn | null = null;
/** Register/unregister 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);
}
}
```
`api/client.ts` is unchanged — the middleware still calls `redirectToLogin()`; only its behaviour changes.
### `shell/navigation-bridge.tsx` (new)
```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;
}
```
### `app.tsx` (wrap routes in a pathless layout)
Add a pathless layout route around **all** existing routes whose element mounts the bridge plus an
`<Outlet/>`, so the bridge is always present (covers `/login` and the authed area):
```tsx
function RootLayout() {
return (
<>
<NavigationBridge />
<Outlet />
</>
);
}
```
```tsx
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="/login" element={<LoginPage />} />
{/* …existing RequireAuth / AppShell subtree unchanged… */}
<Route path="*" element={<Navigate to="/objects" replace />} />
</Route>,
)
```
### `auth/login-page.tsx` (reason banner + return-to + empty-field guard)
- `const [params] = useSearchParams();`
- Reason banner when `params.get("reason") === "expired"`: render `t("auth.sessionExpired")` (a
non-error info note, distinct from the existing `role="alert"` invalid-credentials message).
- On success, navigate to `safeFrom(params.get("from"))` instead of the hardcoded `/objects`:
```ts
function safeFrom(raw: string | null): string {
if (!raw) return "/objects";
// single leading slash only — reject protocol-relative ("//host") / absolute URLs (open redirect)
return /^\/(?!\/)/.test(raw) ? raw : "/objects";
}
```
(`raw` from `useSearchParams` is already percent-decoded.)
- Disable submit on empty fields: `disabled={login.isPending || !email.trim() || !password}`.
### `auth/require-auth.tsx` (capture the attempted location)
```tsx
const location = useLocation();
// …
if (!user) {
const from = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?from=${from}`} replace />;
}
```
No `reason` here — an unauthenticated deep-link is not an expiry; login simply returns the user to `from`.
### `shell/user-menu.tsx` (logout pending state)
Keep the menu open during logout and show a pending state on the item (Base UI `MenuItem` supports both
`closeOnClick` and `disabled`):
```tsx
<MenuItem closeOnClick={false} disabled={logout.isPending} onClick={onSignOut}>
{logout.isPending ? t("auth.signingOut") : t("auth.signOut")}
</MenuItem>
```
On success the mutation sets `me` to null and navigates to `/login`, so `UserMenu` unmounts (it returns
`null` when `!me`). Logout **error** already surfaces via the global toast (`useLogout` has no
`suppressErrorToast`). `closeOnClick={false}` also prevents a double-submit (the item is disabled while
pending).
### i18n (en + sv parity — 2 new keys)
- `auth.sessionExpired` = "Your session expired — please sign in again." /
"Din session har gått ut — logga in igen."
- `auth.signingOut` = "Signing out…" / "Loggar ut…"
## Data flow
API 401 → `client.ts` middleware → `redirectToLogin()` → registered `navigate("/login?reason=expired&
from=<path>", {replace})` (no reload) → `LoginPage` reads `reason`/`from`, shows the banner → on success
`navigate(safeFrom(from), {replace})`. Deep-link-while-unauthed → `RequireAuth``/login?from=<path>`
same return-to. The `NavigationBridge` keeps `setNavigate` current for the lifetime of the app.
## Error handling / edges
- **Already on `/login`:** `redirectToLogin()` is a no-op (no redirect loop).
- **401 before the bridge mounts** (first paint): `navigateFn` is null → hard `window.location.assign`
to `/login?reason=expired&from=…` (graceful fallback; reason/return still preserved).
- **Open redirect:** `safeFrom` accepts only a single-leading-slash local path; `//evil.com`,
`https://…`, and empty → `/objects`.
- **Login success with no `from`:** defaults to `/objects` (unchanged behaviour).
- **StrictMode double-invoke:** the bridge effect is idempotent (sets the same fn; cleanup nulls it) —
safe.
- **Reason banner is informational**, not `role="alert"` (it is not an error and should not preempt the
credential-error alert region).
## Testing
- **`api/auth-redirect.test.ts`** (unit, jsdom): with `setNavigate(mock)` and a stubbed
`window.location` (pathname `/objects/abc`), `redirectToLogin()` calls `mock` with
`"/login?reason=expired&from=%2Fobjects%2Fabc"` and `{replace:true}`; with `setNavigate(null)` it calls
`window.location.assign` with the same target; when `pathname === "/login"` it does nothing.
- **`auth/login-page` test:** rendering at `/login?reason=expired` shows the `auth.sessionExpired` text;
a successful login at `/login?from=%2Fobjects%2F123` navigates to `/objects/123`; a bad `from`
(`//evil.com`) falls back to `/objects`; the submit button is disabled until both fields are non-empty.
- **`auth/require-auth` test:** when `useMe` resolves to no user, it renders a redirect to
`/login?from=…` carrying the attempted path.
- **`shell/user-menu` test:** with a delayed logout handler, after clicking Sign out the item shows the
`auth.signingOut` pending text (the menu stays open via `closeOnClick={false}`).
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (the #60
parity test guards the 2 new keys); no codename; no new dependency.
## Acceptance criteria
1. A 401 from an API call performs a **router** navigation (no full page reload) to
`/login?reason=expired&from=<attempted path>`; the React app/query cache are not torn down.
2. The login page shows a "session expired" message when `reason=expired`, and on success returns the
user to a **validated** `from` path (defaulting to `/objects`); protocol-relative/absolute `from`
values are rejected.
3. `RequireAuth` redirects unauthenticated users to `/login?from=<attempted path>` so deep links return
after login.
4. Login submit is disabled while either field is empty; the Sign out item shows a pending state
(`auth.signingOut`, disabled) while logging out.
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
- **In-place re-auth modal** that keeps the edit form mounted for full field-level preservation of
in-progress work (deferred by decision; soft redirect ships first).
- Idle/expiry **pre-warning** (e.g. "your session will expire soon") and token/silent refresh.
- Broader auth-event audit surfacing on the client.
@@ -0,0 +1,146 @@
# Split queries.ts: Errors + Query-Key Factory + Domain Modules — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #65 (split the 584-line `queries.ts`, extract the error classes, add a query-key factory, decide the search-invalidation gap).
## Context
`web/src/api/queries.ts` is 584 lines spanning 8 domains (error classes, auth, objects, fields-on-object,
field-definitions, vocab, terms, authorities, search). No hook crosses a domain boundary, so a split is
low-risk. Three concrete problems:
- **Toast path drags in the hook module.** After #63, `query-client.ts` imports `errorMessageKey` from
`error-message.ts`, which imports `{ HttpError, InUseError }` from `./queries` — so the toast wiring
transitively loads all the hooks. Extracting the error classes breaks that chain.
- **No query-key factory.** ~20 key literals (`["objects", params]`, `["object", id]`,
`["terms", vocabularyId]`, …) plus `config-provider.tsx`'s `["config"]` are hand-written and
typo-prone.
- **Object writes don't invalidate `["search"]`.** `useUpdateObject`/`useDeleteObject`/`useSetVisibility`
invalidate `["objects"]`/`["object", id]` but never the search index, so the search panel keeps stale
hits until the user re-searches. **Decision (brainstorming): invalidate** — conventional
eventually-consistent behaviour; the query is `enabled` only with a term, so it's a no-op when not
searching.
**Constraint:** ~30 files import from `../api/queries`. The public import path must stay stable (a
barrel), so the refactor touches the data layer only — feature files don't change.
## Components
### `api/errors.ts` (new) — canonical home for the 4 error classes
Move `HttpError`, `FieldRejection`, `InUseError`, `VisibilityError` here verbatim (no behaviour change).
`error-message.ts` changes its import from `./queries` to `./errors` — this is the decoupling: the toast
path (`query-client → error-message → errors`) no longer loads the hook module. The barrel (below)
**re-exports** the error classes (`export * from "../errors"`), so the existing importers that pull them
from `../api/queries``object-edit-form.tsx`, `object-new-page.tsx`, `publish-control.tsx`,
`search-panel.tsx`, and the tests `mutation-error.test.tsx` / `labelled-record-row.test.tsx` /
`error-message.test.ts` — keep working unchanged.
### `api/query-keys.ts` (new) — one key factory
```ts
export type ObjectListParams = {
limit: number;
offset: number;
sort?: string;
order?: "asc" | "desc";
visibility?: string;
q?: string;
};
export const keys = {
me: () => ["me"] as const,
config: () => ["config"] as const,
objects: () => ["objects"] as const, // family prefix (invalidation)
objectsPage: (p: ObjectListParams) => ["objects", p] as const,
object: (id: string) => ["object", id] as const,
fieldDefinitions: () => ["field-definitions"] as const,
vocabularies: () => ["vocabularies"] as const,
terms: (vocabularyId: string) => ["terms", vocabularyId] as const,
authorities: (kind: string) => ["authorities", kind] as const,
search: () => ["search"] as const, // family prefix (invalidation)
searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const,
};
```
- Every `queryKey:` / `invalidateQueries` / `setQueryData` site in the query modules **and**
`config-provider.tsx`'s `["config"]` routes through `keys.*` — producing the identical arrays, so
cache behaviour is unchanged. (`keys.objects()` prefix-matches `keys.objectsPage(p)` exactly as
`["objects"]` matches `["objects", params]` today.)
- `ObjectListParams` lives here (it *is* part of the key); `objects.ts` imports it from here — one
direction, no import cycle. (It moves out of `queries.ts`'s objects section.)
### `api/queries/` (new directory) — split by domain + barrel
```
api/
client.ts (unchanged)
errors.ts (new)
query-keys.ts (new)
query-client.ts (unchanged — already imports only error-message)
error-message.ts (one-line change: import errors from ./errors)
queries/
index.ts barrel: `export * from "./auth"; … ; export * from "../errors";`
auth.ts useMe, useLogin, useLogout
objects.ts useObjectsPage, useObject, useCreateObject, useUpdateObject, useSetFields, useDeleteObject, useSetVisibility
field-defs.ts useFieldDefinitions, useCreateFieldDefinition, useUpdateFieldDefinition, useDeleteFieldDefinition
vocab.ts useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary, useTerms, useAddTerm, useUpdateTerm, useDeleteTerm
authorities.ts useAuthorities, useCreateAuthority, useUpdateAuthority, useDeleteAuthority
search.ts useSearch, SEARCH_PAGE
```
`api/queries.ts` is deleted; `import { … } from "../api/queries"` resolves to `api/queries/index.ts`.
Each module imports `api` from `../client`, types from `../schema`, error classes from `../errors`, and
`keys` from `../query-keys`. The hook bodies are moved verbatim (only their key literals → `keys.*`).
### `api/query-client.ts`
No change needed — after #63 it imports only `errorMessageKey`. (Once `error-message.ts` points at
`./errors`, the `query-client → error-message → queries` hook dependency is gone.)
### Search invalidation (`objects.ts`)
`useUpdateObject`, `useDeleteObject`, `useSetVisibility` add
`void qc.invalidateQueries({ queryKey: keys.search() })` alongside their existing object invalidations.
## Data flow / behaviour
Identical to today except: after an object update/delete/visibility-change, an active search query
refetches (eventually-consistent with the Meilisearch reindex). All keys, hooks, error classes, and
endpoints are otherwise unchanged.
## Error handling / edges
- The barrel re-exporting errors means the classes are importable from both `../api/errors` (canonical)
and `../api/queries` (compat) — intentional, to avoid churning ~6 importers + tests.
- `as const` keys are readonly tuples; TanStack Query accepts readonly arrays as keys — no type issue.
- No import cycle: `query-keys.ts` exports `ObjectListParams` + `keys` and imports nothing from
`queries/`; `objects.ts` imports both from `query-keys.ts`.
- The search query stays `enabled: term.length > 0`, so invalidating `["search"]` is a no-op when no
search is active.
## Testing
- **Behavior guard (must pass unchanged):** `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`, `error-message.test.ts`,
`mutation-error.test.tsx`, `labelled-record-row.test.tsx` — all import from `../api/queries` (the
barrel) and from the re-exported error classes; they must not need edits. The full app suite is the
integration guard.
- **New `query-keys.test.ts`:** assert the factory arrays — e.g. `keys.objects()``["objects"]`,
`keys.objectsPage(p)``["objects", p]`, `keys.object("x")``["object", "x"]`,
`keys.searchResults("q", null)``["search", "q", null]`.
- **Search-invalidation assertion:** extend `queries.visibility.test.tsx` (or `.authoring`) to seed an
active `["search", …]` query in the cache, run an object update/delete/set-visibility, and assert the
search query is invalidated (e.g. its `isInvalidated`/refetch fires).
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new dependency; no new
i18n keys; no codename; `check:size` unchanged (pure reorg + one invalidate call).
## Acceptance criteria
1. `api/errors.ts` holds the 4 error classes; `error-message.ts` imports them from `./errors`; the barrel
re-exports them so every existing importer (components + tests) still resolves.
2. `queries.ts` is split into `api/queries/{auth,objects,field-defs,vocab,authorities,search}.ts` +
`index.ts`; `api/queries.ts` is deleted; the `../api/queries` import path is unchanged for all ~30
consumers.
3. A `keys` factory in `api/query-keys.ts` is used by every `queryKey`/invalidate/setQueryData site,
including `config-provider.tsx`.
4. `useUpdateObject`/`useDeleteObject`/`useSetVisibility` invalidate `keys.search()`.
5. All existing tests pass unchanged; `typecheck`/`lint`/`build`/`check:colors` green; `check:size`
unchanged; no new dependency; no new i18n keys; no codename.
## Out of scope → follow-ups
- No hook signature/behaviour changes beyond the search invalidation; no endpoint changes.
- `staleTime` / `keepPreviousData` choices stay as-is (intentional per #63's note).
- Repointing the 4 component error-importers from the barrel to `../api/errors` (the re-export keeps them
working; a later cosmetic cleanup could do it).
@@ -0,0 +1,132 @@
# Token-Styled Select — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #51.
## Context
Four raw `<select>` elements (`field-form.tsx:120` data_type, `:138` vocabulary, `:158`
authority_kind; `object-form.tsx:148` visibility) are styled `w-full rounded border px-2 py-1 text-sm`
— different radius/border/padding/height from the sibling `ui/Input` (`h-8 rounded-lg border-input …
focus-visible:ring-3 ring-ring/50`), and crucially they have **no focus ring** (keyboard users get no
focus affordance). This milestone adds a token-styled `ui/Select` (Base UI Select) matching `Input`
and replaces all four. (Decision: all four → `ui/Select`; making the vocabulary picker a searchable
combobox is a deferred follow-up — keeps the working object-editing combobox untouched.)
**Facts:** Base UI Select is `import { Select as SelectPrimitive } from "@base-ui/react/select"`
(namespace; parts Root/Trigger/Value/Icon/Portal/Positioner/Popup/List/Item/ItemIndicator/ItemText) —
no new dependency. `object-form` is react-hook-form (visibility is `register`'d); `field-form` is
useState-controlled (all three selects), with `data_type`/`vocabulary_id`/`authority_kind` `disabled`
on edit. `ui/menu.tsx` + `ui/combobox.tsx` are the established Base UI wrapper patterns. The Input
className to match is in `ui/input.tsx`. Base UI Select is NOT a native `<select>`, so existing tests
using `userEvent.selectOptions` / `HTMLSelectElement` must be rewritten to click interaction.
### Decisions (from brainstorming)
1. **All four selects → `ui/Select`** (uniform token styling + focus ring; lowest risk).
2. New `ui/select.tsx` wraps Base UI Select; **validated by running** (novel primitive).
3. Tests rewritten to Base UI Select interaction (open trigger → click item).
## Components
### `web/src/components/ui/select.tsx` (new)
Wrap Base UI Select in the `ui/*` style (`data-slot`, `cn`, no semicolons), mirroring
`ui/combobox.tsx`/`ui/menu.tsx`. Exports (names final after validate-by-running):
- `Select``SelectPrimitive.Root` (generic value; `value`/`onValueChange`/`defaultValue`/`disabled`/`name`).
- `SelectTrigger``SelectPrimitive.Trigger` styled to **match Input**: `h-8 w-full rounded-lg border
border-input bg-transparent px-2.5 py-1 text-sm inline-flex items-center justify-between
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 dark:bg-input/30` + a
trailing chevron (`SelectPrimitive.Icon` with a lucide `ChevronDown`, `aria-hidden`) and a
`SelectValue` (`SelectPrimitive.Value`) showing the chosen item (with `placeholder`).
- `SelectContent``SelectPrimitive.Portal` + `SelectPrimitive.Positioner` (`sideOffset`, `z-50`) +
`SelectPrimitive.Popup` styled as a card (`min-w-[var(--anchor-width)]` if supported, else
`min-w-32`; `rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none` +
open/close animation data-attrs).
- `SelectItem``SelectPrimitive.Item` row (`flex 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`) +
a `SelectPrimitive.ItemIndicator` (lucide `Check`) and `SelectPrimitive.ItemText`.
- Token classes only (no raw palette). The exact part tree + props (`SelectValue` placeholder,
positioner anchoring, item `value`) **must be confirmed by running the story** (Base UI Select is
novel here), as combobox/menu/toast were.
**Accessibility:** `SelectTrigger` accepts `id` so the existing `<Label htmlFor={id}>` associates →
`getByLabelText`/`getByRole("combobox", { name })` keeps working in tests. (Base UI Select trigger has
role `combobox`; confirm the accessible-name wiring when validating.)
### `web/src/components/ui/select.stories.tsx` (new)
A `Select` with a few `SelectItem`s; a `play` test that opens the trigger and selects an item, asserting
the value/label updates (portal content queried via `within(document.body)`, like the menu story). This
is the validation.
### Replacements
**`object-form.tsx` visibility** (create-mode only): Base UI Select isn't a native input, so replace
the `register("visibility")` `<select>` with a **`Controller`** (`control={form.control}
name="visibility"`) rendering `<Select value={field.value} onValueChange={field.onChange}>` with items
`draft`/`internal` (labels `form.draft`/`form.internal`). Keep `<Label htmlFor="visibility">` → trigger
`id="visibility"`. Default stays `"draft"` (the form's defaultValues already set it).
**`field-form.tsx`** (useState — pass `value`/`onValueChange` directly, no Controller):
- **data_type:** `<Select value={dataType} onValueChange={setDataType} disabled={isEdit}>`, items from
`TYPES` (labels `fields.types.${type}`). `id="field-type"`.
- **vocabulary_id** (when `dataType==="term"`): `<Select value={vocabularyId} onValueChange={setVocabularyId}
disabled={isEdit}>` with a placeholder (`form.selectPlaceholder`) and items from `vocabularies` (value
`vocab.id`, label `vocab.key`). `id="field-vocab"`. The empty/placeholder state: Base UI Select shows
the `SelectValue` placeholder when value is `""`; keep `vocabularyId=""` as the unselected state (the
existing required-check `!vocabularyId` still works).
- **authority_kind** (when `dataType==="authority"`): `<Select value={authorityKind}
onValueChange={setAuthorityKind} disabled={isEdit}>` with an "Any" item (value `""`, label
`fields.anyKind`) + `KINDS` items (labels `authorities.${kind}`). `id="field-kind"`.
No change to submit logic, validation, or the disabled-on-edit behavior — only the control swaps.
## Data flow
Unchanged: the same state/`register`/`Controller` value drives the same submit payloads. Only the
rendered control (native `<select>` → Base UI Select) and its styling change.
## Error handling / edges
- Base UI Select `value=""` must render the placeholder (vocabulary) or the "Any" item (authority_kind)
— verify when running. The authority_kind "Any" is a real selectable item with value `""`.
- `disabled={isEdit}` must visually + functionally disable the trigger (the styling includes
`disabled:` classes).
- The visibility `Controller` default must remain `"draft"` (no regression to the create payload).
- Keyboard: Base UI Select is fully keyboard-operable (the focus ring is the headline fix).
## Testing
- **`select.stories.tsx`** validates the primitive by running (open + select).
- **`object-form.test.tsx`** rewrite the visibility assertions:
- "shows visibility (draft/internal only, not public)": open the Select trigger, assert items `Draft`
+ `Internal` are present and `Public` is absent (query the portal list); select `Internal` and
assert it's reflected. (Replaces `HTMLSelectElement.options` inspection.)
- "edit mode: no visibility control": keep — query by the visibility Label name returns null.
- **`fields.test.tsx`** rewrite the select flows to click interaction:
- data_type → open, click `Authority` → the kind picker appears → open, click `Person` → assert
`authority_kind: "person"` in the create payload.
- data_type → open, click `Term` → the vocabulary picker appears → submit blocked until a vocab is
chosen → open, click the vocab → assert the create payload. Keep the same payload assertions.
- "creates a text field" (default type) — unaffected or minimal change.
- Keep all payload/mutation assertions identical (don't weaken); only the interaction changes.
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (no new keys
expected); no codename. **`check:size`:** Base UI Select adds to the always-loaded form chunk —
report the value (budget 250 KB gz; flag if it exceeds rather than silently raising).
## Acceptance criteria
1. A `ui/Select` (Base UI Select) exists, styled to match `Input` (h-8, rounded-lg, border-input,
focus-visible ring, disabled/aria-invalid states), with a Storybook story validated by running.
2. All four raw `<select>` elements (object-form visibility; field-form data_type / vocabulary /
authority_kind) are replaced by `ui/Select`; they share Input's tokens and have a visible focus ring.
3. Behavior preserved: same values/payloads, disabled-on-edit, create-mode-only visibility, term/authority
conditional pickers, the required-vocabulary check, and the create-form reset.
4. Tests rewritten to Base UI Select interaction (no native `selectOptions`); all payload assertions
unchanged; suite green.
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
- Making the vocabulary picker a **searchable combobox** (generalizing `OptionsCombobox` to `{id,label}`)
— deferred; revisit if vocabulary lists grow large.
- Replacing any other native form controls; a lint guard banning raw `<select>` outside `components/ui/`.
- The `visibility` select gaining a `public` option (intentionally absent — publishing is via
`publish-control`).
@@ -0,0 +1,182 @@
# Unify Vocabulary + Authority CRUD — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #64 (the duplicated Vocabulary-terms + Authorities CRUD surfaces).
## Context
The Vocabulary-terms and Authorities admin surfaces are two copies of one feature ("a labelled record
with an optional external URI: filterable list + inline-edit rows + create form"). The duplication spans
four files and ~280 lines:
- `vocab/term-row.tsx` and `authorities/authority-row.tsx` are byte-for-byte twins except: the mutation
hooks (`useUpdateTerm`/`useDeleteTerm` vs `useUpdateAuthority`/`useDeleteAuthority`), the record type
(`TermView` vs `AuthorityView`), the URI-input id prefix (`term-uri-` / `auth-uri-`), the mutate-arg
shape (`{ vocabularyId, termId, … }` vs `{ id, kind, … }`), and the delete-confirm i18n key.
- `authorities/authorities-page.tsx` and `vocab/vocabulary-terms.tsx` share the filter input, the
4-state list (loading→skeleton / error / empty / no-matches / rows), and the create form
(`LabelEditor` + URI input + `labels.some()` validation + `MutationError` + submit). They differ in the
i18n keys, the create-form heading, and (authorities only) the kind-tabs `<nav>` + `PageTitle` +
`Navigate` guard.
A fix to inline-edit behaviour must currently be made in two places and silently drifts. (These files
already share `LabelEditor`, `ExternalUriLink`, `DeleteConfirmDialog`, and `MutationError`.)
`vocabulary-list.tsx` (vocabularies are `key`-based, not labelled records) and the `objects` RHF surface
are intentionally **not** unified — different shapes.
### Decisions (from brainstorming)
Full unification — three shared components in `src/components/`, with `TermRow`/`AuthorityRow` and the
two pages reduced to thin adapters. All variance is pushed into props; no generics on the row (the adapter
owns the mutate-arg shape). Behavior-preserving — existing tests are the guard.
## Components
### `components/labelled-record-row.tsx` (new) — `LabelledRecordRow`
Owns `editing` / `labels` / `uri` state. Renders the display row (`labelText` + `ExternalUriLink` + Edit
button + `DeleteConfirmDialog`) or the edit view (`LabelEditor` + URI `<Input>` + Save/Cancel +
`MutationError`).
```ts
type RecordLike = { id: string; labels: LabelView[]; external_uri: string | null };
function LabelledRecordRow(props: {
record: RecordLike;
lang: string;
deleteConfirmKey: string; // i18n key for the confirm prompt
savePending: boolean; // update.isPending
saveError: unknown; // update.error
onEditOpen: () => void; // adapter calls update.reset()
onSave: (labels: LabelInput[], uri: string | null, done: () => void) => void;
onDelete: () => Promise<void>;
}): JSX.Element;
```
- Edit button onClick: `onEditOpen(); setLabels(record.labels as LabelInput[]); setUri(record.external_uri ?? ""); setEditing(true);` (preserves the #63 reset-on-edit-open behaviour).
- Save: `onSave(labels, uri.trim() || null, () => setEditing(false))`; Save disabled while `savePending`;
`<MutationError error={saveError} />` below the buttons.
- The URI input id uses `useId()` (replaces the `term-uri-${id}` / `auth-uri-${id}` scheme).
- `record.labels` is `LabelView[]`; cast to `LabelInput[]` for `LabelEditor` (same cast the current rows do).
### `components/labelled-record-create-form.tsx` (new) — `LabelledRecordCreateForm`
Owns its own `labels` / `uri` / `requiredError` state. Renders `heading` + `LabelEditor` + URI `<Input>`
(id via `useId()`) + the `form.required` validation alert + `<MutationError error={error} />` + submit.
```ts
function LabelledRecordCreateForm(props: {
heading: ReactNode;
submitLabel: string;
pending: boolean; // create.isPending
error: unknown; // create.error
onCreate: (labels: LabelInput[], uri: string | null, reset: () => void) => void;
}): JSX.Element;
```
- Submit: `if (!labels.some((l) => l.label)) { setRequiredError(true); return; } setRequiredError(false);
onCreate(labels, uri.trim() || null, () => { setLabels([]); setUri(""); });`
- Submit button disabled while `pending`.
### `components/filtered-record-list.tsx` (new) — `FilteredRecordList<T>`
Owns the `filter` state. Renders the filter `<Input>` **always**, then (loading → `<ListSkeleton>` else the
`<ul>`), so the filter stays visible during load (matches current behaviour).
```ts
function FilteredRecordList<T extends { id: string; labels: LabelView[] }>(props: {
records: T[] | undefined;
lang: string;
isLoading: boolean;
isError: boolean;
loadErrorText: string;
emptyText: string;
renderRow: (record: T) => ReactNode;
}): JSX.Element;
```
- `const q = filter.trim().toLowerCase(); const rows = [...(records ?? [])].filter((r) => !q ||
labelText(r.labels, lang).toLowerCase().includes(q)).sort(byLabel(lang));`
- List states (preserving the current logic exactly): `isError``loadErrorText`; `!isError &&
records?.length === 0` → `emptyText`; `!isError && records && records.length > 0 && rows.length === 0`
`t("common.noMatches")`; else `rows.map(renderRow)`.
- Filter input uses `aria-label={t("common.filter")}` + `placeholder={t("common.filter")}` (unchanged).
### Adapters
**`vocab/term-row.tsx`** keeps its public API `<TermRow vocabularyId term lang />` (its #63 test stays
valid). Body:
```tsx
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 })}
/>
);
```
**`authorities/authority-row.tsx`** keeps `<AuthorityRow authority kind lang />`; analogous with
`useUpdateAuthority`/`useDeleteAuthority`, `deleteConfirmKey="actions.confirmDeleteAuthority"`, save arg
`{ id: authority.id, kind, external_uri: uri, labels }`, delete `{ id: authority.id, kind }`.
**`authorities/authorities-page.tsx`** keeps the `PageTitle`, kind `<nav>`, `Navigate` guard, breadcrumb,
and `useDocumentTitle`. Replaces the inline filter/list with `<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} />} />`, and the inline form with `<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, external_uri: uri, labels },
{ onSuccess: reset })} />`. (Drops the now-unused local `labels`/`uri`/`error`/`filter` state and the
`onCreate` handler.)
**`vocab/vocabulary-terms.tsx`** keeps its `vocab.terms` caption + breadcrumb. Uses `<FilteredRecordList
records={terms} … loadErrorText={t("vocab.loadError")} emptyText={t("vocab.noTerms")} renderRow={(term) =>
<TermRow vocabularyId={id} term={term} lang={lang} />} />` and `<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 })} />`.
## Data flow
No change to the query/mutation layer. Each page owns its data hooks + the page-specific chrome (nav,
headings, breadcrumb) and hands records + render callbacks to the shared list, mutate callbacks to the
shared create form, and per-row mutate callbacks (via the row adapters) to the shared row.
## Error handling / edges
- Inline save errors and create errors still render via `<MutationError>` (status-aware, from #63),
unchanged.
- The `form.required` validation (empty labels) stays in the create form.
- Reset-on-edit-open (`update.reset()`) preserved so a stale save error doesn't linger.
- The empty-vs-no-matches distinction is computed from the **raw** `records` length (empty) vs the
**filtered** `rows` length (no matches), exactly as today.
- `useId()` keeps URI-input ids unique across simultaneously-mounted rows/forms.
## Testing
- **Behavior guard:** existing tests stay green unchanged — `vocab/term-row.test.tsx`,
`authorities/authorities.test.tsx` (page filter/create/list), `vocab/vocabularies.test.tsx`
(out-of-scope list, must not break). Same public component APIs + DOM.
- **New focused tests** for the three components:
- `labelled-record-row.test.tsx`: display → click Edit → Save calls `onSave` and closes on `done`; a
failed save (passing `saveError`) shows the inline error and the row stays editable; Delete invokes
`onDelete`.
- `labelled-record-create-form.test.tsx`: submit with empty labels shows `form.required` and does NOT
call `onCreate`; a valid submit calls `onCreate` and the `reset` clears the inputs.
- `filtered-record-list.test.tsx`: typing in the filter narrows the rendered rows; `records=[]`
`emptyText`; non-empty records + non-matching filter → `common.noMatches`; `isError``loadErrorText`;
`isLoading` → skeleton.
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new i18n keys; en/sv
parity unaffected; no codename; no new dependency. `check:size` should not grow (net code reduction).
## Acceptance criteria
1. `LabelledRecordRow`, `LabelledRecordCreateForm`, `FilteredRecordList<T>` exist in `src/components/` with
the prop shapes above; `TermRow`/`AuthorityRow` and the two pages are adapters over them.
2. `term-row.tsx` + `authority-row.tsx` no longer duplicate the row body; `authorities-page.tsx` +
`vocabulary-terms.tsx` no longer duplicate the filter/list/create-form body.
3. All existing tests pass unchanged; the three new components have focused tests.
4. No behavior change: inline edit/save/delete, create + validation, filtering, the 4 list states, the
authorities kind-nav, and breadcrumbs all work as before.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (not increased); no new
i18n keys; no codename; no new dependency.
## Out of scope → follow-ups
- `vocabulary-list.tsx` (key-based vocabularies) and the `objects` table/detail/RHF surface.
- A field-definition "position"/ordering concept; server-side filtering for large vocabularies (#43).
+6
View File
@@ -4,6 +4,12 @@ set dotenv-load
run:
cargo run -p server
# Build the web SPA + run the server in release mode with the SPA embedded (served at /)
run-release:
# build.rs embeds web/dist, so the frontend must be built first
cd web && pnpm build
cargo run -p server --release --features embed-web
# Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent)
seed:
cargo run -p server -- seed
+146
View File
@@ -0,0 +1,146 @@
# Frontend Guardrails & Test Harness
The web frontend has a handful of CI guardrails and test-harness quirks that trip
new work in the same predictable ways. This file consolidates them so the lessons
are enforced up front instead of rediscovered one failing run at a time.
The canonical gate is `.gitea/workflows/ci.yaml`, which runs (in order) from `web/`:
```
pnpm typecheck # tsc -b --noEmit
pnpm lint # eslint .
pnpm test # vitest run (jsdom + storybook projects)
pnpm build # tsc -b && vite build
pnpm check:size # scripts/check-bundle-size.mjs
pnpm check:colors # scripts/check-no-raw-colors.mjs
```
Run the whole gate locally before pushing. The two `check:*` scripts only have
meaningful output **after** `pnpm build` has produced `dist/`.
---
## Guardrail: bundle size (`check:size`)
`scripts/check-bundle-size.mjs` fails if the **largest single JS chunk** exceeds
**250 KB gzipped**. It measures every file in `dist/assets/*.js` and reports the
biggest.
- The budget is on the *largest chunk*, not the total. Code-split chunks are
measured independently — a heavy primitive that lands in its own lazy chunk
costs that chunk's size, not the entry bundle's. Past additions (Base UI
Select ≈12.6 KB gz, the Drawer ≈12.7 KB gz) paid their cost in a separate
`*.js` chunk and left the main `index` chunk untouched.
- **When a UI primitive pushes the main chunk over budget**, extract the
component that uses it into its own module and `React.lazy()` it (the codebase
already lazy-loads `ObjectEditForm` / `FieldsPage`). This is the standard fix,
especially for anything rendered conditionally (e.g. only on a narrow
viewport) — the wide path then never pays for it.
- Adding one more Base UI primitive to the always-loaded app shell costs
single-digit KB gz; there is comfortable headroom under 250.
## Guardrail: design tokens / no raw colors (`check:colors`)
`scripts/check-no-raw-colors.mjs` fails if any raw Tailwind palette utility
appears **outside `src/components/ui/`**. The regex matches
`{text|bg|border|ring|fill|stroke|from|to|via|decoration|outline|divide|placeholder}-{palette}-{50..950}`
(palette = `neutral|gray|slate|…|rose`).
- In app / shell code use **token classes only**: `bg-accent`, `text-foreground`,
`text-muted-foreground`, `bg-primary`, etc. Numerics like `gap-2` and token
names are never flagged — only `palette-shade` pairs are.
- `src/components/ui/` (the vendored kit) is **exempt** and may use raw colors.
- New status/semantic colors must be exposed as tokens, not raw palette classes.
In Tailwind v4 a token utility (`bg-success`, `text-warning`) only *exists* if
the token is declared in `@theme inline` as `--color-<name>: var(--<name>)`
defining the raw CSS var in `:root` alone is not enough. Opacity modifiers on
tokens (`bg-primary/10`) do resolve.
- For a sweeping token migration, add/enable the `check:colors` guard **last**
(after the file-by-file swap) — it can only pass once the whole migration is
complete.
> **Contrast caveat:** do not trust a WCAG contrast ratio quoted in a plan or a
> prior review — recompute it (OKLCH → linear sRGB → relative luminance) before
> changing a color token. A near-black foreground on a *lighter* colored
> background has *higher* contrast; a single wrong measurement has propagated
> through spec + plan more than once here.
---
## Test harness: two vitest projects
`vite.config.ts` defines **two** vitest projects, and a test file is claimed by
exactly one:
| Project | Environment | Claims |
|-------------|----------------------|-------------------------|
| (default) | jsdom + `src/test/setup.ts` | `*.test.ts(x)` |
| `storybook` | chromium (Playwright, via `storybookTest`) | `*.stories.tsx` |
- Component tests render through **`renderApp`** (`src/test/render.tsx`), which
wraps `QueryClientProvider` + a `createMemoryRouter` and **side-imports
`../i18n`** — so i18n is auto-initialized and you do **not** add extra
providers. Pass an initial route via `renderApp(ui, { route: "/x" })`.
- Story-as-test runs the story's `play()` fn in a real browser:
`pnpm vitest run <file.stories.tsx>`. A canonical pass reports
"1 Test File, 1 Test passed". A first-cold-cache storybook run can emit a
transient `Cannot read properties of null (reading useEffect)` from
`QueryClientProvider` — it does not reproduce once the cache warms; re-run to
confirm green.
## Test harness: MSW handlers (`onUnhandledRequest: "error"`)
`src/test/setup.ts` starts the MSW server with **`onUnhandledRequest: "error"`**
and `resetHandlers()` after each test. Shared handlers live in
`src/test/handlers.ts`.
- Any request with **no matching handler fails the test** with
`[MSW] Cannot bypass a request when using the error strategy`. If a hook hits
an endpoint the shared handlers don't cover (e.g. `PATCH /…/terms/:id` for an
update-mutation success test), **add a per-test override**
`server.use(http.patch(url, () => new HttpResponse(null, { status: 204 })))`
rather than editing the shared `handlers.ts`. The `afterEach` reset keeps the
override test-local.
## Test harness: React Testing Library accessible-name collisions
`getByRole('textbox', { name: /key/i })` throws on **multiple matches** when two
inputs resolve to the same accessible name — e.g. a create-form input
(`<Label htmlFor>`) and a rename input (`aria-label`) both named "Key" once a
rename form is open.
- Use `getAllByRole(...)` and select by position (the rename form renders after
the create form in the DOM, so take the last), or give the inputs distinct
accessible names.
## Test harness: Storybook routers and portals
- `.storybook/preview.tsx` already wraps every story in
`QueryClientProvider` + `ConfigProvider` + `MemoryRouter`. **Do not add a
`MemoryRouter` decorator** inside a story — nesting throws
*"You cannot render a `<Router>` inside another `<Router>`"*. To exercise
URL-dependent state, drive it through `play()` interactions (click a header
link) rather than seeding `initialEntries` via a nested router.
- Portalled content (Base UI Popup/Portal, toasts) renders outside the canvas.
Assert it with `within(document.body).findByText(...)`, and open triggers from
the `play` context with `userEvent.click(canvas.getByRole('button', { name: 'Open' }))`.
---
## Code-style split (convention, not lint)
ESLint passes either way, so this is enforced by review, not tooling — **check a
neighbor file before writing**:
- `src/components/ui/` (vendored shadcn / base-nova kit): **no semicolons**,
single-quote base style.
- The rest of `src/` (app source, hooks, pages): **double-quote + semicolons**.
- `*.stories.tsx`: single-quote, no semicolons.
A component placed in `ui/` must match the no-semicolon kit style even though the
rest of the app uses semicolons.
> Note: `react-refresh/only-export-components` is configured as `warn`
> (`allowConstantExport: true`), so exporting a component **and** a helper from
> one `.tsx` (e.g. `button.tsx` exporting `buttonVariants`) is an accepted
> tradeoff when a plan needs the helper colocated — `pnpm lint` still exits 0.
+10
View File
@@ -4,6 +4,16 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Collection</title>
<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>
</head>
<body>
<div id="root"></div>
+4 -3
View File
@@ -14,6 +14,7 @@
"lint": "eslint .",
"gen:api": "openapi-typescript http://localhost:8080/api-docs/openapi.json -o src/api/schema.d.ts",
"check:size": "node scripts/check-bundle-size.mjs",
"check:colors": "node scripts/check-no-raw-colors.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@@ -50,8 +51,8 @@
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/browser": "3.2.6",
"@vitest/coverage-v8": "3.2.6",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "4.1.8",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
@@ -68,7 +69,7 @@
"typescript": "~5.8.3",
"typescript-eslint": "^8.60.1",
"vite": "^6.3.5",
"vitest": "^3.2.2"
"vitest": "^4.1.8"
},
"msw": {
"workerDirectory": [
+176 -341
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { join } from "node:path";
const BUDGET_KB = 150;
const BUDGET_KB = 250;
const dir = "dist/assets";
const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
if (jsFiles.length === 0) {
+42
View File
@@ -0,0 +1,42 @@
// Fails if any raw Tailwind color utility appears outside src/components/ui/.
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
const root = "src";
const excludeDir = join("src", "components", "ui");
const RAW_COLOR =
/(?: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/;
function walk(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (path === excludeDir) continue;
files.push(...walk(path));
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
files.push(path);
}
}
return files;
}
const files = walk(root);
const offenses = [];
for (const file of files) {
const lines = readFileSync(file, "utf8").split("\n");
for (let i = 0; i < lines.length; i++) {
const match = RAW_COLOR.exec(lines[i]);
if (match) offenses.push(`${relative(".", file)}:${i + 1}: ${match[0]}`);
}
}
if (offenses.length > 0) {
console.error(
`raw color utilities found outside components/ui/ (${offenses.length}):`,
);
for (const offense of offenses) console.error(` ${offense}`);
process.exit(1);
}
console.log(`no raw color utilities outside components/ui/ (${files.length} files scanned)`);
+46
View File
@@ -0,0 +1,46 @@
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();
});
+20 -4
View File
@@ -1,7 +1,23 @@
/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped
* for a router navigation if needed. */
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 {
if (window.location.pathname !== "/login") {
window.location.assign("/login");
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);
}
}
+26
View File
@@ -0,0 +1,26 @@
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" });
});
+18
View File
@@ -0,0 +1,18 @@
import { HttpError, InUseError } from "./errors";
/** 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";
}
+28
View File
@@ -0,0 +1,28 @@
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";
}
}
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, test } from "vitest";
import { renderHook, waitFor, within } from "@testing-library/react";
import { QueryClientProvider, useMutation } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { ReactNode } from "react";
import i18n from "../i18n";
import { ToastRegion } from "../components/ui/toast";
import { server } from "../test/server";
import { makeQueryClient } from "./query-client";
import { HttpError, useDeleteVocabulary, useUpdateTerm } from "./queries";
// The toast manager is a module-scope singleton shared across renders, so each
// test mounts a fresh region and tears it down afterwards to keep toasts from
// one case bleeding into the next.
function makeWrapper() {
const queryClient = makeQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ToastRegion>{children}</ToastRegion>
</QueryClientProvider>
);
};
}
afterEach(async () => {
// Allow any pending toast state updates to flush before the next test mounts.
await Promise.resolve();
});
describe("mutation feedback toasts", () => {
test("a meta.successMessage mutation shows its success toast on success", async () => {
server.use(
http.patch(
"/api/admin/vocabularies/:id/terms/:term_id",
() => new HttpResponse(null, { status: 204 }),
),
);
const { result, unmount } = renderHook(() => useUpdateTerm(), {
wrapper: makeWrapper(),
});
await result.current.mutateAsync({
vocabularyId: "v1",
termId: "t1",
external_uri: null,
labels: [{ lang: "en", label: "Bronze" }],
});
await waitFor(() => {
expect(
within(document.body).getByText(i18n.t("toast.saved")),
).toBeInTheDocument();
});
unmount();
});
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();
});
test("a suppressErrorToast mutation failing adds no toast", async () => {
server.use(
http.delete(
"/api/admin/vocabularies/:id",
() => new HttpResponse(null, { status: 500 }),
),
);
const { result, unmount } = renderHook(() => useDeleteVocabulary(), {
wrapper: makeWrapper(),
});
await expect(result.current.mutateAsync("v1")).rejects.toThrow();
// Give the MutationCache onError a turn; assert it stayed silent.
await Promise.resolve();
expect(within(document.body).queryByText(i18n.t("toast.error"))).toBeNull();
unmount();
});
});
-547
View File
@@ -1,547 +0,0 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client";
import type { components } from "./schema";
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";
}
}
type UserView = components["schemas"]["UserView"];
type LoginRequest = components["schemas"]["LoginRequest"];
export function useMe() {
return useQuery({
queryKey: ["me"],
queryFn: async (): Promise<UserView | null> => {
const { data, response } = await api.GET("/api/admin/me");
if (response.status === 401) return null;
if (!data) throw new Error("failed to load session");
return data;
},
retry: false,
});
}
export function useObjectsPage(limit: number, offset: number) {
return useQuery({
queryKey: ["objects", { limit, offset }],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/objects", {
params: { query: { limit, offset } },
});
if (error || !data) throw new Error("failed to load objects");
return data;
},
});
}
export function useObject(id: string) {
return useQuery({
queryKey: ["object", id],
queryFn: async () => {
const { data, response } = await api.GET("/api/admin/objects/{id}", {
params: { path: { id } },
});
if (response.status === 404) return null;
if (!data) throw new Error("failed to load object");
return data;
},
// A 404 resolves to null rather than erroring, so don't retry it.
retry: false,
});
}
export function useFieldDefinitions() {
return useQuery({
queryKey: ["field-definitions"],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/field-definitions");
if (error || !data) throw new Error("failed to load field definitions");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: LoginRequest) => {
const { response } = await api.POST("/api/admin/login", { body });
if (response.status !== 204) {
throw new Error(response.status === 401 ? "invalid" : "network");
}
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.POST("/api/admin/logout");
},
onSuccess: () => qc.setQueryData(["me"], null),
});
}
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
export function useTerms(vocabularyId: string | null | undefined) {
return useQuery({
queryKey: ["terms", vocabularyId],
enabled: !!vocabularyId,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId! } },
});
if (error || !data) throw new Error("failed to load terms");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useAuthorities(kind: string | null | undefined) {
return useQuery({
queryKey: ["authorities", kind],
enabled: !!kind,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/authorities", {
params: { query: { kind: kind! } },
});
if (error || !data) throw new Error("failed to load authorities");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: ObjectCreateRequest) => {
const { data, error } = await api.POST("/api/admin/objects", { body });
if (error || !data) throw new Error("create failed");
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
});
}
export function useUpdateObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
const { response } = await api.PUT("/api/admin/objects/{id}", {
params: { path: { id } },
body,
});
if (response.status !== 204) throw new Error("update failed");
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: ["objects"] });
void qc.invalidateQueries({ queryKey: ["object", id] });
},
});
}
export function useSetFields() {
const qc = useQueryClient();
return useMutation({
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");
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: ["object", id] });
},
});
}
export function useDeleteObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { response } = await api.DELETE("/api/admin/objects/{id}", {
params: { path: { id } },
});
if (response.status !== 204) throw new Error("delete failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
});
}
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
type LabelInput = components["schemas"]["LabelInput"];
export function useVocabularies() {
return useQuery({
queryKey: ["vocabularies"],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies");
if (error || !data) throw new Error("failed to load vocabularies");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => {
const { data, error } = await api.POST("/api/admin/vocabularies", { body });
if (error || !data) throw new Error("create vocabulary failed");
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useAddTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
vocabularyId,
external_uri,
labels,
}: {
vocabularyId: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId } },
body: { external_uri, labels },
});
if (response.status !== 201) throw new Error("add term failed");
},
onSuccess: (_result, { vocabularyId }) =>
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useCreateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
kind,
external_uri,
labels,
}: {
kind: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/authorities", {
body: { kind, external_uri, labels },
});
if (response.status !== 201) throw new Error("create authority failed");
},
onSuccess: (_result, { kind }) =>
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
const SEARCH_PAGE = 20;
export function useSearch(q: string, visibility: string | null) {
const term = q.trim();
return useInfiniteQuery({
queryKey: ["search", term, visibility],
enabled: term.length > 0,
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const { data, error, response } = await api.GET("/api/admin/search", {
params: {
query: {
q: term,
...(visibility ? { visibility } : {}),
offset: pageParam,
limit: SEARCH_PAGE,
},
},
});
if (error || !data) throw new HttpError(response.status);
return data;
},
placeholderData: keepPreviousData,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, page) => n + page.hits.length, 0);
return loaded < lastPage.estimated_total ? loaded : undefined;
},
});
}
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"] }),
});
}
type Visibility = "draft" | "internal" | "public";
/** 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";
}
}
export function useSetVisibility() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
params: { path: { id } },
body: { visibility },
});
if (response.status !== 204) throw new VisibilityError(response.status);
},
onSuccess: (_result, { id }) => {
void qc.invalidateQueries({ queryKey: ["object", id] });
void qc.invalidateQueries({ queryKey: ["objects"] });
},
});
}
export function useUpdateTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
vocabularyId,
termId,
external_uri,
labels,
}: {
vocabularyId: string;
termId: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
params: { path: { id: vocabularyId, term_id: termId } },
body: { external_uri, labels },
});
if (response.status !== 204) throw new Error("update term failed");
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useDeleteTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
params: { path: { id: vocabularyId, term_id: termId } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete term failed");
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useRenameVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, key }: { id: string; key: string }) => {
const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
params: { path: { id } },
body: { key },
});
if (response.status !== 204) throw new Error("rename failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useDeleteVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
params: { path: { id } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete vocabulary failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useUpdateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
id,
external_uri,
labels,
}: {
id: string;
kind: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/authorities/{id}", {
params: { path: { id } },
body: { external_uri, labels },
});
if (response.status !== 204) throw new Error("update authority failed");
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
export function useDeleteAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id }: { id: string; kind: string }) => {
const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
params: { path: { id } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete authority failed");
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
export function useUpdateFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
key,
required,
group,
labels,
}: {
key: string;
required: boolean;
group: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
params: { path: { key } },
body: { required, group, labels },
});
if (response.status !== 204) throw new Error("update field failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
});
}
export function useDeleteFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => {
const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
params: { path: { key } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete field failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
});
}
+51
View File
@@ -0,0 +1,51 @@
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"];
export function useMe() {
return useQuery({
queryKey: keys.me(),
queryFn: async (): Promise<UserView | null> => {
const { data, response } = await api.GET("/api/admin/me");
if (response.status === 401) return null;
if (!data) throw new Error("failed to load session");
return data;
},
retry: false,
});
}
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: LoginRequest) => {
const { response } = await api.POST("/api/admin/login", { body });
if (response.status !== 204) {
throw new Error(response.status === 401 ? "invalid" : "network");
}
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.me() }),
meta: { suppressErrorToast: true },
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.POST("/api/admin/logout");
},
onSuccess: () => qc.setQueryData(keys.me(), null),
});
}
+93
View File
@@ -0,0 +1,93 @@
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"];
export function useAuthorities(kind: string | null | undefined) {
return useQuery({
queryKey: keys.authorities(kind),
enabled: !!kind,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/authorities", {
params: { query: { kind: kind! } },
});
if (error || !data) throw new Error("failed to load authorities");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
kind,
external_uri,
labels,
}: {
kind: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/authorities", {
body: { kind, external_uri, labels },
});
if (response.status !== 201) throw new HttpError(response.status);
},
onSuccess: (_result, { kind }) =>
qc.invalidateQueries({ queryKey: keys.authorities(kind) }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
}
export function useUpdateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
id,
external_uri,
labels,
}: {
id: string;
kind: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/authorities/{id}", {
params: { path: { id } },
body: { external_uri, labels },
});
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }),
meta: { successMessage: "toast.saved", suppressErrorToast: true },
});
}
export function useDeleteAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id }: { id: string; kind: string }) => {
const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
params: { path: { id } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
});
}
+83
View File
@@ -0,0 +1,83 @@
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"];
export function useFieldDefinitions() {
return useQuery({
queryKey: keys.fieldDefinitions(),
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/field-definitions");
if (error || !data) throw new Error("failed to load field definitions");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
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 HttpError(response.status);
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
}
export function useUpdateFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
key,
required,
group,
labels,
}: {
key: string;
required: boolean;
group: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
params: { path: { key } },
body: { required, group, labels },
});
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }),
meta: { successMessage: "toast.saved", suppressErrorToast: true },
});
}
export function useDeleteFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => {
const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
params: { path: { key } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
});
}
+8
View File
@@ -0,0 +1,8 @@
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";
+157
View File
@@ -0,0 +1,157 @@
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";
export function useObjectsPage(params: ObjectListParams) {
return useQuery({
queryKey: keys.objectsPage(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;
},
});
}
export function useObject(id: string) {
return useQuery({
queryKey: keys.object(id),
queryFn: async () => {
const { data, response } = await api.GET("/api/admin/objects/{id}", {
params: { path: { id } },
});
if (response.status === 404) return null;
if (!data) throw new Error("failed to load object");
return data;
},
// A 404 resolves to null rather than erroring, so don't retry it.
retry: false,
});
}
export function useCreateObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: ObjectCreateRequest) => {
const { data, error, response } = await api.POST("/api/admin/objects", { body });
if (error || !data) throw new HttpError(response.status);
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.objects() }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
}
export function useUpdateObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
const { response } = await api.PUT("/api/admin/objects/{id}", {
params: { path: { id } },
body,
});
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: keys.objects() });
void qc.invalidateQueries({ queryKey: keys.object(id) });
void qc.invalidateQueries({ queryKey: keys.search() });
},
meta: { successMessage: "toast.saved", suppressErrorToast: true },
});
}
export function useSetFields() {
const qc = useQueryClient();
return useMutation({
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 HttpError(response.status);
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: keys.object(id) });
},
meta: { suppressErrorToast: true },
});
}
export function useDeleteObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { response } = await api.DELETE("/api/admin/objects/{id}", {
params: { path: { id } },
});
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: keys.objects() });
void qc.invalidateQueries({ queryKey: keys.search() });
},
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
});
}
export function useSetVisibility() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
params: { path: { id } },
body: { visibility },
});
if (response.status !== 204) throw new VisibilityError(response.status);
},
onSuccess: (_result, { id }) => {
void qc.invalidateQueries({ queryKey: keys.object(id) });
void qc.invalidateQueries({ queryKey: keys.objects() });
void qc.invalidateQueries({ queryKey: keys.search() });
},
meta: { successMessage: "toast.published", suppressErrorToast: true },
});
}
+39
View File
@@ -0,0 +1,39 @@
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { api } from "../client";
import { HttpError } from "../errors";
import { keys } from "../query-keys";
const SEARCH_PAGE = 20;
export function useSearch(q: string, visibility: string | null) {
const term = q.trim();
return useInfiniteQuery({
queryKey: keys.searchResults(term, visibility),
enabled: term.length > 0,
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const { data, error, response } = await api.GET("/api/admin/search", {
params: {
query: {
q: term,
...(visibility ? { visibility } : {}),
offset: pageParam,
limit: SEARCH_PAGE,
},
},
});
if (error || !data) throw new HttpError(response.status);
return data;
},
placeholderData: keepPreviousData,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, page) => n + page.hits.length, 0);
return loaded < lastPage.estimated_total ? loaded : undefined;
},
});
}
+160
View File
@@ -0,0 +1,160 @@
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"];
export function useVocabularies() {
return useQuery({
queryKey: keys.vocabularies(),
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies");
if (error || !data) throw new Error("failed to load vocabularies");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => {
const { data, error, response } = await api.POST("/api/admin/vocabularies", { body });
if (error || !data) throw new HttpError(response.status);
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
}
export function useRenameVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, key }: { id: string; key: string }) => {
const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
params: { path: { id } },
body: { key },
});
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }),
meta: { successMessage: "toast.renamed", suppressErrorToast: true },
});
}
export function useDeleteVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
params: { path: { id } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
});
}
export function useTerms(vocabularyId: string | null | undefined) {
return useQuery({
queryKey: keys.terms(vocabularyId),
enabled: !!vocabularyId,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId! } },
});
if (error || !data) throw new Error("failed to load terms");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useAddTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
vocabularyId,
external_uri,
labels,
}: {
vocabularyId: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId } },
body: { external_uri, labels },
});
if (response.status !== 201) throw new HttpError(response.status);
},
onSuccess: (_result, { vocabularyId }) =>
qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
}
export function useUpdateTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
vocabularyId,
termId,
external_uri,
labels,
}: {
vocabularyId: string;
termId: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
params: { path: { id: vocabularyId, term_id: termId } },
body: { external_uri, labels },
});
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }),
meta: { successMessage: "toast.saved", suppressErrorToast: true },
});
}
export function useDeleteTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
params: { path: { id: vocabularyId, term_id: termId } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new HttpError(response.status);
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }),
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
});
}
+44
View File
@@ -0,0 +1,44 @@
import {
MutationCache,
QueryClient,
type MutationMeta,
} from "@tanstack/react-query";
import i18n from "../i18n";
import { toastManager } from "../toast/toast-manager";
import { errorMessageKey } from "./error-message";
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);
}
/** Builds the app's QueryClient, including the MutationCache that bridges every
* mutation to the toast region (catch-all error toast + opt-in success toast).
* Shared by main.tsx and tests so the toast wiring stays consistent. */
export function makeQueryClient(): QueryClient {
return 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),
});
}
},
}),
});
}
+24
View File
@@ -0,0 +1,24 @@
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);
});
+24
View File
@@ -0,0 +1,24 @@
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,
};
+14
View File
@@ -0,0 +1,14 @@
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;
};
}
}
+13
View File
@@ -411,6 +411,8 @@ export interface components {
/** @description Full admin view of a catalogue object (all fields, all visibility levels). */
AdminObjectView: {
brief_description?: string | null;
/** @description RFC3339 UTC timestamp. */
created_at: string;
current_location?: string | null;
current_owner?: string | null;
/** @description Flexible field values (key -> value). */
@@ -425,6 +427,8 @@ export interface components {
recorder?: string | null;
/** @description `YYYY-MM-DD` or null. */
recording_date?: string | null;
/** @description RFC3339 UTC timestamp. */
updated_at: string;
/** @description "draft" | "internal" | "public". */
visibility: components["schemas"]["Visibility"];
};
@@ -599,6 +603,7 @@ export interface components {
id: string;
object_name: string;
object_number: string;
recording_date?: string | null;
snippet?: string | null;
visibility: components["schemas"]["Visibility"];
};
@@ -1089,6 +1094,14 @@ export interface operations {
limit?: number;
/** @description default 0 */
offset?: number;
/** @description object_number | object_name | updated_at | created_at | visibility (default object_number) */
sort?: string;
/** @description asc | desc (default asc) */
order?: string;
/** @description draft | internal | public — filter; unknown values ignored */
visibility?: string;
/** @description quick filter: ILIKE match on object_number or object_name */
q?: string;
};
header?: never;
path?: never;
+28
View File
@@ -0,0 +1,28 @@
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),
);
});
+63 -56
View File
@@ -1,18 +1,19 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route, RouterProvider } from "react-router-dom";
import { NavigationBridge } from "./shell/navigation-bridge";
import { RequireAuth } from "./auth/require-auth";
import { LoginPage } from "./auth/login-page";
import { AppShell } from "./shell/app-shell";
import { ObjectsPage } from "./objects/objects-page";
import { ObjectDetail } from "./objects/object-detail";
import { SelectPrompt } from "./objects/select-prompt";
import { SearchPage } from "./search/search-page";
import { SelectSearchPrompt } from "./search/select-search-prompt";
import { VocabulariesPage } from "./vocab/vocabularies-page";
import { VocabularyTerms } from "./vocab/vocabulary-terms";
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
import { AuthoritiesPage } from "./authorities/authorities-page";
import { FormSkeleton, ListSkeleton } from "@/components/ui/skeletons";
const ObjectNewPage = lazy(() =>
import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })),
@@ -26,60 +27,66 @@ const FieldsPage = lazy(() =>
import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })),
);
function FormFallback() {
return <div role="status" className="p-4 text-sm text-neutral-400">Loading</div>;
}
export function App() {
function RootLayout() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
<Route
path="/objects/new"
element={
<Suspense fallback={<FormFallback />}>
<ObjectNewPage />
</Suspense>
}
/>
<Route path="/objects" element={<ObjectsPage />}>
<Route index element={<SelectPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
<Route
path=":id/edit"
element={
<Suspense fallback={<FormFallback />}>
<ObjectEditForm />
</Suspense>
}
/>
</Route>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
<Route
path="/fields"
element={
<Suspense fallback={<FormFallback />}>
<FieldsPage />
</Suspense>
}
/>
<Route path="/" element={<Navigate to="/objects" replace />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</Routes>
</BrowserRouter>
<>
<NavigationBridge />
<Outlet />
</>
);
}
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
<Route
path="/objects/new"
element={
<Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
<ObjectNewPage />
</Suspense>
}
/>
<Route path="/objects" element={<ObjectsPage />}>
<Route path=":id" element={<ObjectDetail />} />
<Route
path=":id/edit"
element={
<Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
<ObjectEditForm />
</Suspense>
}
/>
</Route>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
<Route
path="/fields"
element={
<Suspense fallback={<ListSkeleton />}>
<FieldsPage />
</Suspense>
}
/>
<Route path="/" element={<Navigate to="/objects" replace />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/objects" replace />} />
</Route>,
),
);
export function App() {
return <RouterProvider router={router} />;
}
+32
View File
@@ -12,6 +12,7 @@ function tree() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/objects" element={<div>objects landing</div>} />
<Route path="/objects/:id" element={<div>object detail</div>} />
</Routes>
);
}
@@ -34,3 +35,34 @@ test("invalid credentials show an inline error", async () => {
expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(),
);
});
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();
});
+25 -6
View File
@@ -1,24 +1,40 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useState, type FormEvent } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useLogin } from "../api/queries";
import { useConfig } from "../config/config-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageTitle } from "@/components/ui/page-title";
/** 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";
}
export function LoginPage() {
const { t } = useTranslation();
const { app_name } = useConfig();
const navigate = useNavigate();
const [params] = useSearchParams();
const sessionExpired = params.get("reason") === "expired";
const login = useLogin();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
document.title = app_name;
}, [app_name]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
login.mutate(
{ email, password },
{ onSuccess: () => navigate("/objects", { replace: true }) },
{ onSuccess: () => navigate(safeFrom(params.get("from")), { replace: true }) },
);
};
@@ -31,7 +47,10 @@ export function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">{t("app.name")}</h1>
<PageTitle>{app_name}</PageTitle>
{sessionExpired && (
<p className="text-sm text-muted-foreground">{t("auth.sessionExpired")}</p>
)}
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
@@ -53,11 +72,11 @@ export function LoginPage() {
/>
</div>
{errorKey && (
<p role="alert" className="text-sm text-red-600">
<p role="alert" className="text-sm text-destructive">
{t(errorKey)}
</p>
)}
<Button type="submit" className="w-full" disabled={login.isPending}>
<Button type="submit" className="w-full" disabled={login.isPending || !email.trim() || !password}>
{t("auth.signIn")}
</Button>
</form>
+9 -4
View File
@@ -1,16 +1,21 @@
import { screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { Route, Routes } from "react-router-dom";
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={<div>login page</div>} />
<Route path="/login" element={<LoginStub />} />
<Route element={<RequireAuth />}>
<Route path="/objects" element={<div>secret objects</div>} />
</Route>
@@ -23,8 +28,8 @@ test("renders children when authenticated", async () => {
expect(await screen.findByText("secret objects")).toBeInTheDocument();
});
test("redirects to /login when unauthenticated", async () => {
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("login page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText(/from=%2Fobjects/)).toBeInTheDocument());
});
+8 -3
View File
@@ -1,13 +1,18 @@
import { Navigate, Outlet } from "react-router-dom";
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 <div role="status" aria-label="loading" />;
if (isLoading) return <AppShellSkeleton />;
if (!user) return <Navigate to="/login" replace />;
if (!user) {
const from = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?from=${from}`} replace />;
}
return <Outlet />;
}
+32 -69
View File
@@ -1,21 +1,22 @@
import { useState, type FormEvent } from "react";
import { NavLink, Navigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
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";
type LabelInput = components["schemas"]["LabelInput"];
import { useLang } from "../lib/use-lang";
import { segmentClass } from "../lib/class-recipes";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
const KINDS = ["person", "organisation", "place"] as const;
export function AuthoritiesPage() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { kind } = useParams();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const lang = useLang();
const isValidKind = (KINDS as readonly string[]).includes(kind ?? "");
const currentKind = isValidKind ? (kind as string) : "person";
@@ -23,82 +24,44 @@ export function AuthoritiesPage() {
const { data: authorities, isLoading, isError } = useAuthorities(currentKind);
const create = useCreateAuthority();
const [labels, setLabels] = useState<LabelInput[]>([]);
const [error, setError] = useState(false);
useDocumentTitle(t("nav.authorities"));
useBreadcrumb([{ label: t("nav.authorities") }]);
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
const onCreate = (event: FormEvent) => {
event.preventDefault();
if (!labels.some((l) => l.label)) {
setError(true);
return;
}
setError(false);
create.mutate(
{ kind: kind as string, external_uri: null, labels },
{ onSuccess: () => setLabels([]) },
);
};
return (
<div className="overflow-auto p-4">
<div role="tablist" className="mb-3 flex gap-2">
<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}`}
role="tab"
aria-selected={k === currentKind}
className={({ isActive }) =>
`rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}`
}
className={({ isActive }) => segmentClass(isActive, "px-3 py-1 text-sm")}
>
{t(`authorities.${k}`)}
</NavLink>
))}
</div>
</nav>
<ul className="mb-4">
{isLoading && (
<li className="text-sm text-neutral-400"></li>
)}
{isError && (
<li className="text-sm text-red-600">{t("authorities.loadError")}</li>
)}
{!isLoading && !isError && authorities?.length === 0 && (
<li className="text-sm text-neutral-500">{t("authorities.empty")}</li>
)}
{authorities?.map((a) => (
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
))}
</ul>
<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} />}
/>
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium">
{t("authorities.new")} · {t(`authorities.${currentKind}`)}
</div>
<LabelEditor value={labels} onChange={setLabels} />
{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("authorities.create")}
</Button>
</form>
<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>
);
}
+70 -4
View File
@@ -33,13 +33,13 @@ test("lists authorities for the kind and creates one", async () => {
test("kind tabs link to the other kinds", async () => {
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByRole("tab", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
});
test("aria-selected is on the tab element and reflects the active kind", async () => {
test("aria-current marks the active kind link", async () => {
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByRole("tab", { name: /^person$/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: /^place$/i })).toHaveAttribute("aria-selected", "false");
expect(await screen.findByRole("link", { name: /^person$/i })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: /^place$/i })).not.toHaveAttribute("aria-current");
});
test("create without EN label shows required alert and does not POST", async () => {
@@ -69,3 +69,69 @@ test("unknown kind redirects to person list", async () => {
renderApp(tree(), { route: "/authorities/banana" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
});
test("authorities render sorted by label", async () => {
server.use(
http.get("/api/admin/authorities", () =>
HttpResponse.json([
{ id: "a-zoe", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Zoe" }] },
{ id: "a-adam", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Adam" }] },
]),
),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Adam")).toBeInTheDocument();
const items = screen.getAllByRole("listitem");
const texts = items.map((item) => item.textContent ?? "");
const adam = texts.findIndex((text) => text.includes("Adam"));
const zoe = texts.findIndex((text) => text.includes("Zoe"));
expect(adam).toBeLessThan(zoe);
});
test("filter narrows the authority list", async () => {
server.use(
http.get("/api/admin/authorities", () =>
HttpResponse.json([
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
{ id: "a-grace", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Grace Hopper" }] },
]),
),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "grace");
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument();
});
test("create posts the entered external_uri", async () => {
let body: unknown;
server.use(
http.post("/api/admin/authorities", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "a-c" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/^label$/i), "Carl von Linné");
await userEvent.type(screen.getByLabelText(/external uri/i), "https://viaf.org/456");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() =>
expect((body as { external_uri: string })?.external_uri).toBe("https://viaf.org/456"),
);
});
test("read row shows its external_uri as a link", async () => {
server.use(
http.get("/api/admin/authorities", () =>
HttpResponse.json([
{ id: "a-ada", kind: "person", external_uri: "https://viaf.org/123", labels: [{ lang: "en", label: "Ada Lovelace" }] },
]),
),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /viaf\.org/ })).toBeInTheDocument();
});
+14 -67
View File
@@ -1,77 +1,24 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";
import { LabelledRecordRow } from "../components/labelled-record-row";
type AuthorityView = components["schemas"]["AuthorityView"];
type LabelInput = components["schemas"]["LabelInput"];
export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) {
const { t } = useTranslation();
const updateAuthority = useUpdateAuthority();
const deleteAuthority = useDeleteAuthority();
const [editing, setEditing] = useState(false);
const [labels, setLabels] = useState<LabelInput[]>(authority.labels as LabelInput[]);
const [uri, setUri] = useState(authority.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={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
<Input id={`auth-uri-${authority.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={updateAuthority.isPending}
onClick={() =>
updateAuthority.mutate(
{ id: authority.id, kind, external_uri: uri.trim() || null, labels },
{ onSuccess: () => setEditing(false) },
)
}
>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
{t("form.cancel")}
</Button>
</div>
</li>
);
}
const update = useUpdateAuthority();
const del = useDeleteAuthority();
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(authority.labels, lang)}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setLabels(authority.labels as LabelInput[]);
setUri(authority.external_uri ?? "");
setEditing(true);
}}
>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteAuthority")}
onConfirm={() => deleteAuthority.mutateAsync({ id: authority.id, kind })}
/>
</li>
<LabelledRecordRow
record={{ ...authority, external_uri: authority.external_uri ?? null }}
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 })}
/>
);
}
+5 -4
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { InUseError } from "../api/queries";
import { errorMessageKey } from "../api/error-message";
import {
AlertDialog,
AlertDialogTrigger,
@@ -37,7 +37,8 @@ export function DeleteConfirmDialog({
} catch (err) {
// Keep the dialog open; show the blocking reason. Never let the rejected
// mutation escape as an unhandled rejection.
setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected"));
const { key, opts } = errorMessageKey(err);
setMessage(t(key, opts));
return;
}
setOpen(false);
@@ -47,7 +48,7 @@ export function DeleteConfirmDialog({
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button variant="ghost" size="sm" className="text-red-600">
<Button variant="ghost" size="sm" className="text-destructive">
{triggerLabel ?? t("actions.delete")}
</Button>
}
@@ -56,7 +57,7 @@ export function DeleteConfirmDialog({
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
{message && (
<p role="alert" className="text-sm text-red-600">
<p role="alert" className="text-sm text-destructive">
{message}
</p>
)}
+12
View File
@@ -0,0 +1,12 @@
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>
);
}
@@ -0,0 +1,51 @@
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();
});
@@ -0,0 +1,68 @@
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>
)}
</>
);
}
+17 -2
View File
@@ -31,9 +31,9 @@ export const Empty: Story = {
}
export const Prefilled: Story = {
args: { value: [{ lang: 'en', label: 'Bronze' }] },
args: { value: [{ lang: 'sv', label: 'Brons' }] },
play: async ({ canvas }) => {
await expect(canvas.getByLabelText('Label')).toHaveValue('Bronze')
await expect(canvas.getByLabelText('Label')).toHaveValue('Brons')
},
}
@@ -44,3 +44,18 @@ export const Editing: Story = {
await expect(input).toHaveValue('Ceramic')
},
}
// An entry that already has labels in other languages (e.g. English) shows only the
// default-language label, with a hint that the other-language labels are preserved.
export const WithOtherLanguages: Story = {
args: {
value: [
{ lang: 'en', label: 'Bronze' },
{ lang: 'sv', label: 'Brons' },
],
},
play: async ({ canvas }) => {
await expect(canvas.getByLabelText('Label')).toHaveValue('Brons')
await expect(canvas.getByText(/other languages/i)).toBeInTheDocument()
},
}
+70 -3
View File
@@ -8,9 +8,23 @@ 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); }} />;
function Harness({
initial = [],
onChange,
}: {
initial?: LabelInput[];
onChange?: (v: LabelInput[]) => void;
}) {
const [value, setValue] = useState<LabelInput[]>(initial);
return (
<LabelEditor
value={value}
onChange={(v) => {
setValue(v);
onChange?.(v);
}}
/>
);
}
test("emits a single label at the instance default language", async () => {
@@ -31,3 +45,56 @@ test("clearing the input emits an empty array", async () => {
await userEvent.clear(input);
await waitFor(() => expect(seen[seen.length - 1]).toEqual([]));
});
test("editing preserves labels in other languages", async () => {
const seen: LabelInput[][] = [];
renderApp(
<Harness
initial={[
{ lang: "en", label: "Bronze" },
{ lang: "sv", label: "Brons" },
]}
onChange={(v) => seen.push(v)}
/>,
);
const input = screen.getByLabelText(/^label$/i);
expect((input as HTMLInputElement).value).toBe("Brons");
await userEvent.clear(input);
await userEvent.type(input, "Brons (ny)");
await waitFor(() => {
expect(seen[seen.length - 1]).toEqual([
{ lang: "en", label: "Bronze" },
{ lang: "sv", label: "Brons (ny)" },
]);
});
});
test("shows a hint when the entry has labels in other languages", () => {
renderApp(
<Harness
initial={[
{ lang: "en", label: "Bronze" },
{ lang: "sv", label: "Brons" },
]}
/>,
);
expect(screen.getByText(/other languages/i)).toBeInTheDocument();
});
test("no hint when only the default-language label exists", () => {
renderApp(<Harness initial={[{ lang: "sv", label: "Brons" }]} />);
expect(screen.queryByText(/other languages/i)).not.toBeInTheDocument();
});
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);
});
+17 -8
View File
@@ -1,3 +1,4 @@
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
@@ -7,9 +8,10 @@ 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. */
/** Single-language label editor. Authors one label at the instance default language.
* Editing only touches the default-language entry labels in other languages on the
* same record are preserved (not collapsed), so editing a term/authority that already
* has e.g. an English label keeps it. */
export function LabelEditor({
value,
onChange,
@@ -18,18 +20,25 @@ export function LabelEditor({
onChange: (labels: LabelInput[]) => void;
}) {
const { t } = useTranslation();
const inputId = useId();
const { default_language } = useConfig();
const current =
value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";
const current = value.find((l) => l.lang === default_language)?.label ?? "";
const hasOtherLanguages = value.some((l) => l.lang !== default_language);
const set = (label: string) =>
onChange(label.trim() ? [{ lang: default_language, label }] : []);
onChange([
...value.filter((l) => l.lang !== default_language),
...(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)} />
<Label htmlFor={inputId}>{t("labels.label")}</Label>
<Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />
{hasOtherLanguages && (
<p className="text-xs text-muted-foreground">{t("labels.otherLanguages")}</p>
)}
</div>
);
}
@@ -0,0 +1,28 @@
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();
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("");
});
@@ -0,0 +1,76 @@
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>
);
}
@@ -0,0 +1,74 @@
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>,
);
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
const dialog = within(document.body);
const confirmButtons = await dialog.findAllByRole("button", { name: /delete/i });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
expect(onDelete).toHaveBeenCalled();
});

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