Implementation plan — Color primitives & palettes (Doc 2)
Spec: specs/approved/color-primitives-palettes.md · status
approvedPlan 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 sparsecolor-orange-*(brand), sparsecolor-red/green/amber-*(status),color-chart-1…5. No blue/info, no full ramps. - Semantic family-slot tokens (
defaults.ts, idsapp-bg,primary-bg,success-bg,info-bg, …): each hasreferences(light) +darkReferences(dark) pointing at a primitive.info-*is a TODO placeholder pointing at zinc (defaults.ts:94). - Families (
families.ts): each slot'stokenId→ a semantic token; emission writes owned globals (--primary,--card…) +--{family}-{slot}viafamilyCssLines.
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
- 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). UsingapplyTheme/themewill be ambiguous. Recommendation: name Doc 2's conceptpalette—STARTER_PALETTES,activePaletteId,applyPalette(id),resetPalette(id)— and keep "theme" for the spec's user-facing label only. (Spec text saysapplyTheme; I'll useapplyPaletteinternally unless you prefer otherwise.) - Primitive ID scheme + migration (highest risk). Role ramps want ids like
neutral-500,primary-500,info-500. Today's ids arecolor-zinc-500,color-orange-500, etc., and existing family-era saved libraries reference those ids inapp-bg.referencesetc. Renaming primitives dangles those refs. Doc 1'sisLegacyColourDatahard-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 savedreferences/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. - Colour picker install path. Kibo UI Color Picker is a shadcn-registry component, but the app (
src/) is not a shadcn project —preview-host/is.npx shadcn add @kibo-ui/color-pickerwon't drop cleanly intosrc/. Options: (a) hand-vendor Kibo's source intosrc/components/pages/properties/ColorPicker/(own it, on-brand, MIT → NOTICE), or (b) use the spec's fallbackreact-colorful(~2.8 kB npm dep) for the app picker. Recommendation: start withreact-colorfulwrapped in ourColorPicker.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, eachRecord<RoleName, string[11]>(neutral,primary,accent,success,warning,error,info) +white/blackconstants +{id,name,swatch}. ExportRoleName+ thePalettetype. The Orange bundle is the single sourcedefaults.tsreads (no duplicated hex).src/tokens/defaults.ts— primitive section rewrite. Replacecolor-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-*(eachgroup:= the role, e.g.'Color / Primary'), pluswhite#ffffff/black#000000. Keepcolor-chart-1…5(referenced bydeferredVarFallback) or re-point them toaccent/info/success/warning/errorstep-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-bg→white/neutral-950,app-fg→neutral-950/neutral-50,block-bg→neutral-50/neutral-900, the feedback surfaces (success-bg→success-50/success-950, …info-bg→info-50/info-950— closes the §94 TODO),primary-*→primary-500,destructive-*→error-500,secondary/tertiary/ghost→ neutral steps,*-accent→accent-600/accent-400,*-subtle→neutral-500/neutral-400. (All 12 families, L·D.)src/tokens/types.ts— addkind?: 'solid' | 'gradient'(defaultsolid). Primitives always solid; only the new Gradients group isgradient.src/tokens/defaults.ts— Gradients group — seed ~3–6tier:'semantic', kind:'gradient', group:'Gradients'tokens whosevalueis 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) — extendmigrateTierNames(or addmigratePrimitiveIds) to remap old primitive ids (color-zinc-N→neutral-N,color-orange-N→primary-N,color-red/green/amber-N→error/success/warning-N) inside savedreferences/darkReferences; wire intoinitLibrary/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 fromSTARTER_PALETTES, persisted/editable),activePaletteId: string(defaultorange). 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 factorySTARTER_PALETTES[id](confirm via existingResetPresetModal).- Persist palettes +
activePaletteIdinuseLocalSave.
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, optionalEyeDropper. Wrappingreact-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 (reusesColorPickerfor 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 → opensColorPicker;+ Add colour; Gradients group withGradientEditor++ Add gradient. (ReuseScaleGeneratorModalfor 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.tsx—kind:'gradient'tokens emit theirvalueverbatim (the resolver must not chase thevar()s inside as references — confirm;referencesis separate so it should already pass through). Role-ramp primitives emit as today.src/tokens/families.ts—resolveLight/resolveDarkalready returnt.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 withvar(--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;agentsMdcontract;tokens.css/theme.csssamples; 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
errorramp → botherrorsurface +destructivebutton 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.
accentrole is half-deferred in Doc 1 (--accenthas no owning family) — accent primitives exist and feed*-accentslots + charts, but gradients must avoidvar(--accent)(§7). Keep gradient presets on owned vars only; add a test asserting that.- Picker scope —
react-colorfulkeeps 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.tschange; generate the ramps fromthemes.tsto avoid hand-typing 77 hexes twice.