Developer Export — Bundle + Playground Implementation Plan
Goal: Ship a one-command CLI that snapshots a Tostada library into a committable
design/bundle (tokens.css, Tailwind preset, layout-principles.md, design-system.json, AGENTS.md, README.md), plus a new top-level Developer page whose Library browser lets a dev browse every token/component/principle, tweak a component's snippet against a live preview, and export the bundle as a ZIP.Architecture: A new framework-agnostic
src/export/module holds pure generator functions(library, manifest) → stringand abuildBundle()that returns thedesign/file map. The browser path zips that map (+ atostada.jsonsnapshot) viafflate; the Node path is an esbuild-bundled copy the existingbin/tostada.jsimports to write the folder to disk. The Developer page reuses thepreview-hostiframe (token broadcast +#variant/<name>?axis=valueadapters) and the existinggetSnippet/fallbackSnippet; freeform snippet editing compiles JSX withsucrase+new Functioninside a new preview-host live route. The oldDocumentationPageis removed.Tech Stack: React 19 + TypeScript (Vite), Tailwind v4, Zustand + immer, React Router 7, localStorage. Tests: Vitest + @testing-library + jsdom (unit/component), Playwright (E2E). New deps:
sucrase(runtime, live-eval),esbuild(build-time, Node CLI bundle).fflatealready present.
Scope check
This spec spans two subsystems that could be separate plans:
- A — Export engine + CLI (Tasks 1–9): pure generators,
buildBundle,zipForUi, the Node bundle, and thetostada exportsubcommand. Ships working software on its own (the CLI emits a correct bundle; the browser can already download it via a temporary button). - B — Developer page (Tasks 10–17): the Library browser, three detail views, the live-eval playground, and
DocumentationPageremoval.
They share src/export/, so I'm keeping one plan with a natural checkpoint at Task 9 (engine + CLI green, bundle correct) before the UI work. If you want to ship in two PRs, cut at Task 9. Each task still ends green + committed.
Plan-level decisions (lock before any code)
The spec left a few things implicit at the file level. Resolving them here so the engineer doesn't guess:
- CLI file stays
bin/tostada.js(notbin/tostada-ui.js).package.jsonbinis{ "tostada": "./bin/tostada.js" }and the command istostada. We extend the existing file with subcommand routing: baretostadakeeps serving the GUI (today's behavior);tostada exportruns the new path. The spec's "bin/tostada-ui.js" refers to the packagetostada-ui, not a rename of the file. - Developer page is a top-level route
/developerinAppShell(the spec says "new top-level Developer section"). We remove thedocumentationsub-route + nav entry fromLibraryPageand deleteDocumentationPage. ADeveloperlink is added toTopNav. - Node bundle of
src/export/is produced by anesbuildscript (scripts/build-cli.mjs) writingdist-cli/export.mjs, with the shadcn_manifest.jsonembedded viaresolveJsonModule.dist-cliis added topackage.jsonfilesand abuild:cliscript. The generators must stay pure string functions with zero browser/React imports so they bundle cleanly for Node. - Live-eval uses
sucrase(transform(code, { transforms: ['jsx','typescript'] })) +new Function, executed inside preview-host on a new#liveroute +tostada:snippetmessage, with the bundled shadcn components as scope (viaimport.meta.glob('./shadcn/*.tsx', { eager: true })). The editor is a plain<textarea>for v1 (no CodeMirror dep) — "light and tight". theme.csscurated set (§9.4): map exactly these semantic tokens to Tailwind v4@themekeys —bg-*→--color-*,fg-*→--color-*,border-*→--color-*, thetypography-*family/size →--font-*/--text-*, andradius→--radius-*. The generator readstier === 'semantic'tokens and maps by id prefix; unknown prefixes are skipped (not an error). Final id list is pinned in Task 2.
File structure overview
Create:
src/export/
├── index.ts # barrel: re-exports generators + buildBundle (Node entry)
├── types.ts # ExportLibrary, ComponentDoc, BundleFiles, ManifestShape
├── componentDocs.ts # join manifest + componentRules + snippets → ComponentDoc[]
├── componentDocs.test.ts
├── themeCss.ts # generateThemeCss (Tailwind v4 @theme)
├── themeCss.test.ts
├── layoutPrinciplesMd.ts # generateLayoutPrinciplesMd (v2)
├── layoutPrinciplesMd.test.ts
├── agentsMd.ts # generateAgentsMd (5 sections)
├── agentsMd.test.ts
├── designSystemJson.ts # generateDesignSystemJson (v2 + components)
├── designSystemJson.test.ts
├── readmeMd.ts # generateReadmeMd
├── readmeMd.test.ts
├── bundle.ts # buildBundle(library, manifest) → BundleFiles
├── bundle.test.ts
├── zipForUi.ts # browser ZIP wrapper (design/ + tostada.json)
└── zipForUi.test.ts
scripts/
└── build-cli.mjs # esbuild: src/export/index.ts → dist-cli/export.mjs
src/components/pages/developer/
├── DeveloperPage.tsx # top-level: header (Export bundle) · guide · browser
├── DeveloperPage.test.tsx
├── HowToInstallGuide.tsx
├── HowToInstallGuide.test.tsx
├── LibraryBrowser.tsx # left list: search + filters + 4 categories
├── LibraryBrowser.test.tsx
├── useLibraryItems.ts # compose items from tokens/manifest/principles
├── useLibraryItems.test.ts
├── detail/TokenDetail.tsx
├── detail/TokenDetail.test.tsx
├── detail/PrincipleDetail.tsx
├── detail/PrincipleDetail.test.tsx
├── detail/ComponentDetail.tsx # preview + rules + snippet + controls
├── detail/ComponentDetail.test.tsx
├── SnippetEditor.tsx # textarea + debounce + post to live preview
├── SnippetEditor.test.tsx
└── ComponentControls.tsx # variant-axis disclosure
ComponentControls.test.tsx
preview-host/src/live/
├── LiveSnippetRenderer.tsx # #live route: sucrase compile + render in scope
└── scope.ts # named-export map from import.meta.glob
e2e/
└── developer-export.spec.ts
Modify:
package.json # name already tostada-ui; add sucrase, esbuild, build:cli, files:["dist","dist-cli","bin"]
bin/tostada.js # subcommand routing: serve (default) | export
src/components/layout/AppShell.tsx # add /developer route
src/components/layout/TopNav.tsx # add Developer link
src/components/pages/LibraryPage.tsx # remove documentation route + nav entry
preview-host/src/App.tsx # add #live route
src/lib/docs.ts # generateTokensCss moves to src/export (re-export shim or relocate)
REBUILD_PLAN.md · docs/changelog.md · CLAUDE.md · README.md · docs/library/documentation-export.md
Delete:
src/components/pages/documentation/DocumentationPage.tsx # folds into Developer page
Conventions every task follows
- Every task ends with
npm run lint && npm run build && npm run testgreen plus one commit. Playwright runs at Task 18. - Run all
npm/npxcommands from/mnt/c/Apps/Uni/tostada(the project root holds the configs). - Path alias
@/*→./src/*. Tests live next to the file under test. - The
src/export/generators are pure — input is plain data (ExportLibrary,ManifestShape), output isstring. NouseStore, nowindow, no React. This is what lets the same code run in the browser and in Node. - Commit style:
feat(devexport): …,chore(devexport): …,test(devexport): …,docs(devexport): …. - The pre-existing repo lint errors (RethemerPage, input.tsx, etc.) are out of scope — keep new files lint-clean; don't touch unrelated files.
Shared types (defined in Task 1, referenced throughout)
// src/export/types.ts
import type { TokenMap, DesignToken } from '@/tokens/types'
import type {
ShellPrinciple, LayoutPrinciple, OverlayPrinciple, NavigationPrinciple,
} from '@/principles/v2/types'
import type { ComponentRules } from '@/principles/componentRules'
/** The subset of the exportLibrary() payload the generators consume. */
export interface ExportLibrary {
name: string
tokens: TokenMap
shells: ShellPrinciple[]
layouts: LayoutPrinciple[]
overlays: OverlayPrinciple[]
navigation: NavigationPrinciple[]
componentRules: Record<string, ComponentRules>
}
/** Minimal shadcn manifest shape the generators read (a subset of _manifest.json). */
export interface ManifestComponent {
name: string
variants: { axes: Record<string, string[]>; defaults: Record<string, string> } | null
installCommand: string
namedExports: string[]
demoSource: string | null
tokensUsed: string[]
}
export interface ManifestShape {
components: ManifestComponent[]
adapters: string[]
}
/** One component's resolved docs — feeds AGENTS.md, design-system.json, the browser. */
export interface ComponentDoc {
name: string
hasRules: boolean
hasVariants: boolean
axes: Record<string, string[]>
rules: { base: { do: string[]; dont: string[]; notes: string };
perVariant: Record<string, { do: string[]; dont: string[]; notes: string }> }
snippet: { install: string; imports: string; usage: string }
}
export type BundleFiles = Record<string, string> // filename (no design/ prefix) → contents
export const BUNDLE_VERSION = 1
Re-export from @/tokens/types, @/principles/v2/types, and DesignToken as needed. These types are import-only at compile time, so they don't break the Node bundle (esbuild strips types).
Task 0 — Baseline check
Files: none.
- Step 1:
npm run test— expect the current 139 tests green. - Step 2:
npm run build— expect a clean build. - Step 3:
npx playwright test --list— expect 19 specs. - Step 4: No commit — baseline only.
Task 1 — Export module scaffold + relocate generateTokensCss
Files:
- Create:
src/export/types.ts,src/export/index.ts - Create:
src/export/tokensCss.ts,src/export/tokensCss.test.ts - Modify:
src/lib/docs.ts(re-exportgenerateTokensCssfrom the new home for back-compat),src/components/pages/documentation/DocumentationPage.tsximport (will be deleted in Task 10 anyway — leave import working)
Why: Establish the pure module + move the token-CSS generator out of docs.ts so the bundle builder and CLI share one implementation. generateTokensCss is already pure.
Step 1: Write
src/export/types.tswith the Shared types block above.Step 2: Write the failing test
src/export/tokensCss.test.ts:import { describe, it, expect } from 'vitest' import { generateTokensCss } from '@/export/tokensCss' import { DEFAULT_TOKENS } from '@/tokens/defaults' const tokenMap = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])) describe('generateTokensCss', () => { it('emits :root with primitive/semantic/shadcn sections', () => { const css = generateTokensCss('My Lib', tokenMap) expect(css).toMatch(/:root\s*\{/) expect(css).toContain('/* primitive */') expect(css).toContain('/* semantic */') }) it('emits a .dark block when dark values differ', () => { const css = generateTokensCss('My Lib', tokenMap) expect(css).toMatch(/\.dark\s*\{/) }) })Step 3:
npm run test -- export/tokensCss— fails (module missing).Step 4: Implement
src/export/tokensCss.ts— move thegenerateTokensCssfunction and itsresolveLight/resolveDarkhelpers verbatim fromsrc/lib/docs.ts(lines 9–72). Keep imports pointing at@/tokens/*.Step 5: Update
src/lib/docs.ts— delete the moved code; addexport { generateTokensCss } from '@/export/tokensCss'so existing importers keep working until Task 10.Step 6: Write
src/export/index.tsbarrel:export * from './types'andexport { generateTokensCss } from './tokensCss'(more added per task).Step 7:
npm run test -- export/tokensCssgreen;npm run buildgreen.Step 8: Commit:
chore(devexport): scaffold src/export + relocate generateTokensCss.
Task 2 — generateThemeCss (Tailwind v4 @theme)
Files:
- Create:
src/export/themeCss.ts,src/export/themeCss.test.ts - Modify:
src/export/index.ts
Why: React/Tailwind audience consumes tokens as utilities. Curated mapping per plan-decision #5.
Step 1: Write the failing test
src/export/themeCss.test.ts:import { describe, it, expect } from 'vitest' import { generateThemeCss } from '@/export/themeCss' import { DEFAULT_TOKENS } from '@/tokens/defaults' const tokenMap = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])) describe('generateThemeCss', () => { it('wraps mappings in an @theme block', () => { const css = generateThemeCss(tokenMap) expect(css).toMatch(/@theme\s*\{/) }) it('maps a bg-* semantic token to a --color-* var referencing the token var', () => { const css = generateThemeCss(tokenMap) // e.g. --color-bg-accent: var(--bg-accent); expect(css).toMatch(/--color-bg-accent:\s*var\(--bg-accent\);/) }) it('skips token ids whose prefix is not in the curated map', () => { const css = generateThemeCss(tokenMap) expect(css).not.toContain('var(--this-prefix-does-not-exist)') }) })Before implementing, run
node -e "const t=require('./src/tokens/defaults');"is not viable (TS). Instead, in Step 4 pin the curated prefixes by readingDEFAULT_TOKENSids:grep "id: '" src/tokens/defaults.tsto confirm the realbg-*/fg-*/border-*/typography-*/radiusids exist.Step 2:
npm run test -- export/themeCss— fails.Step 3: Implement
src/export/themeCss.ts:import type { TokenMap } from '@/tokens/types' // Curated prefix → Tailwind @theme namespace. Unknown prefixes are skipped. const PREFIX_MAP: { test: (id: string) => boolean; themeVar: (id: string) => string }[] = [ { test: (id) => id.startsWith('bg-'), themeVar: (id) => `--color-${id}` }, { test: (id) => id.startsWith('fg-'), themeVar: (id) => `--color-${id}` }, { test: (id) => id.startsWith('border-'), themeVar: (id) => `--color-${id}` }, { test: (id) => id === 'radius', themeVar: () => `--radius` }, ] export function generateThemeCss(tokens: TokenMap): string { const lines: string[] = [] for (const t of Object.values(tokens)) { if (t.tier !== 'semantic') continue const rule = PREFIX_MAP.find((p) => p.test(t.id)) if (!rule) continue lines.push(` ${rule.themeVar(t.id)}: var(--${t.id});`) } const header = `/* Tailwind v4 @theme — maps semantic tokens to utilities. @import after tokens.css. */` return `${header}\n\n@theme {\n${lines.join('\n')}\n}\n` }Pin the final prefix list to the real ids confirmed in Step 1's grep (add
typography-*→--font-*/--text-*only if those semantic ids exist; otherwise omit and note in §9.4).Step 4:
npm run test -- export/themeCssgreen; addexport { generateThemeCss } from './themeCss'to the barrel.Step 5:
npm run buildgreen; commit:feat(devexport): add generateThemeCss (Tailwind v4 @theme).
Task 3 — generateLayoutPrinciplesMd (v2)
Files:
- Create:
src/export/layoutPrinciplesMd.ts,src/export/layoutPrinciplesMd.test.ts - Modify:
src/export/index.ts
Why: The export must serialize the v2 model (shells/layouts-with-blocks/overlays/navigation/applicationTags), not the M1 shape docs.ts still emits.
Step 1: Write the failing test using the seeded default preset:
import { describe, it, expect } from 'vitest' import { generateLayoutPrinciplesMd } from '@/export/layoutPrinciplesMd' import { DEFAULT_PRINCIPLES_STATE as P } from '@/principles/v2/defaults' const lib = { shells: P.shells, layouts: P.layouts, overlays: P.overlays, navigation: P.navigation } describe('generateLayoutPrinciplesMd', () => { it('renders the shell, its content area, and the 7 layouts', () => { const md = generateLayoutPrinciplesMd(lib) expect(md).toContain('## Shells') expect(md).toContain('App shell') expect(md).toContain('## Layouts') expect(md).toContain('Dashboard') }) it('renders a layout block composition with usage labels', () => { const md = generateLayoutPrinciplesMd(lib) expect(md).toContain('Stats row') // a default block expect(md).toContain('KPIs across the top') }) it('renders overlays and navigation levels', () => { const md = generateLayoutPrinciplesMd(lib) expect(md).toContain('## Overlays') expect(md).toContain('Drawer') expect(md).toContain('## Navigation') expect(md).toContain('horizontal-nav') }) it('renders application tags with polarity', () => { const md = generateLayoutPrinciplesMd(lib) expect(md).toMatch(/content-type:\s*dashboard/) }) })Step 2:
npm run test -- export/layoutPrinciplesMd— fails.Step 3: Implement
generateLayoutPrinciplesMd(lib: Pick<ExportLibrary,'shells'|'layouts'|'overlays'|'navigation'>): string. Sections## Shells/## Layouts/## Overlays/## Navigation. For each shell: name, variant,contentArea,responsive.breakpoints. For each layout: name, variant, blocks (name+usage+ flex/grid summary),applicationTags(renderkey:value (polarity)). For each overlay: name, kind, per-kind config, regions, tags. For each navigation: bound container + orderedlevels(#order component @ location) + tags. Reuse the markdown-table helper pattern fromdocs.ts.Step 4:
npm run test -- export/layoutPrinciplesMdgreen; barrel export.Step 5:
npm run buildgreen; commit:feat(devexport): add v2 generateLayoutPrinciplesMd.
Task 4 — componentDocs (join manifest + rules + snippets)
Files:
- Create:
src/export/componentDocs.ts,src/export/componentDocs.test.ts - Modify:
src/export/index.ts
Why: One resolved ComponentDoc[] feeds AGENTS.md (Task 5), design-system.json (Task 6), and the Library browser (Task 12). Build it once.
Step 1: Write the failing test:
import { describe, it, expect } from 'vitest' import { buildComponentDocs } from '@/export/componentDocs' import type { ManifestShape } from '@/export/types' const manifest: ManifestShape = { adapters: ['button'], components: [ { name: 'button', installCommand: 'npx shadcn@latest add button', namedExports: ['Button'], demoSource: null, tokensUsed: [], variants: { axes: { variant: ['default','outline'], size: ['default','sm'] }, defaults: {} } }, { name: 'card', installCommand: 'npx shadcn@latest add card', namedExports: ['Card'], demoSource: '<Card/>', tokensUsed: [], variants: null }, ], } const componentRules = { button: { base: { do: ['Use for primary actions'], dont: ['Don’t use for nav'], notes: 'n' }, perVariant: { 'variant:outline': { do: ['Secondary actions'], dont: [], notes: '' } } }, } describe('buildComponentDocs', () => { it('marks adapter components as having variants + rules', () => { const docs = buildComponentDocs(manifest, componentRules as never) const button = docs.find((d) => d.name === 'button')! expect(button.hasVariants).toBe(true) expect(button.hasRules).toBe(true) expect(button.axes.variant).toContain('outline') expect(button.rules.perVariant['variant:outline'].do).toContain('Secondary actions') expect(button.snippet.usage).toContain('Button') // from getSnippet adapter }) it('falls back to the demo snippet + empty rules for non-adapter, un-ruled components', () => { const docs = buildComponentDocs(manifest, {} as never) const card = docs.find((d) => d.name === 'card')! expect(card.hasRules).toBe(false) expect(card.hasVariants).toBe(false) expect(card.snippet.imports).toContain('Card') // fallbackSnippet from namedExports }) })Step 2:
npm run test -- export/componentDocs— fails.Step 3: Implement
buildComponentDocs(manifest, componentRules) → ComponentDoc[]:- For each
manifest.components:axes = c.variants?.axes ?? {},hasVariants = Object.keys(axes).length > 0. rules: fromcomponentRules[c.name]viaresolveRules(import from@/principles/componentRules);hasRules = base has any do/dont/notes OR any perVariant entry.snippet:getSnippet(c.name, {})ifmanifest.adapters.includes(c.name), elsefallbackSnippet({ componentName, installCommand, namedExports, demoSource }). Import both from@/lib/componentSnippets.Note:
getSnippet/fallbackSnippet/resolveRulesare pure and browser/Node-safe (no React, no window) — safe for the esbuild bundle.
- For each
Step 4: green; barrel export
buildComponentDocs.Step 5:
npm run buildgreen; commit:feat(devexport): add buildComponentDocs (manifest × rules × snippets).
Task 5 — generateAgentsMd (5 sections)
Files:
- Create:
src/export/agentsMd.ts,src/export/agentsMd.test.ts - Modify:
src/export/index.ts
Why: The AI-native deliverable — the single file the dev's agent reads. Five sections per spec §5/§7.
Step 1: Write the failing test:
import { describe, it, expect } from 'vitest' import { generateAgentsMd } from '@/export/agentsMd' import type { ComponentDoc } from '@/export/types' const docs: ComponentDoc[] = [{ name: 'button', hasRules: true, hasVariants: true, axes: { variant: ['default','outline'] }, rules: { base: { do: ['Primary actions'], dont: ['Not for nav'], notes: 'note' }, perVariant: { 'variant:outline': { do: ['Secondary'], dont: [], notes: '' } } }, snippet: { install: 'npx shadcn@latest add button', imports: 'import { Button }', usage: '<Button/>' }, }] describe('generateAgentsMd', () => { const md = generateAgentsMd('My Lib', docs) it('has all five sections', () => { expect(md).toContain('## Orientation') expect(md).toContain('## Workflow — using a component') expect(md).toContain('## Workflow — composing a page') expect(md).toContain('## Components') expect(md).toContain('## Notes') }) it('emits a per-component block with do/dont/notes + snippet + per-variant override', () => { expect(md).toContain('### button') expect(md).toContain('Primary actions') expect(md).toContain('Not for nav') expect(md).toContain('npx shadcn@latest add button') expect(md).toContain('variant:outline') }) it('emits the no-rules placeholder for un-ruled components', () => { const md2 = generateAgentsMd('L', [{ ...docs[0], name: 'card', hasRules: false, rules: { base: { do: [], dont: [], notes: '' }, perVariant: {} } }]) expect(md2).toContain('_No usage rules yet._') }) })Step 2:
npm run test -- export/agentsMd— fails.Step 3: Implement
generateAgentsMd(libraryName, docs: ComponentDoc[]): stringproducing the five sections exactly as §7 "generateAgentsMd output shape": Orientation (paths +@import './design/tokens.css'), the two numbered workflows (static prose from §7), Components (one### <name>block per doc — Do/Don't bullets, Notes, fenced ```jsx snippet of install+imports+usage, per-variant overrides only whenObject.keys(rules.perVariant).length), and the Notes seam (the## Notes (this session)template + the instruction to append to./.tostada-notes.md). Un-ruled component →_No usage rules yet._under Do/Don't/Notes.Step 4: green; barrel export.
Step 5:
npm run buildgreen; commit:feat(devexport): add generateAgentsMd.
Task 6 — generateDesignSystemJson (v2 + components)
Files:
- Create:
src/export/designSystemJson.ts,src/export/designSystemJson.test.ts - Modify:
src/export/index.ts
Why: Machine-readable form; must carry components (rules+snippet) + v2 layoutPrinciples and ignore the M1 principles array.
Step 1: Write the failing test:
import { describe, it, expect } from 'vitest' import { generateDesignSystemJson } from '@/export/designSystemJson' import { DEFAULT_PRINCIPLES_STATE as P } from '@/principles/v2/defaults' import { DEFAULT_TOKENS } from '@/tokens/defaults' import type { ComponentDoc } from '@/export/types' const lib = { name: 'My Lib', tokens: Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])), shells: P.shells, layouts: P.layouts, overlays: P.overlays, navigation: P.navigation, componentRules: {}, } const docs: ComponentDoc[] = [{ name: 'button', hasRules: false, hasVariants: false, axes: {}, rules: { base: { do: [], dont: [], notes: '' }, perVariant: {} }, snippet: { install: 'i', imports: 'im', usage: 'u' } }] describe('generateDesignSystemJson', () => { it('round-trips and carries tokens, layoutPrinciples, components', () => { const json = JSON.parse(generateDesignSystemJson(lib as never, docs)) expect(json.name).toBe('My Lib') expect(json.version).toBe(1) expect(json.tokens.semantic.length).toBeGreaterThan(0) expect(json.layoutPrinciples.shells).toHaveLength(1) expect(json.layoutPrinciples.layouts).toHaveLength(7) expect(json.components[0].name).toBe('button') }) it('does not include an M1 principles array', () => { const json = JSON.parse(generateDesignSystemJson(lib as never, docs)) expect(json.principles).toBeUndefined() }) })Step 2:
npm run test -- export/designSystemJson— fails.Step 3: Implement — payload
{ name, version: BUNDLE_VERSION, generatedAt, tokens: {primitive,semantic,shadcn}, layoutPrinciples: {shells,layouts,overlays,navigation}, components: docs }.JSON.stringify(payload, null, 2). (Note:generatedAtmakes output non-deterministic; the bundle test in Task 7 asserts on a regex/version, not byte-equality.)Step 4: green; barrel export.
Step 5:
npm run buildgreen; commit:feat(devexport): add v2 generateDesignSystemJson.
Task 7 — generateReadmeMd + buildBundle
Files:
Create:
src/export/readmeMd.ts,src/export/readmeMd.test.ts,src/export/bundle.ts,src/export/bundle.test.tsModify:
src/export/index.tsStep 1: Write
readmeMd.test.ts:import { describe, it, expect } from 'vitest' import { generateReadmeMd } from '@/export/readmeMd' describe('generateReadmeMd', () => { it('documents every bundle file', () => { const md = generateReadmeMd('My Lib') for (const f of ['tokens.css','theme.css','layout-principles.md','design-system.json','AGENTS.md']) { expect(md).toContain(f) } expect(md).toContain("@import './design/tokens.css'") }) })Step 2: Write
bundle.test.ts:import { describe, it, expect } from 'vitest' import { buildBundle } from '@/export/bundle' import { DEFAULT_PRINCIPLES_STATE as P } from '@/principles/v2/defaults' import { DEFAULT_TOKENS } from '@/tokens/defaults' const manifest = { adapters: [], components: [ { name: 'card', installCommand: 'npx shadcn@latest add card', namedExports: ['Card'], demoSource: '<Card/>', tokensUsed: [], variants: null }] } const lib = { name: 'My Lib', tokens: Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])), shells: P.shells, layouts: P.layouts, overlays: P.overlays, navigation: P.navigation, componentRules: {}, } describe('buildBundle', () => { const files = buildBundle(lib as never, manifest as never) it('returns the six design/ files, all non-empty', () => { expect(Object.keys(files).sort()).toEqual( ['AGENTS.md','README.md','design-system.json','layout-principles.md','theme.css','tokens.css']) for (const v of Object.values(files)) expect(v.length).toBeGreaterThan(0) }) })Step 3: Run both — fail.
Step 4: Implement
generateReadmeMd(libraryName)(one section per file, plain language, the@importline) andbuildBundle(library, manifest):export function buildBundle(library: ExportLibrary, manifest: ManifestShape): BundleFiles { const docs = buildComponentDocs(manifest, library.componentRules) return { 'tokens.css': generateTokensCss(library.name, library.tokens), 'theme.css': generateThemeCss(library.tokens), 'layout-principles.md': generateLayoutPrinciplesMd(library), 'design-system.json': generateDesignSystemJson(library, docs), 'AGENTS.md': generateAgentsMd(library.name, docs), 'README.md': generateReadmeMd(library.name), } }Step 5: both green; barrel export
buildBundle,generateReadmeMd.Step 6:
npm run buildgreen; commit:feat(devexport): add generateReadmeMd + buildBundle.
Task 8 — zipForUi (browser ZIP wrapper)
Files:
- Create:
src/export/zipForUi.ts,src/export/zipForUi.test.ts - Modify:
src/export/index.ts
Why: The UI Export button needs a ZIP with design/<file> + tostada.json at the root. fflate is already a dep.
Step 1: Write the failing test:
import { describe, it, expect } from 'vitest' import { unzipSync, strFromU8 } from 'fflate' import { zipForUi } from '@/export/zipForUi' describe('zipForUi', () => { it('prefixes design/ and adds tostada.json at the root', () => { const files = { 'tokens.css': ':root{}', 'README.md': '# x' } const tostadaJson = '{"name":"My Lib"}' const zip = zipForUi(files, tostadaJson) const out = unzipSync(zip) expect(Object.keys(out).sort()).toEqual(['design/README.md','design/tokens.css','tostada.json']) expect(strFromU8(out['design/tokens.css'])).toBe(':root{}') expect(strFromU8(out['tostada.json'])).toBe(tostadaJson) }) })Step 2:
npm run test -- export/zipForUi— fails.Step 3: Implement:
import { zipSync, strToU8 } from 'fflate' import type { BundleFiles } from './types' export function zipForUi(files: BundleFiles, tostadaJson: string): Uint8Array { const entries: Record<string, Uint8Array> = { 'tostada.json': strToU8(tostadaJson) } for (const [name, contents] of Object.entries(files)) entries[`design/${name}`] = strToU8(contents) return zipSync(entries) }Step 4: green; barrel export.
Step 5:
npm run buildgreen; commit:feat(devexport): add zipForUi.
Checkpoint candidate is Task 9 (next) — engine done after it.
Task 9 — Node bundle + tostada export CLI subcommand
Files:
- Create:
scripts/build-cli.mjs,e2e/../(node test below) - Modify:
package.json(scripts + deps + files),bin/tostada.js - Create:
src/export/cli.test.ts(Vitest, runs the built CLI vianode)
Why: The Node path. esbuild bundles src/export/index.ts (+ embedded manifest) so bin/tostada.js can import generators without TS.
Step 1: Add deps + scripts.
npm i -D esbuild. Inpackage.json: add"build:cli": "node scripts/build-cli.mjs", set"files": ["dist", "dist-cli", "bin"], and make"build"runtsc -b && vite build && npm run build:cli.Step 2: Write
scripts/build-cli.mjs:import { build } from 'esbuild' await build({ entryPoints: ['src/export/index.ts'], bundle: true, platform: 'node', format: 'esm', target: 'node18', outfile: 'dist-cli/export.mjs', loader: { '.json': 'json' }, // Embed the shadcn manifest so the CLI ships with the component list. define: {}, }) // Also copy the manifest next to the bundle for the CLI to read. import { copyFileSync, mkdirSync } from 'node:fs' mkdirSync('dist-cli', { recursive: true }) copyFileSync('preview-host/src/shadcn/_manifest.json', 'dist-cli/_manifest.json')src/export/index.tsmust not transitively import anything browser-only.getSnippet/fallbackSnippet/resolveRules/docshelpers are pure. If esbuild errors on a browser import, that import is the bug — fix the offending generator to stay pure (the test in Task 4 guards this).Step 3: Write the failing node integration test
src/export/cli.test.ts:import { describe, it, expect, beforeAll } from 'vitest' import { execFileSync } from 'node:child_process' import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' // A minimal tostada.json fixture (exportLibrary payload). const fixture = JSON.stringify({ name: 'CLI Lib', tokens: {}, shells: [], layouts: [], overlays: [], navigation: [], componentRules: {}, }) describe('tostada export CLI', () => { beforeAll(() => { execFileSync('npm', ['run', 'build:cli'], { stdio: 'ignore' }) }) it('writes the design/ folder from a cwd tostada.json', () => { const dir = mkdtempSync(join(tmpdir(), 'tostada-cli-')) writeFileSync(join(dir, 'tostada.json'), fixture) execFileSync('node', [join(process.cwd(), 'bin/tostada.js'), 'export'], { cwd: dir }) for (const f of ['tokens.css','theme.css','layout-principles.md','design-system.json','AGENTS.md','README.md']) { expect(existsSync(join(dir, 'src/design', f))).toBe(true) } expect(readFileSync(join(dir, 'src/design/AGENTS.md'), 'utf8').length).toBeGreaterThan(0) }) it('exits non-zero with a clear message when no tostada.json is found', () => { const dir = mkdtempSync(join(tmpdir(), 'tostada-cli-empty-')) let failed = false; let out = '' try { execFileSync('node', [join(process.cwd(), 'bin/tostada.js'), 'export'], { cwd: dir, encoding: 'utf8' }) } catch (e: any) { failed = true; out = String(e.stderr ?? e.stdout ?? '') } expect(failed).toBe(true) expect(out).toMatch(/Couldn't find .*tostada\.json/) }) })Step 4:
npm run test -- export/cli— fails.Step 5: Modify
bin/tostada.js— add subcommand routing at the top:const [, , subcommand, ...rest] = process.argv if (subcommand === 'export') { const { runExport } = await import('./export-cmd.mjs') await runExport(rest) process.exit(0) } // …existing GUI-serving code unchanged for the no-subcommand case…Create
bin/export-cmd.mjs:import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { buildBundle } from '../dist-cli/export.mjs' const here = fileURLToPath(new URL('.', import.meta.url)) const manifest = JSON.parse(readFileSync(join(here, '..', 'dist-cli', '_manifest.json'), 'utf8')) function arg(flags, name) { const i = flags.indexOf(name); return i >= 0 ? flags[i + 1] : undefined } export async function runExport(flags) { const from = arg(flags, '--from') ?? './tostada.json' const out = arg(flags, '--out') ?? './src/design' if (!existsSync(from)) { console.error(`Couldn't find ${from}. Export your library from the app first, or pass --from <path>.`) process.exit(1) } let library try { library = JSON.parse(readFileSync(from, 'utf8')) } catch (e) { console.error(`Couldn't parse ${from}: ${e.message}`); process.exit(1) } const files = buildBundle(library, manifest) mkdirSync(resolve(out), { recursive: true }) for (const [name, contents] of Object.entries(files)) writeFileSync(join(resolve(out), name), contents) const counts = `${Object.keys(library.tokens ?? {}).length} tokens · ${manifest.components.length} components · ${(library.shells?.length ?? 0) + (library.layouts?.length ?? 0)} principles` console.log(`✓ Wrote ${Object.keys(files).length} files to ${out} (${counts})`) }Step 6:
npm run test -- export/cligreen (the beforeAll runsbuild:cli).Step 7:
npm run lint && npm run build && npm run testgreen; commit:feat(devexport): add tostada export CLI subcommand + Node bundle.
Task 10 — Developer page skeleton + route + Export button (DocumentationPage removed)
Files:
- Create:
src/components/pages/developer/DeveloperPage.tsx,.test.tsx - Modify:
src/components/layout/AppShell.tsx,src/components/layout/TopNav.tsx,src/components/pages/LibraryPage.tsx - Delete:
src/components/pages/documentation/DocumentationPage.tsx
Why: Land the new top-level surface with a working Export-bundle button (uses zipForUi + the store), before the browser/detail work.
Step 1: Write the smoke test
DeveloperPage.test.tsx: renders the page (wrap inMemoryRouter), asserts an "Export bundle" button + the page heading exist.Step 2:
npm run test -- developer/DeveloperPage— fails.Step 3: Implement
DeveloperPage.tsx— header with title + Export bundle button. The button: reads the store, buildslibrary: ExportLibrary(name + tokens + v2 slices + componentRules), imports_manifest.json(import manifest from '../../../../preview-host/src/shadcn/_manifest.json'— confirm relative path; Vite resolves JSON), callsbuildBundle+exportLibrary()(from store) +zipForUi, and triggers download (<library-slug>-bundle.zip) reusing thedownloadBlobhelper pattern from the oldDocumentationPage. Button states per §6 (idle/generating/downloaded/error). Below the header, render placeholders for the guide + browser (filled in Tasks 11–12).Step 4: Wire the route. In
AppShell.tsxadd<Route path="/developer/*" element={<DeveloperPage />} />. InTopNav.tsxadd aDeveloperlink. InLibraryPage.tsxremove thedocumentationnav entry +<Route path="documentation" …>+ theDocumentationPageimport. DeleteDocumentationPage.tsx.Step 5: Manually verify
/developerrenders and Export bundle downloads a ZIP (browser).npm run test -- developer/DeveloperPagegreen.Step 6:
npm run lint && npm run build && npm run testgreen; commit:feat(devexport): add Developer page + Export bundle button; remove DocumentationPage.
Task 11 — HowToInstallGuide
Files:
Create:
src/components/pages/developer/HowToInstallGuide.tsx,.test.tsxModify:
DeveloperPage.tsxStep 1: Test — renders the opener sentence + the four step headings; collapses/expands on click; persists
opentolocalStorage(assert the key is written).Step 2: fail.
Step 3: Implement the collapsible guide per §6 (opener + 4 sections, code blocks for the
@importlines,localStoragekeytostada:devguide-open, default expanded). Reuse theDisclosurepattern from_editorKit.tsx.Step 4: wire into
DeveloperPageabove the browser placeholder.Step 5: green;
npm run build; commit:feat(devexport): add HowToInstallGuide.
Task 12 — useLibraryItems + LibraryBrowser (left list)
Files:
- Create:
useLibraryItems.ts,.test.ts,LibraryBrowser.tsx,.test.tsx - Modify:
DeveloperPage.tsx
Why: The master list across four categories with search + filters.
Step 1: Write
useLibraryItems.test.ts:import { describe, it, expect, beforeEach } from 'vitest' import { renderHook } from '@testing-library/react' import { useLibraryItems } from './useLibraryItems' import { useStore } from '@/store' import { DEFAULT_PRINCIPLES_STATE as P } from '@/principles/v2/defaults' beforeEach(() => useStore.setState({ shells: structuredClone(P.shells), layouts: structuredClone(P.layouts), overlays: structuredClone(P.overlays), navigation: structuredClone(P.navigation), })) describe('useLibraryItems', () => { it('groups items into the four categories', () => { const { result } = renderHook(() => useLibraryItems()) const cats = result.current.map((g) => g.category) expect(cats).toEqual(['Tokens — primitive','Tokens — semantic','Components','Principles']) }) it('flags components that have rules / variants', () => { const { result } = renderHook(() => useLibraryItems()) const comps = result.current.find((g) => g.category === 'Components')!.items expect(comps.length).toBeGreaterThan(0) expect(comps[0]).toHaveProperty('hasRules') expect(comps[0]).toHaveProperty('hasVariants') }) })Step 2: fail.
Step 3: Implement
useLibraryItems()— returns{ category, items: LibraryItem[] }[]. Tokens fromuseStore(s => s.tokens)split by tier; Components from the imported manifest joined withcomponentRules(reusebuildComponentDocs); Principles from the four v2 slices (kind:idstyle id + display name).LibraryItem = { id; name; kind: 'token'|'component'|'principle'; hasRules?; hasVariants?; … }.Step 4: Write
LibraryBrowser.test.tsx— renders four category headers; typing in search narrows items; togglinghas ruleschip filters components; clicking an item firesonSelect(item). (Mirror the PrincipleList test pattern.)Step 5: Implement
LibraryBrowser.tsx— search input, category-aware chips (has rules,has variants— shown only when Components are in scope), grouped list with the has-rules dot,onSelect. Search = substring + word-boundary score (≈30 lines, no dep). Hide empty categories.Step 6: wire into
DeveloperPageas the left rail; page tracksselected: LibraryItem | null; right pane is a placeholder until Tasks 13–15.Step 7: green;
npm run build; commit:feat(devexport): add LibraryBrowser + useLibraryItems.
Task 13 — TokenDetail
Files:
Create:
detail/TokenDetail.tsx,.test.tsxModify:
DeveloperPage.tsx(dispatch whenselected.kind === 'token')Step 1: Test — render for a seeded primitive (e.g. a color) → shows name, value; for a semantic token → shows the referenced primitive + resolved value; Copy button writes
var(--<id>)to clipboard (mocknavigator.clipboard).Step 2: fail.
Step 3: Implement read-only catalog: name + tier, value (+ swatch for colors via existing
SwatchfromTokenRow.tsxwhere applicable), dark value when present, semantic → reference + resolved value, "Copy token name" →var(--<id>).Step 4: wire dispatch in
DeveloperPage; green;npm run build; commit:feat(devexport): add TokenDetail.
Task 14 — PrincipleDetail
Files:
Create:
detail/PrincipleDetail.tsx,.test.tsxModify:
DeveloperPage.tsxStep 1: Test — render for
layout-dashboard→ shows kindlayout, its blocks, andapplicationTags; Copy writes the principle name/id.Step 2: fail.
Step 3: Implement read-only catalog: kind, structure overview (layout blocks / overlay config / nav levels),
applicationTags, Copy name. Look the principle up from the store by id across the four slices.Step 4: wire dispatch; green;
npm run build; commit:feat(devexport): add PrincipleDetail.
Task 15 — ComponentDetail (preview + rules + read-only snippet + Copy)
Files:
- Create:
detail/ComponentDetail.tsx,.test.tsx - Modify:
DeveloperPage.tsx
Why: The playground core, read-only first (preview via existing variant adapter URL; freeform edit lands Task 17).
Step 1: Test — render for
button: asserts the preview iframe (src#variant/buttonor#button), the application-rules panel (do/don't/notes), the snippet (install+imports+usage fromgetSnippet), and a Copy button that writes all three (joined with//separators) to clipboard.Step 2: fail.
Step 3: Implement — header
<name> · variant: <current>+ light/dark toggle (hidden when the library has nodarkValuetokens — check the store). Preview pane:<PrinciplePreview>-style iframe pointed athttp://localhost:5179/#variant/<name>?…(adapter) or#<name>(demo) usinguseTokenBroadcast; min width/height per §6. Rules panel fromresolveRules(componentRules[name], currentVariantKey). Snippet fromgetSnippet/fallbackSnippet. Copy → one block. (Reusepreview-hosttoken broadcast exactly as the layout-principlesPrinciplePreviewdoes.)Step 4: wire dispatch; green;
npm run build; commit:feat(devexport): add ComponentDetail (read-only playground).
Task 16 — ComponentControls (variant axes drive preview + rules + snippet)
Files:
Create:
ComponentControls.tsx,.test.tsxModify:
detail/ComponentDetail.tsxStep 1: Test — render controls for
button(axesvariant,size); selectingvariant=outlinefiresonChange({ variant: 'outline' }); inComponentDetail.test, after selecting a variant, assert the iframesrcgains?variant=outline, the rules panel shows thevariant:outlineoverride, and the snippet updates (viagetSnippet(name, { variant:'outline' })).Step 2: fail.
Step 3: Implement
ComponentControls— aDisclosure(collapsed default) with one<select>per axis from the manifest.onChange(params)lifts toComponentDetail, which recomputes iframe URL +resolveRules(…, 'variant:'+value)+getSnippet. Hide entirely for no-adapter components.Step 4: green;
npm run build; commit:feat(devexport): add ComponentControls (variant axes).
Task 17 — SnippetEditor freeform live-eval (sucrase) + Reset + states
Files:
- Create:
SnippetEditor.tsx,.test.tsx,preview-host/src/live/LiveSnippetRenderer.tsx,preview-host/src/live/scope.ts - Modify:
detail/ComponentDetail.tsx,preview-host/src/App.tsx,package.json(addsucrase)
Why: The "tweak the snippet and see it" payload. Highest-risk task — the live compile runs in preview-host with the bundled components in scope.
Step 1: Add dep.
npm i sucrase.Step 2: Implement
preview-host/src/live/scope.ts— build a name→export map fromimport.meta.glob('../shadcn/*.tsx', { eager: true }), plusReact. ExportbuildScope()returning{ React, ...allNamedExports }.Step 3: Implement
LiveSnippetRenderer.tsx(preview-host) — listens fortostada:snippetmessages{ usage: string }; transforms withsucrase(transform(wrapped, { transforms:['jsx','typescript'] })wherewrapped = "return (" + usage + ")"); evaluates vianew Function(...Object.keys(scope), compiled)(...Object.values(scope)); renders the returned element. On throw → posttostada:snippet-error { message }to parent and keep the last good render. Add a#liveroute inpreview-host/src/App.tsxthat mounts it (tokens still broadcast as today).Step 4: Write
SnippetEditor.test.tsx— a<textarea>bound to the snippet; editing debounces and poststostada:snippet(assert via apostMessagespy on the iframe ref); empty buffer disables Copy + shows the placeholder; Reset restores the default (prop callback).Step 5: fail.
Step 6: Implement
SnippetEditor.tsx—<textarea>(no CodeMirror), debouncedonChange→ post{ type:'tostada:snippet', usage }to the preview iframe; listen fortostada:snippet-error→ surface inline error; empty → placeholder + Copy disabled; Reset →getSnippet/fallbackSnippetdefault. Wire intoComponentDetail: the read-only snippet area (Task 15) becomes the editor; the preview iframe switches to#livewhile editing, back to#variant/<name>when controls drive it. (Decide a single source of truth: editing the code is authoritative; toggling a control regenerates the code and re-posts.)Step 7: Manual verify in-browser: edit
<Button>Hi</Button>→Button variant="outline"and watch the preview change; invalid code → inline error + last-good preview; Reset restores.Step 8:
npm run test -- developer/SnippetEditor developer/ComponentDetailgreen;npm run lint && npm run build && npm run testgreen; commit:feat(devexport): add freeform snippet live-eval (sucrase) + Reset.
Task 18 — E2E suite (Playwright)
Files:
- Create:
e2e/developer-export.spec.ts
Map §10 scenarios. Reuse the resetLibrary init-script + frameLocator patterns from e2e/layout-principles.spec.ts. The Playwright webServer list already starts :5173 + :5179.
Step 1: Stub the 17
test()blocks named "DEV N — …" withtest.fixme()so the suite is discoverable but loud.Step 2:
npx playwright test developer-export --list— 17 discovered.Steps 3–16: Implement each per §10 (Library browser: categories, search, chips, token copy, principle copy; Component playground: preview, variant-adapts-everything, copy, freeform edit, invalid, empty, reset, dark toggle, preview-host blocked; UI export ZIP contents; CLI covered by the node test in Task 9). For "UI export ZIP contents," intercept the download (
page.waitForEvent('download')), read the file, unzip withfflatein-test, asserttostada.json+design/six files.Step 17:
npx playwright test developer-export— all green (run from project root).Step 18: Commit:
test(devexport): add E2E suite.
Task 19 — Documentation
Files: REBUILD_PLAN.md, docs/changelog.md, CLAUDE.md, README.md, docs/library/documentation-export.md, new docs/ page, stub specs/developer-export-cloud.md + specs/rethemer-agent-notes.md.
- Step 1: REBUILD_PLAN.md — add
M5.9 — Developer export + Library browser (DONE …). - Step 2: docs/changelog.md — user-facing entry under today.
- Step 3: CLAUDE.md — document
src/export/, the CLIexportsubcommand, the Node bundle (dist-cli,build:cli), the Developer page modules, and the rule "generators insrc/export/must stay pure (no React/window) so they bundle for Node." - Step 4: README.md — quickstart (
npx tostada-ui export+@import). - Step 5: docs/library/documentation-export.md — rewrite for the bundle + Developer page; note DocumentationPage removed.
- Step 6: New docs page "Using Tostada in your codebase" — mirror the in-app guide.
- Step 7: Stub the two follow-up specs (
developer-export-cloud.md,rethemer-agent-notes.md) with a one-paragraph summary each. - Step 8: Commit:
docs(devexport): plan + changelog + concept/library pages + follow-up spec stubs.
Task 20 — Final verification
- Step 1: From project root:
npm run lint && npm run build && npm run test && npx playwright test. - Step 2: Manual QA (per §10): boot
npm run dev+npm run dev:preview-host; click Export bundle → unzip → confirmtostada.json+design/six files; runnpx tostada-ui exportagainst thattostada.json→ confirm the same six files; edit a snippet live; toggle a variant and confirm preview+rules+snippet move together; verifytheme.cssutilities in a scratch Tailwind v4 app; point a real coding agent atAGENTS.md. - Step 3: Open PR summarizing the bundle, the Developer page, the CLI, and the DocumentationPage removal.
Self-review checklist
- Spec coverage. §5 deliverables map to tasks: export module (1), generators incl. rules+snippets+v2 principles (2–6), AGENTS.md 5 sections (5), bundle files (7), ZIP envelope (8), CLI auto-find/overrides (9), Developer page + Export button (10), install guide (11), Library browser 4 categories + search + filters (12), three detail views (13–15), variant controls adapting everything (16), freeform live-eval + Reset + states (17), DocumentationPage removal (10). §10 E2E → Task 18.
- Open questions. All 8 are resolved in the spec; the plan encodes the resolutions (sucrase, esbuild, curated theme set, auto-find, top-level route, taxonomy-by-source).
- Type consistency.
ExportLibrary/ManifestShape/ComponentDoc/BundleFiles(Task 1) flow throughbuildComponentDocs(4) → generators (5–6) →buildBundle(7) →zipForUi(8) and the CLI (9);useLibraryItems(12) reusesbuildComponentDocs. - No placeholders. Each code step shows real test or implementation; the few genuinely deferred items (exact copy strings, A11y order) are called out in the spec, not invented here.
- Risk flag. Task 17 (in-iframe sucrase eval with the bundled component scope) is the highest-risk task; its read-only predecessor (Task 15) ships a working playground even if freeform editing needs iteration.