Layout Principles v2 Implementation Plan
Goal: Build the Layout Principles rebuild end-to-end per specs/layout-principles.md: a brand-new v2 data model (
ShellPrinciple,LayoutPrinciplewith blocks,OverlayPrincipleof five kinds,NavigationPrincipleper container,StackingOrder), a default preset that seeds every library, a live structural preview in the existingpreview-hostiframe with click-to-edit hotspots, and a master/detail editor surface covering all four principle groups. No M1 migration — fresh build.Architecture: New
src/principles/v2/module is the source of truth — types, default preset, tag taxonomy, validation. Store gains five new slices (shells,layouts,overlays,navigation,stackingOrder) that live alongside the existing M1principlesarray (M1 stays on disk underLayoutPrinciplesPageV1.tsxfor one-release rollback per spec §13). The editor is a newLayoutPrinciplesPageV2.tsxdriving aPrinciplePreviewiframe over a new postMessage channeltostada:principles; preview-host gets new hash routes (#shell/<id>,#layout/<id>,#overlay/<id>,#nav/<id>) and structural renderers. Click-to-edit works via region hotspots in the preview that postMessage back to the parent.Tech Stack: React 19 + TypeScript (Vite), Tailwind v4, Zustand + immer, React Router 7, localStorage (alpha). Tests: Vitest + @testing-library + jsdom (unit + component), Playwright (E2E) — both already wired from the typography work.
lucide-reactfor icons. No new npm dependencies.
Scope check
This is one cohesive subsystem (data model → store → editor → preview, all serving Layout Principles). Not splittable into independent plans. The natural progress checkpoint sits roughly at Task 12: by then you have the v2 data model, the default preset, the preview-host renderers, and a read-only viewer page. Tasks 13–22 layer on editing. Each task ends with npm run lint && npm run build && npm run test green and a commit; the suite stays shippable throughout.
File structure overview
Create:
src/principles/v2/
├── types.ts # All v2 type definitions
├── tagTaxonomy.ts # Seed taxonomy for ApplicationTag suggestions
├── defaults.ts # Default preset (1 shell + 1 nav + 7 layouts + 4 overlays + stacking)
├── validation.ts # parsePrinciplesState + type guards
├── types.test.ts
├── tagTaxonomy.test.ts
├── defaults.test.ts
└── validation.test.ts
src/lib/
├── principlesBroadcast.ts # postMessage bridge — parent → iframe
└── principlesBroadcast.test.ts
src/components/pages/properties/
├── LayoutPrinciplesPageV2.tsx # New page (master/detail + iframe)
├── LayoutPrinciplesPageV2.test.tsx
├── PrincipleList.tsx # Left rail with 4 groups
├── PrincipleList.test.tsx
├── PrinciplePreview.tsx # Iframe wrapper + hotspot listener
├── PrinciplePreview.test.tsx
├── TagInput.tsx # Single tag input with chip suggestions
├── TagInput.test.tsx
├── ApplicationTagsField.tsx # Use + avoid TagInputs bundled
├── ApplicationTagsField.test.tsx
├── ShellEditor.tsx # Inline editor for shell fields
├── ShellEditor.test.tsx
├── LayoutEditor.tsx # Inline editor for layout fields
├── LayoutEditor.test.tsx
├── OverlayEditor.tsx # Inline editor (discriminated by kind)
├── OverlayEditor.test.tsx
├── NavigationEditor.tsx # Container + ordered nav levels
├── NavigationEditor.test.tsx
├── BlockCanvas.tsx # Dedicated canvas for layout blocks
├── BlockCanvas.test.tsx
├── ResetPresetModal.tsx # Confirm + execute reset
├── ResetPresetModal.test.tsx
└── StackingOrderEditor.tsx # Reorderable list of stack layers
preview-host/src/principles/
├── ShellRenderer.tsx # Structural shell + bound nav
├── LayoutRenderer.tsx # Structural layout (blocks / grid / flex)
├── OverlayRenderer.tsx # Shell + open overlay
├── NavigationRenderer.tsx # Nav levels in a container outline
├── RegionHotspot.tsx # Region overlay + click-to-edit postMessage
├── usePrinciplesSync.ts # Receives tostada:principles payload
└── types.ts # Mirrored v2 types for the iframe side
e2e/
└── layout-principles.spec.ts
Modify:
src/store/index.ts # Add v2 slices; leave M1 alongside
src/lib/useLocalSave.ts # Persist v2 keys
src/components/pages/properties/PropertiesPage.tsx # Route 'layout' to V2; keep V1 reachable for rollback
src/components/pages/properties/LayoutPrinciplesPage.tsx # Rename to LayoutPrinciplesPageV1.tsx
preview-host/src/App.tsx # Add hash routes for the four new renderers
REBUILD_PLAN.md
docs/changelog.md
docs/concepts/layout-principles.md
docs/library/layout-principles.md
tostada/CLAUDE.md
Conventions every task follows
- Every task ends with
npm run lint && npm run build && npm run testall green, plus a single commit. The Playwright suite stays in sync but runs only at Task 22. - Path alias
@/*→./src/*(intsconfig.app.json). Use it. - Tests live next to the file under test (
*.test.ts(x)Vitest picks up via the existingvitest.config.ts). - TDD where it makes sense (types, validation, taxonomy, defaults, broadcast, tag input, reset modal). For renderers + editors, write a thin smoke test first (renders without crashing + a key interaction), then implement, then add the focused assertions.
- Commit style:
feat(principles): …for net-new behavior,chore(principles): …for scaffolding,test(principles): …for test-only work,docs(principles): …for docs. Use conventional commits. - Naming: ids use kebab-case (
shell-app,layout-dashboard,overlay-drawer,nav-shell,block-stats-row). The seed preset uses these.
Plan-level decisions (lock these before any code)
The spec has two open questions still flagged. The plan resolves them so the engineer doesn't have to guess. Push back here if you disagree:
Tag taxonomy (spec Q1, "current lean" in the spec): ship the small set as v1 taxonomy.
{ 'content-type': ['list', 'detail', 'article', 'dashboard'], 'has-filters': ['yes', 'no'], 'audience': ['internal', 'external'] }Suggestions in the chip input are these keys + values; users can type any key:value not in the list and it joins the suggestion pool for next time.
Valid nav components per container (spec Q9): allow-list lives in
tagTaxonomy.tsnext to the seed:{ shell: ['horizontal-nav', 'vertical-nav', 'breadcrumb', 'tabs'], layout: ['tabs', 'breadcrumb', 'pagination'], modal: ['tabs', 'back-button'], drawer: ['tabs', 'back-button'], popover: ['tabs'], 'full-page-overlay': ['back-button', 'tabs'], }The NavigationEditor's component picker reads from this map.
Task 0 — Verify infra carries forward
Files: none modified.
Why: Vitest + Playwright + jsdom are already wired from the typography work. This task is a five-minute sanity check before we start adding tests.
- Step 1:
npm run test— expect the current 57 tests green. - Step 2:
npx playwright test --list— expect 10 specs discovered. - Step 3:
npm run build— expect a clean build (no TS errors). - Step 4: No commit — this is a baseline check.
Task 1 — V2 type definitions
Files:
Create:
src/principles/v2/types.tsCreate:
src/principles/v2/types.test.tsStep 1: Write the failing tests in
src/principles/v2/types.test.ts:import { describe, it, expect } from 'vitest' import type { ShellPrinciple, LayoutPrinciple, OverlayPrinciple, NavigationPrinciple, ApplicationTag, LayoutBlock, } from '@/principles/v2/types' describe('v2 types', () => { it('ShellPrinciple has no navigation field', () => { // Single source of truth: nav lives in NavigationPrinciple, not on the shell. const s: ShellPrinciple = { id: 'shell-app', name: 'App shell', kind: 'shell', variant: 'app', spacing: { gap: 'space-md', padding: 'space-lg' }, contentArea: { maxWidth: 'container-xl', padding: 'space-md' }, responsive: { breakpoints: { mobile: 640, tablet: 768, desktop: 1024 } }, } // @ts-expect-error — navigation must NOT be a field on ShellPrinciple const bad: ShellPrinciple = { ...s, navigation: 'whatever' } void bad expect(s.kind).toBe('shell') }) it('LayoutPrinciple can hold ordered blocks', () => { const l: LayoutPrinciple = { id: 'layout-dashboard', name: 'Dashboard', kind: 'layout', variant: 'dashboard', blocks: [ { id: 'block-stats', name: 'Stats row', usage: 'KPIs across the top' }, ], applicationTags: [], } expect(l.blocks).toHaveLength(1) expect(l.blocks![0].id).toBe('block-stats') }) it('OverlayPrinciple is discriminated on kind', () => { const drawer: OverlayPrinciple = { id: 'overlay-drawer', name: 'Drawer', kind: 'drawer', applicationTags: [], drawer: { placement: 'right', width: 'container-sm', backdrop: true, dismissOnOverlayClick: true, trigger: 'icon' }, } const modal: OverlayPrinciple = { id: 'overlay-modal', name: 'Modal', kind: 'modal', applicationTags: [], modal: { size: 'md', dismissOnOverlayClick: true }, } expect(drawer.kind).toBe('drawer') expect(modal.modal?.size).toBe('md') }) it('NavigationPrinciple is bound to a specific container instance', () => { const nav: NavigationPrinciple = { id: 'nav-shell-app', name: 'App shell nav', kind: 'navigation', container: { kind: 'shell', id: 'shell-app' }, levels: [ { order: 1, component: 'horizontal-nav', location: 'header', applicationTags: [] }, { order: 2, component: 'vertical-nav', location: 'left', applicationTags: [] }, ], } expect(nav.container.id).toBe('shell-app') expect(nav.levels[0].order).toBe(1) }) it('ApplicationTag carries optional polarity', () => { const useTag: ApplicationTag = { key: 'audience', value: 'internal', polarity: 'use' } const avoidTag: ApplicationTag = { key: 'audience', value: 'marketing', polarity: 'avoid' } const defaultTag: ApplicationTag = { key: 'has-filters', value: 'yes' } expect(useTag.polarity).toBe('use') expect(avoidTag.polarity).toBe('avoid') expect(defaultTag.polarity).toBeUndefined() }) it('LayoutBlock can carry its own tags', () => { const b: LayoutBlock = { id: 'block-aside', name: 'Aside', usage: 'Secondary actions / metadata', applicationTags: [{ key: 'has-filters', value: 'yes', polarity: 'use' }], } expect(b.applicationTags).toHaveLength(1) }) })Step 2:
npm run test -- v2/types— expect failure (types.tsdoesn't exist).Step 3: Implement
src/principles/v2/types.ts— paste the full type set from spec §7 verbatim. Key shape (compressed for the plan):// src/principles/v2/types.ts export type ContainerKind = | 'shell' | 'layout' | 'modal' | 'drawer' | 'popover' | 'full-page-overlay' export interface ApplicationTag { key: string value: string polarity?: 'use' | 'avoid' } export interface SpacingConfig { gap: string padding: string rhythm?: string } export interface ContentAreaConfig { maxWidth: string padding: string gutters?: string } export interface ResponsiveConfig { breakpoints: Record<string, number> overrides?: Record<string, Record<string, unknown>> } export interface GridConfig { columns: number gap: string rowGap?: string ratio?: string } export interface FlexConfig { direction: 'row' | 'column' wrap?: 'nowrap' | 'wrap' justify?: 'start' | 'center' | 'end' | 'between' | 'around' align?: 'start' | 'center' | 'end' | 'stretch' gap?: string grow?: boolean } export interface A11yGuidance { landmarks?: string[] focusOrder?: string[] keyboard?: string notes?: string } export interface ShellPrinciple { id: string name: string kind: 'shell' variant: 'app' | 'marketing' | 'docs' | 'auth' | 'custom' spacing: SpacingConfig contentArea: ContentAreaConfig responsive: ResponsiveConfig transitionSpeed?: 'fast' | 'normal' | 'slow' accessibility?: A11yGuidance notes?: string } export interface LayoutBlock { id: string name: string usage: string flex?: FlexConfig grid?: GridConfig spacing?: SpacingConfig applicationTags?: ApplicationTag[] } export interface LayoutPrinciple { id: string name: string kind: 'layout' variant: | 'dashboard' | 'list' | 'detail' | 'article' | 'settings' | 'split' | 'full-width' | 'custom' blocks?: LayoutBlock[] grid?: GridConfig flex?: FlexConfig spacing?: SpacingConfig applicationTags: ApplicationTag[] accessibility?: A11yGuidance notes?: string } export interface OverlayPrinciple { id: string name: string kind: 'drawer' | 'modal' | 'toast' | 'popover' | 'full-page-overlay' applicationTags: ApplicationTag[] regions?: { header?: boolean; footer?: boolean } drawer?: { placement: 'left' | 'right' | 'top' | 'bottom' width: string backdrop: boolean dismissOnOverlayClick: boolean trigger: 'icon' | 'text' | 'programmatic' } modal?: { size: 'sm' | 'md' | 'lg' | 'fullscreen'; dismissOnOverlayClick: boolean } popover?: { anchor: string; placement: string } fullPageOverlay?: { dismiss: 'close-button' | 'esc' | 'route-change' } accessibility?: A11yGuidance notes?: string } export interface NavLevel { order: number component: string location: string applicationTags: ApplicationTag[] accessibility?: A11yGuidance } export interface NavigationPrinciple { id: string name: string kind: 'navigation' container: { kind: ContainerKind; id: string } levels: NavLevel[] notes?: string } export interface StackingOrder { order: string[] // top → bottom } export type AnyPrinciple = | ShellPrinciple | LayoutPrinciple | OverlayPrinciple | NavigationPrinciple export interface PrinciplesState { shells: ShellPrinciple[] layouts: LayoutPrinciple[] overlays: OverlayPrinciple[] navigation: NavigationPrinciple[] stackingOrder: StackingOrder }Step 4:
npm run test -- v2/types— expect green.Step 5:
npm run build— confirm no TS errors anywhere.Step 6: Commit:
feat(principles): add v2 type definitions.
Task 2 — Tag taxonomy + nav-component allow-list
Files:
Create:
src/principles/v2/tagTaxonomy.tsCreate:
src/principles/v2/tagTaxonomy.test.tsStep 1: Write failing tests:
import { describe, it, expect } from 'vitest' import { SEED_TAG_TAXONOMY, NAV_COMPONENTS_BY_CONTAINER } from '@/principles/v2/tagTaxonomy' describe('SEED_TAG_TAXONOMY', () => { it('exposes content-type, has-filters, audience as the v1 keys', () => { expect(Object.keys(SEED_TAG_TAXONOMY).sort()) .toEqual(['audience', 'content-type', 'has-filters']) }) it('content-type values include list/detail/article/dashboard', () => { expect(SEED_TAG_TAXONOMY['content-type']).toEqual( expect.arrayContaining(['list', 'detail', 'article', 'dashboard'])) }) }) describe('NAV_COMPONENTS_BY_CONTAINER', () => { it('shell allows horizontal-nav and vertical-nav', () => { expect(NAV_COMPONENTS_BY_CONTAINER.shell).toEqual( expect.arrayContaining(['horizontal-nav', 'vertical-nav'])) }) it('popover only allows tabs', () => { expect(NAV_COMPONENTS_BY_CONTAINER.popover).toEqual(['tabs']) }) it('every container kind has at least one component', () => { for (const c of ['shell','layout','modal','drawer','popover','full-page-overlay'] as const) { expect(NAV_COMPONENTS_BY_CONTAINER[c].length).toBeGreaterThan(0) } }) })Step 2: Run test, confirm failure.
Step 3: Implement
src/principles/v2/tagTaxonomy.ts:import type { ContainerKind } from './types' export const SEED_TAG_TAXONOMY: Record<string, string[]> = { 'content-type': ['list', 'detail', 'article', 'dashboard'], 'has-filters': ['yes', 'no'], 'audience': ['internal', 'external'], } export const NAV_COMPONENTS_BY_CONTAINER: Record<ContainerKind, string[]> = { shell: ['horizontal-nav', 'vertical-nav', 'breadcrumb', 'tabs'], layout: ['tabs', 'breadcrumb', 'pagination'], modal: ['tabs', 'back-button'], drawer: ['tabs', 'back-button'], popover: ['tabs'], 'full-page-overlay': ['back-button', 'tabs'], } export const NAV_LOCATIONS = ['header', 'left', 'right', 'top', 'bottom', 'inline'] as constStep 4: Run test, confirm green.
Step 5: Commit:
feat(principles): seed tag taxonomy + nav allow-list.
Task 3 — Default preset
Files:
- Create:
src/principles/v2/defaults.ts - Create:
src/principles/v2/defaults.test.ts
Why: Every fresh library starts with this exact state. Reset-to-default restores to this. The renderers and editors target this shape.
Step 1: Write failing tests:
import { describe, it, expect } from 'vitest' import { DEFAULT_PRINCIPLES_STATE } from '@/principles/v2/defaults' describe('DEFAULT_PRINCIPLES_STATE', () => { const s = DEFAULT_PRINCIPLES_STATE it('has exactly one shell, named "App shell"', () => { expect(s.shells).toHaveLength(1) expect(s.shells[0].id).toBe('shell-app') expect(s.shells[0].variant).toBe('app') }) it('has the 7 default layouts in spec order', () => { expect(s.layouts.map((l) => l.variant)).toEqual([ 'article', 'dashboard', 'list', 'detail', 'split', 'settings', 'full-width', ]) }) it('every layout has at least one block', () => { for (const l of s.layouts) { expect(l.blocks?.length ?? 0).toBeGreaterThan(0) } }) it('has 4 overlays: drawer, modal, full-page-overlay, toast', () => { const kinds = s.overlays.map((o) => o.kind).sort() expect(kinds).toEqual(['drawer', 'full-page-overlay', 'modal', 'toast']) }) it('drawer / modal / full-page-overlay default to header + footer regions', () => { for (const o of s.overlays) { if (o.kind === 'toast') continue expect(o.regions?.header).toBe(true) expect(o.regions?.footer).toBe(true) } }) it('has exactly one NavigationPrinciple bound to the shell', () => { expect(s.navigation).toHaveLength(1) const nav = s.navigation[0] expect(nav.container).toEqual({ kind: 'shell', id: 'shell-app' }) expect(nav.levels).toHaveLength(2) expect(nav.levels[0]).toMatchObject({ order: 1, component: 'horizontal-nav', location: 'header' }) expect(nav.levels[1]).toMatchObject({ order: 2, component: 'vertical-nav', location: 'left' }) }) it('stackingOrder lists toast > modal > drawer > popover > tooltip', () => { expect(s.stackingOrder.order).toEqual( ['toast', 'modal', 'drawer', 'popover', 'tooltip']) }) it('shell spacing + content area use semantic token refs (not raw px)', () => { const sh = s.shells[0] expect(sh.spacing.gap).toMatch(/^space-/) expect(sh.spacing.padding).toMatch(/^space-/) expect(sh.contentArea.maxWidth).toMatch(/^container-/) }) })Step 2: Run test, confirm failure.
Step 3: Implement
defaults.tsfollowing spec §7 "Default preset (seeded on first run)" verbatim. Skeleton:import type { PrinciplesState } from './types' export const DEFAULT_PRINCIPLES_STATE: PrinciplesState = { shells: [ { id: 'shell-app', name: 'App shell', kind: 'shell', variant: 'app', spacing: { gap: 'space-md', padding: 'space-lg', rhythm: 'space-xl' }, contentArea: { maxWidth: 'container-xl', padding: 'space-md' }, responsive: { breakpoints: { mobile: 640, tablet: 768, desktop: 1024 } }, transitionSpeed: 'normal', accessibility: { landmarks: ['banner', 'navigation', 'main'], focusOrder: ['header', 'nav', 'content'] }, }, ], layouts: [ { id: 'layout-article', name: 'Content page', kind: 'layout', variant: 'article', blocks: [ { id: 'block-content-header', name: 'Content header', usage: 'Title + intro / meta' }, { id: 'block-main-content', name: 'Main content', usage: 'Body copy / sections' }, ], applicationTags: [{ key: 'content-type', value: 'article', polarity: 'use' }], }, { id: 'layout-dashboard', name: 'Dashboard', kind: 'layout', variant: 'dashboard', blocks: [ { id: 'block-stats-row', name: 'Stats row', usage: 'KPIs across the top', grid: { columns: 4, gap: 'space-md' } }, { id: 'block-widget-grid', name: 'Widget grid', usage: 'Cards / charts', grid: { columns: 3, gap: 'space-md' } }, ], applicationTags: [{ key: 'content-type', value: 'dashboard', polarity: 'use' }], }, { id: 'layout-list', name: 'List', kind: 'layout', variant: 'list', blocks: [ { id: 'block-toolbar', name: 'Toolbar', usage: 'Search · filters · primary action', flex: { direction: 'row', justify: 'between', align: 'center', gap: 'space-sm' } }, { id: 'block-list', name: 'List', usage: 'Table or rows' }, ], applicationTags: [ { key: 'content-type', value: 'list', polarity: 'use' }, { key: 'has-filters', value: 'yes', polarity: 'use' }, ], }, { id: 'layout-detail', name: 'Detail', kind: 'layout', variant: 'detail', blocks: [ { id: 'block-detail-header', name: 'Detail header', usage: 'Title + actions', flex: { direction: 'row', justify: 'between', align: 'center' } }, { id: 'block-detail-body', name: 'Detail body', usage: 'Sections' }, { id: 'block-aside', name: 'Aside', usage: 'Secondary info / metadata', applicationTags: [{ key: 'has-filters', value: 'no', polarity: 'avoid' }] }, ], applicationTags: [{ key: 'content-type', value: 'detail', polarity: 'use' }], }, { id: 'layout-split', name: 'Split', kind: 'layout', variant: 'split', blocks: [ { id: 'block-list-pane', name: 'List pane', usage: 'Selectable items, left' }, { id: 'block-detail-pane', name: 'Detail pane', usage: 'Selected item, right' }, ], grid: { columns: 2, gap: 'space-md', ratio: '1fr 2fr' }, applicationTags: [{ key: 'content-type', value: 'list', polarity: 'use' }], }, { id: 'layout-settings', name: 'Settings', kind: 'layout', variant: 'settings', blocks: [ { id: 'block-settings-nav', name: 'Settings nav', usage: 'Category sub-nav' }, { id: 'block-settings-panel', name: 'Settings panel', usage: 'Selected category fields' }, ], applicationTags: [{ key: 'audience', value: 'internal', polarity: 'use' }], }, { id: 'layout-full-width', name: 'Full width', kind: 'layout', variant: 'full-width', blocks: [{ id: 'block-content', name: 'Content', usage: 'Full-bleed landing / marketing' }], applicationTags: [{ key: 'audience', value: 'external', polarity: 'use' }], }, ], overlays: [ { id: 'overlay-drawer', name: 'Drawer', kind: 'drawer', regions: { header: true, footer: true }, drawer: { placement: 'right', width: 'container-sm', backdrop: true, dismissOnOverlayClick: true, trigger: 'icon' }, applicationTags: [{ key: 'audience', value: 'internal', polarity: 'use' }], }, { id: 'overlay-modal', name: 'Modal', kind: 'modal', regions: { header: true, footer: true }, modal: { size: 'md', dismissOnOverlayClick: true }, applicationTags: [], }, { id: 'overlay-full-page', name: 'Full-page overlay', kind: 'full-page-overlay', regions: { header: true, footer: true }, fullPageOverlay: { dismiss: 'close-button' }, applicationTags: [], }, { id: 'overlay-toast', name: 'Toast', kind: 'toast', applicationTags: [], }, ], navigation: [ { id: 'nav-shell-app', name: 'App shell navigation', kind: 'navigation', container: { kind: 'shell', id: 'shell-app' }, levels: [ { order: 1, component: 'horizontal-nav', location: 'header', applicationTags: [ { key: 'audience', value: 'internal', polarity: 'use' }, ], }, { order: 2, component: 'vertical-nav', location: 'left', applicationTags: [ { key: 'audience', value: 'internal', polarity: 'use' }, ], }, ], }, ], stackingOrder: { order: ['toast', 'modal', 'drawer', 'popover', 'tooltip'], }, }Step 4: Run test, confirm green.
Step 5: Commit:
feat(principles): seed default preset (1 shell + 1 nav + 7 layouts + 4 overlays + stacking).
Task 4 — Validation + parser
Files:
- Create:
src/principles/v2/validation.ts - Create:
src/principles/v2/validation.test.ts
Why: Centralised type guards. Used by the store on init and by ResetPresetModal to confirm the default preset is well-formed. Defensive against shape drift from future imports.
Step 1: Write failing tests:
import { describe, it, expect } from 'vitest' import { isShellPrinciple, isLayoutPrinciple, isOverlayPrinciple, isNavigationPrinciple, validatePrinciplesState, } from '@/principles/v2/validation' import { DEFAULT_PRINCIPLES_STATE } from '@/principles/v2/defaults' describe('principle type guards', () => { it('isShellPrinciple narrows correctly', () => { expect(isShellPrinciple(DEFAULT_PRINCIPLES_STATE.shells[0])).toBe(true) expect(isShellPrinciple(DEFAULT_PRINCIPLES_STATE.layouts[0])).toBe(false) }) it('isOverlayPrinciple matches drawer, modal, toast, popover, full-page-overlay', () => { const kinds = DEFAULT_PRINCIPLES_STATE.overlays.map((o) => isOverlayPrinciple(o) ? o.kind : null) expect(kinds).toEqual(expect.arrayContaining(['drawer', 'modal', 'toast', 'full-page-overlay'])) }) }) describe('validatePrinciplesState', () => { it('accepts the default preset', () => { const result = validatePrinciplesState(DEFAULT_PRINCIPLES_STATE) expect(result.ok).toBe(true) }) it('rejects state missing shells', () => { const bad = { ...DEFAULT_PRINCIPLES_STATE, shells: undefined as unknown as [] } const result = validatePrinciplesState(bad) expect(result.ok).toBe(false) expect(result.errors).toContain('shells: expected array') }) it('rejects a NavigationPrinciple bound to a missing shell', () => { const bad = { ...DEFAULT_PRINCIPLES_STATE, navigation: [{ id: 'nav-x', name: 'x', kind: 'navigation' as const, container: { kind: 'shell' as const, id: 'shell-does-not-exist' }, levels: [], }], } const result = validatePrinciplesState(bad) expect(result.ok).toBe(false) expect(result.errors.join(' ')).toMatch(/missing shell/) }) })Step 2: Run test, confirm failure.
Step 3: Implement
src/principles/v2/validation.ts:import type { ShellPrinciple, LayoutPrinciple, OverlayPrinciple, NavigationPrinciple, PrinciplesState, } from './types' export function isShellPrinciple(x: unknown): x is ShellPrinciple { return typeof x === 'object' && x !== null && (x as { kind?: unknown }).kind === 'shell' } export function isLayoutPrinciple(x: unknown): x is LayoutPrinciple { return typeof x === 'object' && x !== null && (x as { kind?: unknown }).kind === 'layout' } export function isOverlayPrinciple(x: unknown): x is OverlayPrinciple { if (typeof x !== 'object' || x === null) return false const k = (x as { kind?: unknown }).kind return k === 'drawer' || k === 'modal' || k === 'toast' || k === 'popover' || k === 'full-page-overlay' } export function isNavigationPrinciple(x: unknown): x is NavigationPrinciple { return typeof x === 'object' && x !== null && (x as { kind?: unknown }).kind === 'navigation' } export interface ValidationResult { ok: boolean errors: string[] } export function validatePrinciplesState(state: unknown): ValidationResult { const errors: string[] = [] if (typeof state !== 'object' || state === null) { return { ok: false, errors: ['state: expected object'] } } const s = state as Partial<PrinciplesState> if (!Array.isArray(s.shells)) errors.push('shells: expected array') if (!Array.isArray(s.layouts)) errors.push('layouts: expected array') if (!Array.isArray(s.overlays)) errors.push('overlays: expected array') if (!Array.isArray(s.navigation)) errors.push('navigation: expected array') if (!s.stackingOrder || !Array.isArray(s.stackingOrder.order)) { errors.push('stackingOrder.order: expected array') } if (errors.length) return { ok: false, errors } const shellIds = new Set(s.shells!.map((sh) => sh.id)) const layoutIds = new Set(s.layouts!.map((l) => l.id)) const overlayIds = new Set(s.overlays!.map((o) => o.id)) for (const nav of s.navigation!) { const target = nav.container const targetSet = target.kind === 'shell' ? shellIds : target.kind === 'layout' ? layoutIds : overlayIds if (!targetSet.has(target.id)) { errors.push(`navigation ${nav.id}: missing ${target.kind} ${target.id}`) } } return { ok: errors.length === 0, errors } }Step 4: Run test, confirm green.
Step 5: Commit:
feat(principles): add v2 validation + type guards.
Task 5 — Store slices + persistence
Files:
Modify:
src/store/index.tsModify:
src/lib/useLocalSave.tsCreate:
src/store/principles-v2.test.tsStep 1: Write failing tests in
src/store/principles-v2.test.ts:import { describe, it, expect, beforeEach } from 'vitest' import { useStore } from '@/store' import { DEFAULT_PRINCIPLES_STATE } from '@/principles/v2/defaults' describe('v2 principles slice', () => { beforeEach(() => { useStore.setState({ shells: DEFAULT_PRINCIPLES_STATE.shells, layouts: DEFAULT_PRINCIPLES_STATE.layouts, overlays: DEFAULT_PRINCIPLES_STATE.overlays, navigation: DEFAULT_PRINCIPLES_STATE.navigation, stackingOrder: DEFAULT_PRINCIPLES_STATE.stackingOrder, }) }) it('exposes the five slices from the default preset', () => { const s = useStore.getState() expect(s.shells).toHaveLength(1) expect(s.layouts).toHaveLength(7) expect(s.overlays).toHaveLength(4) expect(s.navigation).toHaveLength(1) expect(s.stackingOrder.order).toHaveLength(5) }) it('updateShell patches by id', () => { useStore.getState().updateShell('shell-app', { name: 'Renamed shell' }) expect(useStore.getState().shells[0].name).toBe('Renamed shell') }) it('addLayoutBlock appends a block to a layout', () => { useStore.getState().addLayoutBlock('layout-dashboard', { id: 'block-new', name: 'New block', usage: 'Extra', }) const layout = useStore.getState().layouts.find((l) => l.id === 'layout-dashboard')! expect(layout.blocks!.some((b) => b.id === 'block-new')).toBe(true) }) it('reorderNavLevels persists new order', () => { useStore.getState().reorderNavLevels('nav-shell-app', [2, 1]) const nav = useStore.getState().navigation[0] expect(nav.levels.map((l) => l.component)).toEqual(['vertical-nav', 'horizontal-nav']) }) it('resetPrinciplesToDefault restores the preset', () => { useStore.getState().updateShell('shell-app', { name: 'Modified' }) useStore.getState().resetPrinciplesToDefault() expect(useStore.getState().shells[0].name).toBe('App shell') }) it('removeShell cascade-deletes bound NavigationPrinciples', () => { useStore.getState().removeShell('shell-app') expect(useStore.getState().shells).toHaveLength(0) // Nav bound to that shell is gone too. expect(useStore.getState().navigation.some((n) => n.container.id === 'shell-app')).toBe(false) }) })Step 2: Run test, confirm failure (slices don't exist yet).
Step 3: Modify
src/store/index.ts— add the v2 slice. Detailed shape:import { DEFAULT_PRINCIPLES_STATE } from '@/principles/v2/defaults' import type { ShellPrinciple, LayoutPrinciple, OverlayPrinciple, NavigationPrinciple, LayoutBlock, StackingOrder, } from '@/principles/v2/types' // Add to TostadaState: // shells: ShellPrinciple[] // layouts: LayoutPrinciple[] // overlays: OverlayPrinciple[] // navigation: NavigationPrinciple[] // stackingOrder: StackingOrder // updateShell, updateLayout, updateOverlay, updateNavigation // addLayoutBlock, updateLayoutBlock, removeLayoutBlock, reorderLayoutBlocks // addNavLevel, updateNavLevel, removeNavLevel, reorderNavLevels // removeShell, removeLayout, removeOverlay (cascade-delete bound nav) // setStackingOrder // resetPrinciplesToDefaultImplement each as immer mutations; for cascade-delete in
removeShell/removeLayout/removeOverlay, filternavigationto drop any entry whosecontainer.idequals the deleted id.Step 4: Initialise the slices — in the
create<TostadaState>body, seed the five fields fromDEFAULT_PRINCIPLES_STATE. IninitLibraryandimportLibrary, read v2 keys from the parsed JSON if present; otherwise leave defaults in place. (No migration from the M1principlesarray.)Step 5: Persist v2 keys in
useLocalSave— addshells,layouts,overlays,navigation,stackingOrderto thelocalStorage.setItempayload alongside the existing fields. Also extendexportLibrary()to include them.Step 6: Run test, confirm green; full Vitest suite green;
npm run buildgreen.Step 7: Commit:
feat(principles): add v2 store slices (shells/layouts/overlays/nav/stacking) with cascade delete.
Task 6 — Principles broadcast bridge
Files:
- Create:
src/lib/principlesBroadcast.ts - Create:
src/lib/principlesBroadcast.test.ts - Create:
preview-host/src/principles/usePrinciplesSync.ts - Modify:
preview-host/src/App.tsx
Why: Mirror useTokenBroadcast. Parent posts { type: 'tostada:principles', payload: PrinciplesState }; iframe receives it and stores it in a context that all renderers read from. Includes the same tostada:preview-ready re-push handshake.
Step 1: Write failing tests:
import { describe, it, expect, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { usePrinciplesBroadcast } from '@/lib/principlesBroadcast' import { DEFAULT_PRINCIPLES_STATE } from '@/principles/v2/defaults' import { useStore } from '@/store' describe('usePrinciplesBroadcast', () => { it('posts the current PrinciplesState to the iframe on attach', () => { const post = vi.fn() const iframe = { contentWindow: { postMessage: post } } as unknown as HTMLIFrameElement const ref = { current: iframe } useStore.setState({ shells: DEFAULT_PRINCIPLES_STATE.shells, layouts: DEFAULT_PRINCIPLES_STATE.layouts, overlays: DEFAULT_PRINCIPLES_STATE.overlays, navigation: DEFAULT_PRINCIPLES_STATE.navigation, stackingOrder: DEFAULT_PRINCIPLES_STATE.stackingOrder, }) renderHook(() => usePrinciplesBroadcast(ref)) expect(post).toHaveBeenCalled() const payload = post.mock.calls[0][0] expect(payload.type).toBe('tostada:principles') expect(payload.payload.shells).toHaveLength(1) }) it('re-pushes when the iframe posts tostada:preview-ready', () => { // see also useTokenBroadcast.test as the reference pattern }) })Step 2: Run test, confirm failure.
Step 3: Implement
src/lib/principlesBroadcast.ts— copy the shape ofuseTokenBroadcastexactly:import { useEffect, useMemo, useRef } from 'react' import { useStore } from '@/store' export function usePrinciplesBroadcast(iframeRef: React.RefObject<HTMLIFrameElement | null>) { const shells = useStore((s) => s.shells) const layouts = useStore((s) => s.layouts) const overlays = useStore((s) => s.overlays) const navigation = useStore((s) => s.navigation) const stackingOrder = useStore((s) => s.stackingOrder) const payload = useMemo( () => ({ shells, layouts, overlays, navigation, stackingOrder }), [shells, layouts, overlays, navigation, stackingOrder], ) const payloadRef = useRef(payload) payloadRef.current = payload function send() { const win = iframeRef.current?.contentWindow if (!win) return try { win.postMessage({ type: 'tostada:principles', payload: payloadRef.current }, '*') } catch {} } useEffect(() => { send() }, [payload]) useEffect(() => { function onMessage(e: MessageEvent) { const data = e.data as { type?: string } if (data?.type === 'tostada:preview-ready') send() } window.addEventListener('message', onMessage) return () => window.removeEventListener('message', onMessage) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) }Step 4: Implement
preview-host/src/principles/usePrinciplesSync.ts:import { useEffect, useState } from 'react' import type { PrinciplesState } from './types' export function usePrinciplesSync(): PrinciplesState | null { const [state, setState] = useState<PrinciplesState | null>(null) useEffect(() => { function onMessage(e: MessageEvent) { const data = e.data as { type?: string; payload?: unknown } if (data?.type !== 'tostada:principles') return setState(data.payload as PrinciplesState) } window.addEventListener('message', onMessage) try { window.parent?.postMessage({ type: 'tostada:preview-ready' }, '*') } catch {} return () => window.removeEventListener('message', onMessage) }, []) return state }Step 5: Mirror types to the iframe side. Create
preview-host/src/principles/types.tsre-exporting the same shape assrc/principles/v2/types.ts(literal copy — preview-host is a sibling Vite app and cannot import across the boundary). Add a TS-level comment at the top of both files: "Keep in sync — preview-host/src/principles/types.ts mirrors src/principles/v2/types.ts."Step 6: Run test, confirm green.
Step 7: Commit:
feat(principles): add principles broadcast bridge between editor + preview-host.
Task 7 — preview-host: ShellRenderer + NavigationRenderer
Files:
- Create:
preview-host/src/principles/ShellRenderer.tsx - Create:
preview-host/src/principles/NavigationRenderer.tsx - Create:
preview-host/src/principles/RegionHotspot.tsx - Modify:
preview-host/src/App.tsx
Why: The shell render is the central preview — it includes the header, content margins, and the nav levels from its bound NavigationPrinciple. NavigationRenderer is the level-placement logic and is reused by other renderers (e.g. a modal with a nav level inside it).
Step 1: Write a smoke test for the route (no Vitest, just a Playwright assertion later — but write the renderer first with a thin Vitest render-without-crash check):
// preview-host/src/principles/ShellRenderer.test.tsx — keep this simple. import { describe, it, expect } from 'vitest' import { render } from '@testing-library/react' import { ShellRenderer } from './ShellRenderer' import { DEFAULT_PRINCIPLES_STATE } from '../../../src/principles/v2/defaults' describe('ShellRenderer', () => { it('renders the shell outline with header + content + nav slots', () => { const shell = DEFAULT_PRINCIPLES_STATE.shells[0] const nav = DEFAULT_PRINCIPLES_STATE.navigation[0] const { container } = render(<ShellRenderer shell={shell} nav={nav} />) expect(container.querySelector('[data-region="header"]')).not.toBeNull() expect(container.querySelector('[data-region="content"]')).not.toBeNull() expect(container.querySelector('[data-nav-level="1"]')).not.toBeNull() }) })(Note: this test runs from preview-host. Add
preview-host/vitest.config.tsmirroring the main one if it doesn't already exist; otherwise run the test from the main app's Vitest by importing the file directly — easier path is to host the test insrc/principles/v2/and import the preview-host module by relative path.)Step 2: Run test, confirm failure.
Step 3: Implement
ShellRenderer.tsx:import type { ShellPrinciple, NavigationPrinciple } from './types' import { NavigationRenderer } from './NavigationRenderer' import { RegionHotspot } from './RegionHotspot' interface Props { shell: ShellPrinciple; nav?: NavigationPrinciple } export function ShellRenderer({ shell, nav }: Props) { return ( <div style={{ position: 'relative', width: '100%', height: '100%', padding: `var(--${shell.spacing.padding})`, maxWidth: `var(--${shell.contentArea.maxWidth})`, margin: '0 auto', fontFamily: 'var(--font-family-body, system-ui)', }} > <RegionHotspot region="header"> <div data-region="header" style={{ height: 56, borderBottom: '1px dashed var(--border-default)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: `0 var(--${shell.spacing.padding})`, }} > <span style={{ opacity: 0.4, fontSize: 11 }}>header</span> {nav && <NavigationRenderer nav={nav} container="shell" location="header" />} </div> </RegionHotspot> <div style={{ display: 'flex', height: 'calc(100% - 56px)' }}> {nav && <NavigationRenderer nav={nav} container="shell" location="left" />} <RegionHotspot region="content"> <div data-region="content" style={{ flex: 1, padding: `var(--${shell.contentArea.padding})`, border: '1px dashed var(--border-default)', opacity: 0.7, }} > <span style={{ fontSize: 11, opacity: 0.5 }}>content area</span> </div> </RegionHotspot> </div> </div> ) }Step 4: Implement
NavigationRenderer.tsx— for a givencontainer+locationfilter, render all matching nav levels:import type { NavigationPrinciple, ContainerKind } from './types' interface Props { nav: NavigationPrinciple; container: ContainerKind; location: string } export function NavigationRenderer({ nav, container, location }: Props) { if (nav.container.kind !== container) return null const levels = nav.levels.filter((l) => l.location === location) if (levels.length === 0) return null return ( <div style={{ display: 'flex', flexDirection: location === 'header' ? 'row' : 'column', gap: 4, padding: 8, background: 'var(--bg-accent-subtle)', borderRight: location === 'left' ? '1px dashed var(--border-default)' : undefined, borderBottom: location === 'header' ? '1px dashed var(--border-default)' : undefined, }} > {levels.map((l) => ( <span key={l.order} data-nav-level={l.order} data-nav-component={l.component} style={{ fontSize: 10, padding: '2px 6px', border: '1px solid var(--border-default)', borderRadius: 4, opacity: 0.75, }} > #{l.order} {l.component} </span> ))} </div> ) }Step 5: Implement
RegionHotspot.tsx— a transparent wrapper that posts a click event to the parent:import type { PropsWithChildren } from 'react' interface Props { region: string } export function RegionHotspot({ region, children }: PropsWithChildren<Props>) { function onClick(e: React.MouseEvent) { e.stopPropagation() try { window.parent?.postMessage({ type: 'tostada:region-clicked', region }, '*') } catch {} } return <div onClick={onClick} style={{ cursor: 'pointer' }}>{children}</div> }Step 6: Wire a hash route in
preview-host/src/App.tsx— case#shell/<id>: read the principles state fromusePrinciplesSync, find the shell by id and the bound nav, render<ShellRenderer shell={...} nav={...} />. Add a fallback "Waiting for principles…" whilestate === null.Step 7: Run test, confirm green. Manually open
http://localhost:5179/#shell/shell-app(with the dev server running) and confirm the structural outline shows.Step 8: Commit:
feat(preview-host): add ShellRenderer + NavigationRenderer + RegionHotspot.
Task 8 — preview-host: LayoutRenderer
Files:
Create:
preview-host/src/principles/LayoutRenderer.tsxModify:
preview-host/src/App.tsxStep 1: Write the render-without-crash test mirroring Task 7's pattern. Assertions:
- For a layout with
grid.columns: 4, the rendered grid container hasgridTemplateColumnsresolving to 4 tracks. - For a layout with blocks, every block shows its
nameandusage.
- For a layout with
Step 2: Run test, confirm failure.
Step 3: Implement
LayoutRenderer.tsx:import type { LayoutPrinciple, LayoutBlock } from './types' import { RegionHotspot } from './RegionHotspot' function BlockView({ block }: { block: LayoutBlock }) { return ( <div data-block={block.id} style={{ padding: 12, border: '1px dashed var(--border-default)', background: 'var(--bg-base)', display: block.flex ? 'flex' : block.grid ? 'grid' : 'block', flexDirection: block.flex?.direction, gap: block.flex?.gap ? `var(--${block.flex.gap})` : block.grid?.gap ? `var(--${block.grid.gap})` : undefined, gridTemplateColumns: block.grid ? `repeat(${block.grid.columns}, 1fr)` : undefined, justifyContent: block.flex?.justify, alignItems: block.flex?.align, }} > <div style={{ fontSize: 11, fontWeight: 600 }}>{block.name}</div> <div style={{ fontSize: 10, opacity: 0.6 }}>{block.usage}</div> </div> ) } interface Props { layout: LayoutPrinciple } export function LayoutRenderer({ layout }: Props) { if (layout.blocks && layout.blocks.length > 0) { return ( <RegionHotspot region="layout"> <div style={{ display: layout.grid ? 'grid' : 'flex', flexDirection: layout.flex?.direction ?? 'column', gap: layout.spacing?.gap ? `var(--${layout.spacing.gap})` : '12px', gridTemplateColumns: layout.grid ? (layout.grid.ratio ?? `repeat(${layout.grid.columns}, 1fr)`) : undefined, padding: 16, }} > {layout.blocks.map((b) => <BlockView key={b.id} block={b} />)} </div> </RegionHotspot> ) } // No blocks: render the layout's own grid as a placeholder. return ( <div data-region="layout" style={{ padding: 16, border: '1px dashed var(--border-default)' }}> <span style={{ fontSize: 11, opacity: 0.5 }}>{layout.variant}</span> </div> ) }Step 4: Add
#layout/<id>topreview-host/src/App.tsxrouting.Step 5: Run test, confirm green. Manually inspect
#layout/layout-dashboard.Step 6: Commit:
feat(preview-host): add LayoutRenderer for block-composition preview.
Task 9 — preview-host: OverlayRenderer
Files:
Create:
preview-host/src/principles/OverlayRenderer.tsxModify:
preview-host/src/App.tsxStep 1: Write tests:
- Drawer with
placement: rightrenders an outlined panel anchored right. - Modal renders a centered card with
headerandfooterslots whenregions.header/footerare true. - Toast renders a small badge (no regions).
- Drawer with
Step 2: Run, confirm failure.
Step 3: Implement
OverlayRenderer.tsx— render the shell underneath (re-useShellRenderer) then layer the overlay on top withposition: absolute. Use a switch onoverlay.kind:function DrawerView({ overlay }: { overlay: OverlayPrinciple }) { const d = overlay.drawer! const horizontal = d.placement === 'left' || d.placement === 'right' return ( <div style={{ position: 'absolute', [d.placement]: 0, top: d.placement === 'left' || d.placement === 'right' ? 0 : undefined, right: d.placement === 'top' || d.placement === 'bottom' ? 0 : undefined, width: horizontal ? `var(--${d.width})` : '100%', height: horizontal ? '100%' : '40%', background: 'var(--bg-base)', border: '1px solid var(--border-default)', display: 'flex', flexDirection: 'column', }}> {overlay.regions?.header && <div style={{ padding: 12, borderBottom: '1px dashed var(--border-default)' }}>header</div>} <div style={{ flex: 1, padding: 12 }}>body</div> {overlay.regions?.footer && <div style={{ padding: 12, borderTop: '1px dashed var(--border-default)' }}>footer</div>} </div> ) } // Similar shapes for ModalView, FullPageOverlayView, ToastView.Step 4: Add
#overlay/<id>route inpreview-host/src/App.tsx.Step 5: Run test, confirm green.
Step 6: Commit:
feat(preview-host): add OverlayRenderer for drawer/modal/full-page/toast.
Task 10 — preview-host: nav route + cross-renderer wiring
Files:
Modify:
preview-host/src/App.tsxStep 1: Add
#nav/<id>route — looks up the navigation by id, looks up its bound container, renders the container with the nav levels visible (usesShellRendererifcontainer.kind === 'shell'; placeholder card for layout/overlay containers in v1).Step 2: Manual test — open
#nav/nav-shell-appand confirm it shows the shell outline with the two nav levels in header + left.Step 3: Commit:
feat(preview-host): add #nav/<id> route.
Task 11 — Page skeleton + iframe wiring (read-only)
Files:
- Rename:
src/components/pages/properties/LayoutPrinciplesPage.tsx→LayoutPrinciplesPageV1.tsx - Create:
src/components/pages/properties/LayoutPrinciplesPageV2.tsx - Modify:
src/components/pages/properties/PropertiesPage.tsx
Why: Land a read-only viewer first: master/detail layout, iframe in the right pane showing the default shell, no editing. Verify the broadcast bridge end-to-end before any editor work.
Step 1: Rename V1.
git mv src/components/pages/properties/LayoutPrinciplesPage.tsx src/components/pages/properties/LayoutPrinciplesPageV1.tsx. Update its single named export fromLayoutPrinciplesPagetoLayoutPrinciplesPageV1(and any callers — there's exactly one inPropertiesPage.tsx).Step 2: Create
LayoutPrinciplesPageV2.tsx— skeleton with<PageHeader>, left rail (empty for now), iframe pointing athttp://localhost:5179/#shell/shell-app, and theusePrinciplesBroadcast(iframeRef)hook attached. Use the existing pattern fromBlockPickerModal.tsxfor the iframe wiring.Step 3: Update
PropertiesPage.tsx— route<Route path="layout" element={<LayoutPrinciplesPageV2 />} />.Step 4: Manually verify in the browser: navigate to
/library/properties/layout, see the iframe load, see the shell structural outline render. Tokens already broadcast (existing typography work); typography vars resolve in the renderer.Step 5: Smoke test in Vitest —
LayoutPrinciplesPageV2.test.tsxrenders the page in jsdom (won't load the iframe content, but asserts the iframe element + correctsrcexist).Step 6: Run lint + build + test green.
Step 7: Commit:
feat(principles): land LayoutPrinciplesPageV2 read-only skeleton (M1 renamed for rollback).
Task 12 — PrincipleList (4 groups)
Files:
Create:
src/components/pages/properties/PrincipleList.tsxCreate:
src/components/pages/properties/PrincipleList.test.tsxModify:
src/components/pages/properties/LayoutPrinciplesPageV2.tsxStep 1: Write failing tests:
import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PrincipleList } from './PrincipleList' import { useStore } from '@/store' import { DEFAULT_PRINCIPLES_STATE } from '@/principles/v2/defaults' function seed() { useStore.setState(DEFAULT_PRINCIPLES_STATE) } describe('PrincipleList', () => { it('renders four groups with the seeded counts', () => { seed() render(<PrincipleList selectedId={null} onSelect={() => {}} />) expect(screen.getByTestId('group-shells')).toHaveTextContent(/Shells/i) expect(screen.getByTestId('group-layouts')).toHaveTextContent(/Layouts/i) expect(screen.getByTestId('group-overlays')).toHaveTextContent(/Overlays/i) expect(screen.getByTestId('group-navigation')).toHaveTextContent(/Navigation/i) }) it('clicking an item fires onSelect with id + kind', async () => { seed() const onSelect = vi.fn() const user = userEvent.setup() render(<PrincipleList selectedId={null} onSelect={onSelect} />) await user.click(screen.getByTestId('item-layout-dashboard')) expect(onSelect).toHaveBeenCalledWith({ id: 'layout-dashboard', kind: 'layout' }) }) })Step 2: Run, confirm failure.
Step 3: Implement
PrincipleList.tsx— four<section data-testid="group-<kind>">blocks, each iterating its store slice; items havedata-testid="item-<id>". Selected item highlights viaselectedId === item.id. Props:{ selectedId: string | null; onSelect: (s: { id: string; kind: 'shell'|'layout'|'overlay'|'navigation' }) => void }.Step 4: Wire
PrincipleListintoLayoutPrinciplesPageV2.tsx's left rail. Selection drives the iframesrc(#shell/<id>/#layout/<id>/ etc).Step 5: Run test, confirm green.
Step 6: Commit:
feat(principles): add PrincipleList with 4 groups + selection.
Task 13 — Region-click bridge to inline panel
Files:
- Modify:
src/components/pages/properties/LayoutPrinciplesPageV2.tsx - Create:
src/components/pages/properties/PrinciplePreview.tsx - Create:
src/components/pages/properties/PrinciplePreview.test.tsx
Why: Receive tostada:region-clicked messages from the iframe and surface a state hook the editor can react to.
Step 1: PrinciplePreview test — render the component with a mocked iframe, dispatch a
messageevent with{ type: 'tostada:region-clicked', region: 'header' }, assert theonRegionClickcallback fires with'header'.Step 2: Run, fail.
Step 3: Implement
PrinciplePreview.tsx— props:{ src: string; onRegionClick: (region: string) => void }. UsesusePrinciplesBroadcast(iframeRef)internally; attaches awindow.addEventListener('message', ...)that filters fortostada:region-clicked.Step 4: Replace the raw
<iframe>inLayoutPrinciplesPageV2.tsxwith<PrinciplePreview>. Maintain anactiveRegionstate at the page level.Step 5: Run lint/build/test green.
Step 6: Commit:
feat(principles): wire region-click bridge from iframe to editor.
Task 14 — TagInput
Files:
- Create:
src/components/pages/properties/TagInput.tsx - Create:
src/components/pages/properties/TagInput.test.tsx
Why: Reusable single-polarity input. ApplicationTagsField (next task) wraps two of these.
Step 1: Write failing tests:
import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { TagInput } from './TagInput' describe('TagInput', () => { it('renders current tags as chips', () => { render(<TagInput tags={[{ key: 'audience', value: 'internal' }]} suggestions={{ audience: ['internal', 'external'] }} onChange={() => {}} />) expect(screen.getByText('audience: internal')).toBeInTheDocument() }) it('adds a tag on Enter (key:value form)', async () => { const onChange = vi.fn() const user = userEvent.setup() render(<TagInput tags={[]} suggestions={{}} onChange={onChange} />) await user.type(screen.getByPlaceholderText(/add tag/i), 'has-filters:yes{Enter}') expect(onChange).toHaveBeenCalledWith([{ key: 'has-filters', value: 'yes' }]) }) it('Backspace removes the last chip when input is empty', async () => { const onChange = vi.fn() const user = userEvent.setup() render(<TagInput tags={[{ key: 'a', value: 'b' }]} suggestions={{}} onChange={onChange} />) await user.click(screen.getByPlaceholderText(/add tag/i)) await user.keyboard('{Backspace}') expect(onChange).toHaveBeenCalledWith([]) }) it('rejects malformed input (missing colon)', async () => { const onChange = vi.fn() const user = userEvent.setup() render(<TagInput tags={[]} suggestions={{}} onChange={onChange} />) await user.type(screen.getByPlaceholderText(/add tag/i), 'oops{Enter}') expect(onChange).not.toHaveBeenCalled() }) })Step 2: Run, fail.
Step 3: Implement. Parse
key:value, render chips with×to remove, Backspace on empty input removes last. Suggestions show as a popover from the input value. No polarity field — that's the caller's job.Step 4: Run, green.
Step 5: Commit:
feat(principles): add TagInput.
Task 15 — ApplicationTagsField (use + avoid)
Files:
Create:
src/components/pages/properties/ApplicationTagsField.tsxCreate:
src/components/pages/properties/ApplicationTagsField.test.tsxStep 1: Write failing tests — render with mixed tags (some
polarity: 'use', some'avoid', some undefined defaulting to'use'), assert each ends up in the correct sub-input.Step 2: Run, fail.
Step 3: Implement. Two
<TagInput>instances:- When to use — passes tags whose
polarity !== 'avoid', stampspolarity: 'use'on every emitted tag. - When NOT to use — passes tags with
polarity === 'avoid', stampspolarity: 'avoid'. - Concatenates results on every change and emits one
onChange(tags).
- When to use — passes tags whose
Step 4: Run, green.
Step 5: Commit:
feat(principles): add ApplicationTagsField (use + avoid).
Task 16 — ShellEditor (inline panel)
Files:
- Create:
src/components/pages/properties/ShellEditor.tsx - Create:
src/components/pages/properties/ShellEditor.test.tsx - Modify:
src/components/pages/properties/LayoutPrinciplesPageV2.tsx
Why: First editor — sets the pattern for Layout / Overlay editors.
Step 1: Failing tests. Render the editor for the seeded
shell-app. Assert: changing the shellnameinput firesupdateShellwith{ name }; the breakpoints inputs accept numbers and patchresponsive.breakpoints.<key>; Advanced disclosure is initially collapsed and reveals more fields when clicked.Step 2: Run, fail.
Step 3: Implement.
- Props:
{ shellId: string }. Reads the shell from the store; callsupdateShellon every commit. - Basic fields:
name,variant(select: app/marketing/docs/auth/custom),contentArea.maxWidth(text — token ref),contentArea.padding(text — token ref). - Advanced (collapsed by default):
spacing.gap,spacing.padding,spacing.rhythm, the three breakpoints inresponsive.breakpoints,transitionSpeed, A11y guidance freeform notes. - Reuse
EditableCellfromTokenRow.tsxfor inline text edits.
- Props:
Step 4: Wire into
LayoutPrinciplesPageV2.tsx: when a shell is selected (oractiveRegion === 'header'/'content'), render<ShellEditor shellId={...} />below the preview.Step 5: Run, green. Manual check: edit
maxWidthtocontainer-md, see the iframe re-render with the narrower content area.Step 6: Commit:
feat(principles): add ShellEditor.
Task 17 — LayoutEditor (inline) + entry to BlockCanvas
Files:
Create:
src/components/pages/properties/LayoutEditor.tsxCreate:
src/components/pages/properties/LayoutEditor.test.tsxModify:
src/components/pages/properties/LayoutPrinciplesPageV2.tsxStep 1: Failing tests. Render for
layout-dashboard. Assert: name, variant select, grid columns input commit;ApplicationTagsFieldshows the seededcontent-type: dashboardusetag; clicking "Edit blocks" firesonOpenBlockCanvas(layoutId).Step 2: Run, fail.
Step 3: Implement.
- Basic fields: name, variant, grid (columns + gap) OR flex (direction + gap),
ApplicationTagsField(the use + avoid tags). - Advanced (collapsed):
spacing.*,accessibility.*,notes. - Footer row: "Edit blocks" button → fires the prop callback.
- Basic fields: name, variant, grid (columns + gap) OR flex (direction + gap),
Step 4: Wire into the page. Page-level state tracks
blockCanvasOpenFor: string | null.Step 5: Run, green.
Step 6: Commit:
feat(principles): add LayoutEditor.
Task 18 — OverlayEditor (discriminated by kind)
Files:
Create:
src/components/pages/properties/OverlayEditor.tsxCreate:
src/components/pages/properties/OverlayEditor.test.tsxStep 1: Failing tests. Render for
overlay-drawer; assert placement select showsrightand switching toleftcallsupdateOverlay. Render foroverlay-modal; assert size select appears. Render foroverlay-toast; assert it tells the user "Toast positioning is configured on the component (deferred)" since v1 doesn't model positioning.Step 2: Run, fail.
Step 3: Implement. Switch on
overlay.kind:- drawer: placement, width (token ref), backdrop, dismissOnOverlayClick, trigger.
- modal: size, dismissOnOverlayClick.
- popover: anchor (token ref), placement.
- full-page-overlay: dismiss strategy.
- toast: empty editor with a one-line note.
- All overlays:
regions.header/regions.footertoggles;ApplicationTagsField.
Step 4: Run, green.
Step 5: Commit:
feat(principles): add OverlayEditor (kind-discriminated).
Task 19 — NavigationEditor
Files:
Create:
src/components/pages/properties/NavigationEditor.tsxCreate:
src/components/pages/properties/NavigationEditor.test.tsxStep 1: Failing tests.
- Renders the
nav-shell-appeditor: two levels, each with component dropdown (limited toNAV_COMPONENTS_BY_CONTAINER[container.kind]) and a location select. - Reorder buttons (Up / Down) swap levels and call
reorderNavLevels. - "Add level" appends a new level with the next
orderinteger. - "Remove level" deletes by
orderand re-numbers the remaining levels.
- Renders the
Step 2: Run, fail.
Step 3: Implement.
- Props:
{ navigationId: string }. Reads the navigation from the store. - Header shows the bound container (e.g. "Shell — App shell") with no edit affordance (binding is set at creation time).
- Levels list, each row: order number, component select (filtered by container kind), location select (from
NAV_LOCATIONS),ApplicationTagsField, Up/Down/Delete buttons. - "Add level" button at the bottom.
- Props:
Step 4: Run, green.
Step 5: Commit:
feat(principles): add NavigationEditor with per-container allow-list.
Task 20 — BlockCanvas (dedicated surface)
Files:
Create:
src/components/pages/properties/BlockCanvas.tsxCreate:
src/components/pages/properties/BlockCanvas.test.tsxStep 1: Failing tests.
- Renders the blocks of
layout-dashboard: 2 cards visible. - "Add block" appends a new block; the layout now has 3 blocks.
- Selecting a card opens an inline property editor (usage text input, flex / grid toggle,
ApplicationTagsField). - Up/Down buttons reorder; Delete removes.
- Renders the blocks of
Step 2: Run, fail.
Step 3: Implement.
- Props:
{ layoutId: string; onClose: () => void }. Renders as a full-page modal overlay (use the existingBlockPickerModalshape as a structural reference). - Left column: stacked block cards (name + usage), Add / Up / Down / Delete affordances.
- Right column: selected block's properties.
- Close button reverts to the principle list.
- Props:
Step 4: Wire into
LayoutPrinciplesPageV2.tsx— whenblockCanvasOpenForis set, render<BlockCanvas layoutId={blockCanvasOpenFor} onClose={...} />.Step 5: Run, green.
Step 6: Commit:
feat(principles): add BlockCanvas.
Task 21 — ResetPresetModal + StackingOrderEditor + "..." menu
Files:
Create:
src/components/pages/properties/ResetPresetModal.tsxCreate:
src/components/pages/properties/ResetPresetModal.test.tsxCreate:
src/components/pages/properties/StackingOrderEditor.tsxModify:
src/components/pages/properties/LayoutPrinciplesPageV2.tsxStep 1: ResetPresetModal failing test. Render with a modified library; modal lists how many shells/layouts/overlays/nav will be overwritten; clicking Replace fires
resetPrinciplesToDefault; Cancel does nothing.Step 2: Run, fail.
Step 3: Implement
ResetPresetModal.tsx— same shape asReassignOnDeleteModalfrom the typography work. Pulls counts off the current store state for the body copy.Step 4: Implement
StackingOrderEditor.tsx— a small ordered list of strings with Up/Down buttons.onChange(order: string[])callssetStackingOrder({ order }). Surfaced under Advanced in the Shell editor.Step 5: Add the "..." menu to
LayoutPrinciplesPageV2.tsxheader with "Reset to default preset" → opensResetPresetModal.Step 6: Run, green.
Step 7: Commit:
feat(principles): add reset modal + stacking-order editor + page "..." menu.
Task 22 — E2E suite (Playwright)
Files:
- Create:
e2e/layout-principles.spec.ts
Map each spec §10 scenario to a Playwright test. Reuse __tostadaStore (already exposed in dev from typography work) for state seeding.
Step 1: Stub the suite. Eight
test()blocks named "E2E N — Flow N: …" mirroring spec §10. Each starts withtest.fail()so the suite fails loudly until each is implemented.Step 2:
npx playwright test layout-principles— confirm 8+ failures.Step 3 — E2E 1 (Flow 1 happy path). Clear localStorage, navigate to
/library/properties/layout, assert all four groups in the rail have non-zero counts and the preview iframe is visible.await page.goto('/library/properties/layout') await expect(page.getByTestId('group-shells')).toContainText('1') await expect(page.getByTestId('group-layouts')).toContainText('7') await expect(page.getByTestId('group-overlays')).toContainText('4') await expect(page.getByTestId('group-navigation')).toContainText('1') await expect(page.frameLocator('iframe').locator('[data-region="header"]')).toBeVisible()Step 4 — E2E 2 (Flow 2 structural edit). Open shell editor; change
contentArea.maxWidthfromcontainer-xltocontainer-md; assert the iframe[data-region="content"]maxWidthcomputed style narrows.Step 5 — E2E 3 (Flow 3 add overlay). Open
overlay-drawer, change placement fromrighttoleft; assert iframe drawer position attribute updates.Step 6 — E2E 4 (Flow 4 use/avoid tags). Open
layout-list; in the avoid TagInput addaudience:marketing; reload; assert the tag persists withpolarity: 'avoid'.Step 7 — E2E 5 (Flow 5 edit blocks). Open
layout-dashboard→ "Edit blocks" → "Add block"; name it; assert iframe re-renders with the new block visible.Step 8 — E2E 6 (Flow 6 reset to default). Modify a shell name; open "..." → Reset; confirm; assert shell name is back to "App shell".
Step 9 — E2E 7 (Flow 7 read preview). No interaction — open the page, assert the structural preview has expected
data-region/data-nav-levelelements.Step 10 — E2E 8 (Flow 8 add nav level). Open the navigation editor for
nav-shell-app; add a level (componentbreadcrumb, locationheader); assert the iframe renders a third nav-level chip withdata-nav-component="breadcrumb".Step 11 — Edge case. Stop preview-host (use
await page.route('**:5179/**', r => r.abort())to block), reload; assert fallback message appears and the inline panel still works.Step 12:
npx playwright test layout-principles— all 9 green.Step 13: Commit:
test(principles): add E2E suite (9 scenarios).
Task 23 — Documentation
Files:
Modify:
REBUILD_PLAN.mdModify:
docs/changelog.mdModify:
docs/concepts/layout-principles.mdModify:
docs/library/layout-principles.mdModify:
tostada/CLAUDE.mdStep 1: REBUILD_PLAN.md — promote from M5.6 "needs iteration" to a real milestone
M5.8 — Layout Principles v2 (DONE 2026-...). Match the shape of the typography M5.7 entry.Step 2: docs/changelog.md — user-facing entry under today's date:
Layout Principles, rebuilt. New v2 surface with shell / layout / overlay / navigation principles. Default preset ships with every library — open Library → Layout Principles to start tweaking. See concepts/layout-principles.
Step 3: docs/concepts/layout-principles.md — full concept doc: the four principle types, the navigation-per-container model, application tags (use / avoid), the default preset.
Step 4: docs/library/layout-principles.md — usage doc: how to add a layout, how to edit blocks, how to define navigation levels, how to reset.
Step 5: tostada/CLAUDE.md — add the v2 module map (
src/principles/v2/,src/lib/principlesBroadcast.ts,preview-host/src/principles/) and the rule: "Layout Principles never carry navigation fields directly — navigation lives inNavigationPrinciple, one per container instance."Step 6: Commit:
docs(principles): rebuild plan + changelog + concept + library pages.
Task 24 — Final verification
- Step 1:
npm run lint && npm run build && npm run test && npm run e2e— all green. - Step 2: Manual QA:
- Boot
npm run dev+npm run dev:preview-host. - Clear localStorage, reload, assert default preset is in place.
- Edit a shell's
maxWidth→ confirm iframe responds within 200ms. - Add a block to
layout-dashboardvia BlockCanvas → confirm iframe shows it. - Add a use tag and an avoid tag to
layout-list→ reload → confirm both persist. - Reset to default → confirm the modal lists the right counts and the reset restores defaults.
- Test rollback: temporarily change
PropertiesPage.tsxto pointlayoutatLayoutPrinciplesPageV1→ confirm M1 surface still renders correctly.
- Boot
- Step 3: Open PR with a body summarising the four principle types, the new postMessage channel, the default preset, and the rollback story.
Self-review checklist
- Spec coverage. Every §5 scope checkbox has a task: data model (Task 1), navigation principles (Tasks 1 + 19), default preset (Task 3), live structural preview (Tasks 6–10), click-to-edit hotspots (Tasks 7, 13), progressive disclosure (in Tasks 16–18 via Advanced sections), usage guidance use/avoid (Tasks 14–15), stacking order (Task 21). Spec §10 E2E maps 1:1 in Task 22.
- Open questions resolved in the plan. Q1 (taxonomy) and Q9 (per-container nav allow-list) are locked at the top under "Plan-level decisions".
- Type consistency.
PrinciplesState(Task 1) drives the store slice (Task 5), the broadcast payload (Task 6), and the iframe sync (Task 6) — single source of truth.ApplicationTagshape (Task 1) is consumed identically byTagInput(Task 14) andApplicationTagsField(Task 15). - No placeholders. Every implementation step shows the actual code or names the actual file + change. The handful of
// see existing patternreferences point to concrete in-repo files (BlockPickerModal.tsx,useTokenBroadcast.ts,ReassignOnDeleteModal.tsx) that the engineer can read directly. - Rollback. Task 11 renames M1 to
LayoutPrinciplesPageV1.tsxrather than deleting it, and Task 5 leaves the M1principlesarray intact in the store. A one-line swap inPropertiesPage.tsxrestores M1 without data loss (matches spec §13).