# Design-Token Adoption Across Feature Screens — Design

**Date:** 2026-06-07
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #49.

## Context

`web/src/index.css` defines a full semantic OKLCH token set (the shadcn defaults:
`--foreground`, `--muted-foreground`, `--primary`, `--destructive`, `--accent`, `--border`,
`--radius`, light + `.dark`) and `ui/*` components use it. But the **feature screens bypass it**:
~120 hardcoded Tailwind color utilities outside `components/ui/` — `text-red-600` ×27 (not
`--destructive`, a *different* red), `text-neutral-400/500/600` ×47 (three shades for "muted
text"), `bg-neutral-50/100`, plus **two competing accents** (`bg-indigo-50`/`text-indigo-600`/
`bg-indigo-600` for selection/links/chips vs the near-black `--primary` for buttons and
`bg-neutral-800` for the publish stepper). Status colors (visibility badge `amber`/`green`,
search highlight `yellow`) aren't tokens; `rounded` (0.25rem, ×23) ignores the `--radius` token;
`ui/Card` has zero usages; the uppercase caption label appears with 4 different recipes.
(Recent table/detail/toast work *did* introduce token usage — `bg-primary` ×6, `bg-destructive`,
`text-foreground` — so the codebase is now mixed; this finishes the job.)

### Decisions (from brainstorming)
1. **One brand accent: indigo `--primary`.** Set `--primary`/`--ring` to an indigo so primary
   buttons, selected rows, links, and chips share one recognizable accent (the existing indigo
   usages map straight onto `bg-primary`/`text-primary`).
2. **Add status tokens** (`--success`/`--warning`/`--highlight`) and route the visibility badge +
   search highlight through them (via Badge variants).
3. **Enforce** with a CI/lint guard banning raw color utilities outside `components/ui/`.
4. **Dark-mode toggle stays #59.** This migration makes the `.dark` token set *work* (semantic
   tokens adapt), unblocking #59; the toggle/persistence is not in scope.
5. **One milestone** (the guard can only pass once the migration is complete, so it lands last).

## Token changes (`web/src/index.css`)
- **Indigo primary** in `:root`: `--primary: oklch(0.511 0.262 276.966)` (≈ indigo-600),
  `--primary-foreground: oklch(0.985 0 0)`, `--ring: oklch(0.511 0.262 276.966)`. In `.dark`: a
  lighter indigo that reads on dark (≈ indigo-400, `oklch(0.673 0.182 276.966)`) +
  `--primary-foreground: oklch(0.205 0 0)`, matching `--ring`. (Implementer may fine-tune to a
  Tailwind indigo shade; keep `--primary-foreground` contrast AA.)
- **Status tokens** in `:root` and `.dark`, exposed in `@theme inline` as `--color-success`,
  `--color-success-foreground`, `--color-warning`, `--color-warning-foreground`,
  `--color-highlight`, `--color-highlight-foreground`. Light values ≈ success
  `oklch(0.6 0.13 160)` / warning `oklch(0.72 0.15 75)` / highlight `oklch(0.905 0.16 100)` with
  readable foregrounds; dark variants adjusted for contrast. `--destructive` unchanged.
- `--accent`/`--muted` stay the neutral shadcn grays (hover/subtle surfaces). Selected/active
  states use `bg-primary/10` (a light indigo tint) rather than repurposing `--accent`.

## Component updates
- **`ui/badge.tsx`:** add `success` and `warning` variants (token-based, with dark variants);
  **`VisibilityBadge`** (`web/src/objects/visibility-badge.tsx`) selects a variant — `public` →
  `success`, `internal` → `warning`, `draft` → the default/secondary — instead of patching
  `bg-amber-100`/`bg-green-100`.
- **`search/highlight.tsx`:** `bg-yellow-200` → `bg-highlight` (token).
- **Shared caption:** add a single `.label-caption` utility (in `index.css` `@layer
  components`, or a tiny `ui` helper) = `text-xs font-medium uppercase tracking-wide
  text-muted-foreground`; replace the 4 ad-hoc recipes (object-detail, object-form,
  publish-control, field-list, vocabulary-terms).
- **`ui/Card`:** adopt for clearly hand-rolled bordered panels where it's a clean swap (e.g. the
  object-detail container). Not forced onto every bordered div — low priority within scope.

