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