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; if you're consuming swatchbook as a user, the Reference section is the right door.
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)
| 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. | none (peer: storybook) |
@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)
| App | Role |
|---|---|
apps/storybook | Dogfood Storybook that runs the addon against packages/tokens. Every block lives here as a story. |
apps/docs | This Docusaurus site. |
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
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
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
The simpler of the two flows. Applies to the docs site, emitted CSS 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 (all fourteen read straight from the project)
- TypeScript type emission (via Terrazzo's token-tools; not currently exposed as a CLI)
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)
↓
useTokenSnapshot() store ← packages/blocks/src/internal/channel-tokens.ts
↓ (useSyncExternalStore)
every block re-renders with fresh data
Key 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 publishes a channel event rather than a full reload. A full reload would drop toolbar state,
args, scroll position. Broadcasting a snapshot through Storybook's channel lets blocks re-render in place viauseSyncExternalStore. - 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
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', …)
↓
useChannelGlobals() store ← packages/blocks/src/internal/channel-globals.ts
↓
useProject() recomputes activeTheme ← packages/blocks/src/internal/use-project.ts
↓
blocks re-render with new active theme / resolved tokens
Two paths through useProject:
- Inside story renders. The addon's preview decorator mounts
SwatchbookProvideraround every story, seeding context from the virtual module.useOptionalSwatchbookData()finds it, and the hook is cheap. - MDX doc blocks + autodocs. No story render is active, so no provider. The hook falls back to
useVirtualModuleFallback(enabled=true)which reads the virtual module directly and subscribes toglobalsUpdatedvia the channel.useGlobals()fromstorybook/preview-apiwouldn't work here; it throws outside a story render.
Why the channel instead of useGlobals: MDX doc blocks render in a context where Storybook's preview HooksContext isn't mounted. The channel is the one communication surface that works in both story render and MDX contexts.
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 client
Fourteen 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_consumer_output - 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
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 fourteen MCP tools in one file.
And the companion Sharp corners page for the "don't step there" list.