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, LayoutPrinciple with blocks, OverlayPrinciple of five kinds, NavigationPrinciple per container, StackingOrder), a default preset that seeds every library, a live structural preview in the existing preview-host iframe 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 M1 principles array (M1 stays on disk under LayoutPrinciplesPageV1.tsx for one-release rollback per spec §13). The editor is a new LayoutPrinciplesPageV2.tsx driving a PrinciplePreview iframe over a new postMessage channel tostada: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-react for 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 test all green, plus a single commit. The Playwright suite stays in sync but runs only at Task 22.
  • Path alias @/*./src/* (in tsconfig.app.json). Use it.
  • Tests live next to the file under test (*.test.ts(x) Vitest picks up via the existing vitest.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:

  1. 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.

  2. Valid nav components per container (spec Q9): allow-list lives in tagTaxonomy.ts next 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.ts

  • Create: src/principles/v2/types.test.ts

  • Step 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.ts doesn'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.ts

  • Create: src/principles/v2/tagTaxonomy.test.ts

  • Step 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 const
    
  • Step 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.ts following 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.ts

  • Modify: src/lib/useLocalSave.ts

  • Create: src/store/principles-v2.test.ts

  • Step 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
    //   resetPrinciplesToDefault
    

    Implement each as immer mutations; for cascade-delete in removeShell / removeLayout / removeOverlay, filter navigation to drop any entry whose container.id equals the deleted id.

  • Step 4: Initialise the slices — in the create<TostadaState> body, seed the five fields from DEFAULT_PRINCIPLES_STATE. In initLibrary and importLibrary, read v2 keys from the parsed JSON if present; otherwise leave defaults in place. (No migration from the M1 principles array.)

  • Step 5: Persist v2 keys in useLocalSave — add shells, layouts, overlays, navigation, stackingOrder to the localStorage.setItem payload alongside the existing fields. Also extend exportLibrary() to include them.

  • Step 6: Run test, confirm green; full Vitest suite green; npm run build green.

  • 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 of useTokenBroadcast exactly:

    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.ts re-exporting the same shape as src/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.ts mirroring 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 in src/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 given container + location filter, 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 from usePrinciplesSync, find the shell by id and the bound nav, render <ShellRenderer shell={...} nav={...} />. Add a fallback "Waiting for principles…" while state === 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.tsx

  • Modify: preview-host/src/App.tsx

  • Step 1: Write the render-without-crash test mirroring Task 7's pattern. Assertions:

    • For a layout with grid.columns: 4, the rendered grid container has gridTemplateColumns resolving to 4 tracks.
    • For a layout with blocks, every block shows its name and usage.
  • 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> to preview-host/src/App.tsx routing.

  • 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.tsx

  • Modify: preview-host/src/App.tsx

  • Step 1: Write tests:

    • Drawer with placement: right renders an outlined panel anchored right.
    • Modal renders a centered card with header and footer slots when regions.header/footer are true.
    • Toast renders a small badge (no regions).
  • Step 2: Run, confirm failure.

  • Step 3: Implement OverlayRenderer.tsx — render the shell underneath (re-use ShellRenderer) then layer the overlay on top with position: absolute. Use a switch on overlay.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 in preview-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.tsx

  • Step 1: Add #nav/<id> route — looks up the navigation by id, looks up its bound container, renders the container with the nav levels visible (uses ShellRenderer if container.kind === 'shell'; placeholder card for layout/overlay containers in v1).

  • Step 2: Manual test — open #nav/nav-shell-app and 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.tsxLayoutPrinciplesPageV1.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 from LayoutPrinciplesPage to LayoutPrinciplesPageV1 (and any callers — there's exactly one in PropertiesPage.tsx).

  • Step 2: Create LayoutPrinciplesPageV2.tsx — skeleton with <PageHeader>, left rail (empty for now), iframe pointing at http://localhost:5179/#shell/shell-app, and the usePrinciplesBroadcast(iframeRef) hook attached. Use the existing pattern from BlockPickerModal.tsx for 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.tsx renders the page in jsdom (won't load the iframe content, but asserts the iframe element + correct src exist).

  • 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.tsx

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

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

  • Step 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 have data-testid="item-<id>". Selected item highlights via selectedId === item.id. Props: { selectedId: string | null; onSelect: (s: { id: string; kind: 'shell'|'layout'|'overlay'|'navigation' }) => void }.

  • Step 4: Wire PrincipleList into LayoutPrinciplesPageV2.tsx's left rail. Selection drives the iframe src (#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 message event with { type: 'tostada:region-clicked', region: 'header' }, assert the onRegionClick callback fires with 'header'.

  • Step 2: Run, fail.

  • Step 3: Implement PrinciplePreview.tsx — props: { src: string; onRegionClick: (region: string) => void }. Uses usePrinciplesBroadcast(iframeRef) internally; attaches a window.addEventListener('message', ...) that filters for tostada:region-clicked.

  • Step 4: Replace the raw <iframe> in LayoutPrinciplesPageV2.tsx with <PrinciplePreview>. Maintain an activeRegion state 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.tsx

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

  • Step 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', stamps polarity: 'use' on every emitted tag.
    • When NOT to use — passes tags with polarity === 'avoid', stamps polarity: 'avoid'.
    • Concatenates results on every change and emits one onChange(tags).
  • 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 shell name input fires updateShell with { name }; the breakpoints inputs accept numbers and patch responsive.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; calls updateShell on 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 in responsive.breakpoints, transitionSpeed, A11y guidance freeform notes.
    • Reuse EditableCell from TokenRow.tsx for inline text edits.
  • Step 4: Wire into LayoutPrinciplesPageV2.tsx: when a shell is selected (or activeRegion === 'header' / 'content'), render <ShellEditor shellId={...} /> below the preview.

  • Step 5: Run, green. Manual check: edit maxWidth to container-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.tsx

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

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

  • Step 1: Failing tests. Render for layout-dashboard. Assert: name, variant select, grid columns input commit; ApplicationTagsField shows the seeded content-type: dashboard use tag; clicking "Edit blocks" fires onOpenBlockCanvas(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.
  • 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.tsx

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

  • Step 1: Failing tests. Render for overlay-drawer; assert placement select shows right and switching to left calls updateOverlay. Render for overlay-modal; assert size select appears. Render for overlay-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.footer toggles; ApplicationTagsField.
  • Step 4: Run, green.

  • Step 5: Commit: feat(principles): add OverlayEditor (kind-discriminated).


Task 19 — NavigationEditor

Files:

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

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

  • Step 1: Failing tests.

    • Renders the nav-shell-app editor: two levels, each with component dropdown (limited to NAV_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 order integer.
    • "Remove level" deletes by order and re-numbers the remaining levels.
  • 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.
  • 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.tsx

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

  • Step 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.
  • Step 2: Run, fail.

  • Step 3: Implement.

    • Props: { layoutId: string; onClose: () => void }. Renders as a full-page modal overlay (use the existing BlockPickerModal shape 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.
  • Step 4: Wire into LayoutPrinciplesPageV2.tsx — when blockCanvasOpenFor is 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.tsx

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

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

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

  • Step 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 as ReassignOnDeleteModal from 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[]) calls setStackingOrder({ order }). Surfaced under Advanced in the Shell editor.

  • Step 5: Add the "..." menu to LayoutPrinciplesPageV2.tsx header with "Reset to default preset" → opens ResetPresetModal.

  • 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 with test.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.maxWidth from container-xl to container-md; assert the iframe [data-region="content"] maxWidth computed style narrows.

  • Step 5 — E2E 3 (Flow 3 add overlay). Open overlay-drawer, change placement from right to left; assert iframe drawer position attribute updates.

  • Step 6 — E2E 4 (Flow 4 use/avoid tags). Open layout-list; in the avoid TagInput add audience:marketing; reload; assert the tag persists with polarity: '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-level elements.

  • Step 10 — E2E 8 (Flow 8 add nav level). Open the navigation editor for nav-shell-app; add a level (component breadcrumb, location header); assert the iframe renders a third nav-level chip with data-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.md

  • Modify: docs/changelog.md

  • Modify: docs/concepts/layout-principles.md

  • Modify: docs/library/layout-principles.md

  • Modify: tostada/CLAUDE.md

  • Step 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 in NavigationPrinciple, 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-dashboard via 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.tsx to point layout at LayoutPrinciplesPageV1 → confirm M1 surface still renders correctly.
  • 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. ApplicationTag shape (Task 1) is consumed identically by TagInput (Task 14) and ApplicationTagsField (Task 15).
  • No placeholders. Every implementation step shows the actual code or names the actual file + change. The handful of // see existing pattern references 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.tsx rather than deleting it, and Task 5 leaves the M1 principles array intact in the store. A one-line swap in PropertiesPage.tsx restores M1 without data loss (matches spec §13).