Sizing, Spacing & Layout Tokens Implementation Plan

Goal: Give size (spacing, radius, layout widths) its own newbie-legible home — a single --spacing/--radius base that rescales everything via CSS calc(), semantic intents (inset/stack/inline), a none/small/medium/full radius level, size-content-* widths, and a per-component Fit/Fill/Fixed sizing model with min/max + advanced inset. Architecture: Two milestones. Milestone A (unflagged, additive): new size tokens — primitives + semantic intents seeded in defaults.ts, a sizing.ts scale generator mirroring typography.ts, a new Sizing Properties sub-tab, and injector/export emission (which already works for calc() strings — no resolver change). Milestone B (feature-flagged): the riskier component-sizing machinery — a componentSizing store slice carrying per-axis Fit/Fill/Fixed + min/max + advanced inset, resolver functions in sizing.ts, validation, UI, and preview wiring. Tech Stack: React + TypeScript (Vite) · Zustand + immer · Tailwind v4 (@theme in preview-host) · Vitest + Playwright · pure export generators (no React/window).


Phasing & recommendation

The spec (§13 Rollout) explicitly separates two halves with different risk:

  • Milestone A — Size tokens. Additive, safe, ships unflagged. Self-contained and independently testable: tokens exist, render in the Sizing tab, inject into previews, and export. A new library gets a coherent spacing/radius/layout vocabulary.
  • Milestone B — Component-sizing machinery. Fit/Fill/Fixed mode state, min/max, advanced inset, validation, extensibility UI. Riskier, lands behind src/lib/featureFlags.ts per the trunk-based rule, until it stabilizes.

