# 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).
