# 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 ; case "authority": return ; case "localized_text": return <>{pickLocalized(value, lang)}; case "date": return <>{formatDate(value, lang)}; case "boolean": return ; 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 ; return {value} {t("objects.unknownRef")}; } 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 ; return {value} {t("objects.unknownRef")}; } 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; 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 ` (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 (
{label}
{empty ? "—" : value}
); } ``` - **Header → actions toolbar:** keep `object_name` (`

`) + `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"; // ...

{object.object_name}

{t("actions.edit")}
``` - **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 } /> ``` 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.