Typography System Implementation Plan

Goal: Build the typography token layer end-to-end per specs/typography-system.md: widen the DesignToken model, ship 21 default tokens (3 family + 7 size primitives + 11 semantic), add a Google-Fonts picker and modular-scale generator, expose the new Typography surface in the editor, and route the values into both the live preview and the documentation export — fully covered by Vitest + Playwright suites built up alongside the code.

Architecture: Reuse the existing single DesignToken map. Widen references to string | { family: string; size: string } (decision §9 q1). Store typography static fields (fontWeight, lineHeight, optional letterSpacing etc.) directly on the semantic token. Emit primitives as Tailwind-v4-compatible CSS variables so text-* / font-sans utilities in the preview-host iframe pick them up automatically. Emit semantic tokens as namespaced CSS vars (--typography-heading-family, …) into the exported tokens.css for docs + Rethemer consumption. Reuse TokenContext.useTokenInjection, useTokenBroadcast, generateTokensCss, the TokenRow editing primitives, and the existing modal pattern.

Tech Stack: React 19 + TypeScript (Vite), Tailwind v4 (@tailwindcss/vite), Zustand + immer, React Router 7, localStorage (alpha — no Supabase), lucide-react icons. Tests: Vitest 1.x + @testing-library/react + jsdom for unit/component tests; Playwright for E2E.


Scope check

This is a single subsystem (typography tokens + their editor + their emission). It does not split cleanly into sub-plans, so it stays as one plan. The first three tasks are infrastructure (test runners + the schema-widening + the static-field extension) that everything else depends on; the rest can be executed roughly in order, but unit tasks for individual modules (Tasks 4-9) are independent and can be parallelised once Task 3 lands.


Naming convention (lock this in before any code)

The spec uses two different shapes in different paragraphs (font-family.heading vs --font-heading-family). The plan commits to one consistent convention so every task is unambiguous:

Layer Convention Example
Primitive token id (family) font-family-<role> font-family-heading, font-family-body, font-family-code
Primitive token id (size) font-size-<step> font-size-xs, font-size-md, font-size-3xl
Semantic token id typography-<role> typography-display, typography-body, typography-micro
Primitive CSS variable --<token-id> --font-family-heading, --font-size-md
Semantic CSS variables --<token-id>-<facet> (one per facet that's set) --typography-heading-family, --typography-heading-size, --typography-heading-weight, --typography-heading-line-height, --typography-heading-letter-spacing
Tailwind alias (preview-host) Map our primitives onto the @theme block --font-sans: var(--font-family-body); etc.

If the user disagrees with any of these on plan review, change them here before any task ships — every later task references this table.


File structure overview

Create:

tostada/
├── vitest.config.ts                                              # Vitest config
├── playwright.config.ts                                          # Playwright config
├── src/
│   ├── test-setup.ts                                             # jsdom + globals for Vitest
│   ├── tokens/
│   │   ├── typography.ts                                         # modular scale + token helpers
│   │   ├── typography.test.ts                                    # unit tests
│   │   ├── fontLoader.ts                                         # Google Fonts <link> injection
│   │   ├── fontLoader.test.ts                                    # unit tests
│   │   ├── googleFontsCatalog.json                               # bundled font catalog (Top ~200)
│   │   └── typographyVars.ts                                     # CSS var emitter for typography
│   └── components/pages/properties/
│       ├── TypographyPrimitivesPage.tsx
│       ├── TypographyPrimitivesPage.test.tsx
│       ├── TypographySemanticPage.tsx
│       ├── TypographySemanticPage.test.tsx
│       ├── FontPickerModal.tsx
│       ├── FontPickerModal.test.tsx
│       ├── ScaleGeneratorModal.tsx
│       ├── ScaleGeneratorModal.test.tsx
│       ├── ReassignOnDeleteModal.tsx
│       └── ReassignOnDeleteModal.test.tsx
└── e2e/
    └── typography.spec.ts                                        # Playwright E2E

Modify:

tostada/
├── package.json                                                  # test deps + scripts
├── tsconfig.app.json                                             # include test files / "types": ["vitest/globals"]
├── src/
│   ├── tokens/types.ts                                           # widen references, add typography fields
│   ├── tokens/defaults.ts                                        # append 21 typography tokens
│   ├── tokens/TokenContext.tsx                                   # extend useTokenInjection for typography
│   ├── lib/docs.ts                                               # extend generateTokensCss for typography
│   ├── lib/shadcnCss.ts                                          # update reference resolver for union type
│   ├── lib/useTokenBroadcast.ts                                  # broadcast typography vars too
│   ├── components/pages/properties/PropertiesPage.tsx            # new Typography nav section + routes
│   ├── components/pages/properties/TokenRow.tsx                  # widen useResolvedValue for union type
│   └── components/pages/properties/_shared.tsx                   # if any shared helpers needed
└── preview-host/src/globals.css                                  # alias --font-sans/--font-mono/--text-* to our tokens

Conventions every task follows

  • Every task starts with a failing test (TDD). The skill is strict on this — don't skip the "run it to make sure it fails" step.
  • Test file naming: *.test.ts(x) next to the file under test (Vitest picks these up via vitest.config.ts).
  • Commit after every task ends (one commit per task, conventional commits style: feat(tokens): ... / test(tokens): ...).
  • Run npm run lint && npm run build && npm run test before every commit; the commit step in each task assumes you have run all three and they pass.
  • Path alias @/* is configured (see tsconfig.app.json). Use it.
  • Don't refactor unrelated code in a typography task. Park it.

Task 0 — Stand up Vitest

Files:

  • Create: tostada/vitest.config.ts
  • Create: tostada/src/test-setup.ts
  • Modify: tostada/package.json (devDeps + scripts)
  • Modify: tostada/tsconfig.app.json (include test types)

Why: the repo has zero test infra today. Every subsequent task is TDD, so this lands first.

  • Step 1: Install Vitest + testing-library + jsdom

    npm install --save-dev vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @types/node
    
  • Step 2: Create vitest.config.ts

    // tostada/vitest.config.ts
    import { defineConfig } from 'vitest/config'
    import react from '@vitejs/plugin-react'
    import path from 'node:path'
    
    export default defineConfig({
      plugins: [react()],
      resolve: {
        alias: { '@': path.resolve(__dirname, './src') },
      },
      test: {
        environment: 'jsdom',
        globals: true,
        setupFiles: ['./src/test-setup.ts'],
        include: ['src/**/*.{test,spec}.{ts,tsx}'],
        css: false,
      },
    })
    
  • Step 3: Create src/test-setup.ts

    // src/test-setup.ts
    import '@testing-library/jest-dom/vitest'
    import { afterEach } from 'vitest'
    import { cleanup } from '@testing-library/react'
    
    afterEach(() => cleanup())
    
    // jsdom doesn't implement matchMedia; tests that check dark mode rely on it.
    if (!window.matchMedia) {
      window.matchMedia = (() => ({
        matches: false, media: '', onchange: null,
        addEventListener: () => {}, removeEventListener: () => {},
        addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false,
      })) as unknown as typeof window.matchMedia
    }
    
  • Step 4: Add scripts to package.json — under "scripts":

    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui"
    
  • Step 5: Add "vitest/globals" to tsconfig.app.json types Edit the "types" array in tsconfig.app.json from ["vite/client"] to ["vite/client", "vitest/globals", "@testing-library/jest-dom"].

  • Step 6: Write a smoke test to prove Vitest runs Create src/test-setup.test.ts:

    import { describe, it, expect } from 'vitest'
    describe('vitest', () => {
      it('runs', () => { expect(1 + 1).toBe(2) })
    })
    
  • Step 7: Run npm run test — confirm the smoke test passes. Delete src/test-setup.test.ts afterwards.

  • Step 8: Commitchore(test): set up vitest + testing-library + jsdom


Task 1 — Stand up Playwright