Recommendation: build and ship Milestone A end-to-end first (it's releasable on its own), then start Milestone B behind the flag. If you prefer two separate plan documents, A and B split cleanly at Task 9. This single document keeps them together because B reuses A's types and sizing.ts module.

Conventions used below

  • Spacing base --spacing = 0.25rem (4px). space-N px ≈ 4 × N.
  • Group strings drive the Properties UI section headers: 'Sizing / Spacing', 'Sizing / Radius', 'Sizing / Layout'.
  • Commit after every green task (trunk-based: small green increments straight to main).
  • Run a single test file with npm run test -- src/tokens/sizing.test.ts.

File Structure

Milestone A

  • Create src/tokens/sizing.ts — scale generator + token-builder helpers (mirrors typography.ts). Later (B) also hosts the resolvers.
  • Create src/tokens/sizing.test.ts — unit tests.
  • Modify src/tokens/defaults.ts — seed spacing primitives + semantic intents, radius base + 4 semantic levels (remove radius-base/radius), layout sizes + size-fill.
  • Modify src/lib/librarySections.ts — add the Sizing nav section.
  • Create src/components/pages/properties/TokensSizingPage.tsx — base dials + read-only derived ramp + alias selects + extensibility rows.
  • Modify src/components/pages/properties/PropertiesPage.tsx — add sizing/* routes.
  • Modify preview-host/src/globals.css — alias sizing vars under @theme inline with fallbacks.
  • Modify src/export/tokensCss.ts — verify size tokens emit (likely no change; covered by a test).

Milestone B

  • Create src/lib/featureFlags.ts — flag module.
  • Create src/lib/featureFlags.test.ts.
  • Modify src/tokens/sizing.ts — add InsetValue/AxisSizing/SizeMode types + resolveInset, resolveAxisSizing, validateBounds, validateContainerSizing.
  • Modify src/tokens/sizing.test.ts — resolver + validation tests.
  • Modify src/store/index.ts — add a componentSizing slice (mirrors componentThemes).
  • Create src/components/pages/properties/ComponentSizingControls.tsx — Fit/Fill/Fixed + min/max + advanced inset UI (flag-gated).
  • Modify src/lib/componentCss.ts — emit .tostada-cmp-{id} sizing rules from the resolvers.
  • Modify preview-host component(s) (Button/Card) — consume inset-*/radius-* and demonstrate Fill/Fixed.

Milestone A — Size tokens (unflagged)

Task 1: Spacing scale generator (sizing.ts)

Files:

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

Mirror src/tokens/typography.ts:computeModularScale (it returns an array of { step, …, cssValue, displayLabel }). Spacing differs: the ramp is multiplier-based off a single base, and step 0 is the literal 0.

  • Step 1: Write the failing test. In src/tokens/sizing.test.ts:
import { describe, it, expect } from 'vitest'
import { SPACING_STEPS, computeSpacingRamp } from '@/tokens/sizing'

describe('computeSpacingRamp', () => {
  it('exposes the trimmed step set (~12 steps, not linear 0–24)', () => {
    expect(SPACING_STEPS).toEqual([0, 1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24])
  })

  it('emits a calc() chain off --spacing for non-zero steps', () => {
    const ramp = computeSpacingRamp({ basePx: 4 })
    const s4 = ramp.find((r) => r.step === 4)!
    expect(s4.cssValue).toBe('calc(var(--spacing) * 4)')
    expect(s4.px).toBe(16)            // 4px base × 4
    expect(s4.displayLabel).toBe('16px')
  })

  it('step 0 is the literal 0, never calc(... * 0)', () => {
    const ramp = computeSpacingRamp({ basePx: 4 })
    const s0 = ramp.find((r) => r.step === 0)!
    expect(s0.cssValue).toBe('0')
    expect(s0.px).toBe(0)
  })

  it('recomputes px when the base changes (one dial rescales all)', () => {
    const ramp = computeSpacingRamp({ basePx: 5 })
    expect(ramp.find((r) => r.step === 4)!.px).toBe(20)
  })

  it('rejects a non-positive base', () => {
    expect(() => computeSpacingRamp({ basePx: 0 })).toThrow()
  })
})
  • Step 2: Run it and confirm it fails (npm run test -- src/tokens/sizing.test.ts) — module/exports missing.
  • Step 3: Implement the minimal generator. In src/tokens/sizing.ts:
export const SPACING_STEPS = [0, 1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24] as const
export type SpacingStep = (typeof SPACING_STEPS)[number]

export interface SpacingRampValue {
  step: SpacingStep
  px: number
  cssValue: string      // '0' | 'calc(var(--spacing) * 4)'
  displayLabel: string  // '16px'
}

/** Multiplier ramp off a single `--spacing` base. Step 0 is the literal 0. */
export function computeSpacingRamp({ basePx }: { basePx: number }): SpacingRampValue[] {
  if (!(basePx > 0)) throw new Error('spacing base must be > 0')
  return SPACING_STEPS.map((step) => ({
    step,
    px: basePx * step,
    cssValue: step === 0 ? '0' : `calc(var(--spacing) * ${step})`,
    displayLabel: `${basePx * step}px`,
  }))
}
  • Step 4: Run the tests — confirm green.
  • Step 5: Commitfeat(sizing): spacing ramp generator.

Task 2: Radius scale helper (sizing.ts)

Files:

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

Radius has no primitive ramp — its 4 semantic values hold the calc directly (none=0, small=calc(var(--radius) - 2px), medium=var(--radius), full=9999px).

  • Step 1: Write the failing test. Append:
import { RADIUS_LEVELS, computeRadiusValues } from '@/tokens/sizing'

describe('computeRadiusValues', () => {
  it('has exactly four levels none/small/medium/full', () => {
    expect(RADIUS_LEVELS).toEqual(['none', 'small', 'medium', 'full'])
  })
  it('holds calc directly off --radius (no intermediate ramp)', () => {
    const v = computeRadiusValues()
    expect(v.none).toBe('0')
    expect(v.small).toBe('calc(var(--radius) - 2px)')
    expect(v.medium).toBe('var(--radius)')
    expect(v.full).toBe('9999px')
  })
})
  • Step 2: Run — confirm it fails.
  • Step 3: Implement. Append to sizing.ts:
export const RADIUS_LEVELS = ['none', 'small', 'medium', 'full'] as const
export type RadiusLevel = (typeof RADIUS_LEVELS)[number]

export function computeRadiusValues(): Record<RadiusLevel, string> {
  return {
    none: '0',
    small: 'calc(var(--radius) - 2px)',
    medium: 'var(--radius)',
    full: '9999px',
  }
}
  • Step 4: Run — green.
  • Step 5: Commitfeat(sizing): radius calc levels.

Task 3: DesignToken builders for the seed (sizing.ts)

Files:

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

Centralize token construction so defaults.ts (and the extensibility store writes in Task 13) build the same shapes. Read the exact DesignToken interface in src/tokens/types.ts first (id,name,tier,value,references?,group?,usage?).

  • Step 1: Write the failing test. Append:
import { buildSpacingTokens, buildRadiusTokens, buildLayoutTokens } from '@/tokens/sizing'

describe('seed builders', () => {
  it('builds the --spacing base + ramp primitives in the Spacing group', () => {
    const toks = buildSpacingTokens({ basePx: 4 })
    const base = toks.find((t) => t.id === 'spacing')!
    expect(base).toMatchObject({ tier: 'primitive', value: '0.25rem', group: 'Sizing / Spacing' })
    const s4 = toks.find((t) => t.id === 'space-4')!
    expect(s4).toMatchObject({ tier: 'primitive', value: 'calc(var(--spacing) * 4)' })
  })

  it('builds semantic intents that reference ramp ids (string references)', () => {
    const toks = buildSpacingTokens({ basePx: 4 })
    const insetMd = toks.find((t) => t.id === 'inset-md')!
    expect(insetMd).toMatchObject({ tier: 'semantic', references: 'space-4', group: 'Sizing / Spacing' })
    // intent step counts: inset 5, stack 4, inline 3
    expect(toks.filter((t) => t.id.startsWith('inset-')).length).toBe(5)
    expect(toks.filter((t) => t.id.startsWith('stack-')).length).toBe(4)
    expect(toks.filter((t) => t.id.startsWith('inline-')).length).toBe(3)
  })

  it('builds radius base primitive + 4 semantic levels holding calc verbatim', () => {
    const toks = buildRadiusTokens()
    expect(toks.find((t) => t.id === 'radius')).toMatchObject({ tier: 'primitive', group: 'Sizing / Radius' })
    const small = toks.find((t) => t.id === 'radius-small')!
    expect(small).toMatchObject({ tier: 'semantic', value: 'calc(var(--radius) - 2px)' })
    expect(small.references).toBeUndefined()  // calc held directly, emitted verbatim
  })

  it('builds layout content widths + size-fill as semantic verbatim values', () => {
    const toks = buildLayoutTokens()
    expect(toks.find((t) => t.id === 'size-content-md')).toMatchObject({ tier: 'semantic', value: '45ch', group: 'Sizing / Layout' })
    expect(toks.find((t) => t.id === 'size-fill')).toMatchObject({ value: '100%' })
  })
})
  • Step 2: Run — confirm it fails.
  • Step 3: Implement. Append to sizing.ts (import DesignToken from ./types):
import type { DesignToken } from './types'

const SPACING_GROUP = 'Sizing / Spacing'
const RADIUS_GROUP = 'Sizing / Radius'
const LAYOUT_GROUP = 'Sizing / Layout'

// intent → ramp step. md is the default anchor in every intent.
const INTENT_MAP = {
  inset:  { xs: 2, sm: 3, md: 4, lg: 6, xl: 8 },
  stack:  { sm: 2, md: 4, lg: 6, xl: 10 },
  inline: { sm: 2, md: 3, lg: 6 },
} as const

export function buildSpacingTokens({ basePx }: { basePx: number }): DesignToken[] {
  const baseRem = basePx / 16
  const out: DesignToken[] = [
    { id: 'spacing', name: 'Spacing base', tier: 'primitive', value: `${baseRem}rem`, group: SPACING_GROUP },
  ]
  for (const r of computeSpacingRamp({ basePx })) {
    out.push({ id: `space-${r.step}`, name: `Space ${r.step}`, tier: 'primitive', value: r.cssValue, group: SPACING_GROUP })
  }
  for (const [intent, steps] of Object.entries(INTENT_MAP)) {
    for (const [alias, step] of Object.entries(steps)) {
      out.push({ id: `${intent}-${alias}`, name: `${intent} ${alias}`, tier: 'semantic', value: '', references: `space-${step}`, group: SPACING_GROUP })
    }
  }
  return out
}

export function buildRadiusTokens({ baseRem = 0.625 }: { baseRem?: number } = {}): DesignToken[] {
  const v = computeRadiusValues()
  return [
    { id: 'radius', name: 'Radius base', tier: 'primitive', value: `${baseRem}rem`, group: RADIUS_GROUP },
    ...RADIUS_LEVELS.map((level): DesignToken => ({
      id: `radius-${level}`, name: `Radius ${level}`, tier: 'semantic', value: v[level], group: RADIUS_GROUP,
    })),
  ]
}

export function buildLayoutTokens(): DesignToken[] {
  return [
    { id: 'size-content-sm', name: 'Content sm', tier: 'semantic', value: '20ch', group: LAYOUT_GROUP },
    { id: 'size-content-md', name: 'Content md', tier: 'semantic', value: '45ch', group: LAYOUT_GROUP },
    { id: 'size-content-lg', name: 'Content lg', tier: 'semantic', value: '60ch', group: LAYOUT_GROUP },
    { id: 'size-fill', name: 'Fill', tier: 'semantic', value: '100%', group: LAYOUT_GROUP },
  ]
}

Note: the radius-* semantic tokens hold calc in value with no references, so the injector (resolve() returns token.value when references is not a string) emits them verbatim — exactly the gradient pass-through path. The inset/stack/inline intents use a string references, so resolve() chases inset-md → space-4 → calc(var(--spacing) * 4), staying reactive to the base.

  • Step 4: Run — green.
  • Step 5: Commitfeat(sizing): seed token builders.

Task 4: Seed the tokens in defaults.ts

Files:

  • Modify: src/tokens/defaults.ts

  • Step 1: Write the failing test. New src/tokens/defaults.sizing.test.ts:

import { describe, it, expect } from 'vitest'
import { DEFAULT_TOKENS } from '@/tokens/defaults'   // confirm the real export name first

const byId = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t]))

