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 a DesignToken (tier semantic) — 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

  1. SlotName gains 'input' and 'ring'. Spec §7's SlotName lists 5 slots, but the OWNS table makes app own --input and --ring (which §9 #2 keeps distinct from --border). Five slots can't carry them. Resolution: SlotName = 'background' | 'foreground' | 'accent' | 'subtle' | 'border' | 'input' | 'ring'. input/ring slots appear only on the app family.
  2. info slots reference color-zinc-500 as a placeholder. The palette has no blue hue (spec §9 #8 — Doc 2 adds it). info ships structurally; its background token references color-zinc-500 with a per-token note flagging the Doc 2 gap, so nothing renders unstyled.
  3. App chrome (--bg-accent etc.) is intentionally no longer injected. After the reset the old semantic ids vanish; Tostada's own chrome falls back to the static :root in 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.tsFamily / FamilySlot / FamilyKind / SlotName types, DEFAULT_FAMILIES, the OWNS table, 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 in src/store/index.ts to 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 — keep DesignToken; add nothing there (families live in families.ts). TokenTier keeps 'component' only for the lone radius binding.
  • src/tokens/defaults.ts — replace the semantic colour block + the entire component colour block with the seeded slot DesignTokens; keep primitives, typography, and radius.
  • src/tokens/TokenContext.tsx — emit primitives + familyCssLines(...) instead of raw --{id} for colour.
  • src/lib/componentCss.ts — emit familyCssLines(...) + 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.tsfamilies + componentThemes slices and actions; hard-reset path in initLibrary / importLibrary.
  • src/lib/useLocalSave.ts — persist families + componentThemes.
  • src/lib/librarySections.ts — replace tokens/component nav item; Semantic page covers Surfaces/Components.
  • src/components/pages/properties/PropertiesPage.tsx — remove the tokens/component route.
  • 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.tsxdelete (+ its test).
  • preview-host/src/demos/badge.tsx, checkbox.tsx, toggle.tsx — replace hardcoded blue-* with family vars.

Phase A — Data model, seed, store, persistence

Task A1: Family types

Files:

  • Create: src/tokens/families.ts

  • Step 1: Write the types + the OWNS table. Put this at the top of families.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.ts

  • Create: src/tokens/families.test.ts

  • Step 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 (emittedVarsForSlot is 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.ts

  • Modify: src/tokens/families.test.ts

  • Modify: src/tokens/defaults.ts

  • Step 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_FAMILIES undefined).
  • Step 3a: Add the seed slot tokens to defaults.ts. Replace the existing semantic colour block (bg-*, fg-*, border-*) and the entire component colour block (the --background--radius bindings) except radius with these semantic slot tokens. Keep primitives, typography, and the radius component 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_FAMILIES to families.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.ts

  • Step 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 (families not 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 ComponentTheme type (mirror spec §7) near the top.
    • Extend TostadaState with:
  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) => …)). addFamily returns a new id name-slugged + uniquified; deleteFamily re-points affected componentThemes to fallbackFamilyId before removing (throws/no-ops if applied and fallback is null — UI enforces the modal). applyFamily upserts a ComponentTheme. overridePart/revertPart mutate ComponentTheme.overrides at 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.ts

  • Modify: 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: read families + componentThemes from the store and include them in the persisted JSON + the effect dep array.
    • In initLibrary / importLibrary: when parsed.tokens contains any token with tier === 'component' whose id is not radius, treat the library as legacy → hard reset: seed s.tokens = tokenMapFromArray(DEFAULT_TOKENS), s.families = structuredClone(DEFAULT_FAMILIES), s.componentThemes = [], and ignore parsed.tokens/parsed.families. Otherwise, load parsed.families ?? structuredClone(DEFAULT_FAMILIES) and parsed.componentThemes ?? []. (The export-first warning is a UI concern — Task C6.)
  • 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.ts

  • Modify: src/tokens/families.test.ts

  • Step 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 in componentCss.ts — copy the two resolveLight/resolveDark functions in, or import shared ones if you extract them; keeping them local here keeps families.ts dependency-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.ts

  • Modify: src/tokens/families.test.ts

  • Step 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.tsx

  • Step 1: Write the failing test. Create src/tokens/TokenContext.families.test.ts that renders useTokenInjection (or extract the string-builder into a pure buildInjectedCss(tokens, families) and test that directly — recommended: extract the builder so it's unit-testable without the DOM). Test asserts the injected :root contains --card, --primary, --block-border, and the deferred --accent alias.

  • Step 2: Run — verify it fails.

  • Step 3: Implement. Extract the CSS-string construction in useTokenInjection into export 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 semantic bg-*/fg-* ids (they no longer exist). useTokenInjection reads families from 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.ts

  • Modify: src/lib/useTokenBroadcast.ts

  • Step 1: Write the failing test. In src/lib/componentCss.test.ts (create if absent): buildComponentCss(tokens, families) output contains --card, --primary, --ghost-foreground, the --accent fallback, 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 just radius) + typography primitives + deferredVarFallback() in :root. Update useTokenBroadcast to read families from 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.ts

  • Modify: their .test.ts files

  • Step 1: Write the failing test. Update tokensCss.test.ts to pass families and assert --card / --primary / --block-border appear; themeCss.test.ts asserts the @theme block maps family-scoped colour vars (--color-block-bg etc.) 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 the bg-*/fg-*/border-* prefix logic). Update all callers (bundle.ts, index.ts, the CLI builder) to thread families.

  • 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.tsx if present)

  • Modify: src/components/pages/properties/PropertiesPage.tsx, src/lib/librarySections.ts

  • Step 1: Remove the TokensComponentPage import + <Route path="tokens/component" …> from PropertiesPage.tsx.

  • Step 2: Remove the { path: 'tokens/component', label: 'Component mapping' } nav item from librarySections.ts; rename the tokens/semantic label to Families (or Surfaces & Components).

  • Step 3: Delete the file. Run npm run test + tsc -b (via npm run build dry or npx 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.tsx

  • Step 1: Write the failing test. Render <FamilyCard family={block} onOpen={fn} />; assert it shows the family name, a preview region styled with var(--block-bg) / var(--block-fg) / var(--block-border), and clicking calls onOpen.

  • 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 via emittedVarsForSlot(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.tsx

  • Step 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 family button per group.

  • Step 2: Run — verify it fails.

  • Step 3: Implement. Replace the current semantic-token table with: read families from the store, group by kind, render <FamilyCard> per family + a + New family button calling addFamily(kind, 'New family'). Track openFamilyId state; render <FamilyModal> when set. Keep the Developer-mode branch rendering ReadOnlyTokenList (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.tsx

  • Step 1: Write the failing test. Render <FamilyModal familyId="block" onClose={fn} />; assert: the family usage text is shown in an editable field; each slot row shows its note + a light/dark primitive <select>; editing the usage calls editUsage; changing a slot's primitive calls updateToken(slot.tokenId, { references }); setting a slot to "no colour" calls updateToken(slot.tokenId, …)/marks null; + Add token and + Add slot affordances 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 (light references, dark darkReferences) over primitives → updateToken. A "no colour" option sets the slot's tokenId to null (store addSlot/a setSlotToken action — add one to the slice if missing). Reuse the existing TokenSelect/EditableCell/Swatch primitives from TokenRow.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 = emittedVarsForSlot per non-null slot, each with a var(--…) 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 with grep -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: initLibrary returns/sets a needsHardReset flag 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 keep initLibrary silent per Task A5, gate the modal on a legacyDetected selector 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.tsx

  • Step 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 calls applyFamily('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 in ComponentDetailPage near 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 the secondary family'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) to componentCss.ts: for each ComponentTheme, 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 to buildComponentCss. 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.ts

  • Step 1: Write the failing test (store-level first). overridePart('button','variant','border', tokenId) adds a variant-scoped override; componentThemeCss then emits the overridden value for that part; revertPart(...) removes it and the family default returns. Then a component test: clicking a part opens PartInspector showing the applied value + an override control + a revert control; choosing scope variant calls overridePart(..., 'variant', …).

  • Step 2: Run — verify it fails.

  • Step 3: Implement. PartInspector popover: 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. componentThemeCss layers 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.tsx or a new DeleteFamilyModal.tsx)

  • Modify: TokensSemanticPage.tsx

  • Step 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 affected componentThemes re-point.

  • Step 2: Run — verify it fails.

  • Step 3: Implement. A delete affordance on FamilyCard/modal → if componentThemes.some(t => t.familyId === id), open the fallback modal (list affected componentIds, 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.ts

  • Step 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.ts

  • Step 1: Write the test. Import the preview-host manifest JSON snippets, run findHardcodedColours over 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.tsx

  • Regenerate: preview-host/src/components/_manifest.json (whatever script builds it — check package.json sync:*)

  • 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 use data-[state=checked]:bg-primary …. For toggle's fill/stroke-blue-500 use fill-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 test to 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.md contains 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.md

  • Step 1: Rewrite the CLAUDE.md "Token tiers" section: two colour layers gone → families (surfaces + components), two-lane emission + the OWNS table, "states are derived," radius still 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 (the TokenTier/component narrowing + 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 (the families thread reached bundle.ts + the esbuild CLI).
  • Manual: npm run dev + npm run dev:preview-host, dark-mode pass across the component gallery; confirm exported theme.css compiles in a vanilla shadcn project.
  • Use the verification-before-completion skill 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: info has 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 takes families explicitly; thread the arg through all callers (Tasks B3–B5) or tsc will catch the misses.
  • Deviations flagged up top: SlotName + 'input'/'ring'; info placeholder; app-chrome no longer injected.