Skip to main content
Version: 0.13

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)

PackageRoleDepends on
@unpunnyfuns/swatchbook-coreFramework-free DTCG loader. Parses a resolver, normalizes axes, resolves themes, emits CSS and diagnostics.
@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.— (peer: storybook)
@unpunnyfuns/swatchbook-switcherFramework-agnostic axis / preset popover UI. Used by the addon toolbar and the docs-site navbar.
@unpunnyfuns/swatchbook-mcpModel Context Protocol server exposing a project to AI agents. Runs without Storybook.core

Apps (private workspaces, not published)

AppRole
apps/storybookDogfood Storybook that runs the addon against packages/tokens. Every block lives here as a story.
apps/docsThis Docusaurus site.

Fixtures

PathRole
packages/tokensMulti-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: Axis[]; // axes driving composition (mode, brand, contrast…)
disabledAxes: DisabledAxis[];// axes the consumer explicitly suppressed
presets: Preset[]; // named quick-select axis tuples
chrome: Record<string, string>; // block-chrome → consumer-token alias map
themes: Theme[]; // cartesian product of axis contexts
themesResolved: Record<string, TokenMap>; // theme.name → resolved tokens
graph: TokenMap; // default theme's resolved tokens
sourceFiles: string[]; // every file that fed the build (for watchers)
cwd: string; // loader cwd — config-relative paths resolve here
parserInput?: ParserInput; // raw Terrazzo parse output (resolver path only) — see `emitViaTerrazzo`
diagnostics: Diagnostic[]; // parser / validator / resolver findings
}

Properties:

  • Themes are eagerly resolved. project.themesResolved[name] is ready at load time — no lazy I/O.
  • 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.
  • parserInput is the Terrazzo escape hatch. The { tokens, sources, resolver } triple Terrazzo's programmatic build() wants. Retained on the Project so emitViaTerrazzo drives the Terrazzo plugin pipeline without re-parsing. Undefined for layered + plain-parse paths; resolver-backed only.

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, themes, presets, resolved maps, cssVarPrefix, plus parserInput for integrations that want to drive Terrazzo's plugin pipeline. See the Integrations section 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

projectCss(project) ← packages/core/src/css.ts

:root { --prefix-color-…: … }
[data-prefix-mode="Dark"] { … }

The same Project also feeds:

  • resolveTheme(project, name) — look up a single theme's resolved tokens
  • project.diagnostics — the <Diagnostics /> block reads this
  • the MCP server's tool handlers (all twelve 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 via useSyncExternalStore.
  • Outside the preview iframe, the channel never fires. The docs site renders blocks against the virtual module's baked-at-build values — 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.

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 SwatchbookProvider around 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 to globalsUpdated via the channel. useGlobals() from storybook/preview-api wouldn'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 (Claude Desktop, Claude Code, …)

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.

  • 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 twelve MCP tools in one file.

And the companion Sharp corners page for the "don't step there" list.