diff --git a/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md b/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md new file mode 100644 index 0000000..4f87185 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md @@ -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`). +- `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 })` → +`{uri}`. 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 `` (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 `` above the terms list; filter terms by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The add-term `external_uri` `` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`. +- `term-row.tsx`: read mode renders `` under the label when present; the edit-mode `external_uri` `` gets `type="url"` + the placeholder. + +**Authorities** +- `authorities-page.tsx`: a filter `` above the list; filter by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The **create form gains an `external_uri` ``** (`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 `` under the label when present; edit `external_uri` `` gets `type="url"` + placeholder. + +**Fields** +- `field-list.tsx`: a filter `` 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 `{count}` (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 A–Z 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).