Files:

  • Create: tostada/playwright.config.ts

  • Create: tostada/e2e/.gitkeep

  • Modify: tostada/package.json (deps + scripts)

  • Step 1: Install Playwright

    npm install --save-dev @playwright/test
    npx playwright install chromium
    
  • Step 2: Create playwright.config.ts

    import { defineConfig, devices } from '@playwright/test'
    
    export default defineConfig({
      testDir: './e2e',
      fullyParallel: true,
      forbidOnly: !!process.env.CI,
      retries: process.env.CI ? 2 : 0,
      reporter: process.env.CI ? 'github' : 'list',
      use: {
        baseURL: 'http://localhost:5174',
        trace: 'on-first-retry',
      },
      projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }],
      webServer: {
        command: 'npm run dev',
        url: 'http://localhost:5174',
        reuseExistingServer: !process.env.CI,
        timeout: 60_000,
      },
    })
    
  • Step 3: Add scripts to package.json:

    "e2e": "playwright test",
    "e2e:ui": "playwright test --ui"
    
  • Step 4: Create e2e/smoke.spec.ts to prove the harness runs:

    import { test, expect } from '@playwright/test'
    test('app boots', async ({ page }) => {
      await page.goto('/')
      await expect(page).toHaveTitle(/tostada/i)
    })
    
  • Step 5: Run npm run e2e — confirm pass. Keep the file; it doubles as a boot sanity check.

  • Step 6: Commitchore(test): set up playwright


Task 2 — Widen DesignToken.references to a union

Files:

  • Modify: src/tokens/types.ts
  • Modify: src/tokens/TokenContext.tsx
  • Modify: src/lib/docs.ts
  • Modify: src/lib/shadcnCss.ts
  • Modify: src/components/pages/properties/TokenRow.tsx
  • Create: src/tokens/types.test.ts

