Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.4 KiB
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)
- One brand accent: indigo
--primary. Set--primary/--ringto an indigo so primary buttons, selected rows, links, and chips share one recognizable accent (the existing indigo usages map straight ontobg-primary/text-primary). - Add status tokens (
--success/--warning/--highlight) and route the visibility badge + search highlight through them (via Badge variants). - Enforce with a CI/lint guard banning raw color utilities outside
components/ui/. - Dark-mode toggle stays #59. This migration makes the
.darktoken set work (semantic tokens adapt), unblocking #59; the toggle/persistence is not in scope. - 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-foregroundcontrast AA.) - Status tokens in
:rootand.dark, exposed in@theme inlineas--color-success,--color-success-foreground,--color-warning,--color-warning-foreground,--color-highlight,--color-highlight-foreground. Light values ≈ successoklch(0.6 0.13 160)/ warningoklch(0.72 0.15 75)/ highlightoklch(0.905 0.16 100)with readable foregrounds; dark variants adjusted for contrast.--destructiveunchanged. --accent/--mutedstay the neutral shadcn grays (hover/subtle surfaces). Selected/active states usebg-primary/10(a light indigo tint) rather than repurposing--accent.
Component updates
ui/badge.tsx: addsuccessandwarningvariants (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 patchingbg-amber-100/bg-green-100.search/highlight.tsx:bg-yellow-200→bg-highlight(token).- Shared caption: add a single
.label-captionutility (inindex.css@layer components, or a tinyuihelper) =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/10opacity modifier on a token works in Tailwind v4 — verify it resolves.- A few utilities are genuinely semantic-ambiguous (e.g.
text-neutral-900for an emphasized value vs body) — map totext-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
CssCheckstory (visibility-badge.stories.tsx) asserts the public badge's resolvedbg-green-100oklch — it must be updated to the new--successtoken's resolved value (run the story to read the actual computed color, as the original did). Add Badgesuccess/warningstories. pnpm typecheck/lint/test/buildgreen; the newcheck:colorspasses (proves the migration is complete — zero raw utilities outsideui/);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
--primary/--ringare indigo; primary buttons, selected rows, links, chips use it (one accent). Status tokens (--success/--warning/--highlight) exist (+@theme+.dark).VisibilityBadgeand the search highlight use token-based Badge variants /--highlight(no hardcoded amber/green/yellow); one shared caption utility.- No raw color utilities outside
components/ui/; bareroundedstandardized to the radius token;check:colorsguard added to the gate and passing. - No behavior/layout change;
CssCheckstory updated;typecheck/lint/test/build/check:sizegreen; no codename. - 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/Cardonto every panel (adopt only the clean swaps).