Core
Published as @unpunnyfuns/swatchbook-core. Framework-free DTCG loader. Parses token files via Terrazzo, resolves aliases, composes themes through a resolver or layered-axes config, and emits CSS variables + TypeScript types. 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. Eagerly resolves every theme permutation, so downstream consumers can read themesResolved[name] directly without further I/O.
import { loadProject } from '@unpunnyfuns/swatchbook-core';
const project = await loadProject(config, process.cwd());
// project: { config, axes, presets, themes, themesResolved, graph, diagnostics }
resolveTheme(project, name)
Pick a single composed theme out of a project.
const { tokens } = resolveTheme(project, 'Light · Default');
Throws with a helpful "known themes" message if the name is unknown.
emitCss(themes, themesResolved, options?)
Concatenated stylesheet. With options.axes supplied (or when called through projectCss), emits :root for the default tuple plus one compound-selector block per non-default tuple.
const css = emitCss(project.themes, project.themesResolved, {
prefix: 'ds',
axes: project.axes,
});
Single-axis projects keep the familiar single-attribute shape — [data-<prefix>-theme="…"] with a prefix set (default swatch), [data-theme="…"] when you opt out with cssVarPrefix: ''.
projectCss(project)
emitCss with project defaults applied (prefix + axes). The form you almost always want.
emitTypes(project)
TypeScript source declaring the token-path union + SwatchbookTokenMap. The addon preset writes this to .swatchbook/tokens.d.ts so useToken() has typed paths.
permutationID(input)
Stringify a tuple to the form used as Theme.name and CSS data-attribute values:
permutationID({ mode: 'Dark', brand: 'Brand A' }); // → "Dark · Brand A"
permutationID({ theme: 'Light' }); // → "Light"
emitViaTerrazzo(project, options?)
Axis-aware wrapper around Terrazzo's programmatic build(). Derives compound-selector permutations from project.themes (or project.presets via selection), pins @terrazzo/plugin-css's variableName to the project's cssVarPrefix, and runs whatever additional Terrazzo plugins you pass — plugin-css-in-js, plugin-tailwind, plugin-swift, plugin-js, plugin-sass, plugin-vanilla-extract, plugin-token-listing.
import { loadProject, emitViaTerrazzo } from '@unpunnyfuns/swatchbook-core';
import cssInJsPlugin from '@terrazzo/plugin-css-in-js';
import tailwindPlugin from '@terrazzo/plugin-tailwind';
const project = await loadProject(config, cwd);
const files = await emitViaTerrazzo(project, {
cssOptions: { filename: 'tokens.css' },
plugins: [
cssInJsPlugin({ filename: 'tokens.js' }),
tailwindPlugin({ filename: 'tailwind.tokens.css' }),
],
selection: 'themes', // default — full cartesian fan-out for runtime CSS
});
// files → [{ filename, contents }, ...]
Options:
| Option | What |
|---|---|
selection | 'themes' (default, full cartesian), 'presets' (one entry per declared preset), or explicit Array<{ input, name? }>. Library consumers (MUI / Vuetify) usually want 'presets'. |
cssOptions | Extra @terrazzo/plugin-css options (filename, include / exclude, transform, utility, colorDepth). permutations and variableName are managed internally — passing them here is a no-op. |
plugins | Additional Terrazzo plugins to run alongside plugin-css. |
Requires a resolver-backed project. Layered (config.axes) and plain-parse paths don't populate parserInput; the wrapper throws with a clear error on those.
Not the addon's emission path. Storybook's runtime CSS still comes from projectCss with chrome aliases. emitViaTerrazzo is for library-level consumers driving their own build or for future display-side integrations.
Types
Config
interface Config {
/** Required for plain-parse and layered; optional when `resolver` is set. */
tokens?: string[];
resolver?: string; // mutually exclusive with `axes`
axes?: AxisConfig[]; // mutually exclusive with `resolver`
presets?: Preset[];
/** Partial tuple keyed by axis name — e.g. `{ mode: 'Dark', brand: 'Brand A' }`. Omitted axes fall back to each axis's own `default`. */
default?: Partial<Record<string, string>>;
/** Axis names to suppress from the toolbar and CSS emission; each is pinned to its default context. */
disabledAxes?: string[];
/** Map from block chrome roles (CHROME_ROLES) to target token paths — emits :root aliases redirecting --swatchbook-* chrome reads to consumer tokens. */
chrome?: Record<string, string>;
cssVarPrefix?: string;
outDir?: string;
}
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: string[];
default: string;
description?: string;
source: 'resolver' | 'layered' | 'synthetic';
}
Project
interface Project {
config: Config;
axes: Axis[];
/** Axis names suppressed via `config.disabledAxes` and validated against the resolver. */
disabledAxes: string[];
presets: Preset[];
/** Validated chrome-alias entries from `config.chrome`. Invalid entries are dropped and reported as diagnostics. */
chrome: Record<string, string>;
themes: Theme[];
themesResolved: Record<string, TokenMap>;
graph: TokenMap; // default theme's resolved tokens
/** Absolute paths of every file loaded while building the project (resolver + `$ref` targets, overlay files, or globbed plain-parse files). */
sourceFiles: string[];
/** Loader cwd — what all config-relative paths resolved against. */
cwd: string;
/** Retained Terrazzo parse output for `emitViaTerrazzo`. Present for resolver-backed projects; undefined for layered / plain-parse paths. */
parserInput?: ParserInput;
diagnostics: Diagnostic[];
}
ParserInput
Pass-through bag of the three values Terrazzo's programmatic build() needs. Consumers treat it as opaque — feed the whole object into build() (or into emitViaTerrazzo, which handles that for you).
interface ParserInput {
tokens: Record<string, TokenNormalized>; // unified pre-permutation token graph
sources: InputSourceWithDocument[]; // parser's input sources
resolver: Resolver; // the Terrazzo resolver object
}
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
};
}
See the Integrations section for factories that build these for Tailwind and CSS-in-JS.
Theme
interface Theme {
name: string;
input: Record<string, string>; // the axis tuple
sources: string[];
}
Diagnostic
interface Diagnostic {
severity: 'error' | 'warn' | 'info';
group: string;
message: string;
filename?: string;
line?: number;
column?: number;
}
Do / don't
- ✅ Use this package at build time — Node scripts, SSR, Storybook presets. It has no DOM dependency.
- ✅ Treat
emitCssoutput as the source of truth for CSS vars; don't parallel-hand-write them. - ❌ Don't import from
@terrazzo/parserdirectly unless you need features core doesn't expose. - ❌ Don't ship
Projectobjects to the browser — they carry full raw-AST references. UsethemesResolvedprojections instead.