Core
Published as @unpunnyfuns/swatchbook-core. Framework-free DTCG loader. Parses token files via Terrazzo, builds a walkable token graph (a data structure you can query for any token's value in any axis combination), resolves aliases per tuple. No React, no Storybook dependency.
Install
npm install @unpunnyfuns/swatchbook-core
Functions
defineSwatchbookConfig(config)
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',
});
loadProject(config, cwd?)
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, … }
project.resolveAt(tuple)
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' });
project.defaultTokens
The resolved TokenMap at the project's default tuple. Convenience for global views.
for (const [path, token] of Object.entries(project.defaultTokens)) {
// …
}
emitAxisProjectedCss(project, options?)
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.
Browser-safe subpaths
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.
| Subpath | Export | Purpose |
|---|---|---|
/graph | resolveAt, resolveAllAt, resolveAliasAt, resolveAliasAllAt, getVariance, getAffectedBy, listPaths + TokenGraph / TokenGraphNode / WriteValue types | Graph query helpers — browser-safe, no Terrazzo dependency at query time. See Graph queries below. |
/snapshot-for-wire | snapshotForWire(project, css) + SnapshotForWire type | JSON-friendly subset of Project for cross-boundary transport. |
/themes | enumerateThemes({axes, presets, defaultTuple}) + tupleToName(axes, tuple) + ThemeEntry / ThemeEnumAxis / ThemeEnumPreset types | Enumerate single-axis tuples + presets as a unified theme list; compose stable theme IDs from tuples. |
/match-path | matchPath(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. |
/fuzzy | fuzzyFilter + fuzzyMatches | uFuzzy-backed token search; powers the MCP search_tokens tool. |
/css-var | makeCssVar(path, prefix) | Compose var(--prefix-path) strings — same shape Terrazzo's plugin-css emits. |
/data-attr | dataAttr(prefix, key) | Compose data-prefix-key attribute names — namespaced to cssVarPrefix. |
/style-element | ensureStyleElement(elementId, text) + SWATCHBOOK_STYLE_ELEMENT_ID | Idempotent <style> injector — shared between the addon's preview decorator and the blocks-side stylesheet path. |
import { resolveAt, getVariance } from '@unpunnyfuns/swatchbook-core/graph';
import { snapshotForWire } from '@unpunnyfuns/swatchbook-core/snapshot-for-wire';
Graph queries
@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).
| Export | Signature | Description |
|---|---|---|
resolveAt | (graph, path, tuple) → SwatchbookToken | undefined | Resolved leaf value for one token at a given tuple. Memoizable across calls with the same shared memo map. |
resolveAllAt | (graph, tuple) → TokenMap | Resolved TokenMap for every path in the graph at a given tuple. |
resolveAliasAt | (graph, path, tuple) → SwatchbookToken | undefined | Alias-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) → TokenMap | Full TokenMap with alias-preserving view for every path. |
getVariance | (graph, path) → AxisVarianceResult | AxisVarianceResult (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);
Constants
CHROME_ROLES
Frozen list of the ten role names that block chrome reads from. Independent of config.cssVarPrefix — these always live under the --swatchbook-* namespace so blocks remain stylable regardless of consumer prefix.
import { CHROME_ROLES } from '@unpunnyfuns/swatchbook-core';
CHROME_ROLES;
// → ['borderDefault', 'surfaceDefault', 'surfaceMuted', 'surfaceRaised',
// 'textDefault', 'textMuted', 'accentBg', 'accentFg',
// 'bodyFontFamily', 'bodyFontSize']
DEFAULT_CHROME_MAP
Built-in chrome values used when config.chrome is empty or partial. Color roles use light-dark() so zero-config chrome auto-flips with the active color-scheme (which Storybook's preview iframe, MDX docs pages, and the OS preference all participate in).
import { DEFAULT_CHROME_MAP, type ChromeRole } from '@unpunnyfuns/swatchbook-core';
DEFAULT_CHROME_MAP.surfaceDefault; // 'light-dark(#ffffff, #0f172a)'
DEFAULT_CHROME_MAP.accentBg; // 'light-dark(#1d4ed8, #3b82f6)'
ChromeRole
type ChromeRole = (typeof CHROME_ROLES)[number];
// 'borderDefault' | 'surfaceDefault' | 'surfaceMuted' | …
Types
Config
Three valid shapes: ResolverConfig | LayeredConfig | PlainConfig. Each variant has a different load strategy; TypeScript picks the right shape based on which field is present, so invalid combinations like { resolver, axes } are caught at compile time. See Config reference for the full per-field semantics.
type Config = ResolverConfig | LayeredConfig | PlainConfig;
ResolverConfig
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;
}
LayeredConfig
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;
}
PlainConfig
Plain-parse: single synthetic axis (theme), no resolver, no overlays. The simplest "I just have token files" case.
interface PlainConfig extends CommonConfig {
tokens: string[];
resolver?: never;
axes?: never;
}
AxisConfig
interface AxisConfig {
name: string;
description?: string;
contexts: Record<string, string[]>; // contextName → file paths / globs
default: string;
}
Preset
interface Preset {
name: string;
axes: Partial<Record<string, string>>; // axisName → contextName
description?: string;
}
Axis
interface Axis {
name: string;
contexts: readonly string[];
default: string;
description?: string;
source: 'resolver' | 'layered' | 'synthetic';
}
Project
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](./diagnostics.mdx) for the catalog by group. */
diagnostics: Diagnostic[];
}
TokenGraph
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.
TokenGraphNode
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.
TokenListingByPath
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).
type TokenListingByPath = Record<string, ListedToken>;
See Sharing Terrazzo options for registering extra platforms (swift, android, …) so additional names.<platform> entries populate.
SwatchbookIntegration
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.
Diagnostic
interface Diagnostic {
severity: DiagnosticSeverity;
group: string;
message: string;
filename?: string;
line?: number;
}
DiagnosticSeverity
type DiagnosticSeverity = 'error' | 'warn' | 'info';
AxisVarianceResult
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> }
>;
VarianceKind
type VarianceKind = 'constant' | 'single' | 'multi';
SwatchbookToken
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.
TokenMap
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.
ListedToken
The per-token shape from @terrazzo/plugin-token-listing, re-exported for typing convenience. Each entry carries the authoritative plugin-css-emitted CSS variable names, a CSS-ready previewValue, the pre-resolution originalValue, and a source.loc pointer to the authoring file + line. Composed map: TokenListingByPath.
ResolveAt
type ResolveAt = (tuple: Record<string, string>) => TokenMap;
The shape of project.resolveAt.
Do / don't
- ✅ Use this package at build time — Node scripts, SSR, Storybook presets. It has no DOM dependency.
- ✅ Ship
project.tokenGraphto the browser via/snapshot-for-wire—TokenGraphis JSON-serializable. Query it on the browser side with the/graphhelpers. - ✅ Use
getVariance(graph, path)from/graphfor per-token variance classification. - ❌ Don't import from
@terrazzo/parserdirectly unless you need features core doesn't expose. - ❌ Don't ship the full
Projectobject to the browser —resolveAtis a function andcwdis a Node-side absolute path. UsesnapshotForWire(project, css)to get a JSON-friendly subset.