## Migration map (mechanical, ~120 sites, outside `components/ui/`)
| From | To |
|---|---|
| `text-red-600` | `text-destructive` |
| `text-neutral-400` / `-500` / `-600` | `text-muted-foreground` |
| `text-neutral-700` / `-900` | `text-foreground` |
| `bg-neutral-50` / `-100` | `bg-muted` |
| `bg-neutral-200` (active nav) | `bg-accent` |
| `bg-indigo-50` (selected row) | `bg-primary/10` |
| `bg-indigo-600` / `text-indigo-600` | `bg-primary` / `text-primary` |
| `bg-neutral-800` (publish stepper / tabs) | `bg-primary` (with `text-primary-foreground`) |
| bare `rounded` (×23) | `rounded-md` |
| `bg-amber-*`/`bg-green-*`/`bg-yellow-*` (badge/highlight) | via Badge variants / `--highlight` |
Apply judgment on a few one-offs (e.g. `border-red-300`/`border-green-300` on the combobox/drawer
→ `border-destructive`/`border-success` or keep neutral `border`). The goal: **zero raw color
utilities outside `components/ui/`** after the migration.

## Enforcement (`web/scripts/check-no-raw-colors.mjs`)
A grep-style Node script (mirroring `check-bundle-size.mjs`) that scans `src/**/*.{ts,tsx}`,
**excluding `src/components/ui/`**, and fails (exit 1) if it finds a raw palette utility matching
`(text|bg|border|ring|fill|stroke|from|to|via)-(neutral|gray|slate|zinc|stone|red|orange|amber|
yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}`.
Print each offending file:line. Add a `check:colors` script and wire it into the test gate (e.g.
the `check` flow / CI). A short inline allowlist (path or `eslint-disable`-style comment) only if
a genuine exception arises. **Added last**, after the migration, so the gate is green.

## Data flow / architecture
Pure styling refactor — no behavior, routing, API, or data changes. Tokens in `index.css` →
Tailwind utility classes (`bg-primary`, `text-muted-foreground`, …) in feature components →
resolved at build. `ui/*` components already consume tokens and gain the new Badge variants.

## Error handling / edges
- The migration must not change layout/spacing — only color/radius utilities (and Badge variant
  selection). Don't restructure markup.
- `bg-primary/10` opacity modifier on a token works in Tailwind v4 — verify it resolves.
- A few utilities are genuinely semantic-ambiguous (e.g. `text-neutral-900` for an emphasized
  value vs body) — map to `text-foreground`; muted captions → `text-muted-foreground`.
- The enforcement regex must not flag token utilities (`text-foreground`, `bg-primary`) or
  non-color numerics (`gap-2`, `w-44`) — scope it to the palette names above.

## Testing
- **Existing `CssCheck` story** (`visibility-badge.stories.tsx`) asserts the public badge's
  resolved `bg-green-100` oklch — it **must be updated** to the new `--success` token's resolved
  value (run the story to read the actual computed color, as the original did). Add Badge
  `success`/`warning` stories.
- `pnpm typecheck` / `lint` / `test` / `build` green; the **new `check:colors` passes** (proves
  the migration is complete — zero raw utilities outside `ui/`); `check:size` ≈ unchanged (CSS
  token churn only); no codename; en/sv untouched (no strings).
- Manual smoke: the app still renders (now indigo-accented); selected rows/links/buttons share
  the indigo; visibility badges + search highlight use the status tokens; nothing relies on a
  removed color. (No automated visual-regression in the repo — the guard + updated stories +
  smoke are the safety net.)

## Acceptance criteria
1. `--primary`/`--ring` are indigo; primary buttons, selected rows, links, chips use it (one
   accent). Status tokens (`--success`/`--warning`/`--highlight`) exist (+ `@theme` + `.dark`).
2. `VisibilityBadge` and the search highlight use token-based Badge variants / `--highlight`
   (no hardcoded amber/green/yellow); one shared caption utility.
3. **No raw color utilities outside `components/ui/`**; bare `rounded` standardized to the radius
   token; `check:colors` guard added to the gate and passing.
4. No behavior/layout change; `CssCheck` story updated; `typecheck`/`lint`/`test`/`build`/
   `check:size` green; no codename.
5. Dark-mode tokens are coherent (light + `.dark`) so #59 (toggle) is unblocked — but the toggle
   is not added here.

## Out of scope → follow-ups
- The **dark-mode toggle** + persistence (#59) — this only makes the tokens dark-ready.
- Per-screen **layout/spacing** redesign, density changes, typography scale (#57).
- Forcing `ui/Card` onto every panel (adopt only the clean swaps).
