Token Colour Families Implementation Plan
Goal: Replace Tostada's two colour token tiers (
semantic+component) with a single layer of editable colour families (surfaces + components) that bundle background / foreground(s) / border, carry usage rules, emit shadcn vars, and can be applied to components. Architecture: Families are persisted store data (user-extensible). Each family slot points at aDesignToken(tiersemantic) — so the existing reference/DAG resolver is untouched. A shared pure emitter turns families into CSS in two lanes: owned global shadcn vars (--card,--primary…) and family-scoped vars (--block-border,--ghost-foreground). Four emission sites (app injector, preview iframe, export tokens.css, export theme.css) all call that one emitter. UI: a family-card gallery + modal editor, plus an apply-family + per-part override flow on components. Tech Stack: React + TypeScript (Vite), Zustand + immer, Vitest + @testing-library, Playwright. CSS custom properties for propagation.
Source spec
specs/draft/token-model-merge.md (Doc 1 of 4). Read it before starting — this plan implements it.
Phasing & scope check
The feature spans 6 phases. Each phase ends with working, committed, tested software. Build in order; later phases depend on earlier ones.
| Phase | What it delivers | Depends on |
|---|---|---|
| A | Family data model + seed + store slices + persistence + hard reset | — |
| B | Shared family→CSS emitter wired into all 4 emission sites + deferred-var fallback | A |
| C | Semantic page: family-card gallery + family modal editor | A, B |
| D | Apply-family to components + per-part override (instance/variant) | A, B |
| E | Component-coverage audit + fix 3 hardcoded-colour violators | B |
| F | Docs: CLAUDE.md, AGENTS.md export, concept pages, changelog | A–E |
Phase D is the largest (new interactive surface on the components pages). If you need to ship incrementally, A→B→C→E→F is a coherent release on its own (families exist, edit, emit, audited) and D can follow. Keep D in this plan but treat its commit as an optional second PR.
Resolved-in-plan deviations from the spec
SlotNamegains'input'and'ring'. Spec §7'sSlotNamelists 5 slots, but theOWNStable makesappown--inputand--ring(which §9 #2 keeps distinct from--border). Five slots can't carry them. Resolution:SlotName = 'background' | 'foreground' | 'accent' | 'subtle' | 'border' | 'input' | 'ring'.input/ringslots appear only on theappfamily.infoslots referencecolor-zinc-500as a placeholder. The palette has no blue hue (spec §9 #8 — Doc 2 adds it).infoships structurally; its background token referencescolor-zinc-500with a per-token note flagging the Doc 2 gap, so nothing renders unstyled.- App chrome (
--bg-accentetc.) is intentionally no longer injected. After the reset the old semantic ids vanish; Tostada's own chrome falls back to the static:rootin src/index.css. That is acceptable (and incidentally fixes the original "stuck on blue" bug). Not in scope to rewire app chrome onto families.
File structure
New files
src/tokens/families.ts—Family/FamilySlot/FamilyKind/SlotNametypes,DEFAULT_FAMILIES, theOWNStable, and pure helpers (emittedVarsForSlot,familyCssLines).src/tokens/families.test.ts— unit tests for the helpers + seed invariants.src/store/familiesSlice.ts(optional split) — family + componentTheme actions. (This plan keeps them inline insrc/store/index.tsto match the existing single-store pattern; create the file only if the store grows unwieldy.)src/components/pages/properties/FamilyCard.tsx— one gallery card (preview block).src/components/pages/properties/FamilyModal.tsx— the family editor modal.src/components/pages/components/ApplyFamilyControl.tsx— the "Apply family" picker on a component.src/components/pages/components/PartInspector.tsx— per-part override popover.
Modified files
src/tokens/types.ts— keepDesignToken; add nothing there (families live infamilies.ts).TokenTierkeeps'component'only for the loneradiusbinding.src/tokens/defaults.ts— replace the semantic colour block + the entire component colour block with the seeded slotDesignTokens; keep primitives, typography, andradius.src/tokens/TokenContext.tsx— emit primitives +familyCssLines(...)instead of raw--{id}for colour.src/lib/componentCss.ts— emitfamilyCssLines(...)+ remaining component-tier (radius) + typography primitives + deferred-var fallback.src/export/tokensCss.ts/src/export/themeCss.ts— emit from families.src/export/agentsMd.ts— document the family contract + state-derivation rules.src/store/index.ts—families+componentThemesslices and actions; hard-reset path ininitLibrary/importLibrary.src/lib/useLocalSave.ts— persistfamilies+componentThemes.src/lib/librarySections.ts— replacetokens/componentnav item; Semantic page covers Surfaces/Components.src/components/pages/properties/PropertiesPage.tsx— remove thetokens/componentroute.src/components/pages/properties/TokensSemanticPage.tsx— family-card gallery.src/components/pages/properties/ReadOnlyTokenList.tsx— adapt for the family/slot shape (developer mode).src/components/pages/properties/TokensComponentPage.tsx— delete (+ its test).preview-host/src/demos/badge.tsx,checkbox.tsx,toggle.tsx— replace hardcodedblue-*with family vars.
Phase A — Data model, seed, store, persistence
Task A1: Family types
Files:
Create:
src/tokens/families.tsStep 1: Write the types + the
OWNStable. Put this at the top offamilies.ts:
import type { TokenMap } from './types'
export type FamilyKind = 'surface' | 'component'
export type SlotName =
| 'background' | 'foreground' | 'accent' | 'subtle' | 'border' | 'input' | 'ring'
export interface FamilySlot {
slot: SlotName
/** id of a DesignToken (tier 'semantic'); null = "no colour" (transparent). */
tokenId: string | null
/** Extra CSS var names this slot writes, beyond its own `--{family}-{slot}`. */
emits: string[]
/** Per-token lower-level usage note ("panel outline"). Seeded, user-editable. */
note: string
}
export interface Family {
id: string // 'block', 'primary', 'ghost', …
name: string
kind: FamilyKind
/** Family-level usage rule. Seeded default, user-editable. */
usage: string
slots: FamilySlot[]
}
/**
* Each global shadcn var is written by exactly one owning family (spec §7).
* Used to seed `emits` and to assert "one owner per var" in tests.
*/
export const OWNS: Record<string, { family: string; slot: SlotName }> = {
background: { family: 'app', slot: 'background' },
foreground: { family: 'app', slot: 'foreground' },
border: { family: 'app', slot: 'border' },
input: { family: 'app', slot: 'input' },
ring: { family: 'app', slot: 'ring' },
card: { family: 'block', slot: 'background' },
'card-foreground': { family: 'block', slot: 'foreground' },
popover: { family: 'block-highlight', slot: 'background' },
'popover-foreground': { family: 'block-highlight', slot: 'foreground' },
primary: { family: 'primary', slot: 'background' },
'primary-foreground': { family: 'primary', slot: 'foreground' },
secondary: { family: 'secondary', slot: 'background' },
'secondary-foreground':{ family: 'secondary', slot: 'foreground' },
destructive: { family: 'destructive', slot: 'background' },
'destructive-foreground': { family: 'destructive', slot: 'foreground' },
}
- Step 2: Commit (
feat(tokens): family types + OWNS table). No test yet — types only; the seed test in A3 exercises them.
Task A2: Slot → emitted vars helper
Files:
Modify:
src/tokens/families.tsCreate:
src/tokens/families.test.tsStep 1: Write the failing test. In
families.test.ts:
import { describe, it, expect } from 'vitest'
import { emittedVarsForSlot } from './families'
describe('emittedVarsForSlot', () => {
it('emits the scoped var plus any owned global vars', () => {
expect(emittedVarsForSlot('block', { slot: 'background', tokenId: 'x', emits: ['card'], note: '' }))
.toEqual(['card', 'block-background'])
})
it('lane-2 slot emits only its scoped var', () => {
expect(emittedVarsForSlot('ghost', { slot: 'foreground', tokenId: 'x', emits: [], note: '' }))
.toEqual(['ghost-foreground'])
})
it('null token still reports its var names (caller skips emission)', () => {
expect(emittedVarsForSlot('ghost', { slot: 'background', tokenId: null, emits: [], note: '' }))
.toEqual(['ghost-background'])
})
})
- Step 2: Run
npm run test -- families— verify it fails (emittedVarsForSlotis not exported). - Step 3: Implement. Add to
families.ts:
/** All CSS var names a slot writes: its owned global vars + `--{family}-{slot}`. */
export function emittedVarsForSlot(familyId: string, slot: FamilySlot): string[] {
return [...slot.emits, `${familyId}-${slot.slot}`]
}
- Step 4: Run
npm run test -- families— verify it passes. - Step 5: Commit (
feat(tokens): emittedVarsForSlot helper).
Task A3: Seed DEFAULT_FAMILIES + slot tokens
Files:
Modify:
src/tokens/families.tsModify:
src/tokens/families.test.tsModify:
src/tokens/defaults.tsStep 1: Write the failing invariant tests. Append to
families.test.ts:
import { DEFAULT_FAMILIES } from './families'
import { OWNS } from './families'
describe('DEFAULT_FAMILIES seed', () => {
it('has 7 surface + 5 component families', () => {
expect(DEFAULT_FAMILIES.filter(f => f.kind === 'surface')).toHaveLength(7)
expect(DEFAULT_FAMILIES.filter(f => f.kind === 'component')).toHaveLength(5)
})
it('every OWNS global var is emitted by exactly one slot', () => {
const owners: Record<string, number> = {}
for (const f of DEFAULT_FAMILIES)
for (const s of f.slots)
for (const v of s.emits) owners[v] = (owners[v] ?? 0) + 1
for (const v of Object.keys(OWNS)) expect(owners[v]).toBe(1)
})
it('every family carries a non-empty usage rule and per-token notes', () => {
for (const f of DEFAULT_FAMILIES) {
expect(f.usage.length).toBeGreaterThan(0)
for (const s of f.slots) expect(s.note.length).toBeGreaterThan(0)
}
})
})
- Step 2: Run — verify it fails (
DEFAULT_FAMILIESundefined). - Step 3a: Add the seed slot tokens to
defaults.ts. Replace the existingsemanticcolour block (bg-*,fg-*,border-*) and the entirecomponentcolour block (the--background…--radiusbindings) exceptradiuswith these semantic slot tokens. Keep primitives, typography, and theradiuscomponent binding. Each references an existing primitive:
// ─── Family slot tokens (semantic tier) ────────────────────────────────
// App (surface lvl1)
{ id: 'app-bg', name: 'App background', tier: 'semantic', value: '', references: 'color-white', darkReferences: 'color-zinc-950', group: 'Family / app' },
{ id: 'app-fg', name: 'App text', tier: 'semantic', value: '', references: 'color-zinc-900', darkReferences: 'color-zinc-50', group: 'Family / app' },
{ id: 'app-accent', name: 'App accent text',tier: 'semantic', value: '', references: 'color-orange-500', darkReferences: 'color-orange-400', group: 'Family / app' },
{ id: 'app-subtle', name: 'App subtle text',tier: 'semantic', value: '', references: 'color-zinc-500', darkReferences: 'color-zinc-400', group: 'Family / app' },
{ id: 'app-border', name: 'App border', tier: 'semantic', value: '', references: 'color-zinc-200', darkReferences: 'color-zinc-800', group: 'Family / app' },
{ id: 'app-input', name: 'App input border', tier: 'semantic', value: '', references: 'color-zinc-200', darkReferences: 'color-zinc-800', group: 'Family / app' },
{ id: 'app-ring', name: 'App focus ring', tier: 'semantic', value: '', references: 'color-orange-500', darkReferences: 'color-orange-400', group: 'Family / app' },
// Block (surface lvl2 → --card)
{ id: 'block-bg', name: 'Block background', tier: 'semantic', value: '', references: 'color-white', darkReferences: 'color-zinc-900', group: 'Family / block' },
{ id: 'block-fg', name: 'Block text', tier: 'semantic', value: '', references: 'color-zinc-900', darkReferences: 'color-zinc-50', group: 'Family / block' },
{ id: 'block-accent', name: 'Block accent text',tier: 'semantic', value: '', references: 'color-orange-500', darkReferences: 'color-orange-400', group: 'Family / block' },
{ id: 'block-subtle', name: 'Block subtle text',tier: 'semantic', value: '', references: 'color-zinc-500', darkReferences: 'color-zinc-400', group: 'Family / block' },
{ id: 'block-border', name: 'Block border', tier: 'semantic', value: '', references: 'color-zinc-200', darkReferences: 'color-zinc-800', group: 'Family / block' },
// Block highlight (surface lvl3 → --popover)
{ id: 'block-highlight-bg', name: 'Block hi background', tier: 'semantic', value: '', references: 'color-zinc-50', darkReferences: 'color-zinc-800', group: 'Family / block-highlight' },
{ id: 'block-highlight-fg', name: 'Block hi text', tier: 'semantic', value: '', references: 'color-zinc-900', darkReferences: 'color-zinc-50', group: 'Family / block-highlight' },
{ id: 'block-highlight-accent', name: 'Block hi accent', tier: 'semantic', value: '', references: 'color-orange-500', darkReferences: 'color-orange-400', group: 'Family / block-highlight' },
{ id: 'block-highlight-subtle', name: 'Block hi subtle', tier: 'semantic', value: '', references: 'color-zinc-500', darkReferences: 'color-zinc-400', group: 'Family / block-highlight' },
{ id: 'block-highlight-border', name: 'Block hi border', tier: 'semantic', value: '', references: 'color-zinc-200', darkReferences: 'color-zinc-700', group: 'Family / block-highlight' },
// Feedback surfaces (lane-2 only)
{ id: 'success-bg', name: 'Success bg', tier: 'semantic', value: '', references: 'color-green-500', group: 'Family / success' },
{ id: 'success-fg', name: 'Success text', tier: 'semantic', value: '', references: 'color-white', group: 'Family / success' },
{ id: 'success-border', name: 'Success border', tier: 'semantic', value: '', references: 'color-green-500', group: 'Family / success' },
{ id: 'warning-bg', name: 'Warning bg', tier: 'semantic', value: '', references: 'color-amber-500', group: 'Family / warning' },
{ id: 'warning-fg', name: 'Warning text', tier: 'semantic', value: '', references: 'color-zinc-900', group: 'Family / warning' },
{ id: 'warning-border', name: 'Warning border', tier: 'semantic', value: '', references: 'color-amber-500', group: 'Family / warning' },
{ id: 'error-bg', name: 'Error bg', tier: 'semantic', value: '', references: 'color-red-500', group: 'Family / error' },
{ id: 'error-fg', name: 'Error text', tier: 'semantic', value: '', references: 'color-white', group: 'Family / error' },
{ id: 'error-border', name: 'Error border', tier: 'semantic', value: '', references: 'color-red-600', group: 'Family / error' },
// info: palette has no blue hue yet (spec §9 #8 → Doc 2). Placeholder ref.
{ id: 'info-bg', name: 'Info bg', tier: 'semantic', value: '', references: 'color-zinc-500', group: 'Family / info', description: 'TODO Doc 2: needs blue/info hue' },
{ id: 'info-fg', name: 'Info text', tier: 'semantic', value: '', references: 'color-white', group: 'Family / info' },
{ id: 'info-border', name: 'Info border', tier: 'semantic', value: '', references: 'color-zinc-500', group: 'Family / info' },
// Component families
{ id: 'primary-bg', name: 'Primary bg', tier: 'semantic', value: '', references: 'color-orange-500', darkReferences: 'color-orange-400', group: 'Family / primary' },
{ id: 'primary-fg', name: 'Primary text', tier: 'semantic', value: '', references: 'color-zinc-950', darkReferences: 'color-zinc-950', group: 'Family / primary' },
{ id: 'primary-border', name: 'Primary border', tier: 'semantic', value: '', references: 'color-orange-500', darkReferences: 'color-orange-400', group: 'Family / primary' },
{ id: 'secondary-bg', name: 'Secondary bg', tier: 'semantic', value: '', references: 'color-zinc-100', darkReferences: 'color-zinc-800', group: 'Family / secondary' },
{ id: 'secondary-fg', name: 'Secondary text', tier: 'semantic', value: '', references: 'color-zinc-900', darkReferences: 'color-zinc-50', group: 'Family / secondary' },
{ id: 'secondary-border', name: 'Secondary border', tier: 'semantic', value: '', references: 'color-zinc-200', darkReferences: 'color-zinc-700', group: 'Family / secondary' },
{ id: 'tertiary-bg', name: 'Tertiary bg', tier: 'semantic', value: '', references: 'color-zinc-50', darkReferences: 'color-zinc-900', group: 'Family / tertiary' },
{ id: 'tertiary-fg', name: 'Tertiary text', tier: 'semantic', value: '', references: 'color-zinc-700', darkReferences: 'color-zinc-200', group: 'Family / tertiary' },
{ id: 'tertiary-border', name: 'Tertiary border', tier: 'semantic', value: '', references: 'color-zinc-200', darkReferences: 'color-zinc-800', group: 'Family / tertiary' },
// ghost: no bg / no border (null slots). Only a foreground.
{ id: 'ghost-fg', name: 'Ghost text', tier: 'semantic', value: '', references: 'color-zinc-900', darkReferences: 'color-zinc-50', group: 'Family / ghost' },
{ id: 'destructive-bg', name: 'Destructive bg', tier: 'semantic', value: '', references: 'color-red-500', darkReferences: 'color-red-600', group: 'Family / destructive' },
{ id: 'destructive-fg', name: 'Destructive text', tier: 'semantic', value: '', references: 'color-white', group: 'Family / destructive' },
{ id: 'destructive-border', name: 'Destructive border', tier: 'semantic', value: '', references: 'color-red-500', darkReferences: 'color-red-600', group: 'Family / destructive' },
- Step 3b: Add
DEFAULT_FAMILIEStofamilies.ts. A helper keeps it terse:
const s = (slot: SlotName, tokenId: string | null, note: string, emits: string[] = []): FamilySlot =>
({ slot, tokenId, note, emits })
export const DEFAULT_FAMILIES: Family[] = [
// ── Surfaces ──
{ id: 'app', name: 'App', kind: 'surface',
usage: 'The base canvas the whole UI sits on. One per app.',
slots: [
s('background', 'app-bg', 'page fill', ['background']),
s('foreground', 'app-fg', 'default text', ['foreground']),
s('accent', 'app-accent', 'colourful on-text'),
s('subtle', 'app-subtle', 'muted/grey text'),
s('border', 'app-border', 'hairlines & dividers',['border']),
s('input', 'app-input', 'input outlines', ['input']),
s('ring', 'app-ring', 'focus ring', ['ring']),
] },
{ id: 'block', name: 'Block', kind: 'surface',
usage: 'Cards, panels, and grouped content sitting on the app.',
slots: [
s('background', 'block-bg', 'panel fill', ['card']),
s('foreground', 'block-fg', 'text on panel', ['card-foreground']),
s('accent', 'block-accent', 'colourful text'),
s('subtle', 'block-subtle', 'muted text'),
s('border', 'block-border', 'panel outline'),
] },
{ id: 'block-highlight', name: 'Block highlight', kind: 'surface',
usage: 'Raised surfaces: popovers, menus, dialogs.',
slots: [
s('background', 'block-highlight-bg', 'raised fill', ['popover']),
s('foreground', 'block-highlight-fg', 'text on raised', ['popover-foreground']),
s('accent', 'block-highlight-accent', 'colourful text'),
s('subtle', 'block-highlight-subtle', 'muted text'),
s('border', 'block-highlight-border', 'raised outline'),
] },
...(['success','warning','error','info'] as const).map((id): Family => ({
id, name: id[0].toUpperCase() + id.slice(1), kind: 'surface',
usage: `Feedback surface for ${id} states.`,
slots: [
s('background', `${id}-bg`, 'status fill'),
s('foreground', `${id}-fg`, 'text on status'),
s('border', `${id}-border`, 'status outline'),
],
})),
// ── Components ──
{ id: 'primary', name: 'Primary', kind: 'component',
usage: 'The main call-to-action style. One emphasis per view.',
slots: [
s('background', 'primary-bg', 'button fill', ['primary']),
s('foreground', 'primary-fg', 'button label', ['primary-foreground']),
s('border', 'primary-border', 'button edge'),
] },
{ id: 'secondary', name: 'Secondary', kind: 'component',
usage: 'Lower-emphasis tonal buttons next to a primary.',
slots: [
s('background', 'secondary-bg', 'button fill', ['secondary']),
s('foreground', 'secondary-fg', 'button label', ['secondary-foreground']),
s('border', 'secondary-border', 'button edge'),
] },
{ id: 'tertiary', name: 'Tertiary', kind: 'component',
usage: 'Quiet, low-emphasis actions. No native shadcn var — applied at component level.',
slots: [
s('background', 'tertiary-bg', 'button fill'),
s('foreground', 'tertiary-fg', 'button label'),
s('border', 'tertiary-border', 'button edge'),
] },
{ id: 'ghost', name: 'Ghost', kind: 'component',
usage: 'Text-only actions: no fill, no border until hovered.',
slots: [
s('background', null, 'no fill'),
s('foreground', 'ghost-fg', 'label only'),
s('border', null, 'no border'),
] },
{ id: 'destructive', name: 'Destructive', kind: 'component',
usage: 'Dangerous actions: delete, remove, irreversible.',
slots: [
s('background', 'destructive-bg', 'button fill', ['destructive']),
s('foreground', 'destructive-fg', 'button label', ['destructive-foreground']),
s('border', 'destructive-border', 'button edge'),
] },
]
- Step 4: Run
npm run test -- families— verify the seed invariants pass. Fix any miscount (7 surfaces = app, block, block-highlight, success, warning, error, info; 5 components). - Step 5: Commit (
feat(tokens): seed DEFAULT_FAMILIES + slot tokens).
Task A4: Store slices — families + componentThemes
Files:
Modify:
src/store/index.tsStep 1: Write the failing test. Create
src/store/families.store.test.ts:
import { describe, it, expect, beforeEach } from 'vitest'
import { useStore } from './index'
import { DEFAULT_FAMILIES } from '@/tokens/families'
beforeEach(() => useStore.setState({ families: structuredClone(DEFAULT_FAMILIES), componentThemes: [] }))
describe('family store', () => {
it('seeds families', () => {
expect(useStore.getState().families.length).toBe(12)
})
it('editUsage updates a family rule', () => {
useStore.getState().editUsage('block', 'My rule')
expect(useStore.getState().families.find(f => f.id === 'block')!.usage).toBe('My rule')
})
it('editNote updates a slot note', () => {
useStore.getState().editNote('block', 'border', 'thin line')
const f = useStore.getState().families.find(f => f.id === 'block')!
expect(f.slots.find(s => s.slot === 'border')!.note).toBe('thin line')
})
it('applyFamily records a component theme', () => {
useStore.getState().applyFamily('button', 'secondary')
expect(useStore.getState().componentThemes).toContainEqual(
expect.objectContaining({ componentId: 'button', familyId: 'secondary' }))
})
})
- Step 2: Run — verify it fails (
familiesnot in state). - Step 3: Implement the slices. In
src/store/index.ts:- Add imports:
import { DEFAULT_FAMILIES, type Family, type SlotName } from '../tokens/families'. - Add a
ComponentThemetype (mirror spec §7) near the top. - Extend
TostadaStatewith:
- Add imports:
families: Family[]
componentThemes: ComponentTheme[]
editUsage: (familyId: string, usage: string) => void
editNote: (familyId: string, slot: SlotName, note: string) => void
addFamily: (kind: Family['kind'], name: string) => string
addSlot: (familyId: string, slot: SlotName, tokenId: string | null) => void
deleteFamily:(familyId: string, fallbackFamilyId: string | null) => void
applyFamily: (componentId: string, familyId: string) => void
overridePart:(componentId: string, scope: 'instance' | 'variant', slot: SlotName, tokenId: string | null, opts?: { variant?: string; instanceId?: string }) => void
revertPart: (componentId: string, scope: 'instance' | 'variant', slot: SlotName, opts?: { variant?: string; instanceId?: string }) => void
- Initialise
families: structuredClone(DEFAULT_FAMILIES),componentThemes: []. - Implement actions with immer (each
set((s) => …)).addFamilyreturns a new idname-slugged + uniquified;deleteFamilyre-points affectedcomponentThemestofallbackFamilyIdbefore removing (throws/no-ops if applied and fallback is null — UI enforces the modal).applyFamilyupserts aComponentTheme.overridePart/revertPartmutateComponentTheme.overridesat the given scope. - Step 4: Run — verify it passes.
- Step 5: Commit (
feat(store): families + componentThemes slices).
Task A5: Persist the new slices + hard reset
Files:
Modify:
src/lib/useLocalSave.tsModify:
src/store/index.ts(initLibrary+importLibrary)Step 1: Write the failing test. Create
src/store/hardReset.test.ts:
import { describe, it, expect } from 'vitest'
import { useStore, LIBRARY_STORAGE_KEY } from './index'
describe('hard reset of legacy colour tiers', () => {
it('drops legacy component-tier colour tokens and re-seeds families', () => {
localStorage.setItem(LIBRARY_STORAGE_KEY, JSON.stringify({
tokens: { primary: { id: 'primary', name: 'old', tier: 'component', value: '#00f' } },
}))
useStore.getState().initLibrary()
// legacy component colour token is gone…
expect(useStore.getState().tokens['primary']).toBeUndefined()
// …and families are seeded
expect(useStore.getState().families.length).toBe(12)
})
})
- Step 2: Run — verify it fails.
- Step 3: Implement.
- In
useLocalSave.ts: readfamilies+componentThemesfrom the store and include them in the persisted JSON + the effect dep array. - In
initLibrary/importLibrary: whenparsed.tokenscontains any token withtier === 'component'whose id is notradius, treat the library as legacy → hard reset: seeds.tokens = tokenMapFromArray(DEFAULT_TOKENS),s.families = structuredClone(DEFAULT_FAMILIES),s.componentThemes = [], and ignoreparsed.tokens/parsed.families. Otherwise, loadparsed.families ?? structuredClone(DEFAULT_FAMILIES)andparsed.componentThemes ?? []. (The export-first warning is a UI concern — Task C6.)
- In
- Step 4: Run — verify it passes.
- Step 5: Commit (
feat(store): persist families; hard-reset legacy colour tiers).
Phase B — Emission
Task B1: familyCssLines — the shared emitter
Files:
Modify:
src/tokens/families.tsModify:
src/tokens/families.test.tsStep 1: Write the failing test.
import { familyCssLines } from './families'
import { DEFAULT_TOKENS } from './defaults'
const tokens = Object.fromEntries(DEFAULT_TOKENS.map(t => [t.id, t]))
describe('familyCssLines', () => {
const out = familyCssLines(DEFAULT_FAMILIES, tokens)
it('emits the owned global var for an owning slot', () => {
expect(out.light).toContain(' --card: #ffffff;')
expect(out.light).toContain(' --primary: #ff8a3d;')
})
it('emits the family-scoped var too', () => {
expect(out.light.some(l => l.startsWith(' --block-border:'))).toBe(true)
})
it('skips null slots (ghost has no background)', () => {
expect(out.light.some(l => l.startsWith(' --ghost-background:'))).toBe(false)
expect(out.light.some(l => l.startsWith(' --ghost-foreground:'))).toBe(true)
})
it('emits a dark line where a slot has a dark reference', () => {
expect(out.dark.some(l => l.startsWith(' --card:'))).toBe(true)
})
})
- Step 2: Run — verify it fails.
- Step 3: Implement. Add to
families.ts(reuse the resolve pattern already incomponentCss.ts— copy the tworesolveLight/resolveDarkfunctions in, or import shared ones if you extract them; keeping them local here keepsfamilies.tsdependency-free):
function resolveLight(tokens: TokenMap, id: string, seen = new Set<string>()): string {
if (seen.has(id)) return tokens[id]?.value ?? ''
seen.add(id)
const t = tokens[id]; if (!t) return ''
if (typeof t.references === 'string' && tokens[t.references]) return resolveLight(tokens, t.references, seen)
return t.value
}
function resolveDark(tokens: TokenMap, id: string, seen = new Set<string>()): string {
if (seen.has(id)) return ''
seen.add(id)
const t = tokens[id]; if (!t) return ''
if (t.darkReferences && tokens[t.darkReferences]) return resolveDark(tokens, t.darkReferences, seen)
if (t.darkValue) return t.darkValue
if (typeof t.references === 'string' && tokens[t.references]) return resolveDark(tokens, t.references, seen)
return t.value
}
export function familyCssLines(families: Family[], tokens: TokenMap): { light: string[]; dark: string[] } {
const light: string[] = []
const dark: string[] = []
for (const f of families) {
for (const slot of f.slots) {
if (!slot.tokenId) continue // "no colour"
const lv = resolveLight(tokens, slot.tokenId)
const dv = resolveDark(tokens, slot.tokenId)
for (const v of emittedVarsForSlot(f.id, slot)) {
if (lv) light.push(` --${v}: ${lv};`)
if (dv && dv !== lv) dark.push(` --${v}: ${dv};`)
}
}
}
return { light, dark }
}
- Step 4: Run — verify it passes.
- Step 5: Commit (
feat(tokens): familyCssLines emitter).
Task B2: Deferred-var fallback
Files:
Modify:
src/tokens/families.tsModify:
src/tokens/families.test.tsStep 1: Write the failing test.
import { deferredVarFallback } from './families'
describe('deferredVarFallback', () => {
it('aliases unowned shadcn vars so previews never go unstyled', () => {
const lines = deferredVarFallback()
expect(lines).toContain(' --accent: var(--block-highlight-background);')
expect(lines).toContain(' --accent-foreground: var(--block-highlight-foreground);')
expect(lines).toContain(' --muted: var(--block-background);')
expect(lines.some(l => l.startsWith(' --chart-1:'))).toBe(true)
})
})
- Step 2: Run — verify it fails.
- Step 3: Implement (spec §8 "deferred var" row):
/** Temporary aliases for shadcn vars no family owns yet (spec §8). */
export function deferredVarFallback(): string[] {
return [
' --accent: var(--block-highlight-background);',
' --accent-foreground: var(--block-highlight-foreground);',
' --muted: var(--block-background);',
' --muted-foreground: var(--block-subtle);',
' --chart-1: var(--color-chart-1);',
' --chart-2: var(--color-chart-2);',
' --chart-3: var(--color-chart-3);',
' --chart-4: var(--color-chart-4);',
' --chart-5: var(--color-chart-5);',
]
}
- Step 4: Run — verify it passes.
- Step 5: Commit (
feat(tokens): deferred-var fallback aliases).
Task B3: Wire emitter into the app injector
Files:
Modify:
src/tokens/TokenContext.tsxStep 1: Write the failing test. Create
src/tokens/TokenContext.families.test.tsthat rendersuseTokenInjection(or extract the string-builder into a purebuildInjectedCss(tokens, families)and test that directly — recommended: extract the builder so it's unit-testable without the DOM). Test asserts the injected:rootcontains--card,--primary,--block-border, and the deferred--accentalias.Step 2: Run — verify it fails.
Step 3: Implement. Extract the CSS-string construction in
useTokenInjectionintoexport function buildInjectedCss(tokens: TokenMap, families: Family[]): string. It emits: (a) primitives + typography as today (--{id}), (b)familyCssLines(families, tokens), (c)deferredVarFallback()in:root. Stop emitting the old raw semanticbg-*/fg-*ids (they no longer exist).useTokenInjectionreadsfamiliesfrom the store and calls the builder.Step 4: Run — verify it passes.
Step 5: Commit (
feat(tokens): inject family vars into app :root).
Task B4: Wire emitter into the preview iframe CSS
Files:
Modify:
src/lib/componentCss.tsModify:
src/lib/useTokenBroadcast.tsStep 1: Write the failing test. In
src/lib/componentCss.test.ts(create if absent):buildComponentCss(tokens, families)output contains--card,--primary,--ghost-foreground, the--accentfallback, and still--radius+ the typography primitives.Step 2: Run — verify it fails.
Step 3: Implement. Change
buildComponentCss(tokens)→buildComponentCss(tokens, families). Body =familyCssLines(families, tokens)(light/dark) + remaining component-tier tokens (now justradius) + typography primitives +deferredVarFallback()in:root. UpdateuseTokenBroadcastto readfamiliesfrom the store and pass them in.Step 4: Run — verify it passes.
Step 5: Commit (
feat(preview): broadcast family vars to iframe).
Task B5: Wire emitter into the export bundle
Files:
Modify:
src/export/tokensCss.ts,src/export/themeCss.tsModify: their
.test.tsfilesStep 1: Write the failing test. Update
tokensCss.test.tsto passfamiliesand assert--card/--primary/--block-borderappear;themeCss.test.tsasserts the@themeblock maps family-scoped colour vars (--color-block-bgetc.) and still--radius.Step 2: Run — verify it fails.
Step 3: Implement.
generateTokensCss(libraryName, tokens, families)emits primitives +familyCssLines+ deferred fallback +radius+ typography (reuse the helper; drop the old per-tier semantic/component loop for colour).generateThemeCss(tokens, families)maps each family slot's scoped var to a--color-*utility key (replace thebg-*/fg-*/border-*prefix logic). Update all callers (bundle.ts,index.ts, the CLI builder) to threadfamilies.Step 4: Run
npm run test -- export— verify it passes.Step 5: Commit (
feat(export): emit families in tokens.css + theme.css).
Phase C — Semantic page (gallery + modal)
Task C1: Delete the component-mapping page + route
Files:
Delete:
src/components/pages/properties/TokensComponentPage.tsx(+.test.tsxif present)Modify:
src/components/pages/properties/PropertiesPage.tsx,src/lib/librarySections.tsStep 1: Remove the
TokensComponentPageimport +<Route path="tokens/component" …>fromPropertiesPage.tsx.Step 2: Remove the
{ path: 'tokens/component', label: 'Component mapping' }nav item fromlibrarySections.ts; rename thetokens/semanticlabel toFamilies(orSurfaces & Components).Step 3: Delete the file. Run
npm run test+tsc -b(vianpm run builddry ornpx tsc -b) to catch dangling references.Step 4: Commit (
refactor(properties): remove component-mapping page).
Task C2: Family card
Files:
Create:
src/components/pages/properties/FamilyCard.tsx,FamilyCard.test.tsxStep 1: Write the failing test. Render
<FamilyCard family={block} onOpen={fn} />; assert it shows the family name, a preview region styled withvar(--block-bg)/var(--block-fg)/var(--block-border), and clicking callsonOpen.Step 2: Run — verify it fails.
Step 3: Implement. A button-card: a preview panel using inline styles
{ background: 'var(--<id>-background … )' }— resolve each slot's var viaemittedVarsForSlot(family.id, slot)[last](the--{family}-{slot}scoped var, always present). Show a sample "Aa" line in the foreground colour and a bordered box.kind === 'component'renders a sample button instead of a panel.Step 4: Run — verify it passes.
Step 5: Commit (
feat(properties): FamilyCard preview).
Task C3: Gallery on the Semantic page
Files:
Modify:
src/components/pages/properties/TokensSemanticPage.tsxStep 1: Write the failing test. Render the page (Designer mode) with the seeded store; assert two group headers (
Surfaces,Components), 7 surface cards + 5 component cards, and a+ New familybutton per group.Step 2: Run — verify it fails.
Step 3: Implement. Replace the current semantic-token table with: read
familiesfrom the store, group bykind, render<FamilyCard>per family + a+ New familybutton callingaddFamily(kind, 'New family'). TrackopenFamilyIdstate; render<FamilyModal>when set. Keep the Developer-mode branch renderingReadOnlyTokenList(adapted in C5).Step 4: Run — verify it passes.
Step 5: Commit (
feat(properties): family-card gallery).
Task C4: Family modal editor
Files:
Create:
src/components/pages/properties/FamilyModal.tsx,FamilyModal.test.tsxStep 1: Write the failing test. Render
<FamilyModal familyId="block" onClose={fn} />; assert: the familyusagetext is shown in an editable field; each slot row shows its note + a light/dark primitive<select>; editing the usage callseditUsage; changing a slot's primitive callsupdateToken(slot.tokenId, { references }); setting a slot to "no colour" callsupdateToken(slot.tokenId, …)/marks null;+ Add tokenand+ Add slotaffordances exist.Step 2: Run — verify it fails.
Step 3: Implement. Modal reads the family + its slot tokens + the primitive list from the store. Live preview panel at top (same style approach as
FamilyCard). Usage rule = an inline-editable textarea →editUsage. Each slot row: swatch + per-token note (inline-editable →editNote) + two<select>s (lightreferences, darkdarkReferences) over primitives →updateToken. A "no colour" option sets the slot'stokenIdto null (storeaddSlot/asetSlotTokenaction — add one to the slice if missing). Reuse the existingTokenSelect/EditableCell/Swatchprimitives fromTokenRow.tsx.Step 4: Run — verify it passes.
Step 5: Commit (
feat(properties): family modal editor).
Task C5: Adapt ReadOnlyTokenList for families (Developer mode)
Files:
Modify:
src/components/pages/properties/ReadOnlyTokenList.tsx(+ test)Step 1: Write the failing test. Given the seeded families, the developer view lists each family with its emitted vars (
var(--card),var(--block-border), …) and a copy chip per var.Step 2: Run — verify it fails.
Step 3: Implement. Add a
families-shaped rendering path (group = family, rows =emittedVarsForSlotper non-null slot, each with avar(--…)copy chip + resolved light/dark). Keep the existing flat-token path for the other tiers.Step 4: Run — verify it passes.
Step 5: Commit (
feat(properties): developer-mode family contract list).
Task C6: Export-first warning before hard reset
Files:
Modify: the AppShell / library bootstrap that calls
initLibrary(find withgrep -rn initLibrary src)Step 1: Write the failing test. When legacy colour tokens are detected, the bootstrap shows a one-time modal with an "Export to JSON" CTA and a "Continue (reset)" button before wiping; "Continue" calls the reset path.
Step 2: Run — verify it fails.
Step 3: Implement. Split detection from reset:
initLibraryreturns/sets aneedsHardResetflag when legacy colour tokens are present (instead of silently wiping). The bootstrap renders the warning modal; its Export button reuses the existing export bundle; its Continue button runs the actual reset. (If you'd rather keepinitLibrarysilent per Task A5, gate the modal on alegacyDetectedselector instead.)Step 4: Run — verify it passes.
Step 5: Commit (
feat(library): export-first warning before reset).
Phase D — Apply family to a component + per-part override
Task D1: ApplyFamilyControl
Files:
Create:
src/components/pages/components/ApplyFamilyControl.tsx(+ test)Modify:
src/components/pages/components/ComponentDetailPage.tsxStep 1: Write the failing test. Render
<ApplyFamilyControl componentId="button" />; assert it lists families, recommends by fit (Component families first for an interactive element), and selecting one callsapplyFamily('button', familyId).Step 2: Run — verify it fails.
Step 3: Implement. A dropdown reading
families; order Component families first, Surfaces under a "Less common" divider (the recommend-by-fit behaviour, spec §6 B). On select →applyFamily. Mount it inComponentDetailPagenear the preview.Step 4: Run — verify it passes.
Step 5: Commit (
feat(components): apply-family control).
Task D2: Component reads its applied family
Files:
Modify:
src/lib/componentCss.ts(or the snippet/style path that renders a component)Step 1: Write the failing test. Given a
ComponentTheme { componentId:'button', familyId:'secondary' }, the CSS/class the component renders with references thesecondaryfamily's vars (e.g. a.tostada-cmp-button { background: var(--secondary); color: var(--secondary-foreground); }rule, or the equivalent applied-style string).Step 2: Run — verify it fails.
Step 3: Implement. Add
componentThemeCss(themes, families)tocomponentCss.ts: for eachComponentTheme, emit a scoped rule binding the component to its family's vars (background/foreground/border slots). For lane-2 families (ghost/tertiary) this is the only way they take effect. Append tobuildComponentCss. Apply overrides on top (D3).Step 4: Run — verify it passes.
Step 5: Commit (
feat(preview): render components with their applied family).
Task D3: Per-part override + revert
Files:
Create:
src/components/pages/components/PartInspector.tsx(+ test)Modify:
src/components/pages/components/ComponentDetailPage.tsx,src/lib/componentCss.tsStep 1: Write the failing test (store-level first).
overridePart('button','variant','border', tokenId)adds a variant-scoped override;componentThemeCssthen emits the overridden value for that part;revertPart(...)removes it and the family default returns. Then a component test: clicking a part opensPartInspectorshowing the applied value + an override control + a revert control; choosing scopevariantcallsoverridePart(..., 'variant', …).Step 2: Run — verify it fails.
Step 3: Implement.
PartInspectorpopover: shows the current applied family value for the clicked slot, a primitive picker to override, a scope toggle (this instance / all of this variant), and Revert (shown when an override exists). Wire clicks on preview parts (fill/text/border) to open it for that slot.componentThemeCsslayers overrides after the base family rule (instance scope via an instance selector/attribute; variant scope via the variant class).Step 4: Run — verify it passes.
Step 5: Commit (
feat(components): per-part override + revert).
Task D4: Delete-family fallback modal
Files:
Create: a small confirm modal (co-locate in
FamilyModal.tsxor a newDeleteFamilyModal.tsx)Modify:
TokensSemanticPage.tsxStep 1: Write the failing test. Deleting a family that's applied to ≥1 component opens a modal listing the affected components and requiring a fallback selection; confirming calls
deleteFamily(id, fallbackId); the affectedcomponentThemesre-point.Step 2: Run — verify it fails.
Step 3: Implement. A delete affordance on
FamilyCard/modal → ifcomponentThemes.some(t => t.familyId === id), open the fallback modal (list affectedcomponentIds, a family<select>for the fallback); confirm →deleteFamily(id, fallbackId). If unused, delete directly.Step 4: Run — verify it passes.
Step 5: Commit (
feat(properties): delete-family fallback modal).
Phase E — Audit + fix violators
Task E1: Coverage audit test
Files:
Create:
src/tokens/coverageAudit.ts,coverageAudit.test.tsStep 1: Write the failing test.
import { findHardcodedColours } from './coverageAudit'
describe('findHardcodedColours', () => {
it('flags a Tailwind colour-scale utility', () => {
expect(findHardcodedColours('<div className="bg-blue-500" />')).toContain('bg-blue-500')
})
it('passes all-token snippets', () => {
expect(findHardcodedColours('<div className="bg-primary text-primary-foreground" />')).toEqual([])
})
})
- Step 2: Run — verify it fails.
- Step 3: Implement. A regex over snippet text matching
(bg|text|border|fill|stroke|ring)-(blue|red|green|amber|yellow|zinc|gray|slate|orange|violet|purple|pink|sky|cyan|emerald)-\d{2,3}→ return the matches. (Same pattern proven in the spec's §8 scan.) - Step 4: Run — verify it passes.
- Step 5: Commit (
feat(tokens): hardcoded-colour audit).
Task E2: Manifest-wide audit test (the CI gate)
Files:
Create:
src/tokens/coverageAudit.manifest.test.tsStep 1: Write the test. Import the preview-host manifest JSON snippets, run
findHardcodedColoursover each, and assert zero violations. This test will FAIL until E3 — that's the point (it's the regression gate).Step 2: Run — confirm it fails listing
badge/checkbox/toggle.Step 3: No implementation here — fixing is E3. Leave the test red and proceed.
Step 4: Commit (
test(tokens): manifest colour-coverage gate (currently red)).
Task E3: Fix the 3 violators
Files:
Modify:
preview-host/src/demos/badge.tsx,checkbox.tsx,toggle.tsxRegenerate:
preview-host/src/components/_manifest.json(whatever script builds it — checkpackage.jsonsync:*)Step 1: Replace
bg-blue-500 dark:bg-blue-600(badge) with a family var, e.g.bg-primary text-primary-foreground. For checkbox's checked state usedata-[state=checked]:bg-primary …. For toggle'sfill/stroke-blue-500usefill-primary stroke-primary(or the appropriate family-scoped var).Step 2: Regenerate the manifest so its cached snippets match.
Step 3: Run
npm run test -- coverageAudit.manifest— verify it now passes (green gate).Step 4: Run the full
npm run testto confirm nothing else regressed.Step 5: Commit (
fix(preview): replace hardcoded blue with family vars).
Phase F — Docs
Task F1: AGENTS.md export — family contract + state rules
Files:
Modify:
src/export/agentsMd.ts(+ test)Step 1: Write the failing test. The generated
AGENTS.mdcontains a "Colour families" section listing each family + its emitted vars, and a "States are derived" section with the hover/focus/active/disabled rules from spec §7.Step 2–4: Implement, run, verify.
Step 5: Commit (
docs(export): family contract + state rules in AGENTS.md).
Task F2: CLAUDE.md + concept pages + changelog
Files:
Modify:
CLAUDE.md,docs/concepts/*(tokens page + new "applying families" page),docs/changelog.md,REBUILD_PLAN.md,TEST_PLAN.mdStep 1: Rewrite the CLAUDE.md "Token tiers" section: two colour layers gone → families (surfaces + components), two-lane emission + the
OWNStable, "states are derived,"radiusstill component-tier until Doc 3.Step 2: Replace the docs tokens concept page; add the "applying families to components" page (apply / override scope / revert).
Step 3: Add a changelog entry (breaking: colour model → families). Tick the REBUILD_PLAN milestone. Promote §10 specs into TEST_PLAN.md.
Step 4: Commit (
docs: families model across CLAUDE.md + concept pages + changelog).
Final verification (before calling it done)
-
npm run test— all green (unit + component). -
npx tsc -b— no type errors (theTokenTier/componentnarrowing + new types compile). -
npm run e2e— run the Phase-mapped Playwright specs (spec §10): gallery shows Surfaces/Components; editing Primary/Block/Block-highlight changes the right preview; apply + variant-override + revert; delete-family fallback; fresh-load renders no hardcoded blue. -
npm run build— the CLI export bundle builds (thefamiliesthread reachedbundle.ts+ the esbuild CLI). - Manual:
npm run dev+npm run dev:preview-host, dark-mode pass across the component gallery; confirm exportedtheme.csscompiles in a vanilla shadcn project. - Use the
verification-before-completionskill to confirm each claim above with real command output before marking the feature complete.
Self-review notes
- Spec coverage: A (model/seed/store/persist/reset = §7 data model, §13) · B (two-lane emission + deferred fallback = §7, §8) · C (gallery + modal + dev contract + export-first = §6 A/C, §13) · D (apply + override + delete fallback = §6 B, §8) · E (audit + violators = §5, §8) · F (docs = §11). Every §5 scope checkbox maps to a task.
- Open dependency on Doc 2:
infohas no blue hue (§9 #8) — seeded with a placeholder ref + a flagged note; not a blocker. - Type consistency:
familyCssLines(families, tokens),buildComponentCss(tokens, families),generateTokensCss(name, tokens, families),generateThemeCss(tokens, families)— every emitter takesfamiliesexplicitly; thread the arg through all callers (Tasks B3–B5) ortscwill catch the misses. - Deviations flagged up top:
SlotName+'input'/'ring';infoplaceholder; app-chrome no longer injected.