Implementation plan — Color primitives & palettes (Doc 2)

Spec: specs/approved/color-primitives-palettes.md · status approved Plan date: 2026-06-12 · owner: jborozco Builds on the shipped families model (Doc 1) already in the repo (src/tokens/families.ts).

The model as it stands today (grounded)

Colour resolves through three layers:

primitive            semantic "family-slot" token          family slot (families.ts)
color-orange-500  ←  primary-bg (references→ / darkReferences→)  ←  primary.background
  • Primitives (defaults.ts): color-zinc-50…950 (neutral), a sparse color-orange-* (brand), sparse color-red/green/amber-* (status), color-chart-1…5. No blue/info, no full ramps.
  • Semantic family-slot tokens (defaults.ts, ids app-bg, primary-bg, success-bg, info-bg, …): each has references (light) + darkReferences (dark) pointing at a primitive. info-* is a TODO placeholder pointing at zinc (defaults.ts:94).
  • Families (families.ts): each slot's tokenId → a semantic token; emission writes owned globals (--primary, --card…) + --{family}-{slot} via familyCssLines.

So Doc 2 = widen the primitive layer into role ramps + re-point the semantic layer at them + add themes/picker/gradients. Doc 1's family shape and emission are untouched (the sanctioned "Doc 2 seeds values" hook).

Already present: families.ts, ScaleGeneratorModal, ResetPresetModal, the isLegacyColourData hard-reset in the store. Net-new (as spec says): themes.ts, ColorPicker.tsx, GradientEditor.tsx. No kind field, no palette/applyTheme state yet.


Decisions to confirm before coding

  1. Palette-theme naming — COLLISION. The store already has componentThemes (Doc 1: a component bound to a family + per-part overrides). Doc 2's "themes" (STARTER_THEMES/applyTheme) are a different concept (colour palette bundles). Using applyTheme/theme will be ambiguous. Recommendation: name Doc 2's concept paletteSTARTER_PALETTES, activePaletteId, applyPalette(id), resetPalette(id) — and keep "theme" for the spec's user-facing label only. (Spec text says applyTheme; I'll use applyPalette internally unless you prefer otherwise.)
  2. Primitive ID scheme + migration (highest risk). Role ramps want ids like neutral-500, primary-500, info-500. Today's ids are color-zinc-500, color-orange-500, etc., and existing family-era saved libraries reference those ids in app-bg.references etc. Renaming primitives dangles those refs. Doc 1's isLegacyColourData hard-reset only fires for pre-family (component-tier colour) libs — it won't catch a family-era lib with stale primitive refs. Recommendation: add a seed-version bump + a load-time migration that (a) maps old primitive ids → new role ids in any saved references/darkReferences, or (b) widens the hard-reset to fire when the saved primitives don't match the role scheme. Pick one (lean: migration map, lower data loss). Confirm.
  3. Colour picker install path. Kibo UI Color Picker is a shadcn-registry component, but the app (src/) is not a shadcn projectpreview-host/ is. npx shadcn add @kibo-ui/color-picker won't drop cleanly into src/. Options: (a) hand-vendor Kibo's source into src/components/pages/properties/ColorPicker/ (own it, on-brand, MIT → NOTICE), or (b) use the spec's fallback react-colorful (~2.8 kB npm dep) for the app picker. Recommendation: start with react-colorful wrapped in our ColorPicker.tsx (hex+HSL+visual) — fastest, lean, no registry plumbing — and treat Kibo as an optional later upgrade. Confirm which.

If you're happy with (1) applyPalette naming, (2) a migration map, (3) react-colorful, I'll proceed; else tell me which to change.


Phase A — Data layer: role-ramp primitives, palettes, re-pointed semantics, kind

