Skip to content

Core

@unpunnyfuns/swatchbook-core parses DTCG sources and builds the token graph and project object the rest of swatchbook consumes. It walks that graph for any token’s value in any axis combination and resolves aliases per tuple. No React, no Storybook, and no need to reach past it into @terrazzo/parser for the common cases.

Terminal window
npm install @unpunnyfuns/swatchbook-core

Identity helper for a typed swatchbook.config.ts. No runtime effect, just TypeScript ergonomics. See the Config reference for every field’s semantics.

import { defineSwatchbookConfig } from '@unpunnyfuns/swatchbook-core';
export default defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
default: { mode: 'Light', brand: 'Default' },
cssVarPrefix: 'ds',
});

Parse + resolve a project. Returns a Project carrying the token graph and a resolveAt(tuple) accessor; no resolver calls happen at consumer-read time.

import { loadProject } from '@unpunnyfuns/swatchbook-core';
const project = await loadProject(config, process.cwd());
// project: { config, axes, presets, tokenGraph, defaultTuple,
// defaultTokens, resolveAt, diagnostics, … }

Compose the resolved TokenMap for any axis tuple. Graph-backed; accepts partial tuples (omitted axes fall back to their defaults). Memoized on the canonical tuple key.

const dark = project.resolveAt({ mode: 'Dark' });
const darkBrand = project.resolveAt({ mode: 'Dark', brand: 'Brand A' });

The resolved TokenMap at the project’s default tuple. Convenience for global views.

for (const [path, token] of Object.entries(project.defaultTokens)) {
// …
}

Emit the project’s resolved CSS as a string: the same output the addon publishes into virtual:swatchbook/tokens at preview time. The emitter walks the token graph: each per-axis non-default context gets its own [data-<prefix>-<axis>="<context>"] selector block; the default-tuple tokens go to :root; tokens whose value differs when more than one axis changes get compound [data-…][data-…] selectors. Total block count scales with axes × contexts, not every combination of axes.

import { loadProject, emitAxisProjectedCss } from '@unpunnyfuns/swatchbook-core';
const project = await loadProject(config, process.cwd());
const css = emitAxisProjectedCss(project);
// :root { --sb-color-accent-bg: …; … }
// [data-sb-mode="Dark"] { --sb-color-accent-bg: …; }

For production builds, prefer Terrazzo’s CLI; emitAxisProjectedCss exists primarily for tooling that wants the same emission the addon does.

Leaf utilities consumers need without the loader’s Node deps live on dedicated subpaths. Each is tree-shake-clean, no @terrazzo/parser, no node:* imports, safe to import from preview-side bundles, the Storybook manager, or any browser consumer. Ship project.tokenGraph to the browser through /snapshot-for-wire rather than the full Project: resolveAt is a function and cwd is a Node-side absolute path, so the object isn’t JSON-serializable as-is, and snapshotForWire(project, css) returns the transportable subset.

SubpathExportPurpose
/graphresolveAt, resolveAllAt, resolveAliasAt, resolveAliasAllAt, getVariance, getAffectedBy, listPaths + TokenGraph / TokenGraphNode / WriteValue typesGraph query helpers, browser-safe, no Terrazzo dependency at query time. See Graph queries below.
/snapshot-for-wiresnapshotForWire(project, css) + SnapshotForWire typeJSON-friendly subset of Project for cross-boundary transport.
/themesenumerateThemes({axes, presets, defaultTuple}) + tupleToName(axes, tuple) + ThemeEntry / ThemeEnumAxis / ThemeEnumPreset typesEnumerate single-axis tuples + presets as a unified theme list; compose stable theme IDs from tuples.
/match-pathmatchPath(path, filter)Glob-style path matcher (* single-segment, ** multi-segment, bare prefix treated as descendants); shared by blocks’ filter prop and the MCP list_tokens tool.
/fuzzyfuzzyFilter + fuzzyMatchesuFuzzy-backed token search; powers the MCP search_tokens tool.
/css-varcssVarRef(path, prefix)Compose var(--prefix-path) strings, same shape Terrazzo’s plugin-css emits.
/data-attrdataAttr(prefix, key)Compose data-prefix-key attribute names, namespaced to cssVarPrefix.
/style-elementensureStyleElement(elementId, text) + SWATCHBOOK_STYLE_ELEMENT_IDIdempotent <style> injector, shared between the addon’s preview decorator and the blocks-side stylesheet path.
/color-formatsCOLOR_FORMATS + ColorFormat typeThe canonical color-format set (hex, rgb, hsl, oklch, raw) the toolbar and blocks switch between.
/format-colorformatColor, parseColor + NormalizedColor / FormatColorResult typesCanonical color rendering, shared by blocks, MCP, and the addon so every surface stringifies a color the same way.
/token-value-typesTypographyValue / BorderValue / TransitionValue / ShadowLayer / GradientStop / ColorValue / DashedStrokeStyleValue / TokenType / RealisedToken<T> typesTyped envelopes for DTCG 2025.10 composite $value shapes, one per $type, so a typo’d sub-value key is a compile error rather than a silent Record<string, unknown> miss.
/token-value-cssformatDimension, formatSubColor, formatTokenValueComposite sub-field display formatting (shadow offset/blur/spread, border width, …) shared by the preview tables.
import { resolveAt, getVariance } from '@unpunnyfuns/swatchbook-core/graph';
import { snapshotForWire } from '@unpunnyfuns/swatchbook-core/snapshot-for-wire';