describe('default sizing tokens', () => {
  it('seeds the spacing base + intents', () => {
    expect(byId['spacing']?.tier).toBe('primitive')
    expect(byId['inset-md']?.references).toBe('space-4')
  })
  it('replaces radius-base/radius with a radius base + semantic levels', () => {
    expect(byId['radius']?.tier).toBe('primitive')         // base is now a primitive
    expect(byId['radius-base']).toBeUndefined()            // old primitive gone
    expect(byId['radius-medium']?.value).toBe('var(--radius)')
  })
  it('seeds layout widths', () => {
    expect(byId['size-content-md']?.value).toBe('45ch')
    expect(byId['size-fill']?.value).toBe('100%')
  })
})
  • Step 2: Run — confirm it fails.
  • Step 3: Implement. In defaults.ts: import the builders, remove the radius-base primitive (line ~32) and the radius component token (line ~128), and spread the new tokens into the seed array:
import { buildSpacingTokens, buildRadiusTokens, buildLayoutTokens } from './sizing'
// ...inside the DEFAULT_TOKENS array literal, after typography:
  ...buildSpacingTokens({ basePx: 4 }),
  ...buildRadiusTokens(),
  ...buildLayoutTokens(),

The component-tier radius token is replaced by the primitive radius (same emitted --radius var, so shadcn/preview keep working). Per §8 this needs no migration — Doc 1 is a hard reset; old radius-base/radius data is discarded on load by isLegacyColourData. If that guard doesn't already cover libraries lacking spacing, add a one-line check (see Task 6).

  • Step 4: Run — green. Also run the full token suite (npm run test -- src/tokens) to catch any reader that assumed radius-base exists; fix references to point at radius/radius-medium.
  • Step 5: Commitfeat(sizing): seed spacing/radius/layout tokens in defaults.

