Typography System Implementation Plan
Goal: Build the typography token layer end-to-end per specs/typography-system.md: widen the
DesignTokenmodel, 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
DesignTokenmap. Widenreferencestostring | { family: string; size: string }(decision §9 q1). Store typography static fields (fontWeight,lineHeight, optionalletterSpacingetc.) directly on the semantic token. Emit primitives as Tailwind-v4-compatible CSS variables sotext-*/font-sansutilities in thepreview-hostiframe pick them up automatically. Emit semantic tokens as namespaced CSS vars (--typography-heading-family, …) into the exportedtokens.cssfor docs + Rethemer consumption. ReuseTokenContext.useTokenInjection,useTokenBroadcast,generateTokensCss, theTokenRowediting 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-reacticons. Tests: Vitest 1.x +@testing-library/react+jsdomfor 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 viavitest.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 testbefore every commit; the commit step in each task assumes you have run all three and they pass. - Path alias
@/*is configured (seetsconfig.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/nodeStep 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"totsconfig.app.jsontypes Edit the"types"array intsconfig.app.jsonfrom["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. Deletesrc/test-setup.test.tsafterwards.Step 8: Commit —
chore(test): set up vitest + testing-library + jsdom
Task 1 — Stand up Playwright
Files:
Create:
tostada/playwright.config.tsCreate:
tostada/e2e/.gitkeepModify:
tostada/package.json(deps + scripts)Step 1: Install Playwright
npm install --save-dev @playwright/test npx playwright install chromiumStep 2: Create
playwright.config.tsimport { 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.tsto 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: Commit —
chore(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 test —
src/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 (isTypographyReferencesdoesn't exist,referenceswon't accept object,fontWeightfield missing).Step 3: Widen the type in
src/tokens/types.ts— edit theDesignTokeninterface: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— insrc/tokens/TokenContext.tsx, theresolveandresolveDarkhelpers currently doif (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.tsresolvers — replace the two existingif (t.references && tokens[t.references])checks inresolveLight/resolveDarkwith the sametypeof 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 anyreferencesaccess. (Read the file first — there's only one resolver here.)Step 7: Update
useResolvedValueinTokenRow.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. Runnpm run buildto confirm no type errors in any file that importsDesignToken.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: Commit —
feat(tokens): widen references to union for typography tokens
Task 3 — Modular scale calculator
Files:
Create:
src/tokens/typography.tsCreate:
src/tokens/typography.test.tsStep 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: Commit —
feat(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 validDesignTokens).Step 6: Commit —
feat(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: Commit —
feat(tokens): emit typography CSS variables
Task 6 — Wire typography vars into live preview + docs
Files:
Modify:
src/tokens/TokenContext.tsxModify:
src/lib/docs.tsModify:
src/lib/shadcnCss.tsModify:
preview-host/src/globals.cssModify:
src/lib/useTokenBroadcast.ts(if needed — Task 6 sub-step inspects)Create:
src/lib/docs.test.tsStep 1: Write failing test for
generateTokensCssinsrc/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
generateTokensCssinsrc/lib/docs.ts— after the existing semantic-tier loop, add typography emission viaemitAllTypographyVars. The existingfor (const t of orderedTokens)loop currently emits--<id>: <value>;for everything; for semantic tokens with object references it'll emit an empty line (becauseresolveLightreturns''). 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 ondocs.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: iftypeof t.references === 'object', callemitTypographyVars, else fall through to the existing single-line emit.Step 6: Write a Vitest for
useTokenInjectionto verify the injected<style>contents — add tosrc/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). IfbuildShadcnCssbuilds only the shadcn-tier vars today, extend it to also emitfont-family-*+font-size-*primitive vars (not the typography semantic vars — those are documentation-only for v1). Add a test insrc/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 sotext-sm,font-sans,font-monowork without code changes in the shadcn components. Add inside@theme inlineblock:/* 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 inlineblock first so the snippet slots in correctly.)Step 9: Commit —
feat(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: Commit —
feat(tokens): bundle google fonts catalog (60 families)
Task 8 — Font loader
Files:
Create:
src/tokens/fontLoader.tsCreate:
src/tokens/fontLoader.test.tsStep 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: Implement —
src/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: Commit —
feat(tokens): add google fonts <link> loader
Task 9 — FontPickerModal component
Files:
Create:
src/components/pages/properties/FontPickerModal.tsxCreate:
src/components/pages/properties/FontPickerModal.test.tsxStep 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 existingBlockPickerModalshape for the wrapper (click-outside backdrop, centered card, max-height). Inside:- Header: title
Pick a font+Xclose button. - Search input filtering the bundled catalog.
System / generic stackssection: 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.- Header: title
Step 4: Run
npm run test— confirm pass.Step 5: Commit —
feat(typography): add FontPickerModal
Task 10 — ScaleGeneratorModal component
Files:
Create:
src/components/pages/properties/ScaleGeneratorModal.tsxCreate:
src/components/pages/properties/ScaleGeneratorModal.test.tsxStep 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, default1, unit suffixrem),ratio(number, default1.25, quick picks1.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 ontofont-size-*token IDs.
- Two inputs:
Step 4: Run
npm run test— confirm pass.Step 5: Commit —
feat(typography): add ScaleGeneratorModal
Task 11 — ReassignOnDeleteModal component
Files:
Create:
src/components/pages/properties/ReassignOnDeleteModal.tsxCreate:
src/components/pages/properties/ReassignOnDeleteModal.test.tsxStep 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: Commit —
feat(typography): add ReassignOnDeleteModal
Task 12 — TypographyPrimitivesPage
Files:
Create:
src/components/pages/properties/TypographyPrimitivesPage.tsxCreate:
src/components/pages/properties/TypographyPrimitivesPage.test.tsxStep 1: Write failing test —
TypographyPrimitivesPage.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— copyTokensPrimitivesPage.tsxas the skeleton, adapt:- Filter
tokensfort.group?.startsWith('Typography / ')so only typography primitives show. - Two sections:
FamilyandSize. TheSizesection has a header buttonGenerate scalethat opensScaleGeneratorModal. Both have+ Add font family/+ Add font sizebuttons at the end. - Each row uses
TokenRow.EditableCellfor the value cell. Family rows show aEdit fontbutton that opensFontPickerModal. - Delete handler: count consuming semantic tokens before deletion. If any → open
ReassignOnDeleteModal. If none →deleteTokendirectly. Ifcount === <last of its facet>→ disable. - When
ScaleGeneratorModal.onApply([…7 values…])fires,updateTokeneachfont-size-<step>in turn. - Add
data-testid="font-size-<step>"anddata-testid="delete-<id>"to support the tests.
- Filter
Step 4: Run
npm run test— confirm pass.Step 5: Commit —
feat(typography): add TypographyPrimitivesPage
Task 13 — TypographySemanticPage
Files:
Create:
src/components/pages/properties/TypographySemanticPage.tsxCreate:
src/components/pages/properties/TypographySemanticPage.test.tsxStep 1: Write failing tests —
TypographySemanticPage.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: Implement —
TypographySemanticPage.tsx. ReuseTokenSelectfromTokenRow.tsxfor the family / size dropdowns. Editor fields per token:- Family (dropdown — primitives with
idstartingfont-family-). - Size (dropdown — primitives with
idstartingfont-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.).
- Family (dropdown — primitives with
Step 4: Run
npm run test— confirm pass.Step 5: Commit —
feat(typography): add TypographySemanticPage
Task 14 — Wire Typography into PropertiesPage nav + routes
Files:
Modify:
src/components/pages/properties/PropertiesPage.tsxStep 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 newNavSectionbetweenTokensandLayout:{ 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: Commit —
feat(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.tswith eighttest()blocks named after spec §10 items 1-8. Each starts withtest.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 → pickInter→ Use this font → assert the row's value becomes'Inter', sans-serifand that an inline preview anywhere on the page reportsfont-family: Interin 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 to900, tab away, assertgetComputedStyle(<the preview span>).fontWeight === '900'.Step 6 — E2E #4 (Flow 3 — scale generator): open the modal, set ratio to
1.333, assert the preview'smdrow reads1rem (16px)and thexlrow reads1.776rem. Click Apply, assert the row values updated.Step 7 — E2E #5 (Flow 4 — add primitive): click
+ Add font size, namefont-size-4xl, value3rem. 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 everytypography-*token whosereferences.familyisfont-family-heading. Pickfont-family-body, confirm. Assert: the deleted primitive is gone; all listed tokens now referencefont-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, type16(no unit), assert the change is rejected. Try to add a primitive with idfont-family-body(duplicate), assert rejection copy.Step 11: Run
npm run e2e -- typography— full pass (all 8 green).Step 12: Commit —
test(typography): add E2E suite mirroring spec §10
Task 16 — Documentation
Files:
Modify:
tostada/REBUILD_PLAN.md— append a new milestone entry underM5.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 insrc/tokens/defaults.ts, the emitter issrc/tokens/typographyVars.ts, atoms read from CSS variables only.Step 1: Update
REBUILD_PLAN.md— append the milestone block (use the same shape asM5.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: Commit —
docs(typography): rebuild plan + changelog + concept page
Task 17 — Final verification
Step 1: Run the full suite —
npm 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-sansis 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).
- Boot
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>' helperin Task 12) are concrete and bounded — not "add appropriate handling." - Type consistency:
DesignTokenwidening (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) →TypographyPrimitivesPageconsumes the same shape (Task 12).