Architecture
Map of the repo, the one data structure everything revolves around, and how data moves from a DTCG token file on disk to a rendered doc block. Written for contributors; users consuming swatchbook in their own Storybook want the Reference section instead.
Repo map
Section titled “Repo map”Monorepo under @unpunnyfuns. pnpm workspaces + Turborepo orchestration. Every package is "type": "module" and ships ESM only (tsdown builds).
Published packages (fixed-version group, always release together)
Section titled “Published packages (fixed-version group, always release together)”| Package | Role | Depends on |
|---|---|---|
@unpunnyfuns/swatchbook-core | Framework-free DTCG loader. Parses a resolver, normalizes axes, builds a token graph, resolves themes, emits CSS and diagnostics. | none |
@unpunnyfuns/swatchbook-addon | Storybook 10 addon. Toolbar, preview decorator, Vite plugin that publishes a virtual module. Re-exports -blocks + -switcher. | core, blocks, switcher |
@unpunnyfuns/swatchbook-blocks | MDX doc blocks + the SwatchbookProvider + hooks. Pure React, framework-agnostic beyond Storybook. | core (peer: react, react-dom) |
@unpunnyfuns/swatchbook-switcher | Framework-agnostic axis / preset popover UI. Used by the addon toolbar and the docs-site navbar. | none |
@unpunnyfuns/swatchbook-mcp | Model Context Protocol server exposing a project to AI agents. Runs without Storybook. | core |
Apps (private workspaces, not published)
Section titled “Apps (private workspaces, not published)”| App | Role |
|---|---|
apps/storybook | Dogfood Storybook that runs the addon against packages/tokens. Every block lives here as a story. |
apps/docs | This Astro Starlight site. |
Fixtures
Section titled “Fixtures”| Path | Role |
|---|---|
packages/tokens | Multi-axis DTCG reference fixture: colors, sizes, type, motion, borders, shadows, gradients. The demo Storybook renders against this. |
The Project snapshot
Section titled “The Project snapshot”Everything flows through one shape: Project in packages/core/src/types.ts.
interface Project { config: Config; // the resolved user config axes: readonly Axis[]; // axes driving composition (mode, brand, contrast…) disabledAxes: readonly string[]; // axes the consumer explicitly suppressed presets: readonly Preset[]; // named quick-select axis tuples chrome: Partial<Record<ChromeRole, string>>; // block-chrome → consumer-token alias map defaultTokens: TokenMap; // resolved tokens at the default tuple defaultTuple: Record<string, string>; // axis defaults resolveAt: (tuple: Record<string, string>) => TokenMap; // compose any tuple tokenGraph: TokenGraph; // walkable resolution graph (JSON-serializable) sourceFiles: readonly string[]; // every file that fed the build (for watchers) cwd: string; // loader cwd — config-relative paths resolve here listing: TokenListingByPath; // per-token plugin-token-listing data: names.<platform>, previewValue, source diagnostics: Diagnostic[]; // parser / validator / resolver findings}Properties:
tokenGraphis the primary resolution surface. A JSON-serializableRecord<path, TokenGraphNode>plus axis metadata. Each node carries its baseline value, per-(axis, context) write declarations, alias edges, and a precomputedaffectedByindex. The graph walker (resolveAtin/graph) traverses this structure at query time, with no resolver calls after build. Browser consumers read the same graph via the/graphsubpath.sourceFilesis the canonical watch set. The addon’s Vite plugin and the MCP server both use it to decide what to watch for HMR / live-reload.- Diagnostics flow as data, not exceptions. Parse errors, unresolved aliases, invalid preset axis values all land in
project.diagnosticswith a severity. The<Diagnostics />block renders them. - One axis type.
Axis.sourceis'resolver'(DTCG resolver modifier),'layered'(authored per-axis layer globs), or'synthetic'(single-axis fallback for projects without a resolver or layers). Everything downstream treats them uniformly.
ProjectSnapshot (in packages/blocks/src/types.ts) is the JSON-safe slice of Project that the addon’s virtual module and provider deal in. It’s intentionally pruned: no raw Terrazzo TokenNormalized shapes, just the fields blocks need.
Display-side integrations
Section titled “Display-side integrations”The addon is tool-agnostic. Third-party integrations (Tailwind v4, CSS-in-JS for emotion / styled-components, future emitters) ship as separate packages under @unpunnyfuns/swatchbook-integrations/* and plug into the addon via its integrations: SwatchbookIntegration[] option.
interface SwatchbookIntegration { name: string; virtualModule?: { virtualId: string; render(project: Project): string; };}The addon’s Vite plugin iterates integrations[], resolves each virtualId, serves whatever render(project) produces, and invalidates every integration-contributed module on HMR alongside virtual:swatchbook/tokens. The addon itself learns nothing about Tailwind, emotion, or anything else; integrations own their library-specific logic.
The Project snapshot passed to render carries everything: axes, tokenGraph, presets, defaultTokens, resolveAt, and config (which carries cssVarPrefix, terrazzoPlugins, etc.). See the Integrations guide for factory usage.
Data flow: static build path
Section titled “Data flow: static build path”The simpler of the two flows, used by the docs site build, the CSS emitted for consumers, and the MCP server at startup.
tokens/resolver.json ↓ loadProject(config, cwd) ← packages/core/src/load.ts ↓ Project ← the one shape ↓ emitAxisProjectedCss(project) ← packages/core/src/css-axis-projected.ts ↓ :root { --prefix-color-…: … } [data-prefix-mode="Dark"] { … } [data-prefix-mode="Dark"][data-prefix-brand="Brand A"] { … }The same Project also feeds:
project.resolveAt(tuple): compose the resolved TokenMap for any axis tupleproject.defaultTokens: the default-tuple snapshot for global viewsgetVariance(project.tokenGraph, path)from/graph: per-token variance classificationproject.diagnostics: the<Diagnostics />block reads this- the MCP server’s tool handlers (each reads straight from the project)
- TypeScript type emission (via Terrazzo’s token-tools; not currently exposed as a CLI)
Data flow: dev / HMR path
Section titled “Data flow: dev / HMR path”Inside Storybook, the pipeline grows two links so edits to token files re-render blocks without a full preview reload.
tokens/*.json + resolver.json ↓ (fs.watch on parent dirs, 100ms debounce) swatchbookTokensPlugin ← packages/addon/src/virtual/plugin.ts ↓ (Vite plugin; runs in node) re-run loadProject ↓ invalidate virtual:swatchbook/tokens ↓ (preview iframe) Vite HMR push → preview-side listener ↓ addons.getChannel().emit(TOKENS_UPDATED_EVENT, snapshot) ↓ installHostSource(channel) ← packages/addon/src/host-source.ts ↓ registerProjectSource(patch) ← packages/blocks/src/host.ts ↓ (useSyncExternalStore) every block re-renders with fresh dataKey points:
- Watchers live on parent directories, not on files. Atomic-save editors (VS Code, vim, Zed) unlink + recreate the file inode, which kills file-level watchers. Dir-level + filename filter survives the dance. Same pattern in the MCP server’s bin.
- The addon owns the entire Storybook-channel decode.
host-source.ts’sinstallHostSourceis the one place that translates Storybook’s channel events into the host-agnosticProjectSourceshape blocks understand; blocks themselves carry no Storybook vocabulary and never touchaddons.getChannel(). A full preview reload would drop toolbar state,args, scroll position, so the addon pushes a patch into blocks’ generic ambient project source instead, and blocks re-render in place viauseProjectSource()(useSyncExternalStoreunder the hood). - Outside the preview iframe, the channel never fires. The docs site renders blocks against the virtual module’s baked-at-build values, which is correct behavior for static docs.
- MCP mirrors the pattern.
packages/mcp/src/bin.tswatches the same sourceFiles, reloads vialoadFromConfig, and callssetProject(next)on the already-connected MCP server.
The reactivity model
Section titled “The reactivity model”Separately from token-file edits, swatchbook reacts to user actions: the toolbar axis popover, the color-format menu.
Path:
user clicks a context in the toolbar popover ↓ Storybook globals update (e.g. `swatchbookAxes: { mode: 'Dark' }`) ↓addons.getChannel().emit('globalsUpdated', …) ↓installHostSource(channel) ← packages/addon/src/host-source.ts ↓registerProjectSource({ activeAxes })← packages/blocks/src/host.ts ↓useProject() recomputes activeTheme ← packages/blocks/src/internal/use-project.ts ↓blocks re-render with new active theme / resolved tokensTwo paths through useProject:
- Inside story renders. The addon’s
themedDecoratorreads the toolbar tuple straight offcontext.globals, the plain object Storybook passes into every decorator (no hook involved), and mountsSwatchbookProviderwithsnapshot={wire}(assembled fromuseProjectSource()) and the controlledaxes={tuple}around every story.useOptionalSwatchbookData()finds that provider, and the hook is cheap. - MDX doc blocks + autodocs. No decorator runs, so there’s no
context.globalsto read and no provider to find. The hook falls back touseVirtualModuleFallback(enabled=true), which reads the same ambientuseProjectSource()blocks’ generic/hostsubpath exposes: the storeinstallHostSourcekeeps current via the channel above.
Why the ambient source instead of the decorator’s context.globals: MDX doc blocks render with no story or decorator wrapping them at all, so there’s no context.globals argument to read in the first place. @unpunnyfuns/swatchbook-blocks/host’s registerProjectSource / useProjectSource() is the one communication surface that works in both story render and MDX contexts, and it carries no Storybook vocabulary; any host (an addon, a plugin, a preview server) can feed it the same way.
MCP server
Section titled “MCP server”@unpunnyfuns/swatchbook-mcp is a standalone stdio-transport Model Context Protocol server that exposes a project to AI agents without Storybook.
npx @unpunnyfuns/swatchbook-mcp --config swatchbook.config.ts ↓ loadFromConfig(path) ← packages/mcp/src/load-config.ts ↓ createServer(project) ← packages/mcp/src/server.ts ↓ StdioServerTransport → MCP clientThe tools, all stateless reads against the in-memory Project:
- Orientation:
describe_project,list_axes,get_diagnostics - Discovery:
list_tokens,search_tokens,resolve_theme - Detail:
get_token,get_alias_chain,get_aliased_by,get_color_formats,get_color_contrast,get_axis_variance,get_css_usage - Emission:
emit_css
createServer returns the server augmented with setProject(next) so live-reload (the default) can swap in a fresh project without restarting the transport. See Reference → MCP for the tool signatures.
Where to read next
Section titled “Where to read next”packages/core/src/load.ts: the loader entry. Start here.packages/core/src/types.ts: every shape flowing through the rest.packages/addon/src/virtual/plugin.ts: the HMR plumbing.packages/addon/src/preview.tsx: how the preview decorator wires the provider + channel.packages/blocks/src/internal/use-project.ts: the hook every block pulls state through.packages/mcp/src/server.ts: the MCP tools in one file.
And the companion Sharp corners page for the “don’t step there” list.