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