Sizing, Spacing & Layout Tokens Implementation Plan
Goal: Give size (spacing, radius, layout widths) its own newbie-legible home — a single
--spacing/--radiusbase that rescales everything via CSScalc(), semantic intents (inset/stack/inline), anone/small/medium/fullradius 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 indefaults.ts, asizing.tsscale generator mirroringtypography.ts, a new Sizing Properties sub-tab, and injector/export emission (which already works forcalc()strings — no resolver change). Milestone B (feature-flagged): the riskier component-sizing machinery — acomponentSizingstore slice carrying per-axis Fit/Fill/Fixed + min/max + advanced inset, resolver functions insizing.ts, validation, UI, and preview wiring. Tech Stack: React + TypeScript (Vite) · Zustand + immer · Tailwind v4 (@themein 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.tsper 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-Npx ≈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 (mirrorstypography.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 (removeradius-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— addsizing/*routes. - Modify
preview-host/src/globals.css— alias sizing vars under@theme inlinewith 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— addInsetValue/AxisSizing/SizeModetypes +resolveInset,resolveAxisSizing,validateBounds,validateContainerSizing. - Modify
src/tokens/sizing.test.ts— resolver + validation tests. - Modify
src/store/index.ts— add acomponentSizingslice (mirrorscomponentThemes). - 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: Commit —
feat(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: Commit —
feat(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(importDesignTokenfrom./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 invaluewith noreferences, so the injector (resolve()returnstoken.valuewhenreferencesis not a string) emits them verbatim — exactly the gradient pass-through path. Theinset/stack/inlineintents use a stringreferences, soresolve()chasesinset-md → space-4 → calc(var(--spacing) * 4), staying reactive to the base.
- Step 4: Run — green.
- Step 5: Commit —
feat(sizing): seed token builders.
Task 4: Seed the tokens in defaults.ts
Files:
Modify:
src/tokens/defaults.tsStep 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 theradius-baseprimitive (line ~32) and theradiuscomponent 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
radiustoken is replaced by the primitiveradius(same emitted--radiusvar, so shadcn/preview keep working). Per §8 this needs no migration — Doc 1 is a hard reset; oldradius-base/radiusdata is discarded on load byisLegacyColourData. If that guard doesn't already cover libraries lackingspacing, 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 assumedradius-baseexists; fix references to point atradius/radius-medium. - Step 5: Commit —
feat(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
valueverbatim whenreferencesis absent. - Step 3: (Only if needed) implement the fix and re-run.
- Step 4: Confirm green.
- Step 5: Commit —
test(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 wherevermergeDefaultFamilies-style top-up runs - Modify:
src/tokens/defaults.ts(export amergeDefaultSizingTokenshelper, mirroringmergeDefaultFamilies)
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)indefaults.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: Commit —
feat(sizing): top up missing sizing tokens on load.
Task 7: Sizing nav section + routes
Files:
Modify:
src/lib/librarySections.tsModify:
src/components/pages/properties/PropertiesPage.tsxStep 1: Add the nav section in
librarySections.tsPROPERTIES_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 readingviewprop returning aPageHeader. Runnpm run dev— confirm both sub-tabs appear and route without error. - Step 4: Commit —
feat(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
basePx3/4/6 →updateToken('spacing', { value })), the derived ramp as read-only rows showing computed px (callcomputeSpacingRamp), and the Radius base dial + read-only 4 levels. - Semantic view: intent rows (
inset/stack/inline) asalias → space-N ▾selects thatupdateToken(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,updateTokenwiring). Because base recompute is pure CSS (calc), changing--spacing/--radiusreflows 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):- Pick "Spacious" → a preview Button's padding visibly grows.
- Drag the radius base → Card + Button corners round together.
- Step 3: Run
npm run devand verify both views render and edits cascade live. - Step 4: Commit —
feat(sizing): Sizing properties page (dials + ramp + intents).
Task 9: preview-host aliases + a consuming component
Files:
Modify:
preview-host/src/globals.cssModify: a preview-host component (e.g. Button, Card) under
preview-host/src/components/Step 1: Alias under
@theme inlineinglobals.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 viaradius-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: Commit —
feat(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
localStorageoverridetostada: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: Commit —
feat(flags): featureFlags module + componentSizing flag.
Task 11: Inset value-shape resolver (sizing.ts)
Files:
Modify:
src/tokens/sizing.ts,src/tokens/sizing.test.tsStep 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
InsetValuetype +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: Commit —
feat(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.tsStep 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/AxisSizingtypes,resolveAxisSizing(axis, side),validateBounds(px-compare; only flags when both are comparable px), andvalidateContainerSizing(rejectfit). - Step 4: Run — green.
- Step 5: Commit —
feat(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 666) and reset paths (~587/629).componentId, mutated via immer, included in the persisted snapshot (
- 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 fromsizing.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: Commit —
feat(sizing): componentSizing store slice.
Task 14: Component-sizing UI (flag-gated)
Files:
Create:
src/components/pages/properties/ComponentSizingControls.tsxModify:
src/components/pages/properties/TokensSizingPage.tsx(mount the inset-advanced toggle) and the per-component editor where colour families are applied (theapplyFamilysurface)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).
- Mode radio (containers: Fill/Fixed; components: Fit/Fill/Fixed), Fixed enables a
Step 2: Wire to
setComponentSizingand emit throughcomponentCss(Task 15).Step 3: Manual verify behind the flag (set
localStorage['tostada:flag:componentSizing']='true'): each mode reflows the preview.Step 4: Commit —
feat(sizing): component Fit/Fill/Fixed + advanced inset UI (flagged).
Task 15: Emit component sizing CSS + preview demonstration
Files:
Modify:
src/lib/componentCss.tsModify: a preview-host container component
Step 1: Write the failing test for
componentThemeCss-style emission: given acomponentSizingentry, the.tostada-cmp-{id}rule contains the resolvedwidth/min-width/padding-inlinedeclarations (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: Commit —
feat(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-mdconstrains; → 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 thevar(--inset-md)chip with resolved px (#7). - Step 5: Commit —
test(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: Commit —
feat(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/--radiusbase,inset/stack/inlineintents,none/small/medium/fullradius, 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-xlrefs inspecs/shipped/layout-principles.md+src/principles/v2/defaults.tsto 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: Commit —
docs(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.