snapshotForWire’s output is what @unpunnyfuns/swatchbook-blocksSwatchbookProvider consumes outside Storybook: loadProject builds the Project, snapshotForWire(project, css) reduces it to the JSON-safe wire shape, and that shape is typically written to a file for a standalone consumer to import. Rendering blocks standalone is the task-shaped walkthrough, from build script to axis switching.

import { loadProject, emitAxisProjectedCss } from '@unpunnyfuns/swatchbook-core';
import { snapshotForWire } from '@unpunnyfuns/swatchbook-core/snapshot-for-wire';
import { writeFile } from 'node:fs/promises';
import config from './swatchbook.config.ts';
const project = await loadProject(config, process.cwd());
const css = emitAxisProjectedCss(project);
const wire = snapshotForWire(project, css);
await writeFile('tokens-snapshot.json', JSON.stringify(wire));
import { SwatchbookProvider, TokenTable, useSetAxes } from '@unpunnyfuns/swatchbook-blocks';
import wire from './tokens-snapshot.json';
<SwatchbookProvider snapshot={wire} defaultAxes={{ mode: 'Light' }}>
<TokenTable filter="color.**" />
</SwatchbookProvider>;

axes (controlled: the host drives the tuple and re-renders the provider on change) and defaultAxes (uncontrolled: the provider owns the tuple, flipped through useSetAxes()) are mutually exclusive. The provider mounts the snapshot’s CSS by default; Token CSS covers the mountCss={false} opt-out. See Provider for the full provider API.

A Storybook-hosted render doesn’t need any of this: the addon’s preview decorator assembles the wire snapshot and mounts the provider automatically. The pattern above is for consumers rendering blocks with no addon and no Storybook channel in the page at all. The integration seam for a different host to feed the same blocks is @unpunnyfuns/swatchbook-blocks/host’s registerProjectSource / useProjectSource, which is what the addon itself uses to push Storybook’s channel data in.

@unpunnyfuns/swatchbook-core/graph exports the full set of graph query helpers. All are browser-safe: no @terrazzo/parser, no node:* imports. Import from this subpath whenever resolution or variance queries are needed outside the Node build context (the preview, the Storybook manager, integrations).

ExportSignatureDescription
resolveAt(graph, path, tuple) → SwatchbookToken | undefinedResolved leaf value for one token at a given tuple. Memoizable across calls with the same shared memo map.
resolveAllAt(graph, tuple) → TokenMapResolved TokenMap for every path in the graph at a given tuple.
resolveAliasAt(graph, path, tuple) → SwatchbookToken | undefinedAlias-preserving view; stops at the first alias/partial-alias write and returns a token with aliasOf populated rather than recursing to a leaf.
resolveAliasAllAt(graph, tuple) → TokenMapFull TokenMap with alias-preserving view for every path.
getVariance(graph, path) → AxisVarianceResultAxisVarianceResult (whether the token’s value changes across axes, and which axes) for one token path: which axes affect it, and the stringified value per context per axis.
getAffectedBy(graph, path) → readonly string[]Set of axis names that can change this token’s resolved value at any tuple.
listPaths(graph) → readonly string[]Sorted array of every token path in the graph.
import { resolveAt, getVariance, listPaths } from '@unpunnyfuns/swatchbook-core/graph';
// Resolved value at a non-default tuple:
const token = resolveAt(project.tokenGraph, 'color.accent.bg', { mode: 'Dark' });
// Per-token variance:
const info = getVariance(project.tokenGraph, 'color.accent.bg');
if (info.kind === 'single') {
// info.axis : string
// info.varyingAxes : readonly [string]
} else if (info.kind === 'multi') {
// info.varyingAxes : readonly [string, string, ...string[]]
}
// All paths in the graph:
const paths = listPaths(project.tokenGraph);

