# 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`; 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` (`

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