@reforma/project-tokens
v0.0.2
Published
Portable DTCG 2025.10 compiler for project design tokens.
Readme
@reforma/project-tokens
Compile project DTCG JSON into CSS, Tailwind theme bindings, and mode metadata. The compiler runs locally on Node 24 or newer.
Files
The compiler reads a DTCG resolver and the documents that resolver references.
It writes tokens.css, tailwind.css, and modes.json into the directory
from --out. Without that flag, the directory is .generated beside the resolver.
The JSON stays the source. Ignore the output directory.
tokens/tokens.resolver.json sets, modes, default
tokens/base.json every value for the default context
tokens/dark.json sparse overrides for one other context
tokens/.generated/tokens.css custom properties
tokens/.generated/tailwind.css Tailwind @theme bindings
tokens/.generated/modes.json context names, selectors, revisionThe directory and the JSON filenames are yours. $ref inside the resolver
decides which documents are read. base.json and dark.json above are only
an example: one complete document, then a document that overrides existing
paths for another context. A mode file does not declare types and does not
add tokens that exist only in that mode.
Pass --entry to point at the resolver and --out to choose the output
directory. If you omit --entry, the CLI uses
.reforma/tokens/tokens.resolver.json. If you omit --out, it writes
.generated beside the resolver.
tokens.css puts the default context on :root and each other context on a
selector such as html[data-theme="dark"]. tailwind.css is an
@theme inline block. It includes only names Tailwind already treats as theme
keys, including color, spacing, font, text, radius, and shadow.
Any other group stays a plain variable in tokens.css. modes.json records
the default, each modifier's contexts, the selector for every permutation,
the input revision, and hashes of the source files.
This generated tailwind.css is not the project's Tailwind entry. The app
imports its own Tailwind CSS, then these generated files. check writes
nothing. build, watch, and dev replace the three files when the output
changes, and leave the last good output in place when the JSON is invalid.
CLI
Install the package, then call the reforma-tokens binary from a script.
--entry selects the resolver. The default is .reforma/tokens/tokens.resolver.json.
{
"scripts": {
"dev": "reforma-tokens dev -- next dev",
"build": "reforma-tokens build && next build"
}
}dev compiles, then runs the command after --. That command starts the app and
must not call reforma-tokens again. check, build, and watch take no child
command.
| Command | Behavior |
| -------------------- | -------------------------------------------------------------------------------------- |
| check | Validate every permutation. Write nothing. Exit 1 on errors. |
| build | Compile and replace changed generated files. Exit 1 on errors. |
| watch | Build, then poll dependency hashes. Recover after invalid or missing files. |
| dev -- command ... | Build before starting the command. Watch sources. Forward signals and the exit status. |
--cwd directory selects the workspace. --entry path selects its resolver.
--out directory selects the output directory.
Repeat --context axis=value to supply contexts, including axes without defaults.
Every permutation is validated. The selected or default input supplies :root.
Names are case-sensitive. Compilation stops at 1000 permutations.
A build may also leave staging and lock files in the output directory while it runs.
Watch and dev print one JSON diagnostic per line on stderr and
{ "status": "stale", "revision" } when input is invalid. A successful rebuild
prints { "status": "ready", "revision" } on stdout. The app keeps running after
a bad edit. An invalid first build does not start the dev command.
API
import { compileProjectTokens } from "@reforma/project-tokens";
const result = await compileProjectTokens({ workspaceRoot: process.cwd() });
if (result.ok) {
console.log(result.output?.tokensCss);
console.log(result.permutations);
}Compilation reads sources and returns diagnostics, dependency SHA-256 hashes, an input revision, resolved tokens per permutation, and generated strings. Each token keeps its canonical path, type, resolved value, CSS declarations, and winning source file and JSON Pointer. A missing dependency has a null hash. The API does not write files. Source extensions stay opaque.
Local file references and same-document JSON Pointers resolve. Remote URLs,
paths that leave the workspace, and symlinks that escape it are rejected.
Resolver $ref siblings replace referenced fields shallowly. Ordered composition
replaces whole tokens. Groups merge recursively. Aliases resolve after composition
for each context. $root stays in token identity and aliases, and is omitted
from CSS names.
A mode modifier selects html[data-theme="context"]. Any other axis selects
html[data-token-AXIS="context"]. Each selector constrains every active axis.
:root receives the full default. Context selectors receive only the values
that differ. CSS names join path segments with -, escape CSS characters, and
reject collisions. Recognized Tailwind namespaces become @theme inline bindings.
Other groups stay ordinary CSS variables. The namespace does not infer the type.
Authoring data
The snapshot describes the sources an editor can show:
| Field | Meaning |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| editing | A supported layout with defaultMode, baseFile, and modeFiles, or why structured editing is unavailable. A null mode file is a sparse context. |
| permutations[].groups | Flat group hierarchy, including empty groups and the root at "". Each group has provenance, original metadata in definition, and effective type, description, and deprecated. |
| tokens[].authoring.definition | Original token properties, including unresolved $value aliases, JSON Pointers, and vendor extensions. value stays resolved. |
| tokens[].description, deprecated | Effective metadata after composition and group inheritance. Description belongs to the node. Type and deprecation can be inherited. |
| tokens[].authoring.inheritance | base, inherited, or override in the supported mode layout, or null when ownership is ambiguous. An explicit override stays an override when its value equals the base. |
| tokens[].authoring.canReset, readOnlyReason | Whether reset is allowed, and why the simple value editor cannot change this token. Composite values and tokens supplied through $extends stay readable. |
Group definition is metadata from the last contributing source, without children
or $root. Effective fields describe the composed group. Do not write that object
back as a document. Definitions and resolved values share the snapshot revision,
including candidates from planTokenMutation.
Eligibility describes source structure. A mutation still checks references, usages, the revision, and the full candidate. Missing authoring fields do not mean the token can be edited.
Structured editing
planTokenMutation from @reforma/project-tokens/mutations plans source edits
and compiles the candidate in memory. It returns the original revision, changed
files with before and after text, and the candidate compilation. It writes nothing.
Create a top-level scope with { kind: 'scope.create', path, type } and one of
the exported DTCG_TOKEN_TYPES. group.create adds an inherited subgroup inside
an existing scope. token.create sets $value and metadata in the default mode
and takes the type from that scope. $type is rejected on token create, ordinary
updates, and sparse mode files.
Token operations are update, delete, reset, and rename. Group operations are
delete and rename. A rename is { kind, path, to }. A token destination must
share the scope type. Nested groups stay inside their scope. A scope can be
renamed at the top level. Aliases, JSON Pointers, and mode overrides move with
the node, and values keep their inherited type. A scope type cannot change.
Deleting a nonempty group requires tokens, the exact list of descendant paths.
Mode operations are create, delete, default, rename, and reset. Mode rename is
{ kind: 'mode.rename', mode, to }. Mode reset drops that mode's overrides.
The default mode cannot be reset.
The managed profile is one base set, then a mode modifier, with separate local
JSON files and an empty default context. Every top-level base group declares one
standard $type. Nested groups inherit it. Base owns every token path. A token
$type, a mode $type, a mode-only path, or a cross-type descendant fails with
a path-specific SCOPE_* diagnostic, including expectedType and actualType
on a mismatch. Invalid managed sources produce no output and block structured
writes. The compiler does not infer types or migrate documents.
A sparse context gets its own document on the first edit. Other resolver layouts
remain ordinary DTCG input, and structured editing reports AMBIGUOUS_SOURCE.
Changing the default preserves effective values and aliases. Detached mode
documents stay on disk. An existing file is not reused because its name matches.
Changing the default, including replacing a deleted default, does not yet keep
group-inherited $deprecated metadata across modes. CSS and values are preserved.
Deprecation metadata is not.
buildProjectTokens and withTokenCompilationLock from
@reforma/project-tokens/generation write the same output as the CLI.
Compatibility
The target is DTCG Format 2025.10
and Resolver 2025.10. Terrazzo's parser and CSS tools are pinned to 2.7.1.
The adapter covers tested upstream gaps: shallow resolver-reference overrides,
set references inside contexts, escaped pointers, shared group inheritance,
JSON Pointer $extends, explicit $root, composite array aliases, and provenance.
Neutral parser IDs keep legal names such as constructor and __proto__.
A value gate rejects Terrazzo-only types and dimension units. Unknown vendor
extensions do not turn on Terrazzo's legacy modes. A modifier with one context
produces a nonfatal SINGLE_CONTEXT diagnostic.
CSS uses Terrazzo's web projection, including the dashed fallback for custom
stroke patterns and separate custom properties for typography components. Colors
and color-bearing composites are serialized with Color.js in their native space,
which keeps alpha and avoids Terrazzo's wide-gamut fallback IDs.
Development
From this package:
bun run test
bun run build
bun run verify:projectverify:project packs the built artifact, installs it in a temporary consumer,
and checks the Node API, CLI, standard types, modes, provenance, and Tailwind
output. It needs registry access. If node is older than 24, set
PROJECT_TOKENS_NODE to a Node 24+ binary.