Task 5: Injector & export emission (verify with a test — likely no code change)

Files:

  • Create: src/tokens/sizing.injection.test.ts
  • Modify (only if test fails): src/tokens/TokenContext.tsx, src/export/tokensCss.ts

The injector already emits --${t.id}: ${resolve(t.id)} and resolves string refs / verbatim values, so calc chains and var(--radius) should pass through untouched. Prove it.

  • Step 1: Write the failing test.
import { describe, it, expect } from 'vitest'
import { buildInjectedCss } from '@/tokens/TokenContext'
import { generateTokensCss } from '@/export/tokensCss'
import { DEFAULT_TOKENS } from '@/tokens/defaults'

const map = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t]))

describe('sizing emission', () => {
  it('injector emits the spacing base, calc ramp, intents, radius and layout', () => {
    const css = buildInjectedCss(map, [])
    expect(css).toContain('--spacing: 0.25rem;')
    expect(css).toContain('--space-4: calc(var(--spacing) * 4);')
    expect(css).toContain('--inset-md: calc(var(--spacing) * 4);')   // chased through space-4
    expect(css).toContain('--radius-medium: var(--radius);')
    expect(css).toContain('--size-fill: 100%;')
  })
  it('export emits the same size tokens in tier order', () => {
    const css = generateTokensCss('Demo', map, [])
    expect(css).toContain('--space-4: calc(var(--spacing) * 4);')
    expect(css).toContain('--radius-full: 9999px;')
  })
})
  • Step 2: Run. If green already (expected), skip to Step 4. If a calc string is mangled (e.g. an over-eager resolver), fix the emitter to pass value verbatim when references is absent.
  • Step 3: (Only if needed) implement the fix and re-run.
  • Step 4: Confirm green.
  • Step 5: Committest(sizing): lock injector + export emission.