The foundation; everything else builds on it. Land as one reviewable unit (it's the seed/migration, like Doc 1's).

  • src/tokens/themes.ts (new)STARTER_PALETTES: the 4 bundles (Orange default · Blue · Purple · Teal) from spec §5, each Record<RoleName, string[11]> (neutral,primary,accent,success,warning,error,info) + white/black constants + {id,name,swatch}. Export RoleName + the Palette type. The Orange bundle is the single source defaults.ts reads (no duplicated hex).
  • src/tokens/defaults.ts — primitive section rewrite. Replace color-zinc-* / color-orange-* / color-red/green/amber-* with role ramps generated from the Orange palette: neutral-50…950, primary-50…950, accent-*, success-*, warning-*, error-*, info-* (each group: = the role, e.g. 'Color / Primary'), plus white #ffffff / black #000000. Keep color-chart-1…5 (referenced by deferredVarFallback) or re-point them to accent/info/success/warning/error step-500 per §5 — confirm. radius-base + typography primitives unchanged.
  • src/tokens/defaults.ts — re-point the semantic family-slot tokens per the §5 seed table: app-bgwhite/neutral-950, app-fgneutral-950/neutral-50, block-bgneutral-50/neutral-900, the feedback surfaces (success-bgsuccess-50/success-950, … info-bginfo-50/info-950 — closes the §94 TODO), primary-*primary-500, destructive-*error-500, secondary/tertiary/ghost → neutral steps, *-accentaccent-600/accent-400, *-subtleneutral-500/neutral-400. (All 12 families, L·D.)
  • src/tokens/types.ts — add kind?: 'solid' | 'gradient' (default solid). Primitives always solid; only the new Gradients group is gradient.
  • src/tokens/defaults.ts — Gradients group — seed ~3–6 tier:'semantic', kind:'gradient', group:'Gradients' tokens whose value is a CSS gradient over family-owned vars only (var(--primary), var(--secondary), var(--card), var(--destructive), the --{family}-* scoped vars) — never --accent/--muted/--chart-* (Doc 1-deferred; §7 note).
  • Migration (src/tokens/migrate.ts) — extend migrateTierNames (or add migratePrimitiveIds) to remap old primitive ids (color-zinc-Nneutral-N, color-orange-Nprimary-N, color-red/green/amber-Nerror/success/warning-N) inside saved references/darkReferences; wire into initLibrary/importLibrary (Decision 2).

Gate: tsc; unit tests — every theme defines 7 ramps × 11 steps + white/black; every Doc 1 family slot resolves to a seeded primitive (no empty) in light + dark, all 12 families; info resolves to blue.

Phase B — Store: palettes + apply/edit/reset

  • src/store/index.ts — add palette state: palettes: Palette[] (seeded from STARTER_PALETTES, persisted/editable), activePaletteId: string (default orange). Actions:
    • applyPalette(id) — write the selected palette's 11 ramp steps into the role-ramp primitive tokens (light only; dark is the family seed's job). Family structure + per-part overrides untouched.
    • editRampStep(role, step, hex) / addPaletteColour(name, hex) — write back to the active palette's stored ramps (persist with the theme, not globally).
    • resetPalette(id) — restore factory STARTER_PALETTES[id] (confirm via existing ResetPresetModal).
    • Persist palettes + activePaletteId in useLocalSave.

Gate: applyPalette writes every ramp, leaves families/overrides intact; edit persists in that palette across switch+reload; resetPalette restores factory; add-colour name handling (blank reject / dup auto-suffix).

Phase C — Picker + value editor + Primitives page

  • src/components/pages/properties/ColorPicker.tsx (new) — solid picker (visual area + hue + hex + HSL), keyboard-operable, optional EyeDropper. Wrapping react-colorful (Decision 3) + our hex-validate wrapper (reject malformed, accept 3/6-digit, normalise case).
  • src/components/pages/properties/GradientEditor.tsx (new)Solid | Gradient toggle for gradient-capable (semantic) tokens; Gradient = preset or two-family-swatch builder (reuses ColorPicker for stops).
  • src/components/pages/properties/TokensPrimitivesPage.tsx — restructure to: starter-palette card row (Orange/Blue/Purple/Teal, applied = checkmark → applyPalette); role ramps labelled with current hue + the family slots each feeds (mapping hint); swatch → opens ColorPicker; + Add colour; Gradients group with GradientEditor + + Add gradient. (Reuse ScaleGeneratorModal for the optional anchor→ramp path.)

