umberkit
v0.0.2
Published
Framework-agnostic design-token and theming toolkit: brand color in, validated accessible themed system out.
Downloads
395
Maintainers
Readme
umberkit
Framework-agnostic design-token and theming toolkit: brand color in, validated accessible themed system out. DTCG 2025.10 + Resolver Module as the data model (Terrazzo under the hood), plain CSS custom properties in cascade layers as the universal artifact — no framework required, usable from React, Vue, Svelte, Solid, or plain HTML.
Quick start
npm create umberOr npm create umber -- --accent "#30A46C" to start from your own brand color.
Either way it scaffolds a starter token tree, expands each seed into a full
accessible scale, and runs the first build:
tokens/
├── resolver.json # DTCG Resolver Module 2025.10
├── generated/
│ ├── light.tokens.json # 12-step palettes, light — generated
│ └── dark.tokens.json # 12-step palettes, dark — generated
├── base.tokens.json # non-color primitives: spacing, radius
├── semantic.tokens.json # semantic aliases → palette steps
└── components.tokens.json # component tokens → semantic aliases only
umberkit.config.ts
├── manifest.json # machine-readable public contract — commit this
dist/design/
└── tokens.css # @layer tokens { :root { --um-*: light-dark(…) } }Turning on emit adds tokens.scss (SCSS), theme.css (Tailwind v4),
tokens.ts (typed), and tokens.md + showcase.html (documentation) beside
it.
umberkit build --watch re-emits whenever a token source changes, which is the
half a dev server cannot supply — Vite picks the rebuilt CSS up over HMR the
moment the file lands. A build that fails is reported and the watch continues,
because a half-written token file is an ordinary step in that loop. The config
is deliberately not watched: seeds change rarely, and reloading one mid-watch
would mean re-running generate into the directory being watched.
The manifest lives beside the tokens rather than in dist/, because the
contract ratchet diffs it against git history — it has to be committed.
Theme generation
One seed per palette is the whole input:
export default defineConfig({
palettes: { accent: "#6E56CF", neutral: "#8D8D8D" },
});umberkit generate expands each into a 12-step OKLCH scale following the Radix
step roles (1–2 backgrounds, 3–5 component backgrounds, 6–8 borders, 9–10
solids, 11–12 text), in light and dark, plus alpha counterparts and a companion
foreground for the solid step — 25 tokens per palette per mode.
The lightness and chroma curves are calibrated against the Radix blue, red and green scales converted to OKLCH. Step 9 is the seed verbatim, so the brand color survives even where that breaks the ramp's monotonicity — Radix's yellow does exactly this on purpose. (A seed outside sRGB keeps its lightness and hue exactly; only its chroma is reduced to fit, and the recorded seed is that in-gamut color so the two always agree.) Steps 11 and 12 start at their curve anchors and are then darkened or lightened until they clear WCAG AA and AAA against step 2. Anything the generator cannot fix by choosing a different value — a mid-grey solid has no companion foreground reaching APCA Lc 60 — is reported rather than silently accepted.
Multi-brand
For white-label systems, brands re-seeds palettes per brand. Each brand lists
only the palettes it overrides and inherits the rest; every override goes
through the same generation and contrast validation, per scheme:
export default defineConfig({
palettes: { accent: "#6E56CF", neutral: "#8D8D8D" },
brands: {
acme: { accent: "#E5484D" }, // inherits neutral
},
});umberkit generate writes the brand's scales into the scheme documents as
namespaced primitives (color.brand.acme.accent.*) plus one scheme-agnostic
alias shim per brand (generated/brand-acme.tokens.json) that re-points
color.accent.* at them. A resolver modifier context is a static source list —
it cannot hold different values per scheme — so the shim aliases into the
scheme documents instead, and merges after them: wire the resolver with a
brand modifier listed in resolutionOrder after scheme (umberkit check
audits this wiring and prints the exact snippet to paste; the resolver stays
your file, umberkit never edits it).
The emitted CSS keeps brand switching a pure attribute flip: brand primitives
are ordinary light-dark() properties, and [data-brand="acme"] carries only
re-pointing declarations, identical in both schemes — semantic and component
tokens don't change at all. Because color.brand.acme.* tokens are public
contract, renaming a brand is a breaking change by construction; expect the
output to grow by 25 tokens per brand × overridden palette. A brand entry is a
complete palette input — per-step overrides work per brand but do not merge
with the base palette's.
The three tiers
Which tier lives where in the resolver is the design's load-bearing decision:
- Primitives are the generated palettes. They differ per scheme, so they are the resolver's modifier contexts.
- Semantics alias primitives by step number. Because the primitive they point at is what swaps, the semantic layer is itself scheme-independent and lives in the base set — one definition covering both modes.
- Components alias semantics, never primitives.
The emitted CSS shows the payoff: only primitives carry a mode switch.
@layer tokens {
:root {
color-scheme: light dark;
--um-color-accent-1: light-dark(oklch(99.35% 0.0032 288.39), oklch(18.96% 0.0175 288.03));
--um-color-accent-9: oklch(54.17% 0.179 288.03); /* the seed, both modes */
--um-semantic-color-accent-solid: var(--um-color-accent-9);
--um-component-button-bg: var(--um-semantic-color-accent-solid);
}
}Non-color values that differ by scheme become [data-scheme="dark"] plus a
prefers-color-scheme block; any other resolver axis (brand, density, …)
becomes [data-<axis>="<context>"] carrying only its diff.
Runtime color engine
umberkit/color is the same engine the build uses, as pure functions with no Node
or Effect dependency — so an app that lets users pick a brand color generates
scales at runtime with exactly the code that produced its committed tokens.
import { generateScale, measure, alphaScale } from "umberkit/color";
const scale = generateScale("#6E56CF", { mode: "dark" });
scale.report.passes; // every contrast requirement metChecks and the contract ratchet
umberkit check is the CI entry point. It lints, compares the committed generated
palettes, manifest and emitted artifacts with what the config produces now, and
runs the contract ratchet — exiting 0 clean, 1 on an error, 2 when
only advisories remain.
Emitted artifacts are in scope exactly when git tracks them, which is the
same rule the manifest already answers to: committed means it has to stay
current. Pointing outDir into the source tree — so tokens.ts is typechecked
without a build step — therefore does not open a hole where the output can drift
from its sources. A build directory that is ignored, or simply never committed,
is not checked, so a fresh clone stays quiet. A tracked artifact missing from
disk counts as drift; a git diff gate misses that case, because a diff does
not see a path that is not there.
Six lint rules, all with the resolver in scope so they can reason per mode:
| Rule | Default | Catches |
| --------------------- | ------- | -------------------------------------------------------------------------------------- |
| tier-reference | error | A component reaching past semantics into a primitive, or a primitive depending upward |
| resolves-everywhere | error | A semantic or component token defined in one mode but not another |
| contrast | error | Configured foreground/background pairs falling below 4.5:1 / Lc 60, in any permutation |
| contrast-coverage | warn | A public semantic or component colour no configured pair reaches, and no exemption covers |
| tier-structure | warn | A token whose declared tier is not one of the three |
| tailwind-namespace | warn | A token exported to Tailwind that lands outside a real v4 theme namespace |
contrast gates both WCAG 2.x and APCA — either leg falling short is a
failure, and the message names whichever one it was. Per-pair minWcag /
minApca override the AA defaults, which is the right answer for anything that
is not body text: a focus ring is held to WCAG 1.4.11's 3:1, not 4.5:1.
lint: {
rules: { "tier-structure": "off" },
contrast: [
{ fg: "semantic.color.fg.*", bg: "semantic.color.bg.*" },
{ fg: "semantic.focus.color", bg: "semantic.color.bg.canvas", minWcag: 3, minApca: 45 },
],
}contrast checks the pairs it is given; contrast-coverage checks that the
pairs cover everything. Without it both failures are silent — a semantic colour
added without a matching pair is never checked, and a pair deleted to make CI
green looks identical to one that never needed to exist. Every public semantic
or component colour must appear in some pair, or be exempted with a reason:
lint: {
contrast: [{ fg: "semantic.color.fg.*", bg: "semantic.color.bg.*" }],
exempt: [
{
fg: "semantic.color.fg.muted",
bg: "semantic.color.bg.element",
reason: "disabled control — WCAG 1.4.3 exempts inactive controls",
},
],
}Primitives are out of scope: a palette step has no intrinsic foreground or
background role to pair, and generate already validates the steps that carry a
contrast promise. reason is mandatory: a comment in a config file does not lint, does not diff,
and rots as tokens move, while a reason string is greppable and reviewable. An
exemption whose pattern stops matching is reported, so the reason cannot outlive
its subject. The rule defaults to warn because it reports what a policy does
not cover — raise it to error once the gaps are closed.
apca-contrast is the former name of contrast; it still resolves and warns.
The ratchet compares the manifest against a baseline — an explicit
--against <ref>, else the latest v* tag, else the merge-base with the
default branch, else nothing (which passes: there is no contract before a first
release). Removing, renaming, retyping or un-publishing a public token is
breaking, and fails until contract.version rises:
contract: 1 breaking change(s) to public tokens against tag v0.1.0, but contractVersion is still 1
removed semantic.color.fg.muted: token no longer exists
raise contract.version to 2, or keep the old token as a deprecated aliasAn explicit --against ref that cannot be read is an error, not an absence — a
typo'd tag must not pass as "nothing released yet". After the first release,
set contract.requireBaseline: true so a shallow CI clone with no tags fails
instead of leaving the ratchet permanently green.
umberkit diff shows the same comparison for humans, and prints what a text diff
cannot: the readability delta of every colour change.
value-changed color.accent.10: scheme=light: oklch(51.41% 0.1736 288.03) → oklch(59.8% 0.1874 23.03)
on white: WCAG 6.06 → 4.37 ↓, APCA 80 → 69In CI, auto baselines need tags, so fetch them: fetch-depth: 0.
Other outputs
emit: { scss: true, tailwind: true, ts: true, docs: true }Each artifact also takes a destination of its own, because artifacts that are
built together do not necessarily belong together — tokens.ts wants to sit in
the source tree where it is typechecked and never published, tokens.css is
consumed by source and shipped, and tokens.md is local reference:
emit: {
css: true, // → outDir
ts: { out: "./src/design" }, // committed, typechecked with no build step
docs: { out: "./docs/tokens" }, // local only
}Naming a destination switches the artifact on; anything left unrouted goes to
outDir. Emitting into a committed directory is safe because check compares
tracked artifacts against a fresh build — see above.
SCSS. tokens.scss gives each custom property an SCSS variable
($um-semantic-bg), plus a $um-tokens map and an um-token("semantic.bg")
getter that fails compilation on an unknown id. Values reference the canonical
custom properties rather than repeating them — SCSS compiles ahead of time, so
literals would freeze one scheme; var() keeps data-scheme a runtime flip.
Tailwind v4. theme.css is an @theme inline block whose values reference
umberkit's variables rather than repeating them, so flipping data-scheme
re-points the token and every utility follows with no rebuild:
@theme inline {
--color-bg-canvas: var(--um-semantic-color-bg-canvas);
--spacing-md: var(--um-spacing-md);
}Token groups map onto Tailwind's namespaces directly (spacing.md →
--spacing-md), tier prefixes are stripped (semantic.color.bg.canvas →
--color-bg-canvas), and $type fills the gap for anything else. Component
tokens stay out by default — they belong to component CSS, not to utilities.
Anything that cannot reach a real namespace is reported by the
tailwind-namespace rule instead of silently generating nothing.
Typed TS. tokens.ts gives each token its custom property, its var()
expression, its policy and its resolved value per permutation, as const
throughout so ids autocomplete.
Docs. tokens.md tabulates every token by tier; showcase.html renders the
ramps as swatches with a contrast grid — the part a table cannot show.
Repairs
umberkit fix changes only what the sources already determine: legacy string
colours become DTCG object notation, and policy flags that say nothing are
removed — either because they restate a default or because an enclosing group
already grants them. A flag that contradicts its group stays, and no group flag
is ever invented: that intent cannot be read off the sources. An omitted $type
is deliberately left alone too — declaring it once on a group is idiomatic DTCG,
not an oversight.
A misspelled alias gets a suggestion rather than just a rejection:
Could not resolve alias {color.netural.1}. Did you mean "color.neutral.1", "color.neutral.10"?When something fails
Every command reports a failure as what happened, the specifics, and the next thing to do — never a tag and a JSON blob:
error: no umberkit config found in this directory
looked for:
umberkit.config.ts
umberkit.config.mts
umberkit.config.js
umberkit.config.mjs
→ run `umberkit init` to scaffold one, or run this from the directory that holds your configSet UMBERKIT_DEBUG=1 to append the raw tagged error, which is what a bug
report wants.
Status
Early but complete through its planned scope: init, generate, build,
check, diff, fix, six emitters (CSS, SCSS, Tailwind v4, TS, Markdown, HTML),
the contract manifest and ratchet, six lint rules, and the colour engine.
Not planned: a component or behaviour layer. For that, use Zag.js — umberkit styles it, it does not replace it.
Config
// umberkit.config.ts
import { defineConfig } from "umberkit";
export default defineConfig({
tokens: "./tokens",
outDir: "./dist/design",
prefix: "um", // --um-*
layer: "tokens", // @layer tokens
});layer also takes an object, to declare the order every layer sits in rather
than only umberkit's own:
layer: { name: "tokens", order: ["tokens", "components"] }emits @layer tokens, components; before the rules, so a consuming library's
own layer is ordered against the tokens layer no matter where either stylesheet
is imported. order must list name — a layer left out of the order it
declares is not ordered at all, so that is a config error rather than a
silently ineffective declaration. Nested layers order by their root, so
tokens.base is covered by tokens appearing in the order.
Per-token policy via $extensions.umberkit:
{ "$value": "{color.blue.600}", "$extensions": { "umberkit": { "public": true } } }public— opt-in to the public contract (defaultfalse)css: false— no custom property emitted (aliases to it are inlined)tier— override the tier inferred from the top-level group
The same block on a group applies to every token below it, nearest ancestor winning, so opting a layer in is one line rather than one line per token:
{
"semantic": {
"$type": "color",
"$extensions": { "umberkit": { "public": true } },
"bg": { "canvas": { "$value": "{color.neutral.1}" } },
"scratch": {
"$value": "{color.neutral.5}",
"$extensions": { "umberkit": { "public": false } } // opts out
}
}
}Merging is key-wise, so a token overriding one key keeps what the group gave it
for the rest. umberkit fix removes token-level flags a group already grants,
which makes adopting this one command — and leaves the ones that contradict
their group, since those are the opt-out. Two values do not cascade: the palette
generator's generated stamp, and a tailwind variable name (a name is
per-token by definition; tailwind: false does cascade).
A group flag is a contract decision, so it answers to the ratchet like any other: adding one publishes tokens, which is additive, while removing one un-publishes a whole subtree at once and is breaking.
