@hollis-labs/design-tokens
v0.1.0
Published
The token contract — names and types — plus default values.
Maintainers
Readme
@hollis-labs/design-tokens
Layer 2 of the Hollis Labs frontend layering: the token contract — the names, and their types — plus default values for them.
The one rule: a component may name a token, never a value. It covers scale as much as colour.
text-[13px]names a value exactly as much as#1a1b26does — and scale is the larger half, by about seventy to one.
No React. No components. No framework. No dependencies.
Implements the token contract (CW-20260910-0111, approved review round 2). Built
by CW-20260910-0124 as a synthesis of two working implementations —
libs/sysop-ui and apps/nanite — not an extraction of either.
Section references below (§3, §10, …) are to that contract, which lives at
docs/token-contract.md
in this repo.
Install and use
npm install @hollis-labs/design-tokensWith Tailwind v4 — the normal case
@import "tailwindcss";
@import "@hollis-labs/design-tokens/design-tokens.css";That is the whole setup. You now have bg-surface, text-fg-muted,
border-border-subtle, rounded-panel, text-control, tracking-label, the
shadcn alias layer, and ten themes that swap by attribute.
<html data-theme="dir-a" data-mode="light">data-themepicks the theme. Omit it and you get the default,dir-a.data-mode="light"picks light.class="light"works too, because that is what Nanite's stylesheet already keys off.
Bring your own values instead of the built-ins:
@import "tailwindcss";
@import "@hollis-labs/design-tokens/tokens.css"; /* the contract, no values */…then declare --hl-* yourself, or generate it (see Emitting your own themes).
Without Tailwind
themes.css is plain CSS custom properties, and tokens.css publishes the
contract layer as ordinary --color-* variables at :root as well as through
@theme. So var(--color-fg) works in hand-written CSS with no build step.
From JavaScript
import {
COLOR_TOKENS, TEXT_SCALE, BUILTIN_THEMES, getBuiltinTheme, applyTheme, readToken,
} from '@hollis-labs/design-tokens'What is in it
| | Count | |
|---|---|---|
| Colour tokens | 48 | §3 — 43 at landing, +5 for syntax-* (§3.10, review round 4) |
| shadcn aliases | 24 names, 16 generated | §3.7 — see Findings |
| Font tokens | 2 | --font-sans, --font-mono |
| Type steps added | 4 | micro 9 · caption 10 · label 11 · control 13 |
| Radius steps added | 1 new + 1 alias | panel 10px · control = Tailwind's md |
| Tracking steps added | 2 | tracking-label 0.18em · tracking-eyebrow 0.28em |
| Spacing tokens | 0 | Tailwind's scale is the contract — 99.4% adherence already |
| Built-in themes | 10 | 6 from Nanite (dark + light), 4 from sysop-ui (dark) |
surfaces bg bg-elevated surface surface-hover surface-active
text fg fg-secondary fg-muted fg-faint
borders border border-subtle divider
primary primary primary-hover primary-active primary-muted primary-fg
brand brand brand-hover brand-active brand-muted brand-fg
selection selection selection-fg ring
danger danger danger-hover danger-muted danger-fg
warning warning warning-muted warning-fg
success success success-muted success-fg
info info info-muted info-fg
chart chart-1 … chart-5 ← PLACEHOLDER VALUES. See below.
syntax syntax-key syntax-string syntax-number syntax-boolean syntax-nullThe two layers
--hl-* layer 3 VALUES the ONLY layer that may name a colour
--color-* layer 2 CONTRACT what @theme exposes; what a component namesA component names bg-surface and touches neither. This indirection is Nanite's
design, not a new one — index.css already maps --color-surface: var(--c-surface).
Only the value-layer prefix changed, from --c-* to --hl-*, decided in review
round 2: the value layer is internal, and a two-character prefix on public npm has
real collision surface.
--color-* is not a choice. Tailwind v4 requires that namespace to generate
bg-* / text-* / border-* utilities.
The calls CW-0124 was asked to make, and why
CSS-first or TS-first for values? — Hybrid, and here is the "why"
Neither alone, and the task asked for an argument rather than an assumption:
- The CSS is authoritative at runtime. One stylesheet, zero JavaScript, every theme. That is sysop-ui's property, it is what makes first paint correct and a server-rendered page themeable, and it is the only version that works for a consumer with no bundler.
- The TypeScript is authoritative as a source. It is the only form that can be type-checked, that a theme editor can enumerate, and that a charting library can read as strings.
The failure of picking one is symmetric and observable: CSS-only is what left
sysop-ui with no type contract at all, and TS-only is what makes Nanite need a
runtime step before anything is themed. Generating one from the other gets both
properties from one source — which is the only reason a hybrid is not just two
problems. Direction is TS → CSS (§10), the generated CSS is committed, and
npm run generate:check fails if the two disagree.
One prefix or two? — Two. See The two layers.
Dark/light as modes, or as sibling palettes? — Modes, with dark-only allowed
Nanite's model, which is the more conventional and more useful one. But
sysop-ui's four palettes are alternates, not modes — p1-green-phosphor has
no light variant and does not acquire one by being asked. So Theme.tokens.light
is optional. This is the package's one deviation from the contract as written;
see Findings below.
@theme inline for colours — yes, and it is load-bearing
Without inline, Tailwind resolves --color-bg at :root, so a [data-theme]
on anything below <html> silently changes nothing — descendants inherit an
already-resolved value. With it, the utility references var(--hl-bg) and
resolves at the element, so a themed subtree works. sysop-ui found this; Nanite
gets away without it only because its mode marker sits on the root.
Scale steps go in a plain @theme block: a scale step has no value layer beneath
it, and consumers benefit from var(--text-label) existing.
chart-1..5 ARE PLACEHOLDERS. Read this before using them.
Every built-in theme sets all five to the same magenta, and
theme.chartPalette === 'placeholder' says so in the data.
This is deliberate and it is not an oversight. The chart family is the one family neither implementation had, and review round 2 was explicit: the names enter the contract; the palette is Chrispian's design pass. Five categorical colours that stay mutually distinguishable across ten themes — two of them phosphor palettes, one high-contrast — and survive colourblindness is real design work.
Five identical magentas make a chart obviously wrong. Five plausible colours would make it quietly unreviewed, and plausible-and-unreviewed is precisely how four invented token names reached production in two separately-reviewed codebases.
import { hasDesignedChartPalette, getBuiltinTheme } from '@hollis-labs/design-tokens'
hasDesignedChartPalette(getBuiltinTheme('dir-a')) // false, for every built-inA test asserts this. When the design pass lands, that test should be updated deliberately — not deleted in passing.
Where the values came from
Both working implementations survive whole, because CW-0124 is explicit that losing either costs the epic its working examples.
| Themes | From | Modes |
|---|---|---|
| dir-a (Graphite & Ink — the default), dir-b, dir-d, dir-e, dir-f, nanite-default (Concrete & Signal) | apps/nanite | dark and light |
| sysop-p4-white, sysop-green-phosphor, sysop-amber-phosphor, sysop-hi-contrast | libs/sysop-ui | dark only |
Nanite's typed half was re-verified rather than trusted: 12 value sets × 40 keys, one key set, zero drift. The claim holds exactly.
Why dir-a is the default, and not Nanite's own
Decided by Chrispian on the §12.6 question the contract left open. The obvious
inheritance was nanite-default, and it fails the one thing a default for this
package has to do — demonstrate the contract it ships. Direction C sets
warning and danger to the same red (#d4202e), and its success (#c0c4c8)
and info (#98a0a8) are both greys: three colours across four semantics. That
is Direction C working exactly as designed — "cool concrete, brand red as the
only signal" — which makes it a coherent theme and a poor default for a package
whose contract just promoted success/warning/info/danger to first-class
names.
Two criteria picked dir-a:
- Semantic conventionality. Green / amber / red / blue in the expected roles,
so the default teaches the contract rather than merely satisfying it.
dir-bwas the other muted candidate, and itssuccessis gold — which reads as a warning.dir-danddir-eare distinct but loud; Synthwave and Hacker/Terminal are aesthetic statements, not neutral starting points. - Light mode. All four sysop palettes are dark-only, so any of them as the
default would make the out-of-box experience dark-only while
Theme.tokens.lightis optional. All six Nanite themes carry both.
dir-a is the only built-in that is both conventional and unopinionated, and its
muted register reads as a starting point rather than as someone's choice.
nanite-default remains a built-in; nothing is wrong with it except being the
default. A test asserts the default's four feedback colours are distinct and that
it carries light, so this decision cannot be undone by accident.
The 168 values nobody chose — DERIVED_TOKEN_VALUES
A contract token that neither source had a value for. Filling them mechanically is how this package ships complete themes without inventing a palette; exporting the list is how that stays honest. None of these has been through design review.
import { DERIVED_TOKEN_VALUES } from '@hollis-labs/design-tokens'Four rules produced all 168 — 88 from R1–R3, 80 from R4 — applied once, offline, with the results written into the theme files as literal colours so every value stays inspectable and a theme editor still works:
| | Rule | Why it is a derivation and not an invention |
|---|---|---|
| R1 | X-muted = X at 12% alpha | The contract's own definition — "-muted always means a low-alpha tint used as a background" (§3.8 rule 3). Names no new colour. |
| R2 | X-hover = X 12% toward white · X-active = X 12% toward black · surface-active = surface-hover 15% toward fg | A magnitude, not a hue. sysop-ui's palettes have no interaction states for primary/brand/danger at all; the alternative was repeating the base colour and shipping a button that does not respond. |
| R3 | X-fg = white or the theme's own bg, whichever contrasts more (WCAG) | Measured, and reported rather than asserted: this rule reproduces 39 of the 48 -fg values Nanite's authors chose by hand. The nine misses are all cases where they preferred white on a saturated red or orange at lower measured contrast. So these are contrast-maximal, not style-matched, and a design pass should expect to move some. |
| R4 | syntax-* = five stops at 100/75/50/25/0 between the palette's own fg and fg-faint, in oklab | The endpoints are fg and fg-faint themselves, so only three middle stops are new and every one is a lightness step between two colours the palette already chose. Unlike R1–R3 this fills a family no palette ever had, which is why it needed §3.10 to exist first. The floor is fg-faint and not bg, and that was measured: running to the background drops the bottom two stops to 1.9 / 1.8 / 1.6 / 1.4 : 1 across the sysop palettes — below AA in all four. |
Which tokens they cover:
- Nanite's six need only
warning-fgandinfo-fg— the two members the rectangularity rule (§3.8 rule 1) adds. 24 values. - sysop-ui's four each need the same sixteen: every
-mutedtint, every feedback-fg,surface-active, and the interaction states forprimary,brandanddanger. 64 values. That list is a fair description of what a dense dark-only ops palette never needed. - R4 applies to all ten, in every mode they declare — 80 values. It is the one rule that is uniform across the set, because it is not patching a gap in any particular palette: no palette ever had a syntax family.
Three more gaps were filled from values that already exist in index.css
outside Nanite's TokenKey union, so they are carried rather than derived:
divider, selection-fg (var(--c-fg)), and ring (var(--c-primary)). These
are exactly the names §10 identifies as having drifted out of the typed half.
The mapping tables
sysop-ui → contract, applied identically to all four palettes:
| contract | from | note |
|---|---|---|
| bg | bg | |
| bg-elevated | panel-2 | what shadcn card/popover already resolve to |
| surface | panel-2 | what shadcn muted/secondary/accent already resolve to |
| surface-hover | panel-hover | value-identical to panel-2 in every palette (§12.3) |
| fg / -secondary / -muted / -faint | text / text-muted / text-soft / text-subtle | |
| border | border | 100% — the default edge |
| border-subtle | border-strong | the 80%-alpha one, finally named for what it does |
| divider | border-soft | 70% alpha — the row separator it actually was |
| primary / brand | accent | the kit collapses these; a theme may set them equal (§3.4) |
| primary-fg / brand-fg | accent-foreground | |
| selection / selection-fg / ring | panel-2 / text / ring | |
| success / info | status-done / status-doing | §3.5 evidence 4 |
| danger / warning | danger / warning | |
Dropped, because they are dead in their own codebase: all 8 sidebar-*,
panel-hover-soft, panel-overlay, panel-overlay-strong, shadow,
shadow-strong. The kit's 12 workflow status colours are dashboard idiom (§9.2)
and belong to kit-dashboard, which still has them in its own theme.css — nothing
was lost.
Corrected at publish, 2026-09-11 (CW-20260910-0131): an earlier version of this
paragraph said CW-0125 moves them under the dash prefix. It did not. kit-dashboard
names them status-* today, from its own theme values, and they resolve — all 28
colour names its lib/status.ts reaches for are declared, verified in a consumer's
compiled stylesheet. So the dash prefix is reserved and unused, and status-*
remains what the kit actually ships. Moving the kit onto contract values at all is
CW-20260911-0071, deliberately not in the first release.
Nanite → contract is nearly the identity: it already uses the contract names.
mode-* becomes chat-mode-* idiom, status-ok/warn/danger dissolves into
success/warning/danger, and the six dead composer-* names are not carried.
Closing the type scale later — what it would cost
Chrispian chose extend now, revisit closing later over plain "extend", so the closed semantic scale is deferred, not rejected. Two things that obliges, both done, neither expensive:
- The four new steps are emitted from
TEXT_TOKENS, never hand-written into CSS. - Tailwind's inherited steps live in one place —
INHERITED_TEXT_STEPSinsrc/scales.ts— and nowhere else in this package.
A test (THE CLOSING-KEPT-ALIVE INVARIANT) asserts that no added step shares a
name or a value with an inherited one, so the two sets stay separable.
Cheap, already paid for: this package (one array edit plus a regenerate — no
abstraction was built for it) and the lint config (no-arbitrary-scale already
merges inherited and contract steps from one object).
Expensive, and not paid for: every vendored shadcn component. They name
text-sm and text-xs, the registry keeps producing them, and shadcn went
tri-substrate in July 2026 with Base UI as its default — so the stream of new
vendored components naming Tailwind steps continues indefinitely. Closing means
rewriting each on arrival, forever. Measured today: text-xs 1,098 uses, text-sm
766, text-base 42, lg+ 90 — roughly 2,000 call sites, of which the vendored
ones recur with every registry pull.
And the asymmetry that makes the door worth holding open: extend-then-close breaks every consumer that used a removed step; close-then-open is purely additive. Closing is the reversible direction, and nothing here forecloses it.
Findings — where this implementation disagrees with what it was handed
Reported rather than worked around, per the epic's standing rule. Five separate measured claims in this epic have turned out to count something other than what they named; these are re-derivations, not inherited numbers.
The contract's
Themetype is unimplementable as written, given CW-0124's own requirements. §10 makestokens.lightrequired; CW-0124 requires sysop-ui's dark-only palettes to survive and be demonstrable. Satisfying both means inventing four light palettes, which is a design pass. Implemented withlight?:, which loses nothing —Record<ColorToken, string>still forces completeness on whichever modes a theme declares, so the no-drift property the requirement existed for is intact. This is the one place the contract was not implemented literally."+18 generated shadcn aliases" is not what §3.7's table contains. The table yields 24 alias names, of which 8 map a name onto itself (
primary,border,ring,chart-1..5) and so generate nothing — leaving 16 actually generated. 18 is shadcn's core-name count, not a generated-alias count. All 24 are exported as vocabulary (that is what stopsprimary-foregroundreading as invented); 16 are emitted."5
[data-theme]palettes" in sysop-ui is 4.theme.csshas four[data-theme]selectors plus a:rootfallback that duplicatesp4-whitedeclaration for declaration. A fallback, not a fifth palette.The kit's dead-name count is 16 of 56 (29%), not 12 (21%) — worse, not better. The contract's twelve, plus
card,warning,shadowandoverlay. Two of those matter:--color-warninghas zero references in the kit. §3.5's first piece of evidence for promoting the feedback family is "warningis already in both implementations independently… it arrived there without coordination." True of the declaration; the kit never uses it. The promotion still stands — arguments 4 and 5 are the strong ones and neither depends on this — but the independent-convergence argument is weaker than stated.--color-overlayhas 1 reference, not 3, and it is insidetheme.cssitself (.hud-modal-overlay). That strengthens §8's decision to leave overlay untokenised.
§3.3's mapping table is a component-usage migration, not a value mapping, and using it as one would lose data. It sends both kit
borderand kitborder-strongto contractborder, which discards one of two distinct values and leavesborder-subtlewith none. For values this package mapsborder→border(100%),border-strong→border-subtle(80%),border-soft→divider(70%) — which preserves all three and puts the intensity ordering the right way round for the first time. The usage migration inDEPRECATED_TOKENSfollows §3.3 exactly, as it should.§3.8 rule 2 does not match the token list. The prose gives
-hoverand-activetoprimary,brand,dangerandsurface; the list givesdangeronly-hover. Implemented as listed (§3.5 says so explicitly), and the test asserts the shipped set rather than the prose.The scale values agree exactly with the lint package's fallback. §4/§5/§6 state them in tables, so there was no derivation freedom:
micro9,caption10,label11,control13,panel10,control6,tracking-label0.18. A test pins the agreement so the dayCONTRACT_STEP_VALUESis deleted is a deliberate day. Procedural note: I readvocabulary.jsbefore deriving these, to learn the export names the hard requirement asked for — so this is a confirmation, not a blind second derivation.@layer componentsintheme.cssis NOT tokens — it is nine.hud-*classes, kit-dashboard idiom, and it stays there. But@layer basepartly is generic, and both implementations wrote it independently: the same scrollbar suppression (scrollbar-width: none+::-webkit-scrollbar { display: none }) and the same document-shell lock (html, body, #root { height: 100% }+overflow: hidden) appear in sysop-ui'stheme.cssand Nanite'sindex.css, neither importing the other. That is the same independent-convergence evidence the contract used to justify the shadcn core. It is not tokens and it is not in this package — it belongs indesign-app-runtime, and it is a finding for CW-0126.Confirmed, independently: the four invented token names are live, at the sites reported —
status-running×6 (settings-panel.tsx:97,117),status-info×6 (SystemPromptsViewer.tsx),bg-bg-surface×3 (BooleanChoice/CardRadio/Drawer),warning-fg×1 (SkillDetailView.tsx:421).warning-fgis now a real token, so one of the four is fixed by the contract rather than by a rewrite.
Emitting your own themes
The generator is exported, so a consumer or a kit can produce CSS the same way this package does.
import { emitThemeCss, emitContractCss } from '@hollis-labs/design-tokens'
emitThemeCss(myTheme) // [data-theme="…"] { --hl-*: … }At runtime, for a user-authored theme or a live editor:
import { applyTheme, setMode } from '@hollis-labs/design-tokens'
applyTheme(myTheme) // one <style> element, value layer only
setMode('light')applyTheme overrides only the value layer, so the contract layer and every
utility keep working underneath it. Every DOM helper is a no-op outside a browser
— a tokens package gets imported during SSR and during tests and should not care.
For a kit adding idiom tokens (§9)
An idiom extension carries an idiom prefix, may only add, and may never
shadow a contract name. Claim the prefix in IDIOM_PREFIXES — a one-line PR, so
two kits claiming the same prefix is a merge conflict rather than a silent
overlap. Then emit your own two layers in your own package:
@theme inline { --color-dash-status-doing: var(--hl-dash-status-doing); }
[data-theme="sysop-p4-white"] { --hl-dash-status-doing: rgb(96 165 250); }Reserved today: dash (kit-dashboard), chat (kit-chat).
Burned: status — sysop-ui uses it for 12 workflow lifecycle states and
Nanite for 3 health states; same prefix, incompatible meanings, both live, so it
is available to neither. Retired: sidebar.
Working on this package
npm run build # tsc -> dist/
npm run generate # rewrite css/ from the TypeScript
npm run generate:check # fail if committed css/ has drifted
npm run test:run # 54 tests, including the drift check
npm run typecheckcss/ is generated. Do not edit it by hand. TypeScript is the source; run
npm run generate and commit the result. generate:check runs inside the test
suite, so a hand-edit fails CI — that is the --check mode §10 asks for, wired
into a gate that already runs everywhere rather than into a new job.
Source imports carry .js extensions on purpose: TypeScript resolves them to the
.ts file and emits them unchanged, so dist/ runs in Node with no rewrite step
and no bundler.
Published
0.1.0, the first release, alongside the five other packages in this repo — see the
repo's CHANGELOG.md.
Verified before release by npm pack and installed into a scratch project with no
other design-kit package present — no React, no transitive dependencies at all, both
palette sets demonstrable there through a real Tailwind v4 build. At publish it was
verified again from the tarball in a browser: Metric's value computed to
rgb(244, 244, 245) against the contract's #f4f4f5 for fg, its label to
rgb(82, 82, 91) against #52525b for fg-faint, and the four added type steps
measured 9 / 10 / 11 / 13 px with rounded-panel at 10px and rounded-control at
6px — the declared values, on a screen, through a consumer's own Tailwind build.