Gate: invalid-hex reverts + hint; picker commit updates referencing families live; theme card apply recolours system.

Phase D — Emission, export, Developer read-only

  • src/tokens/TokenContext.tsxkind:'gradient' tokens emit their value verbatim (the resolver must not chase the var()s inside as references — confirm; references is separate so it should already pass through). Role-ramp primitives emit as today.
  • src/tokens/families.tsresolveLight/resolveDark already return t.value; gradient tokens aren't family slots, so no change there — just confirm gradients flow through the primitive/semantic emission path.
  • src/export/tokensCss.ts / themeCss.ts / agentsMd.ts — emit the role-ramp primitives + gradient tokens; agent contract references them ("never hard-code colours" already present).
  • src/components/pages/properties/ReadOnlyTokenList.tsx — Developer-mode: render role ramps + gradients read-only with var(--id) copy; show a gradient's resolved CSS string (extend the existing flat-row/ family-aware renderer; §6 Surface).

Gate: gradient token resolves to its literal string (not chased); no preset references a deferred var; export samples updated; Developer mode shows ramps + gradients.

Phase E — Docs, tests, NOTICE, E2E

  • Docs (§11): CLAUDE.md (themes.ts, role ramps, gradients, picker, kind); README ("starter themes" headline); TEST_PLAN; docs/ concept page (roles vs families) + changelog; agentsMd contract; tokens.css/theme.css samples; NOTICE (Kibo MIT attribution — only if we vendor Kibo, Decision 3).
  • E2E (§10): apply palette → previews recolour; picker sets hex → persists on reload; edit ramp → switch palette + back → edit retained → reset → factory; Solid→Gradient → dark toggle recolours; invalid hex reverts; edit error ramp → both error surface + destructive button recolour.
  • Final gate: tsc · full unit suite · npm run build (app + CLI) · preview-host build · manual QA of 4 palettes in light+dark.

Sequencing & rationale

A (data: ramps + palettes + re-point + kind + migration)  ── the foundation, land first & alone
   └─► B (store: applyPalette/edit/reset)
        └─► C (picker + gradient editor + Primitives page)   ┐ C and D parallelizable
        └─► D (emission/export + Developer read-only)         ┘ once A+B land
             └─► E (docs + E2E + final gate)
  • A first & alone: it's the seed + migration (data-only), independently testable, and the riskiest piece (primitive-id migration interacting with Doc 1's hard-reset). Land it as one reviewable unit so the migration is auditable — mirrors how Doc 1 / the tier rename were handled.
  • B before C/D: the page and emission both consume palette state.
  • C / D parallel: UI vs emission are independent once the store exists.

Risks / watch-items

  • Migration vs Doc 1 hard-reset (Decision 2) — the biggest hazard: a family-era saved lib with stale color-zinc-* refs must not silently dangle. Decide map-vs-reset and add a test that an old saved lib loads with every family slot still resolving.
  • Palette persistence is editable store data — like Doc 1, a code rollback won't erase user-edited palettes in localStorage (§13); note in rollback.
  • accent role is half-deferred in Doc 1 (--accent has no owning family) — accent primitives exist and feed *-accent slots + charts, but gradients must avoid var(--accent) (§7). Keep gradient presets on owned vars only; add a test asserting that.
  • Picker scopereact-colorful keeps it lean; if you want Kibo's Figma-grade feel, that's a vendoring sub-task + NOTICE.
  • Big seed diff — replacing the primitive section + re-pointing ~30 semantic tokens is a large but mechanical defaults.ts change; generate the ramps from themes.ts to avoid hand-typing 77 hexes twice.