Task 6: Legacy-data guard for libraries without sizing tokens

Files:

  • Modify: src/store/index.ts (the load/merge path) or wherever mergeDefaultFamilies-style top-up runs
  • Modify: src/tokens/defaults.ts (export a mergeDefaultSizingTokens helper, mirroring mergeDefaultFamilies)

A persisted alpha library may lack the new size tokens. Top them up on load (additive, non-destructive) like families already do (mergeDefaultFamilies).

  • Step 1: Write the failing test in src/tokens/defaults.sizing.test.ts:
import { mergeDefaultSizingTokens } from '@/tokens/defaults'
it('tops up missing sizing tokens without overwriting user edits', () => {
  const existing = { 'inset-md': { id: 'inset-md', name: 'inset md', tier: 'semantic', value: '', references: 'space-6' } as any }
  const merged = mergeDefaultSizingTokens(existing)
  expect(merged['inset-md'].references).toBe('space-6')   // user edit preserved
  expect(merged['radius-medium']).toBeDefined()           // missing one added
})
  • Step 2: Run — fails.
  • Step 3: Implement mergeDefaultSizingTokens(map) in defaults.ts (add a default only when the id is absent), and call it in the store's load path next to the family merge.
  • Step 4: Run — green.
  • Step 5: Commitfeat(sizing): top up missing sizing tokens on load.

Task 7: Sizing nav section + routes

Files:

  • Modify: src/lib/librarySections.ts

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

  • Step 1: Add the nav section in librarySections.ts PROPERTIES_SECTIONS (place after Typography, before Layout):

{
  label: 'Sizing',
  items: [
    { path: 'sizing/primitives', label: 'Spacing & radius' },
    { path: 'sizing/semantic',   label: 'Intents & layout' },
  ],
},
  • Step 2: Add the routes in PropertiesPage.tsx:
<Route path="sizing/primitives" element={<TokensSizingPage view="primitives" />} />
<Route path="sizing/semantic"   element={<TokensSizingPage view="semantic" />} />
  • Step 3: Stub TokensSizingPage (Task 8 fills it) so the app compiles: a component reading view prop returning a PageHeader. Run npm run dev — confirm both sub-tabs appear and route without error.
  • Step 4: Commitfeat(sizing): Sizing properties sub-tab routes.

Task 8: TokensSizingPage — base dials + derived ramp + alias selects

Files:

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

Mirror TokensPrimitivesPage.tsx/TypographyPrimitivesPage.tsx: read useStore((s) => s.tokens), filter by group?.startsWith('Sizing'), switch on useMode() (Designer = editable, Developer = ReadOnlyTokenList copy chips). Implement the §6 surface:

  • Primitives view: the Spacing base dial (slider + number; presets Compact/Comfortable/Spacious set basePx 3/4/6 → updateToken('spacing', { value })), the derived ramp as read-only rows showing computed px (call computeSpacingRamp), and the Radius base dial + read-only 4 levels.
  • Semantic view: intent rows (inset/stack/inline) as alias → space-N ▾ selects that updateToken(intentId, { references: 'space-N' }); the Layout widths (size-content-*) as editable values.
  • Dev mode leads with the semantic intent chip (var(--inset-md)) and shows the primitive (var(--space-4)) secondarily (§6 Developer mode).

