Files
biggus-dickus/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md

8.0 KiB
Raw Permalink Blame History

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 Badges.
  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<string, Intl.Collator>).
  • 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 })<a href={uri} target="_blank" rel="noopener noreferrer" className="block truncate text-xs text-muted-foreground hover:text-foreground">{uri}</a>. 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 <Input> (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 <Input> above the terms list; filter terms by labelText then sort by byLabel(lang); filtered-empty → common.noMatches. The add-term external_uri <Input> gets type="url" + placeholder={t("labels.uriPlaceholder")}.
  • term-row.tsx: read mode renders <ExternalUriLink uri={term.external_uri} /> under the label when present; the edit-mode external_uri <Input> gets type="url" + the placeholder.

Authorities

  • authorities-page.tsx: a filter <Input> above the list; filter by labelText then sort by byLabel(lang); filtered-empty → common.noMatches. The create form gains an external_uri <Input> (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 <ExternalUriLink uri={authority.external_uri} /> under the label when present; edit external_uri <Input> gets type="url" + placeholder.

Fields

  • field-list.tsx: a filter <Input> 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 <Badge variant="secondary">{count}</Badge> (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 AZ 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).