Frozen list of the nine chrome role names, always under the --swatchbook-* namespace regardless of config.cssVarPrefix. See config.chrome for what each role maps to.

import { CHROME_ROLES } from '@unpunnyfuns/swatchbook-core';
CHROME_ROLES;
// → ['borderDefault', 'surfaceDefault', 'surfaceMuted', 'surfaceRaised',
// 'textDefault', 'textMuted', 'accentBg', 'accentFg',
// 'bodyFontFamily']

Built-in chrome values used when config.chrome is empty or partial: flat hex strings and font stacks for a standalone light-scheme block surface. Per-axis variation comes from mapping roles to your own tokens via config.chrome.

import { DEFAULT_CHROME_MAP, type ChromeRole } from '@unpunnyfuns/swatchbook-core';
DEFAULT_CHROME_MAP.surfaceDefault; // '#ffffff'
DEFAULT_CHROME_MAP.accentBg; // '#1d4ed8'
type ChromeRole = (typeof CHROME_ROLES)[number];
// 'borderDefault' | 'surfaceDefault' | 'surfaceMuted' | …

Three valid shapes: ResolverConfig | LayeredConfig | PlainConfig. Which load-strategy field is present selects the variant, so invalid combinations like { resolver, axes } are compile-time errors. See Picking a theming input for choosing between them and Config reference for the full per-field semantics.

type Config = ResolverConfig | LayeredConfig | PlainConfig;

Resolver-driven: axes derived from a DTCG 2025.10 resolver file’s modifiers. tokens is optional (the resolver’s $ref targets determine which files load).

interface ResolverConfig extends CommonConfig {
resolver: string;
tokens?: string[];
axes?: never;
}

Authored layered axes: per-context overlay globs that layer onto the base tokens. The base tokens is required.

interface LayeredConfig extends CommonConfig {
axes: AxisConfig[];
tokens: string[];
resolver?: never;
}

Plain-parse: single synthetic axis (theme), no resolver, no overlays.

interface PlainConfig extends CommonConfig {
tokens: string[];
resolver?: never;
axes?: never;
}
interface AxisConfig {
name: string;
description?: string;
contexts: Record<string, string[]>; // contextName → file paths / globs
default: string;
}
interface Preset {
name: string;
axes: Partial<Record<string, string>>; // axisName → contextName
description?: string;
}
interface Axis {
name: string;
contexts: readonly string[];
default: string;
description?: string;
source: 'resolver' | 'layered' | 'synthetic';
}
interface Project {
config: Config;
axes: readonly Axis[];
/** Axis names suppressed via `config.disabledAxes` and validated against the resolver. */
disabledAxes: readonly string[];
presets: readonly Preset[];
/** Validated chrome-alias entries from `config.chrome`. Invalid entries are dropped and reported as diagnostics. */
chrome: Partial<Record<ChromeRole, string>>;
/** Resolved TokenMap at the default tuple. Convenience for global views. */
defaultTokens: TokenMap;
/** Axis defaults — `{ axisName: axis.default }` for every axis. */
defaultTuple: Record<string, string>;
/** Compose the resolved TokenMap for any tuple of axis selections. Graph-backed; memoized on the canonical tuple key. */
resolveAt: (tuple: Record<string, string>) => TokenMap;
/**
* Walkable token graph — the primary resolution data structure.
* Query via `@unpunnyfuns/swatchbook-core/graph` helpers.
*/
tokenGraph: TokenGraph;
/** Absolute paths of every file loaded while building the project (resolver + `$ref` targets, overlay files, or globbed plain-parse files). */
sourceFiles: readonly string[];
/** Loader cwd — what all config-relative paths resolved against. */
cwd: string;
/**
* Path-indexed Token Listing data from `@terrazzo/plugin-token-listing`.
* Each entry carries authoritative plugin-css-emitted CSS variable names,
* a CSS-ready `previewValue`, `originalValue` (pre-resolution), and
* `source.loc` pointing back to the authoring file + line. Populated for
* resolver-backed projects; empty `{}` for layered / plain-parse paths.
* Node-side tooling and the wire-payload snapshot draw from this.
*/
listing: TokenListingByPath;
/** Load-time warnings and errors. See [Diagnostics reference](/reference/diagnostics) for the catalog by group. */
diagnostics: Diagnostic[];
}
interface TokenGraph {
nodes: Record<string, TokenGraphNode>;
axes: readonly string[];
axisDefaults: Record<string, string>;
axisContexts: Record<string, readonly string[]>;
}

