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

101 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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).