The advanced-inset toggle and the component Fit/Fill/Fixed controls are Milestone B — gate them out here (don't render) until Task 14.

  • Step 1: Build the component following the existing primitives page (copy its PageHeader, row layout, updateToken wiring). Because base recompute is pure CSS (calc), changing --spacing/--radius reflows previews with no extra writes — assert that manually.
  • Step 2: Smoke test (Playwright) in e2e/sizing.spec.ts (the Milestone-A slice of §10 E2E #1–#2):
    1. Pick "Spacious" → a preview Button's padding visibly grows.
    2. Drag the radius base → Card + Button corners round together.
  • Step 3: Run npm run dev and verify both views render and edits cascade live.
  • Step 4: Commitfeat(sizing): Sizing properties page (dials + ramp + intents).

Task 9: preview-host aliases + a consuming component

Files:

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

  • Modify: a preview-host component (e.g. Button, Card) under preview-host/src/components/

  • Step 1: Alias under @theme inline in globals.css (with fallbacks, mirroring the --text-* block) so Tailwind utilities resolve to our tokens:

  --spacing: var(--spacing, 0.25rem);
  --radius: var(--radius, 0.625rem);
  --radius-md: var(--radius-medium, var(--radius));
  • Step 2: Wire one atom — Button padding via inset-md, Card radius via radius-medium — proving the tokens drive real components (§5 last bullet, Milestone-A part only).
  • Step 3: Run both dev servers (npm run dev + npm run dev:preview-host) and confirm the Button/Card reflect base-dial changes.
  • Step 4: Commitfeat(sizing): preview-host consumes inset/radius tokens.

✅ Milestone A is shippable here. Update docs (Task 18) now or after B.


Milestone B — Component sizing machinery (feature-flagged)

Task 10: Feature-flag module

Files:

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

featureFlags.ts is referenced in CLAUDE.md but does not exist yet — create it.

  • Step 1: Write the failing test.
import { describe, it, expect } from 'vitest'
import { isFlagEnabled, FLAGS } from '@/lib/featureFlags'
describe('featureFlags', () => {
  it('defines componentSizing, off by default', () => {
    expect(FLAGS.componentSizing.default).toBe(false)
    expect(isFlagEnabled('componentSizing')).toBe(false)
  })
})
  • Step 2: Run — fails.
  • Step 3: Implement a minimal registry (default map + optional localStorage override tostada:flag:<name>):
export const FLAGS = { componentSizing: { default: false, label: 'Component Fit/Fill/Fixed sizing' } } as const
export type FlagName = keyof typeof FLAGS
export function isFlagEnabled(name: FlagName): boolean {
  const o = typeof localStorage !== 'undefined' ? localStorage.getItem(`tostada:flag:${name}`) : null
  return o == null ? FLAGS[name].default : o === 'true'
}
  • Step 4: Run — green.
  • Step 5: Commitfeat(flags): featureFlags module + componentSizing flag.

Task 11: Inset value-shape resolver (sizing.ts)

Files:

  • Modify: src/tokens/sizing.ts, src/tokens/sizing.test.ts

  • Step 1: Write the failing test.

import { resolveInset, type InsetValue } from '@/tokens/sizing'
describe('resolveInset', () => {
  it('scalar → padding (square default)', () => {
    expect(resolveInset('var(--inset-md)')).toEqual({ padding: 'var(--inset-md)' })
  })
  it('{x,y} → padding-inline / padding-block (squish/stretch)', () => {
    expect(resolveInset({ x: 'var(--inline-md)', y: 'var(--inset-sm)' })).toEqual({
      'padding-inline': 'var(--inline-md)', 'padding-block': 'var(--inset-sm)',
    })
  })
  it('per-side → four longhands', () => {
    const v: InsetValue = { top: 'a', right: 'b', bottom: 'c', left: 'd' }
    expect(resolveInset(v)).toEqual({ 'padding-top': 'a', 'padding-right': 'b', 'padding-bottom': 'c', 'padding-left': 'd' })
  })
  it('partial {x} falls back to the square default for the unset axis', () => {
    expect(resolveInset({ x: 'var(--inline-md)' } as any, 'var(--inset-md)')).toEqual({
      'padding-inline': 'var(--inline-md)', 'padding-block': 'var(--inset-md)',
    })
  })
})
  • Step 2: Run — fails.
  • Step 3: Implement InsetValue type + resolveInset(value, squareDefault?) per the test (unset axis/side inherits the square default; never emit a partial/empty padding — §8).
  • Step 4: Run — green.
  • Step 5: Commitfeat(sizing): inset value-shape resolver.

Task 12: Fit/Fill/Fixed resolver + bounds validation (sizing.ts)

Files:

  • Modify: src/tokens/sizing.ts, src/tokens/sizing.test.ts

  • Step 1: Write the failing test.

import { resolveAxisSizing, validateBounds, validateContainerSizing, type AxisSizing } from '@/tokens/sizing'
describe('resolveAxisSizing', () => {
  it('fit → fit-content; fill → var(--size-fill); fixed → value', () => {
    expect(resolveAxisSizing({ mode: 'fit' }, 'width')).toEqual({ width: 'fit-content' })
    expect(resolveAxisSizing({ mode: 'fill' }, 'width')).toEqual({ width: 'var(--size-fill)' })
    expect(resolveAxisSizing({ mode: 'fixed', value: 'var(--size-content-md)' }, 'width'))
      .toEqual({ width: 'var(--size-content-md)' })
  })
  it('emits min/max longhands (px or size-content ref)', () => {
    expect(resolveAxisSizing({ mode: 'fill', min: '240px', max: 'var(--size-content-lg)' }, 'width'))
      .toEqual({ width: 'var(--size-fill)', 'min-width': '240px', 'max-width': 'var(--size-content-lg)' })
  })
})
describe('validation', () => {
  it('rejects fit on a container (containers don’t hug)', () => {
    expect(validateContainerSizing({ mode: 'fit' } as AxisSizing).ok).toBe(false)
  })
  it('rejects inverted bounds min > max', () => {
    expect(validateBounds({ min: '640px', max: '240px' }).ok).toBe(false)
  })
})
  • Step 2: Run — fails.
  • Step 3: Implement the SizeMode/AxisSizing types, resolveAxisSizing(axis, side), validateBounds (px-compare; only flags when both are comparable px), and validateContainerSizing (reject fit).
  • Step 4: Run — green.
  • Step 5: Commitfeat(sizing): Fit/Fill/Fixed resolver + bounds validation.

Task 13: componentSizing store slice (extensibility-ready)

Files:

  • Modify: src/store/index.ts

Mirror the componentThemes slice (line 112–126, methods ~337–360): a persisted array keyed by componentId, mutated via immer, included in the persisted snapshot (666) and reset paths (~587/629).

  • Step 1: Write the failing test src/store/sizing.store.test.ts:
import { useStore } from '@/store'
it('sets and clears a component sizing mode', () => {
  useStore.getState().setComponentSizing('button', { width: { mode: 'fill', max: 'var(--size-content-lg)' } })
  expect(useStore.getState().componentSizing.find((c) => c.componentId === 'button')?.width?.mode).toBe('fill')
  useStore.getState().clearComponentSizing('button')
  expect(useStore.getState().componentSizing.find((c) => c.componentId === 'button')).toBeUndefined()
})
  • Step 2: Run — fails.
  • Step 3: Implement the slice: state componentSizing: ComponentSizing[] (type imported from sizing.ts: { componentId, width?: AxisSizing, height?: AxisSizing, inset?: InsetValue }), setComponentSizing(id, patch) / clearComponentSizing(id), seed [], add to persist snapshot + reset clauses + the import-merge clauses.
  • Step 4: Run — green; run the full store suite to confirm persistence round-trips.
  • Step 5: Commitfeat(sizing): componentSizing store slice.

Task 14: Component-sizing UI (flag-gated)

Files:

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

  • Modify: src/components/pages/properties/TokensSizingPage.tsx (mount the inset-advanced toggle) and the per-component editor where colour families are applied (the applyFamily surface)

  • Step 1: Build the controls behind isFlagEnabled('componentSizing'):

    • Mode radio (containers: Fill/Fixed; components: Fit/Fill/Fixed), Fixed enables a size-content-* select, Fill enables min/max number+unit fields (empty by default), switching modes preserves entered min/max (§6 Interactions).
    • Inset ▸ Advanced: collapsed-by-default toggle revealing separate H/V selects, then per-side; collapsing reverts to the single square value.
    • Inline validation hints: inverted min/max rejected on the second-entered value; Fit option absent for containers; duplicate value name rejected on create (§8).
  • Step 2: Wire to setComponentSizing and emit through componentCss (Task 15).

  • Step 3: Manual verify behind the flag (set localStorage['tostada:flag:componentSizing']='true'): each mode reflows the preview.

  • Step 4: Commitfeat(sizing): component Fit/Fill/Fixed + advanced inset UI (flagged).

Task 15: Emit component sizing CSS + preview demonstration

Files:

  • Modify: src/lib/componentCss.ts

  • Modify: a preview-host container component

  • Step 1: Write the failing test for componentThemeCss-style emission: given a componentSizing entry, the .tostada-cmp-{id} rule contains the resolved width/min-width/padding-inline declarations (reuse the Task 11/12 resolvers).

  • Step 2: Run — fails.

  • Step 3: Implement the emission (call resolveAxisSizing + resolveInset, join into the existing .tostada-cmp-{id} rule path).

  • Step 4: Run — green. Then wire a preview container demonstrating Fill/Fixed + a min/max bound (§5 last bullet).

  • Step 5: Commitfeat(sizing): emit component sizing into preview CSS (flagged).

Task 16: E2E — component sizing & extensibility

Files:

  • Modify: e2e/sizing.spec.ts

Add the Milestone-B slice of §10 E2E (run with the flag on):

  • Step 1: container → size-content-md constrains; → Fill stretches; → Fill + max stops at the max (#3).
  • Step 2: Button → Fit hugs its label; box → Fixed keeps its size (#4).
  • Step 3: Advanced inset → Button gets different H vs V padding (squish) (#5).
  • Step 4: Add a new value (inset-2xl) in the Sizing tab → appears and a component can use it (#6); Developer mode shows the var(--inset-md) chip with resolved px (#7).
  • Step 5: Committest(sizing): e2e component sizing + extensibility.

Task 17: Edge-case hardening (§8)

Files:

  • Modify: src/tokens/sizing.ts, src/components/pages/properties/*, src/tokens/sizing.test.ts

Cover the remaining §8 rows with unit tests + small guards:

  • Step 1: Base clamped to ≥1px on 0/negative (inline hint) — test + clamp in the dial handler.
  • Step 2: Deleting a referenced semantic value → block/warn, offer a fallback target, rebind dependents (or fall back to base), flag the row until resolved.
  • Step 3: Duplicate value name rejected on create; partial advanced inset falls back to square (already covered Task 11 — assert at the UI layer too).
  • Step 4: Run the full suite — green.
  • Step 5: Commitfeat(sizing): §8 edge-case guards.

Task 18: Documentation & telemetry (§11, §13)

Files:

  • Modify: CLAUDE.md, README.md, TEST_PLAN.md, docs/changelog.md, docs/concepts/ (new sizing page + Fit/Fill/Fixed explainer + squish/stretch explainer)

  • Step 1: Add the Sizing model to CLAUDE.md (single --spacing/--radius base, inset/stack/inline intents, none/small/medium/full radius, Fit/Fill/Fixed + min/max, extensibility, sizing.ts) and record the layout-principles boundary (breakpoints + per-breakpoint total width live in layout principles, not here).

  • Step 2: README one-liner; promote §10 into TEST_PLAN.md; changelog entry; concept pages.

  • Step 3: Log the §9 Q11 follow-up as a tracked item (convert space-md/container-xl refs in specs/shipped/layout-principles.md + src/principles/v2/defaults.ts to direct values when that area is next touched) — do not do it in this plan.

  • Step 4: Telemetry stubs (§13): spacing-preset + radius-dial usage; Fit/Fill/Fixed mode usage; advanced-inset + min/max usage; intent application vs left-at-default; extensibility add/rename; validation-rejection rates. (Wire only if a telemetry sink exists; otherwise leave TODO comments at the call sites and note it.)

  • Step 5: Commitdocs(sizing): model, boundary, changelog, test plan.


Self-review — spec coverage

Spec requirement (§5 scope) Task
Spacing primitives: --spacing base + derived ramp 1, 3, 4
Spacing semantic intents (inset 5 / stack 4 / inline 3, md default; grids reuse inset) 3, 4, 8
Inset advanced (square / H-V / per-side) 11, 14
Radius base + none/small/medium/full (no primitive ramp) 2, 3, 4
Layout sizes size-content-* + size-fill (Fill/Fixed only, no Fit on containers) 3, 4, 12
Component sizing model Fit/Fill/Fixed + min/max 12, 13, 14, 15
New Sizing sub-tab (primitive + semantic sections; base dials) 7, 8
User-extensible (add/rename/remove; delete needs fallback) 13, 16, 17
Injector/export emit size tokens (calc chains) 5
preview-host components consume tokens (Button inset+Fit, Card radius, container Fill/Fixed+min/max) 9, 15
Edge cases §8 (base clamp, deleted value, min>max, Fit-on-container, partial inset, duplicate name) 6, 12, 17
Rollout: tokens unflagged, component-sizing behind flag A (1–9) unflagged · B (10–17) flagged
Docs impact §11 + layout-principles boundary + Q11 follow-up 18

Deferred (per §5 "Deferred" / non-goals) — intentionally not in this plan: responsive/breakpoint scales; per-component raw px overrides; visual spacing ruler; app-level layout width/breakpoints (owned by layout principles); the Q11 cross-spec edit (tracked, not executed).

Open questions to confirm before Task 8/14 (§9 leans are the working decisions): Q8 base = both slider + presets; Q9 typography stays in its own tab (cross-link only); Q10 extensibility = inline "+ add" row per section, semantics fully editable, primitive ramp add-only. These are settled enough to build; flag if any feels wrong during UI work.