Skip to content

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.

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)”
PackageRoleDepends on
@unpunnyfuns/swatchbook-coreFramework-free DTCG loader. Parses a resolver, normalizes axes, builds a token graph, resolves themes, emits CSS and diagnostics.none
@unpunnyfuns/swatchbook-addonStorybook 10 addon. Toolbar, preview decorator, Vite plugin that publishes a virtual module. Re-exports -blocks + -switcher.core, blocks, switcher
@unpunnyfuns/swatchbook-blocksMDX doc blocks + the SwatchbookProvider + hooks. Pure React, framework-agnostic beyond Storybook.core (peer: react, react-dom)
@unpunnyfuns/swatchbook-switcherFramework-agnostic axis / preset popover UI. Used by the addon toolbar and the docs-site navbar.none
@unpunnyfuns/swatchbook-mcpModel Context Protocol server exposing a project to AI agents. Runs without Storybook.core
AppRole
apps/storybookDogfood Storybook that runs the addon against packages/tokens. Every block lives here as a story.
apps/docsThis Astro Starlight site.
PathRole
packages/tokensMulti-axis DTCG reference fixture: colors, sizes, type, motion, borders, shadows, gradients. The demo Storybook renders against this.

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:

  • tokenGraph is the primary resolution surface. A JSON-serializable Record<path, TokenGraphNode> plus axis metadata. Each node carries its baseline value, per-(axis, context) write declarations, alias edges, and a precomputed affectedBy index. The graph walker (resolveAt in /graph) traverses this structure at query time, with no resolver calls after build. Browser consumers read the same graph via the /graph subpath.
  • sourceFiles is 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.diagnostics with a severity. The <Diagnostics /> block renders them.
  • One axis type. Axis.source is '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.

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.

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 tuple
  • project.defaultTokens: the default-tuple snapshot for global views
  • getVariance(project.tokenGraph, path) from /graph: per-token variance classification
  • project.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)

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 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 owns the entire Storybook-channel decode. host-source.ts’s installHostSource is the one place that translates Storybook’s channel events into the host-agnostic ProjectSource shape blocks understand; blocks themselves carry no Storybook vocabulary and never touch addons.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 via useProjectSource() (useSyncExternalStore under 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.ts watches the same sourceFiles, reloads via loadFromConfig, and calls setProject(next) on the already-connected MCP server.

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 tokens

Two paths through useProject:

  • Inside story renders. The addon’s themedDecorator reads the toolbar tuple straight off context.globals, the plain object Storybook passes into every decorator (no hook involved), and mounts SwatchbookProvider with snapshot={wire} (assembled from useProjectSource()) and the controlled axes={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.globals to read and no provider to find. The hook falls back to useVirtualModuleFallback(enabled=true), which reads the same ambient useProjectSource() blocks’ generic /host subpath exposes: the store installHostSource keeps 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.

@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

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

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