Why: the spec's §7 q1 decision. A typography semantic token has two refs (family + size); color tokens still have one (string). This is the cross-cutting type change every later task depends on.

  • Step 1: Write the failing testsrc/tokens/types.test.ts:

    import { describe, it, expect } from 'vitest'
    import { isTypographyReferences } from '@/tokens/types'
    import type { DesignToken } from '@/tokens/types'
    
    describe('isTypographyReferences', () => {
      it('returns false for a string references', () => {
        expect(isTypographyReferences('color-zinc-500')).toBe(false)
      })
      it('returns false for undefined', () => {
        expect(isTypographyReferences(undefined)).toBe(false)
      })
      it('returns true for a {family, size} object', () => {
        expect(isTypographyReferences({ family: 'font-family-body', size: 'font-size-md' })).toBe(true)
      })
      it('a typography semantic token type-checks with object references', () => {
        const t: DesignToken = {
          id: 'typography-body', name: 'Body', tier: 'semantic', value: '',
          references: { family: 'font-family-body', size: 'font-size-md' },
          fontWeight: 400,
        }
        expect(t.references).toEqual({ family: 'font-family-body', size: 'font-size-md' })
      })
    })
    
  • Step 2: Run npm run test — confirm failure (isTypographyReferences doesn't exist, references won't accept object, fontWeight field missing).

  • Step 3: Widen the type in src/tokens/types.ts — edit the DesignToken interface:

    export type TokenReferences = string | { family: string; size: string }
    
    export interface DesignToken {
      id: string
      name: string
      tier: TokenTier
      value: string
      references?: TokenReferences
      darkValue?: string
      darkReferences?: string
      description?: string
      group?: string
      usage?: SemanticUsage
    
      // Typography static fields (only set on semantic typography tokens).
      fontWeight?: number
      lineHeight?: string | number
      letterSpacing?: string
      fontStyle?: string
      textTransform?: string
      textDecoration?: string
    }
    
    export function isTypographyReferences(
      r: TokenReferences | undefined,
    ): r is { family: string; size: string } {
      return typeof r === 'object' && r !== null && 'family' in r && 'size' in r
    }
    
  • Step 4: Update TokenContext.useTokenInjection — in src/tokens/TokenContext.tsx, the resolve and resolveDark helpers currently do if (token.references && tokens[token.references]). Replace with a type-guarded form (typography tokens don't resolve to a single value; for color/scalar tokens behavior is unchanged):

    import { isTypographyReferences } from './types'
    // …
    const resolve = (id: string, visited = new Set<string>()): string => {
      if (visited.has(id)) return tokens[id]?.value ?? ''
      const token = tokens[id]
      if (!token) return ''
      if (token.references && typeof token.references === 'string' && tokens[token.references]) {
        visited.add(id)
        return resolve(token.references, visited)
      }
      // Typography tokens (object references) emit via the typography emitter — return their literal value here.
      return token.value
    }
    

    Apply the same guard inside resolveDark.

  • Step 5: Update docs.ts resolvers — replace the two existing if (t.references && tokens[t.references]) checks in resolveLight / resolveDark with the same typeof t.references === 'string' guard. Typography emission lives in a separate function (Task 9), so the existing resolvers should ignore object references for now.

  • Step 6: Update src/lib/shadcnCss.ts — apply the same guard to any references access. (Read the file first — there's only one resolver here.)

  • Step 7: Update useResolvedValue in TokenRow.tsx — same string-only guard. The semantic Typography page (Task 11) will render typography tokens through a dedicated component, not via this hook.

  • Step 8: Run npm run test — confirm Task 2's tests pass. Run npm run build to confirm no type errors in any file that imports DesignToken.

  • Step 9: Add a regression test for color tokens — append to types.test.ts:

    it('color tokens with string references still type-check', () => {
      const t: DesignToken = {
        id: 'bg-accent', name: 'Accent', tier: 'semantic', value: '#1b19ff',
        references: 'color-blue-500',
      }
      expect(t.references).toBe('color-blue-500')
    })
    

    Run, confirm pass.

  • Step 10: Commitfeat(tokens): widen references to union for typography tokens


Task 3 — Modular scale calculator

Files:

  • Create: src/tokens/typography.ts

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

  • Step 1: Write failing tests in src/tokens/typography.test.ts:

    import { describe, it, expect } from 'vitest'
    import { computeModularScale, SCALE_STEPS } from '@/tokens/typography'
    
    describe('computeModularScale', () => {
      it('produces 7 values centered on the base (steps -2..+4)', () => {
        const values = computeModularScale({ base: 1, ratio: 1.25 })
        expect(values).toHaveLength(7)
        expect(SCALE_STEPS).toEqual(['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'])
      })
    
      it('md is exactly the base', () => {
        const values = computeModularScale({ base: 1, ratio: 1.25 })
        expect(values[2].step).toBe('md')
        expect(values[2].rem).toBe(1)
      })
    
      it('computes the expected values for base=1rem ratio=1.25', () => {
        const values = computeModularScale({ base: 1, ratio: 1.25 })
        const expectations: Array<[string, number]> = [
          ['xs',  0.64],
          ['sm',  0.8],
          ['md',  1],
          ['lg',  1.25],
          ['xl',  1.5625],
          ['2xl', 1.953125],
          ['3xl', 2.44140625],
        ]
        for (let i = 0; i < 7; i++) {
          expect(values[i].step).toBe(expectations[i][0])
          expect(values[i].rem).toBeCloseTo(expectations[i][1], 5)
        }
      })
    
      it('rejects non-positive base', () => {
        expect(() => computeModularScale({ base: 0, ratio: 1.25 })).toThrow()
        expect(() => computeModularScale({ base: -1, ratio: 1.25 })).toThrow()
      })
    
      it('rejects ratio ≤ 1', () => {
        expect(() => computeModularScale({ base: 1, ratio: 1 })).toThrow()
        expect(() => computeModularScale({ base: 1, ratio: 0.9 })).toThrow()
      })
    
      it('formats rem values as CSS strings with px in parentheses', () => {
        const values = computeModularScale({ base: 1, ratio: 1.25 })
        expect(values[0].rem).toBeCloseTo(0.64, 5)
        expect(values[0].px).toBeCloseTo(10.24, 5)
        expect(values[0].cssValue).toBe('0.64rem')
        expect(values[0].displayLabel).toBe('0.64rem (10.24px)')
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement — create src/tokens/typography.ts:

    export const SCALE_STEPS = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const
    export type ScaleStep = typeof SCALE_STEPS[number]
    
    // step offsets from md (index 2 of SCALE_STEPS).
    const OFFSETS: number[] = [-2, -1, 0, 1, 2, 3, 4]
    
    export interface ScaleValue {
      step: ScaleStep
      rem: number
      px: number
      cssValue: string       // e.g. '0.64rem'
      displayLabel: string   // e.g. '0.64rem (10.24px)'
    }
    
    function round(n: number, places = 5): number {
      const f = 10 ** places
      return Math.round(n * f) / f
    }
    
    export function computeModularScale({ base, ratio }: { base: number; ratio: number }): ScaleValue[] {
      if (!(base > 0)) throw new Error('base must be > 0')
      if (!(ratio > 1)) throw new Error('ratio must be > 1')
      return SCALE_STEPS.map((step, i) => {
        const rem = round(base * Math.pow(ratio, OFFSETS[i]))
        const px = round(rem * 16, 2)
        return {
          step,
          rem,
          px,
          cssValue: `${rem}rem`,
          displayLabel: `${rem}rem (${px}px)`,
        }
      })
    }
    
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(tokens): add modular scale calculator


Task 4 — Default typography tokens

Files:

  • Modify: src/tokens/defaults.ts
  • Create: src/tokens/defaults.test.ts

Why: the 3 + 7 + 11 default tokens land in DEFAULT_TOKENS so a fresh library is non-empty.

  • Step 1: Write failing tests in src/tokens/defaults.test.ts:

    import { describe, it, expect } from 'vitest'
    import { DEFAULT_TOKENS } from '@/tokens/defaults'
    
    function byId(id: string) {
      return DEFAULT_TOKENS.find((t) => t.id === id)
    }
    
    describe('typography defaults', () => {
      it('ships 3 font-family primitives', () => {
        for (const id of ['font-family-heading', 'font-family-body', 'font-family-code']) {
          const t = byId(id)
          expect(t, id).toBeDefined()
          expect(t!.tier).toBe('primitive')
          expect(t!.value).toMatch(/(sans-serif|monospace)$/)
        }
        expect(byId('font-family-code')!.value).toMatch(/monospace$/)
      })
    
      it('ships 7 font-size primitives in order xs..3xl', () => {
        const ids = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'].map((s) => `font-size-${s}`)
        for (const id of ids) {
          const t = byId(id)
          expect(t, id).toBeDefined()
          expect(t!.tier).toBe('primitive')
          expect(t!.value).toMatch(/rem$/)
        }
        expect(byId('font-size-md')!.value).toBe('1rem')
      })
    
      it('ships 11 semantic typography tokens with valid refs + weights', () => {
        const semanticIds = [
          'typography-display', 'typography-heading', 'typography-subheading',
          'typography-title', 'typography-body', 'typography-body-emphasis',
          'typography-caption', 'typography-label', 'typography-button',
          'typography-code', 'typography-micro',
        ]
        for (const id of semanticIds) {
          const t = byId(id)
          expect(t, id).toBeDefined()
          expect(t!.tier).toBe('semantic')
          expect(typeof t!.references).toBe('object')
          expect(t!.fontWeight).toBeGreaterThan(0)
        }
        expect(byId('typography-code')!.references).toEqual({
          family: 'font-family-code', size: 'font-size-md',
        })
      })
    
      it('every semantic typography reference points to a real primitive', () => {
        const ids = new Set(DEFAULT_TOKENS.map((t) => t.id))
        for (const t of DEFAULT_TOKENS) {
          if (t.tier !== 'semantic') continue
          if (typeof t.references === 'object' && t.references) {
            expect(ids.has(t.references.family), `${t.id} family ref`).toBe(true)
            expect(ids.has(t.references.size), `${t.id} size ref`).toBe(true)
          }
        }
      })
    
      it('each semantic typography token carries usage guidance (application rule)', () => {
        const t = byId('typography-display')!
        expect(t.usage?.purpose).toBeTruthy()
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement — append to src/tokens/defaults.ts (after the existing entries):

      // ─── Typography primitives ────────────────────────────────────────────
      { id: 'font-family-heading', name: 'Heading font', tier: 'primitive',
        value: "'Inter', sans-serif", group: 'Typography / Family',
        description: 'Used for titles, headings, and highlights.' },
      { id: 'font-family-body', name: 'Body font', tier: 'primitive',
        value: "'Inter', sans-serif", group: 'Typography / Family',
        description: 'Used for paragraph and content text.' },
      { id: 'font-family-code', name: 'Code font', tier: 'primitive',
        value: "'JetBrains Mono', monospace", group: 'Typography / Family',
        description: 'Used for inline and block code.' },
    
      // 1.25 modular scale, base 1rem. Generated values; keep in sync with
      // computeModularScale({ base: 1, ratio: 1.25 }).
      { id: 'font-size-xs',  name: 'Size xs',  tier: 'primitive', value: '0.64rem',     group: 'Typography / Size' },
      { id: 'font-size-sm',  name: 'Size sm',  tier: 'primitive', value: '0.8rem',      group: 'Typography / Size' },
      { id: 'font-size-md',  name: 'Size md',  tier: 'primitive', value: '1rem',        group: 'Typography / Size' },
      { id: 'font-size-lg',  name: 'Size lg',  tier: 'primitive', value: '1.25rem',     group: 'Typography / Size' },
      { id: 'font-size-xl',  name: 'Size xl',  tier: 'primitive', value: '1.5625rem',   group: 'Typography / Size' },
      { id: 'font-size-2xl', name: 'Size 2xl', tier: 'primitive', value: '1.953125rem', group: 'Typography / Size' },
      { id: 'font-size-3xl', name: 'Size 3xl', tier: 'primitive', value: '2.44140625rem', group: 'Typography / Size' },
    
      // ─── Semantic typography ──────────────────────────────────────────────
      // application rules in usage.purpose mirror spec §5.
      { id: 'typography-display', name: 'Display', tier: 'semantic', value: '',
        references: { family: 'font-family-heading', size: 'font-size-3xl' },
        fontWeight: 700, lineHeight: 1.1, group: 'Typography',
        usage: { purpose: 'Hero headlines and marketing moments only. Max one per page. Never used inside cards, lists, or repeated UI.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-heading', name: 'Heading', tier: 'semantic', value: '',
        references: { family: 'font-family-heading', size: 'font-size-2xl' },
        fontWeight: 700, lineHeight: 1.2, group: 'Typography',
        usage: { purpose: "The page's primary title (semantic h1). One per page.", useWhen: [], avoidWhen: [] } },
      { id: 'typography-subheading', name: 'Subheading', tier: 'semantic', value: '',
        references: { family: 'font-family-heading', size: 'font-size-xl' },
        fontWeight: 600, lineHeight: 1.25, group: 'Typography',
        usage: { purpose: 'Major section titles within a page (semantic h2).', useWhen: [], avoidWhen: [] } },
      { id: 'typography-title', name: 'Title', tier: 'semantic', value: '',
        references: { family: 'font-family-heading', size: 'font-size-lg' },
        fontWeight: 600, lineHeight: 1.3, group: 'Typography',
        usage: { purpose: 'Sub-section, card, and block titles (h3–h4).', useWhen: [], avoidWhen: [] } },
      { id: 'typography-body', name: 'Body', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-md' },
        fontWeight: 400, lineHeight: 1.5, group: 'Typography',
        usage: { purpose: 'Default for all paragraph and prose text.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-body-emphasis', name: 'Body emphasis', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-md' },
        fontWeight: 600, lineHeight: 1.5, group: 'Typography',
        usage: { purpose: 'Inline emphasis inside paragraphs, lead-in sentences, key terms.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-caption', name: 'Caption', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-sm' },
        fontWeight: 400, lineHeight: 1.4, group: 'Typography',
        usage: { purpose: 'Image captions, helper text under form inputs, footnotes.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-label', name: 'Label', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-sm' },
        fontWeight: 500, lineHeight: 1.3, group: 'Typography',
        usage: { purpose: 'Form labels, table column headers, small UI labels.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-button', name: 'Button', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-md' },
        fontWeight: 600, lineHeight: 1, group: 'Typography',
        usage: { purpose: 'Button and link text inside interactive controls.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-code', name: 'Code', tier: 'semantic', value: '',
        references: { family: 'font-family-code', size: 'font-size-md' },
        fontWeight: 400, lineHeight: 1.5, group: 'Typography',
        usage: { purpose: 'Monospace, inline and block code.', useWhen: [], avoidWhen: [] } },
      { id: 'typography-micro', name: 'Micro', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-xs' },
        fontWeight: 400, lineHeight: 1.4, group: 'Typography',
        usage: { purpose: 'Smallest readable text: legal fine print, badge text.', useWhen: [], avoidWhen: [] } },
    
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Run npm run build — confirm typecheck clean (all 21 new tokens are valid DesignTokens).

  • Step 6: Commitfeat(tokens): add 3 + 7 + 11 default typography tokens


Task 5 — Typography CSS variable emitter

Files:

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

Why: semantic typography tokens emit multiple CSS variables per token (family + size + weight + line-height + optional letter-spacing/style/transform/decoration). The existing single-line emitter in TokenContext / docs.ts can't handle this — it gets its own function so both consumers (live preview + exported tokens.css) call into the same logic.

  • Step 1: Write failing tests in src/tokens/typographyVars.test.ts:

    import { describe, it, expect } from 'vitest'
    import { emitTypographyVars } from '@/tokens/typographyVars'
    import type { TokenMap } from '@/tokens/types'
    
    const tokens: TokenMap = {
      'font-family-body': { id: 'font-family-body', name: 'Body', tier: 'primitive', value: "'Inter', sans-serif" },
      'font-size-md': { id: 'font-size-md', name: 'md', tier: 'primitive', value: '1rem' },
      'typography-body': {
        id: 'typography-body', name: 'Body', tier: 'semantic', value: '',
        references: { family: 'font-family-body', size: 'font-size-md' },
        fontWeight: 400, lineHeight: 1.5,
      },
    }
    
    describe('emitTypographyVars', () => {
      it('emits family, size, weight, line-height lines for a typography token', () => {
        const out = emitTypographyVars(tokens['typography-body'], tokens)
        expect(out).toContain("--typography-body-family: 'Inter', sans-serif;")
        expect(out).toContain('--typography-body-size: 1rem;')
        expect(out).toContain('--typography-body-weight: 400;')
        expect(out).toContain('--typography-body-line-height: 1.5;')
      })
    
      it('omits letter-spacing when not set', () => {
        const out = emitTypographyVars(tokens['typography-body'], tokens)
        expect(out).not.toContain('letter-spacing')
      })
    
      it('emits letter-spacing/style/transform/decoration when set', () => {
        const t = { ...tokens['typography-body'], letterSpacing: '0.02em', fontStyle: 'italic', textTransform: 'uppercase', textDecoration: 'underline' }
        const out = emitTypographyVars(t, tokens)
        expect(out).toContain('--typography-body-letter-spacing: 0.02em;')
        expect(out).toContain('--typography-body-style: italic;')
        expect(out).toContain('--typography-body-transform: uppercase;')
        expect(out).toContain('--typography-body-decoration: underline;')
      })
    
      it('returns empty when references are invalid', () => {
        const broken = { ...tokens['typography-body'], references: { family: 'missing', size: 'missing' } }
        expect(emitTypographyVars(broken, tokens)).toBe('')
      })
    
      it('ignores non-typography tokens', () => {
        const color = { id: 'bg-accent', name: 'Accent', tier: 'semantic' as const, value: '#1b19ff', references: 'color-blue-500' }
        expect(emitTypographyVars(color, tokens)).toBe('')
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement — create src/tokens/typographyVars.ts:

    import type { DesignToken, TokenMap } from './types'
    import { isTypographyReferences } from './types'
    
    export function emitTypographyVars(token: DesignToken, tokens: TokenMap): string {
      if (!isTypographyReferences(token.references)) return ''
      const familyTok = tokens[token.references.family]
      const sizeTok = tokens[token.references.size]
      if (!familyTok || !sizeTok) return ''
    
      const lines: string[] = []
      lines.push(`  --${token.id}-family: ${familyTok.value};`)
      lines.push(`  --${token.id}-size: ${sizeTok.value};`)
      if (token.fontWeight != null) lines.push(`  --${token.id}-weight: ${token.fontWeight};`)
      if (token.lineHeight != null) lines.push(`  --${token.id}-line-height: ${token.lineHeight};`)
      if (token.letterSpacing) lines.push(`  --${token.id}-letter-spacing: ${token.letterSpacing};`)
      if (token.fontStyle)     lines.push(`  --${token.id}-style: ${token.fontStyle};`)
      if (token.textTransform) lines.push(`  --${token.id}-transform: ${token.textTransform};`)
      if (token.textDecoration)lines.push(`  --${token.id}-decoration: ${token.textDecoration};`)
      return lines.join('\n')
    }
    
    export function emitAllTypographyVars(tokens: TokenMap): string {
      return Object.values(tokens)
        .filter((t) => t.tier === 'semantic' && isTypographyReferences(t.references))
        .map((t) => emitTypographyVars(t, tokens))
        .filter(Boolean)
        .join('\n')
    }
    
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(tokens): emit typography CSS variables


Task 6 — Wire typography vars into live preview + docs

Files:

  • Modify: src/tokens/TokenContext.tsx

  • Modify: src/lib/docs.ts

  • Modify: src/lib/shadcnCss.ts

  • Modify: preview-host/src/globals.css

  • Modify: src/lib/useTokenBroadcast.ts (if needed — Task 6 sub-step inspects)

  • Create: src/lib/docs.test.ts

  • Step 1: Write failing test for generateTokensCss in src/lib/docs.test.ts:

    import { describe, it, expect } from 'vitest'
    import { generateTokensCss } from '@/lib/docs'
    import { DEFAULT_TOKENS } from '@/tokens/defaults'
    
    function buildMap() {
      return Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t]))
    }
    
    describe('generateTokensCss', () => {
      it('includes primitive typography variables', () => {
        const css = generateTokensCss('Test', buildMap())
        expect(css).toMatch(/--font-family-body:/)
        expect(css).toMatch(/--font-size-md: 1rem;/)
      })
    
      it('includes semantic typography variables (multi-line per token)', () => {
        const css = generateTokensCss('Test', buildMap())
        expect(css).toMatch(/--typography-body-family:/)
        expect(css).toMatch(/--typography-body-size: 1rem;/)
        expect(css).toMatch(/--typography-body-weight: 400;/)
        expect(css).toMatch(/--typography-body-line-height: 1.5;/)
      })
    
      it('emits typography vars in the semantic section, after primitives', () => {
        const css = generateTokensCss('Test', buildMap())
        const primIdx = css.indexOf('--font-size-md')
        const semIdx  = css.indexOf('--typography-body-size')
        expect(primIdx).toBeGreaterThan(-1)
        expect(semIdx).toBeGreaterThan(primIdx)
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Modify generateTokensCss in src/lib/docs.ts — after the existing semantic-tier loop, add typography emission via emitAllTypographyVars. The existing for (const t of orderedTokens) loop currently emits --<id>: <value>; for everything; for semantic tokens with object references it'll emit an empty line (because resolveLight returns ''). Change the loop:

    // After: const light = resolveLight(tokens, t.id)
    // Replace:    if (light) lightLines.push(`  --${t.id}: ${light};`)
    // With:
    if (typeof t.references === 'object' && t.references) {
      const block = emitTypographyVars(t, tokens)
      if (block) lightLines.push(block)
    } else if (light) {
      lightLines.push(`  --${t.id}: ${light};`)
    }
    

    Add the import at the top: import { emitTypographyVars } from '@/tokens/typographyVars'.

  • Step 4: Run npm run test — confirm pass on docs.test.ts.

  • Step 5: Mirror the change in TokenContext.useTokenInjection — the live :root <style> tag injected on the editor page should also include typography vars so the editor's own surfaces can use them later. Same shape: if typeof t.references === 'object', call emitTypographyVars, else fall through to the existing single-line emit.

  • Step 6: Write a Vitest for useTokenInjection to verify the injected <style> contents — add to src/tokens/TokenContext.test.tsx:

    import { describe, it, expect } from 'vitest'
    import { renderHook } from '@testing-library/react'
    import { useTokenInjection } from '@/tokens/TokenContext'
    import { DEFAULT_TOKENS } from '@/tokens/defaults'
    
    describe('useTokenInjection', () => {
      it('injects typography variables into #tostada-tokens', () => {
        const map = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t]))
        renderHook(() => useTokenInjection(map))
        const el = document.getElementById('tostada-tokens')
        expect(el).not.toBeNull()
        expect(el!.textContent).toMatch(/--typography-body-size: 1rem;/)
      })
    })
    

    Run, confirm pass.

  • Step 7: Check src/lib/shadcnCss.ts — read the file. It generates the CSS pushed to preview-host. Decide whether typography belongs in this broadcast (it does — so the iframe can pick up family + size for Tailwind utilities). If buildShadcnCss builds only the shadcn-tier vars today, extend it to also emit font-family-* + font-size-* primitive vars (not the typography semantic vars — those are documentation-only for v1). Add a test in src/lib/shadcnCss.test.ts:

    import { describe, it, expect } from 'vitest'
    import { buildShadcnCss } from '@/lib/shadcnCss'
    import { DEFAULT_TOKENS } from '@/tokens/defaults'
    
    describe('buildShadcnCss', () => {
      it('includes typography primitive variables for Tailwind consumption', () => {
        const map = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t]))
        const css = buildShadcnCss(map)
        expect(css).toMatch(/--font-family-body:/)
        expect(css).toMatch(/--font-size-md:/)
      })
    })
    

    Run, fail, implement (add the primitives to the emitter), pass.

  • Step 8: Update preview-host/src/globals.css — Tailwind v4 reads its theme from CSS vars. Alias the Tailwind defaults to our primitives so text-sm, font-sans, font-mono work without code changes in the shadcn components. Add inside @theme inline block:

    /* Typography tokens — sourced from Tostada Library */
    --font-sans: var(--font-family-body, 'Inter', sans-serif);
    --font-mono: var(--font-family-code, 'JetBrains Mono', monospace);
    --text-xs:  var(--font-size-xs,  0.64rem);
    --text-sm:  var(--font-size-sm,  0.8rem);
    --text-base:var(--font-size-md,  1rem);
    --text-lg:  var(--font-size-lg,  1.25rem);
    --text-xl:  var(--font-size-xl,  1.5625rem);
    --text-2xl: var(--font-size-2xl, 1.953125rem);
    --text-3xl: var(--font-size-3xl, 2.44140625rem);
    

    (Read the existing @theme inline block first so the snippet slots in correctly.)

  • Step 9: Commitfeat(tokens): emit typography vars into preview + tokens.css


Task 7 — Google Fonts catalog (bundled)

Files:

  • Create: src/tokens/googleFontsCatalog.json
  • Create: src/tokens/googleFontsCatalog.test.ts

Why: the spec resolved §9 q2 to "bundled JSON, no API key, works offline." This task seeds a sensible subset and the loader (Task 8) reads from it.

  • Step 1: Write failing test in src/tokens/googleFontsCatalog.test.ts:

    import { describe, it, expect } from 'vitest'
    import catalog from '@/tokens/googleFontsCatalog.json'
    
    describe('googleFontsCatalog', () => {
      it('has at least 50 families', () => {
        expect(Array.isArray(catalog)).toBe(true)
        expect(catalog.length).toBeGreaterThanOrEqual(50)
      })
      it('each entry has family + category + weights', () => {
        for (const entry of catalog) {
          expect(typeof entry.family).toBe('string')
          expect(['sans-serif', 'serif', 'monospace', 'display', 'handwriting']).toContain(entry.category)
          expect(Array.isArray(entry.weights)).toBe(true)
          expect(entry.weights.every((w: unknown) => typeof w === 'number')).toBe(true)
        }
      })
      it('includes the families used by defaults', () => {
        const names = catalog.map((c: { family: string }) => c.family)
        expect(names).toContain('Inter')
        expect(names).toContain('JetBrains Mono')
      })
    })
    
  • Step 2: Run npm run test — confirm failure (file doesn't exist).

  • Step 3: Create src/tokens/googleFontsCatalog.json — seed with ~60 popular families. Format:

    [
      { "family": "Inter",          "category": "sans-serif", "weights": [300, 400, 500, 600, 700] },
      { "family": "Geist",          "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Geist Mono",     "category": "monospace",  "weights": [400, 500, 700] },
      { "family": "Roboto",         "category": "sans-serif", "weights": [300, 400, 500, 700] },
      { "family": "Open Sans",      "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Lato",           "category": "sans-serif", "weights": [300, 400, 700] },
      { "family": "Poppins",        "category": "sans-serif", "weights": [300, 400, 500, 600, 700] },
      { "family": "Manrope",        "category": "sans-serif", "weights": [300, 400, 500, 600, 700] },
      { "family": "DM Sans",        "category": "sans-serif", "weights": [400, 500, 700] },
      { "family": "Space Grotesk",  "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Plus Jakarta Sans","category": "sans-serif","weights":[400, 500, 600, 700] },
      { "family": "Outfit",         "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Work Sans",      "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Source Sans 3",  "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Nunito Sans",    "category": "sans-serif", "weights": [400, 600, 700] },
      { "family": "Nunito",         "category": "sans-serif", "weights": [400, 600, 700] },
      { "family": "Karla",          "category": "sans-serif", "weights": [400, 500, 700] },
      { "family": "Mulish",         "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Figtree",        "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Onest",          "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Sora",           "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Albert Sans",    "category": "sans-serif", "weights": [400, 500, 600, 700] },
      { "family": "Be Vietnam Pro", "category": "sans-serif", "weights": [400, 500, 600, 700] },
    
      { "family": "EB Garamond",    "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Source Serif 4", "category": "serif", "weights": [400, 600, 700] },
      { "family": "Playfair Display","category":"serif","weights": [400, 500, 600, 700] },
      { "family": "Lora",           "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Merriweather",   "category": "serif", "weights": [400, 700] },
      { "family": "PT Serif",       "category": "serif", "weights": [400, 700] },
      { "family": "Cormorant Garamond","category":"serif","weights":[400, 500, 600, 700] },
      { "family": "Crimson Pro",    "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Libre Caslon Text","category":"serif","weights":[400, 700] },
      { "family": "Bitter",         "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Spectral",       "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Fraunces",       "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Newsreader",     "category": "serif", "weights": [400, 500, 600, 700] },
      { "family": "Instrument Serif","category": "serif", "weights": [400] },
    
      { "family": "JetBrains Mono", "category": "monospace", "weights": [400, 500, 600, 700] },
      { "family": "Fira Code",      "category": "monospace", "weights": [400, 500, 600, 700] },
      { "family": "Source Code Pro","category": "monospace", "weights": [400, 500, 600, 700] },
      { "family": "IBM Plex Mono",  "category": "monospace", "weights": [400, 500, 600, 700] },
      { "family": "Roboto Mono",    "category": "monospace", "weights": [400, 500, 600, 700] },
      { "family": "Space Mono",     "category": "monospace", "weights": [400, 700] },
      { "family": "DM Mono",        "category": "monospace", "weights": [400, 500] },
    
      { "family": "Bebas Neue",     "category": "display", "weights": [400] },
      { "family": "Anton",          "category": "display", "weights": [400] },
      { "family": "Archivo Black",  "category": "display", "weights": [400] },
      { "family": "Bricolage Grotesque","category":"display","weights":[400, 500, 600, 700] },
      { "family": "Big Shoulders Display","category":"display","weights":[400, 700, 900] },
      { "family": "Unbounded",      "category": "display", "weights": [400, 500, 600, 700] },
      { "family": "Syne",           "category": "display", "weights": [400, 500, 600, 700] },
    
      { "family": "Caveat",         "category": "handwriting", "weights": [400, 500, 600, 700] },
      { "family": "Dancing Script", "category": "handwriting", "weights": [400, 500, 600, 700] }
    ]
    
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(tokens): bundle google fonts catalog (60 families)


Task 8 — Font loader

Files:

  • Create: src/tokens/fontLoader.ts

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

  • Step 1: Write failing tests in src/tokens/fontLoader.test.ts:

    import { describe, it, expect, beforeEach } from 'vitest'
    import { loadGoogleFont, isGoogleFontLoaded, googleFontLinkHref } from '@/tokens/fontLoader'
    
    describe('googleFontLinkHref', () => {
      it('builds the correct stylesheet url', () => {
        const href = googleFontLinkHref('Inter', [400, 600, 700])
        expect(href).toBe('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap')
      })
      it('encodes spaces in the family name', () => {
        const href = googleFontLinkHref('JetBrains Mono', [400])
        expect(href).toBe('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&display=swap')
      })
    })
    
    describe('loadGoogleFont', () => {
      beforeEach(() => {
        document.head.querySelectorAll('link[data-tostada-font]').forEach((el) => el.remove())
      })
    
      it('injects a <link> tag with the right href', async () => {
        await loadGoogleFont('Inter', [400, 600, 700])
        const link = document.head.querySelector('link[data-tostada-font="Inter"]') as HTMLLinkElement
        expect(link).not.toBeNull()
        expect(link.href).toContain('family=Inter')
      })
    
      it('is idempotent — dedupes by family name', async () => {
        await loadGoogleFont('Inter', [400, 600, 700])
        await loadGoogleFont('Inter', [400, 600, 700])
        const links = document.head.querySelectorAll('link[data-tostada-font="Inter"]')
        expect(links.length).toBe(1)
      })
    
      it('reports loaded state via isGoogleFontLoaded', async () => {
        expect(isGoogleFontLoaded('Inter')).toBe(false)
        await loadGoogleFont('Inter', [400])
        expect(isGoogleFontLoaded('Inter')).toBe(true)
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implementsrc/tokens/fontLoader.ts:

    export function googleFontLinkHref(family: string, weights: number[]): string {
      const fam = family.replace(/ /g, '+')
      const wght = weights.length ? `:wght@${weights.join(';')}` : ''
      return `https://fonts.googleapis.com/css2?family=${fam}${wght}&display=swap`
    }
    
    export function isGoogleFontLoaded(family: string): boolean {
      return !!document.head.querySelector(`link[data-tostada-font="${cssAttrEscape(family)}"]`)
    }
    
    function cssAttrEscape(s: string): string {
      return s.replace(/"/g, '\\"')
    }
    
    export async function loadGoogleFont(family: string, weights: number[] = [400, 600, 700]): Promise<void> {
      if (isGoogleFontLoaded(family)) return
      const link = document.createElement('link')
      link.rel = 'stylesheet'
      link.href = googleFontLinkHref(family, weights)
      link.dataset.tostadaFont = family
      document.head.appendChild(link)
    
      if (document.fonts && typeof document.fonts.ready?.then === 'function') {
        try { await document.fonts.ready } catch { /* ignore */ }
      }
    }
    
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(tokens): add google fonts <link> loader


Task 9 — FontPickerModal component

Files:

  • Create: src/components/pages/properties/FontPickerModal.tsx

  • Create: src/components/pages/properties/FontPickerModal.test.tsx

  • Step 1: Write failing component tests in FontPickerModal.test.tsx:

    import { describe, it, expect, vi } from 'vitest'
    import { render, screen, fireEvent } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import { FontPickerModal } from './FontPickerModal'
    
    describe('FontPickerModal', () => {
      it('lists system stacks above the catalog', () => {
        render(<FontPickerModal onSelect={() => {}} onClose={() => {}} />)
        expect(screen.getByText(/system-ui/i)).toBeInTheDocument()
        expect(screen.getByText(/serif/i)).toBeInTheDocument()
      })
    
      it('filters the catalog by search', async () => {
        const user = userEvent.setup()
        render(<FontPickerModal onSelect={() => {}} onClose={() => {}} />)
        const input = screen.getByPlaceholderText(/search/i)
        await user.type(input, 'JetBrains')
        expect(screen.getByText(/JetBrains Mono/i)).toBeInTheDocument()
        expect(screen.queryByText(/^Inter$/i)).toBeNull()
      })
    
      it('clicking a font + Use this font commits a value', async () => {
        const onSelect = vi.fn()
        const user = userEvent.setup()
        render(<FontPickerModal onSelect={onSelect} onClose={() => {}} />)
        await user.click(screen.getByText(/^Inter$/))
        await user.click(screen.getByRole('button', { name: /use this font/i }))
        expect(onSelect).toHaveBeenCalledWith(expect.stringMatching(/'Inter', sans-serif/))
      })
    
      it('Esc closes the modal', () => {
        const onClose = vi.fn()
        render(<FontPickerModal onSelect={() => {}} onClose={onClose} />)
        fireEvent.keyDown(document, { key: 'Escape' })
        expect(onClose).toHaveBeenCalled()
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement FontPickerModal.tsx — follow the existing BlockPickerModal shape for the wrapper (click-outside backdrop, centered card, max-height). Inside:

    • Header: title Pick a font + X close button.
    • Search input filtering the bundled catalog.
    • System / generic stacks section: 4 quick picks — system-ui, serif, sans-serif, monospace.
    • Catalog list: family name + category badge.
    • Preview pane: renders sample text in the selected font. On select, call loadGoogleFont(family, [400, 600, 700]) first, then render preview (browser may show fallback during load).
    • Footer: Cancel + Use this font.

    Stack value emitted for a selected family — "'<Family>', <category>-fallback", e.g. "'Inter', sans-serif". System picks emit literal stacks ("system-ui, -apple-system, sans-serif" etc.).

    Keep the file under ~300 lines; copy modal scaffolding from BlockPickerModal.tsx.

  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(typography): add FontPickerModal


Task 10 — ScaleGeneratorModal component

Files:

  • Create: src/components/pages/properties/ScaleGeneratorModal.tsx

  • Create: src/components/pages/properties/ScaleGeneratorModal.test.tsx

  • Step 1: Write failing tests in ScaleGeneratorModal.test.tsx:

    import { describe, it, expect, vi } from 'vitest'
    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import { ScaleGeneratorModal } from './ScaleGeneratorModal'
    
    describe('ScaleGeneratorModal', () => {
      it('shows base + ratio inputs with defaults 1 / 1.25', () => {
        render(<ScaleGeneratorModal onApply={() => {}} onClose={() => {}} />)
        expect(screen.getByLabelText(/base/i)).toHaveValue(1)
        expect(screen.getByLabelText(/ratio/i)).toHaveValue(1.25)
      })
    
      it('updates the live preview when the ratio changes', async () => {
        const user = userEvent.setup()
        render(<ScaleGeneratorModal onApply={() => {}} onClose={() => {}} />)
        const ratio = screen.getByLabelText(/ratio/i)
        await user.clear(ratio); await user.type(ratio, '1.333')
        expect(screen.getByText(/^md/i).parentElement?.textContent).toMatch(/1rem/)
        expect(screen.getByText(/^xl/i).parentElement?.textContent).toMatch(/1\.776/)
      })
    
      it('disables Apply for an out-of-range ratio', async () => {
        const user = userEvent.setup()
        render(<ScaleGeneratorModal onApply={() => {}} onClose={() => {}} />)
        const ratio = screen.getByLabelText(/ratio/i)
        await user.clear(ratio); await user.type(ratio, '0.9')
        expect(screen.getByRole('button', { name: /apply/i })).toBeDisabled()
      })
    
      it('calls onApply with the 7 step values on confirm', async () => {
        const onApply = vi.fn()
        const user = userEvent.setup()
        render(<ScaleGeneratorModal onApply={onApply} onClose={() => {}} />)
        await user.click(screen.getByRole('button', { name: /apply/i }))
        expect(onApply).toHaveBeenCalledTimes(1)
        const arg = onApply.mock.calls[0][0]
        expect(arg).toHaveLength(7)
        expect(arg[2]).toEqual(expect.objectContaining({ step: 'md', cssValue: '1rem' }))
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement ScaleGeneratorModal.tsx

    • Two inputs: base (number, default 1, unit suffix rem), ratio (number, default 1.25, quick picks 1.2 / 1.25 / 1.333 / 1.414 / 1.5).
    • Live preview list of the 7 steps using computeModularScale. If the inputs throw, show the error inline and disable Apply.
    • onApply(values: ScaleValue[]) — parent (Task 11) maps these onto font-size-* token IDs.
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(typography): add ScaleGeneratorModal


Task 11 — ReassignOnDeleteModal component

Files:

  • Create: src/components/pages/properties/ReassignOnDeleteModal.tsx

  • Create: src/components/pages/properties/ReassignOnDeleteModal.test.tsx

  • Step 1: Write failing tests in ReassignOnDeleteModal.test.tsx:

    import { describe, it, expect, vi } from 'vitest'
    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import { ReassignOnDeleteModal } from './ReassignOnDeleteModal'
    
    const consumingIds = ['typography-display', 'typography-heading']
    const replacements = [
      { id: 'font-family-body', name: 'Body' },
      { id: 'font-family-code', name: 'Code' },
    ]
    
    describe('ReassignOnDeleteModal', () => {
      it('names the primitive being deleted', () => {
        render(<ReassignOnDeleteModal target="font-family-heading" facet="family"
          consumingTokenIds={consumingIds} replacements={replacements}
          onConfirm={() => {}} onCancel={() => {}} />)
        expect(screen.getByText(/font-family-heading/)).toBeInTheDocument()
      })
    
      it('lists every consuming token', () => {
        render(<ReassignOnDeleteModal target="font-family-heading" facet="family"
          consumingTokenIds={consumingIds} replacements={replacements}
          onConfirm={() => {}} onCancel={() => {}} />)
        expect(screen.getByText(/typography-display/)).toBeInTheDocument()
        expect(screen.getByText(/typography-heading/)).toBeInTheDocument()
      })
    
      it('disables confirm until a replacement is picked', async () => {
        const user = userEvent.setup()
        render(<ReassignOnDeleteModal target="font-family-heading" facet="family"
          consumingTokenIds={consumingIds} replacements={replacements}
          onConfirm={() => {}} onCancel={() => {}} />)
        expect(screen.getByRole('button', { name: /delete/i })).toBeDisabled()
        await user.selectOptions(screen.getByLabelText(/replacement/i), 'font-family-body')
        expect(screen.getByRole('button', { name: /delete/i })).toBeEnabled()
      })
    
      it('calls onConfirm with the chosen replacement', async () => {
        const onConfirm = vi.fn()
        const user = userEvent.setup()
        render(<ReassignOnDeleteModal target="font-family-heading" facet="family"
          consumingTokenIds={consumingIds} replacements={replacements}
          onConfirm={onConfirm} onCancel={() => {}} />)
        await user.selectOptions(screen.getByLabelText(/replacement/i), 'font-family-code')
        await user.click(screen.getByRole('button', { name: /delete/i }))
        expect(onConfirm).toHaveBeenCalledWith('font-family-code')
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement the modal using the same shape as BlockPickerModal. Props:

    interface Props {
      target: string                                         // id being deleted
      facet: 'family' | 'size'                               // which ref slot to repoint
      consumingTokenIds: string[]
      replacements: Array<{ id: string; name: string }>
      onConfirm: (replacementId: string) => void
      onCancel: () => void
    }
    
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(typography): add ReassignOnDeleteModal


Task 12 — TypographyPrimitivesPage

Files:

  • Create: src/components/pages/properties/TypographyPrimitivesPage.tsx

  • Create: src/components/pages/properties/TypographyPrimitivesPage.test.tsx

  • Step 1: Write failing testTypographyPrimitivesPage.test.tsx:

    import { describe, it, expect } from 'vitest'
    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import { TypographyPrimitivesPage } from './TypographyPrimitivesPage'
    
    describe('TypographyPrimitivesPage', () => {
      it('renders the 3 default font-family rows', () => {
        render(<TypographyPrimitivesPage />)
        expect(screen.getByText('font-family-heading')).toBeInTheDocument()
        expect(screen.getByText('font-family-body')).toBeInTheDocument()
        expect(screen.getByText('font-family-code')).toBeInTheDocument()
      })
    
      it('renders the 7 default font-size rows in order', () => {
        render(<TypographyPrimitivesPage />)
        const rows = screen.getAllByTestId(/^font-size-/)
        expect(rows[0]).toHaveAttribute('data-testid', 'font-size-xs')
        expect(rows[6]).toHaveAttribute('data-testid', 'font-size-3xl')
      })
    
      it('Generate scale opens the ScaleGeneratorModal', async () => {
        const user = userEvent.setup()
        render(<TypographyPrimitivesPage />)
        await user.click(screen.getByRole('button', { name: /generate scale/i }))
        expect(screen.getByLabelText(/ratio/i)).toBeInTheDocument()
      })
    
      it('"+ Add font family" appends a new primitive to the store', async () => {
        const user = userEvent.setup()
        render(<TypographyPrimitivesPage />)
        const before = screen.getAllByText(/^font-family-/).length
        await user.click(screen.getByRole('button', { name: /add font family/i }))
        const after = screen.getAllByText(/^font-family-/).length
        expect(after).toBe(before + 1)
      })
    
      it('the last remaining font-family primitive has a disabled delete', () => {
        // (helper to seed store with one family — TODO: factor a setup helper if reused)
        // For now: render and assert when only one exists in DOM, its delete is disabled.
        // Implementation note: rely on data-testid='delete-<id>' on the row.
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: Implement TypographyPrimitivesPage.tsx — copy TokensPrimitivesPage.tsx as the skeleton, adapt:

    • Filter tokens for t.group?.startsWith('Typography / ') so only typography primitives show.
    • Two sections: Family and Size. The Size section has a header button Generate scale that opens ScaleGeneratorModal. Both have + Add font family / + Add font size buttons at the end.
    • Each row uses TokenRow.EditableCell for the value cell. Family rows show a Edit font button that opens FontPickerModal.
    • Delete handler: count consuming semantic tokens before deletion. If any → open ReassignOnDeleteModal. If none → deleteToken directly. If count === <last of its facet> → disable.
    • When ScaleGeneratorModal.onApply([…7 values…]) fires, updateToken each font-size-<step> in turn.
    • Add data-testid="font-size-<step>" and data-testid="delete-<id>" to support the tests.
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(typography): add TypographyPrimitivesPage


Task 13 — TypographySemanticPage

Files:

  • Create: src/components/pages/properties/TypographySemanticPage.tsx

  • Create: src/components/pages/properties/TypographySemanticPage.test.tsx

  • Step 1: Write failing testsTypographySemanticPage.test.tsx:

    import { describe, it, expect } from 'vitest'
    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import { TypographySemanticPage } from './TypographySemanticPage'
    
    describe('TypographySemanticPage', () => {
      it('renders the 11 default semantic tokens', () => {
        render(<TypographySemanticPage />)
        for (const id of [
          'typography-display','typography-heading','typography-subheading','typography-title',
          'typography-body','typography-body-emphasis','typography-caption','typography-label',
          'typography-button','typography-code','typography-micro',
        ]) {
          expect(screen.getByText(id)).toBeInTheDocument()
        }
      })
    
      it('clicking a row expands it (accordion: only one open)', async () => {
        const user = userEvent.setup()
        render(<TypographySemanticPage />)
        await user.click(screen.getByText('typography-heading'))
        expect(screen.getByLabelText(/family/i)).toBeInTheDocument()
        await user.click(screen.getByText('typography-body'))
        // heading editor gone, body editor visible
        expect(screen.queryAllByLabelText(/family/i)).toHaveLength(1)
      })
    
      it('"Add property" reveals optional fields', async () => {
        const user = userEvent.setup()
        render(<TypographySemanticPage />)
        await user.click(screen.getByText('typography-body'))
        expect(screen.queryByLabelText(/letter-spacing/i)).toBeNull()
        await user.click(screen.getByRole('button', { name: /add property/i }))
        await user.click(screen.getByRole('menuitem', { name: /letter-spacing/i }))
        expect(screen.getByLabelText(/letter-spacing/i)).toBeInTheDocument()
      })
    
      it('changing weight commits via updateToken', async () => {
        const user = userEvent.setup()
        render(<TypographySemanticPage />)
        await user.click(screen.getByText('typography-heading'))
        const weight = screen.getByLabelText(/weight/i)
        await user.clear(weight); await user.type(weight, '900')
        await user.tab()  // commit on blur
        expect(weight).toHaveValue(900)
      })
    })
    
  • Step 2: Run npm run test — confirm failure.

  • Step 3: ImplementTypographySemanticPage.tsx. Reuse TokenSelect from TokenRow.tsx for the family / size dropdowns. Editor fields per token:

    • Family (dropdown — primitives with id starting font-family-).
    • Size (dropdown — primitives with id starting font-size-).
    • Weight (number input).
    • Line-height (text input — accepts number or unitless).
    • Optional fields hidden until added via "Add property" menu: letter-spacing, font-style, font-transform, font-decoration.
    • Each commit calls updateToken(id, patch).
    • Accordion: state in the page; only one expanded at a time.
    • Each row shows a tiny inline preview (a single sample word/phrase rendered with style={{ fontFamily: 'var(--typography-<id>-family)', fontSize: 'var(--typography-<id>-size)', fontWeight: 'var(--typography-<id>-weight)' }} etc.).
  • Step 4: Run npm run test — confirm pass.

  • Step 5: Commitfeat(typography): add TypographySemanticPage


Task 14 — Wire Typography into PropertiesPage nav + routes

Files:

  • Modify: src/components/pages/properties/PropertiesPage.tsx

  • Step 1: Write failing E2E — create e2e/typography-nav.spec.ts:

    import { test, expect } from '@playwright/test'
    test('Typography section is reachable from the left rail', async ({ page }) => {
      await page.goto('/library/properties')
      await page.getByRole('button', { name: 'Primitives', exact: true }).first().waitFor()
      await page.getByRole('button', { name: 'Typography primitives' }).click()
      await expect(page.getByText('font-family-heading')).toBeVisible()
      await page.getByRole('button', { name: 'Typography semantic' }).click()
      await expect(page.getByText('typography-body')).toBeVisible()
    })
    
  • Step 2: Run npm run e2e -- typography-nav — confirm failure (no nav items).

  • Step 3: Add the section — in PropertiesPage.tsx, append a new NavSection between Tokens and Layout:

    {
      label: 'Typography',
      items: [
        { path: 'typography/primitives', label: 'Typography primitives' },
        { path: 'typography/semantic',   label: 'Typography semantic' },
      ],
    },
    

    And add routes:

    <Route path="typography/primitives" element={<TypographyPrimitivesPage />} />
    <Route path="typography/semantic"   element={<TypographySemanticPage />} />
    

    Plus the imports at the top.

  • Step 4: Run npm run e2e -- typography-nav — confirm pass.

  • Step 5: Commitfeat(typography): expose Typography in PropertiesPage nav


Task 15 — Full Playwright E2E suite

Files:

  • Create: e2e/typography.spec.ts

Each scenario below maps directly to spec §10 E2E #1–8. Each becomes its own test() in the file. Write all eight, run, fix, repeat. Tests below are sketches — flesh out selectors as the UI lands.

  • Step 1: Stub the suite — create e2e/typography.spec.ts with eight test() blocks named after spec §10 items 1-8. Each starts with test.fail() so the suite fails loudly until each is implemented.

  • Step 2: Run npm run e2e -- typography — confirm 8 failures.

  • Step 3 — E2E #1 (Flow 1 happy path): navigate to Typography Primitives, click font-family-body → Edit font → pick Inter → Use this font → assert the row's value becomes 'Inter', sans-serif and that an inline preview anywhere on the page reports font-family: Inter in its computed style.

    test('Flow 1 — swap body font via Google Fonts', async ({ page }) => {
      await page.goto('/library/properties/typography/primitives')
      await page.getByText('font-family-body').click()
      await page.getByRole('button', { name: /edit font/i }).click()
      await page.getByPlaceholder(/search/i).fill('Inter')
      await page.getByText('Inter', { exact: true }).first().click()
      await page.getByRole('button', { name: /use this font/i }).click()
      await expect(page.locator('[data-testid="font-family-body-value"]')).toContainText(`'Inter', sans-serif`)
    })
    

    Run, fix selectors/data-testids on the page as needed, until green.

  • Step 4 — E2E #2 (Flow 1 offline): use page.route('**fonts.googleapis.com**', r => r.abort()) to simulate blocked network; assert error copy + that the primitive value didn't change.

  • Step 5 — E2E #3 (Flow 2 — semantic weight edit): nav to Typography Semantic, expand typography-heading, change weight to 900, tab away, assert getComputedStyle(<the preview span>).fontWeight === '900'.

  • Step 6 — E2E #4 (Flow 3 — scale generator): open the modal, set ratio to 1.333, assert the preview's md row reads 1rem (16px) and the xl row reads 1.776rem. Click Apply, assert the row values updated.

  • Step 7 — E2E #5 (Flow 4 — add primitive): click + Add font size, name font-size-4xl, value 3rem. Navigate to Semantic, expand a token, assert the new id is selectable in the size dropdown.

  • Step 8 — E2E #6 (delete with consumers — reassign modal): click delete on font-family-heading. Reassign modal opens, lists every typography-* token whose references.family is font-family-heading. Pick font-family-body, confirm. Assert: the deleted primitive is gone; all listed tokens now reference font-family-body.

  • Step 9 — E2E #7 (last-primitive guards): delete primitives down to one family + one size (manual chain in the test). Confirm the last delete button is disabled.

  • Step 10 — E2E #8 (input validation): open scale generator, type ratio 0.9, assert Apply is disabled. In a primitive size row, type 16 (no unit), assert the change is rejected. Try to add a primitive with id font-family-body (duplicate), assert rejection copy.

  • Step 11: Run npm run e2e -- typography — full pass (all 8 green).

  • Step 12: Committest(typography): add E2E suite mirroring spec §10


Task 16 — Documentation

Files:

  • Modify: tostada/REBUILD_PLAN.md — append a new milestone entry under M5.5 (e.g. M5.7 — Typography tokens).

  • Modify: tostada/docs/changelog.md — add an entry: "Typography tokens shipped (primitive font-family + font-size + 11 semantic roles). New Library → Typography surface."

  • Create: tostada/docs/concepts/typography.md — short concept doc, ~1 page: the primitive → semantic model, the application-rule guidance from spec §5.

  • Modify: tostada/CLAUDE.md (the project-level one, if it exists; otherwise leave a TODO in the root CLAUDE.md) — add a note: typography tokens live in src/tokens/defaults.ts, the emitter is src/tokens/typographyVars.ts, atoms read from CSS variables only.

  • Step 1: Update REBUILD_PLAN.md — append the milestone block (use the same shape as M5.5).

  • Step 2: Update docs/changelog.md — one bulletpoint.

  • Step 3: Write docs/concepts/typography.md — 200-400 words, the model + the 11-token application-rule table from spec §5.

  • Step 4: Update CLAUDE.md — small section under "Token tiers" if one exists, else add it.

  • Step 5: Commitdocs(typography): rebuild plan + changelog + concept page


Task 17 — Final verification

  • Step 1: Run the full suitenpm run lint && npm run build && npm run test && npm run e2e. Confirm all four pass.

  • Step 2: Manual QA from spec §10:

    • Boot npm run dev + npm run dev:preview-host.
    • Switch body font via the picker; confirm the Components surface in preview-host repaints with the new family (visible because Tailwind's --font-sans is aliased to our primitive in Task 6 Step 8).
    • Regenerate the scale at ratio 1.333; confirm preview-host text sizes change.
    • Export the library; open the resulting tokens.css; confirm it contains --font-family-*, --font-size-*, and --typography-*-{family,size,weight,line-height} lines.
    • Toggle dark mode; confirm nothing breaks (typography is not dark-overridden in v1; same values in both modes).
  • Step 3: Open a PR — gh pr create with a body summarizing the new surfaces, the data-model change, and the two new test runners.


Self-review checklist (writing-plans skill)

  • Spec coverage: every spec §5 scope checkbox has a task — primitives type (Task 2 + 4), semantic type (Task 2 + 4), Google Fonts picker (Task 7 + 9), system stacks (Task 9), generic CSS fallback (Task 4), modular scale generator (Task 3 + 10), manual edit + add (Task 12 + 13), CSS variable plumbing (Task 5 + 6), atom migration (Task 6 step 8 + preview-host globals). Default values table (§5) → Task 4. UX §6 → Tasks 9-13. Edge cases §8 → Tasks 10-12 + E2E §10.
  • No placeholders: every "implement" step shows the actual code or names the actual file + change to make. The handful of TODOs left in the doc (e.g. data-testid='delete-<id>' helper in Task 12) are concrete and bounded — not "add appropriate handling."
  • Type consistency: DesignToken widening (Task 2) → typography fields used in Task 4 → emitter consumes them in Task 5 → editor renders them in Task 13. Signature consistency: computeModularScale({base, ratio}) (Task 3) → ScaleGeneratorModal.onApply(values: ScaleValue[]) (Task 10) → TypographyPrimitivesPage consumes the same shape (Task 12).