Walkable graph keyed by token path. JSON-serializable (no Maps or Sets) so the same shape works on Node and in the browser. axisDefaults and axisContexts carry enough axis metadata for variance queries without needing the original Axis[] array.

interface TokenGraphNode {
baselineValue: SwatchbookToken;
baselineKind: 'literal' | 'alias' | 'partial-alias';
baselineAliasTarget?: string;
baselinePartialFields?: Record<string, string>;
writes: Record<string, Record<string, WriteValue>>;
aliases: readonly string[];
aliasedBy: readonly string[];
affectedBy: readonly string[];
}

Per-token-path node. baselineValue is the resolved leaf at the default tuple. writes[axisName][contextName] holds per-(axis, context) overlay declarations. affectedBy is a precomputed list of axes that can change this token’s value through any chain. Used by resolveAt to skip resolution for tokens whose value doesn’t depend on anything in the requested tuple.

Path-indexed alias over @terrazzo/plugin-token-listing’s ListedToken shape. Node-side code reads the raw ListedToken shape (listing[path].$extensions['app.terrazzo.listing'].names.css). Blocks receive the slimmed snapshot form where the access path is listing[path].names.css; block authors should call resolveCssVar(path, project) rather than indexing the path manually. The full entry carries originalValue (pre-resolution, aliases preserved) and source.loc (file + line range pointing at the authoring source).

ListedToken itself isn’t re-exported from @unpunnyfuns/swatchbook-core; import it directly from @terrazzo/plugin-token-listing if you need the type, or prefer the insulated SlimListedToken from /snapshot-for-wire for browser-side reads.

type TokenListingByPath = Record<string, ListedToken>;

See Aligning with your token build for registering extra platforms (swift, android, …) so additional names.<platform> entries populate.

Contract for display-side integrations plugged into the addon via its integrations[] option. An integration names a virtual module; the addon’s Vite plugin serves whatever render(project) produces.

interface SwatchbookIntegration {
name: string;
virtualModule?: {
virtualId: string; // e.g. 'virtual:swatchbook/tailwind.css'
render(project: Project): string; // produce the module body
/** When `true`, the addon's preset auto-injects `import '<virtualId>';`
* into the preview bundle. Appropriate for integrations contributing
* global CSS (Tailwind's `@theme` block, a rules-heavy stylesheet).
* Leave `false` (default) when consumers import named exports from
* the virtual module per-site. */
autoInject?: boolean;
};
}

See the Integrations guide for factories that build these for Tailwind and CSS-in-JS.

One load-time diagnostic (error, warn, or info) on Project.diagnostics. See the Diagnostics reference for the interface shape and the full catalog by group.

type DiagnosticSeverity = 'error' | 'warn' | 'info';

Return type of getVariance(graph, path). The kind field distinguishes the three cases. varyingAxes’s length is encoded in the TypeScript type so a switch on kind covers all three exhaustively. The single variant adds a convenience axis: string accessor.

type AxisVarianceResult =
| {
path: string;
kind: 'constant';
varyingAxes: readonly [];
constantAcrossAxes: readonly string[];
perAxis: AxisVariancePerAxis;
}
| {
path: string;
kind: 'single';
axis: string;
varyingAxes: readonly [string];
constantAcrossAxes: readonly string[];
perAxis: AxisVariancePerAxis;
}
| {
path: string;
kind: 'multi';
varyingAxes: readonly [string, string, ...string[]];
constantAcrossAxes: readonly string[];
perAxis: AxisVariancePerAxis;
};
type AxisVariancePerAxis = Record<
string,
{ varying: boolean; contexts: Record<string, string> }
>;

There’s no standalone VarianceKind type for the 'constant' | 'single' | 'multi' union — narrow via switch (result.kind) against the inline literal above.

interface SwatchbookToken {
$type?: string | undefined;
$value?: unknown;
$description?: string | undefined;
aliasOf?: string | undefined;
aliasChain?: readonly string[] | undefined;
aliasedBy?: readonly string[] | undefined;
/** Per-sub-field alias map for composite tokens; shape varies per `$type`. */
partialAliasOf?: unknown;
}

The seven fields downstream consumers actually read off a resolved token. Structurally compatible with Terrazzo’s TokenNormalized, so resolver output flows in without casts; carrying the narrower interface means a Terrazzo-side field rename or restructure doesn’t ripple onto the swatchbook public surface.

type TokenMap = Record<string, SwatchbookToken>;

Resolved-token map keyed by DTCG flat path ("color.accent.bg"). The shape project.defaultTokens and project.resolveAt(tuple) return.

type ResolveAt = (tuple: Record<string, string>) => TokenMap;

The shape of project.resolveAt.