npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

umberkit

v0.0.2

Published

Framework-agnostic design-token and theming toolkit: brand color in, validated accessible themed system out.

Downloads

395

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 umber

Or 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 met

Checks 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 alias

An 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 → 69

In 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 config

Set 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 (default false)
  • 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.