@charcuterie/tokens
v1.9.0
Published
Design tokens for the Charcuterie component library. Zero dependencies, no React.
Downloads
6,702
Readme
@charcuterie/tokens
The design-token layer. Zero dependencies, no React.
castkit/packages/views renders to ePaper PNGs through Satori and needs colour and
spacing values without pulling in a React tree; slatecast has a 60 KB gz budget. Both
are why this is a separate package from @charcuterie/ui. React consumers never see two
names — they import @charcuterie/ui/tokens. It is a build-graph split, not an API split.
Running it
Node 24 runs the TypeScript directly, so nothing needs installing to use any of the scripts:
node scripts/checkContrast.ts # the WCAG 2.2 AA gate — exits non-zero on failure
node scripts/buildTokens.ts # → dist/{variables,theme,first-paint}.css, dist/tokens.json
node scripts/fetchFonts.ts # re-download the three shipped faces → fonts/, src/fonts.css
node scripts/buildPreview.ts # → preview/index.html, the M0 bake-off board
node scripts/buildEpaperPreview.ts # → preview/epaper-palette.html, the ePaper boardThat is deliberate and worth keeping: the M0 bake-off shipped before this workspace existed, and a token change should stay one command away from a rebuilt board.
fetchFonts.ts is the exception that needs a network, and it is the only script here
that writes a tracked binary. It clears fonts/ before refetching, because Google's
filenames carry a content hash and an overwrite would leave the old ones orphaned in the
package forever.
The fonts
Three faces, self-hosted, latin subsets only — Baloo 2 (--font-display), Outfit
(--font-sans) and Victor Mono (--font-mono), all SIL OFL. Consumers add one line:
@import "@charcuterie/tokens/fonts.css";A separate entry point from theme.css on purpose: importing tokens should not force a
font download. A Satori consumer wants the woff2 alone and can reach them at
@charcuterie/tokens/fonts/<name>.woff2.
The mono is Victor Mono, not Dank Mono. That is a licensing constraint rather than a preference, and overriding it per-app is a supported one-liner — the decision has the snippet. In this fleet the licensed Dank Mono woff2 must not enter any repo; it reaches an app by a read-only bind mount off the NAS — how.
Through Yarn, from anywhere in the repo:
yarn workspace @charcuterie/tokens build # tsc → dist/*.js + .d.ts, then the CSS
yarn workspace @charcuterie/tokens test # Vitest, including the contrast gatebuild runs on prepack, so dist/ is generated rather than committed.
Both source workflows are live at once because tsconfig.base.json sets
rewriteRelativeImportExtensions. Source imports siblings as ./contrast.ts — which
is what lets Node run it with no build — and tsc rewrites those to ./contrast.js on
the way out. Without that flag the two workflows are mutually exclusive.
The contrast gate is now a test
src/contrast.test.ts wraps the same audit scripts/checkContrast.ts prints. That is
the only thing M1 changed about it. One source of truth, asked by the board, the
script, and CI alike — a script gates only what somebody remembers to run.
The two tiers
Tier 1 is raw ramps (neutral.50…950). A component may never reference one. They
exist so a variant author has something to build tier 2 out of.
Tier 2 is semantic roles, and it is the only tier components may name:
| Group | Roles |
| --- | --- |
| surface | base, raised, sunken, overlay, inverse |
| content | primary, secondary, muted, disabled, onAccent |
| border | subtle, default, strong, focus |
| intent | {neutral, accent, success, warning, danger, info} × {surface, surfaceHover, border, content, solid, solidHover, onSolid} |
| categorical | {1 … 10} × the same seven roles |
| focus | ring, ringOffset, and focusRing.width / .offset |
intent is the generalization of ripdeck's TONE_CLASS map — the one it currently
declares identically in both VerdictBadge.tsx and TowerAlerts.tsx.
intent means something; categorical deliberately does not
Every member of intent is a claim: danger is not a colour, it is a statement about
what happens if you press the thing, and components switch on it. categorical is the
family for colour a user chose — a Docket label, a project, a chart series — where a
semantic name would be a lie the design system told on their behalf. Numbered, because
there is nothing to name.
It is generated rather than hand-picked: CATEGORICAL_HUES states a hue angle and
buildCategoricalScheme solves each role against a contrast target, per variant, per
scheme. A variant states its character (chromaScale, contentContrast) and never a
hex.
Two gates, not one. Every categorical pair is enrolled in contrastAudit.ts beside the
intents — and additionally gated against each other, because two indexes can both clear
4.5:1 on the same surface and be the same colour as each other with every number on the
board green. getCategoricalDistinctnessFailures measures every pair in OKLab; the
tightest solid pair in the fleet is ΔEok 0.0893, against the 0.0835 Tableau 10 achieves
for itself.
getCategoricalIndex(key) is the stable string → index fallback for rows that predate the
feature. It is a fallback, never an override — a stored pick wins.
Boundary with Swatch: Swatch takes colour arriving from the world (a physical
sticker, an album accent) and can promise nothing about it; categorical is a curated set
the user picks from, so it can promise everything.
Decision.
Why intents carry a solid as well as a surface
The plan named four intent roles. Building the specimen board surfaced a fifth need
immediately. surface is the tinted treatment the fleet already uses for status
pills (bg-blue-950 text-blue-300); a primary button is a saturated fill with its own
text colour. Deriving one from the other is exactly the guesswork this layer exists to
delete, so both are stated — and onSolid is stated too, because whether white or
near-black wins on a given fill genuinely varies per intent (in layered, white fails on
the coral accent and near-black passes at 5.4:1).
The three axes, and the one profile
data-scheme (light | dark) · data-density (comfortable | compact | kiosk) ·
data-variant (the visual direction) — all three are <html> attributes and all three
compose. One attribute flip re-themes everything with zero re-render, because nothing
in React ever observes the change.
ePaper is not a fourth axis. It is a separate export (@charcuterie/tokens/epaper)
because it removes capabilities rather than restyling them: no hover, no opacity, no
shadow, no transition, and colour restricted to a set small enough to hold a sharp edge.
Modelling it as data-scheme="epaper" would imply a data-variant still applies to it,
which it cannot.
Six is what one pixel can be, not what the panel can show
The profile used to claim its palette was "six colours the panel can physically render." That is false, and it is why the restriction read as arbitrary. A Spectra 6 panel sets each pixel to one of six inks; a region of pixels renders far more, because the driver dithers — which is exactly what the fleet's photo path has always relied on.
The real rule is keyed to what is being drawn (decision):
| what | what it may use | count on Spectra 6 |
| --- | --- | --- |
| photographs | the full dithered gamut — not this profile's business | near-continuous |
| flat fills, large areas | inks + blends | 19 |
| borders, small text, icons | inks only | 6 |
import { epaperPanels, spectra6Blends } from "@charcuterie/tokens/epaper"
// a large fill — the panel-side quantizer turns this one flat hex
// into a 50/50 yellow-red checkerboard
const fill = spectra6Blends.yellowRed // #DB8225
// a 1px rule — six inks only, or it becomes a dotted line
const rule = epaperPanels.spectra6.family === "fixedInk"
? epaperPanels.spectra6.inks.black
: undefinedTwo of the fifteen ink pairs are absent rather than present-and-wrong: blackYellow
and yellowBlue quantize to a different pair entirely, so there is no hex that reaches
them.
epaperPanels is keyed by panel and discriminated on family. fixedInk panels carry
inks / emittedInks / blends; continuousTone panels — E Ink Gallery 3's ~50,000
colour ACeP, not in the fleet — carry none of them, because there is no palette to
enumerate and therefore none to invent.
node scripts/buildEpaperPreview.ts draws the whole thing, checkerboards and all, from
src/epaper.ts. The committed board is
docs/previews/2026-07-31-m6g-epaper-palette.html.
The first-paint rule ships from here — copy it, never <link> it
Every app needs one line of CSS in its entry HTML that paints the canvas before any
stylesheet has parsed, or a dark app opens with a full-page white flash. dist/first-paint.css
is that line, generated for both schemes from the same token variables.css reads:
<style>
html, body { background-color: var(--color-surface-base, #131822); color-scheme: dark; }
</style>Paste it. Do not <link> it. A stylesheet request is a network round-trip, and beating
that round-trip is the rule's entire job — linking the file would reintroduce the flash it
exists to prevent while looking like the tidier option. It is exported
(@charcuterie/tokens/first-paint.css) so a build can read it and so "where did this
snippet come from" has an answer.
The var() is the whole reason this ships from a package instead of a wiki page. An
inline <style> is unlayered, and unlayered author CSS beats every @layer regardless
of specificity — Tailwind v4 emits utilities into @layer utilities. So the bare form,
background-color: #131822, does not merely paint early: it outranks bg-surface-base on
<body> permanently, and no data-scheme flip can reach the page background. Light mode
then renders light cards on a dark canvas. Written as a fallback, the literal applies only
while --color-surface-base is undefined — the one moment the rule was ever for.
Three apps hand-copied the bare form and three apps got the bug. The decision record has the measurements.
color-scheme needs no var(): variables.css's [data-scheme] rule is unlayered too, so
it wins on specificity rather than losing to this one.
Gate the copy in your app
buildFirstPaintRule returns the rule alone — one line, no comment — so a consumer's drift
test is one assertion, and it pins the var() as well as the hex:
import { buildFirstPaintRule, daylight } from "@charcuterie/tokens"
expect(indexHtml).toContain(
buildFirstPaintRule(daylight, "dark"),
)That is the gate the three broken apps could not write, because each owned only its own copy.
colour in TypeScript, --color-* in CSS
This split is deliberate and must not be "fixed".
TS identifiers use colour, matching e6Colour / colourMode / getAccentColour in
castkit/packages/views/src/viewStyles.ts, per the house rule about matching existing
nomenclature. CSS custom properties use --color-* because Tailwind v4's @theme only
generates bg-* / text-* / border-* utilities from the --color- namespace.
Renaming them to --colour-* silently produces a stylesheet with no utilities.
Logical properties only
Every spatial value is consumed as a logical property — padding-inline, margin-block,
inset-inline-start, border-inline-start, text-align: start. Never left/right.
In Tailwind that means ms-/me-/ps-/pe-/start-/end-/border-s/border-e.
This costs nothing now and makes RTL nearly free later, which is why it is a rule rather
than a preference. scripts/previewStyles.ts is written entirely this way and is the
first fixture the eventual ESLint rule will be tested against.
Contrast is a test, not a guideline
scripts/checkContrast.ts walks every content-on-surface and intent pair across every
(variant × scheme) and exits non-zero below threshold.
- WCAG 2.2 is the gate — 4.5:1 for text (1.4.3), 3:1 for control boundaries and focus indicators (1.4.11). It is normative and it is what an audit will use.
- APCA is reported alongside — it models perceived contrast far better on dark UI, but WCAG 3 is still a working draft, so gating on it means gating on a moving target.
Two categories are reported but never gated, each with a stated reason:
content.disabled, because WCAG explicitly exempts inactive controls, and gating it would force disabled text to look enabled.- Decorative lines —
border.subtle,border.default, and intent badge outlines. 1.4.11 covers boundaries required to identify a control, not every line on screen.border.strongis gated, because that is the role a text input, checkbox, and switch track draw themselves with — and so is a categorical border, because a pill reading "Homelab" is identified by its colour and by nothing else (decision).
Getting that scoping wrong is not harmless: gating decoration at 3:1 produced 65 "failures" on the first run, which is precisely how a contrast gate gets switched off.
The audit also asserts alias drift: content.onAccent must equal
intent.accent.onSolid, and border.focus must equal focus.ring. Two names for one
value is a bug waiting for the first person who tunes only one of them.
Adding a variant
Copy any file in src/variants/, change the values, add it to src/variants/index.ts,
then run node scripts/checkContrast.ts. If it exits zero, run buildPreview.ts and look
at it. Both steps are required — the gate proves it is readable, not that it is good.
src/variants.test.ts also holds the properties a new direction is most likely to
break: light mode is not pure white, raised and sunken are actually separated from
base, every intent and every categorical index carries all seven roles, every swatch is
opaque 6-digit hex, the ten categorical hues stay tellable apart, and the focus ring has a
non-zero width. Update the roster assertion in that file when you
add one; that assertion exists so a variant cannot be added silently.
Closed at M3: the structural namespaces now reach Tailwind too
theme.css maps --color-* into @theme, which generates bg-* / text-* / border-*.
As of M3 it also bridges five structural namespaces, so a component writes ordinary
utilities and gets our values:
| Published | Reads | Utility it fixes |
| --- | --- | --- |
| --text-{xs…2xl} | --font-size-* | text-sm — and ours is density-scaled |
| --leading-* | --line-height-* | leading-normal |
| --shadow-{low,medium,high} | --elevation-* | shadow-low, scheme-aware |
| --ease-* | --easing-* | ease-standard |
| --spacing | space[1] | p-3 is a token value, not a coincidence |
--radius-*, --tracking-*, --font-*, and --font-weight-* needed no bridge: they
collide with Tailwind's namespaces at :root already, which the M1 collision audit pinned
as intended. --duration-* and --control-* have no Tailwind namespace at all, so
components reach them as duration-(--duration-fast) and h-(--control-height-md).
The set is pinned by THEME_BRIDGES and asserted both ways in tailwindCollisions.test.ts:
an unpublished-but-declared bridge fails, and so does a published-but-undeclared one. Every
entry deliberately redefines an existing utility in every consumer — that is the point,
and it is why it is a decision rather than a tweak:
the ADR.
packages/docs/src/TokenSpecimen.tsx still reads them through var(). It is left that way
as the before-picture; @charcuterie/ui is the after.
Container-query variants are generated here too
theme.css emits one @custom-variant per step of the container-query scale, with a
literal threshold, because a container query's condition is resolved before custom
properties exist — @container (min-inline-size: var(--cq-sm)) is invalid CSS:
@custom-variant cq-sm (@container (min-inline-size: 24rem));So cq-sm: … cq-xl: are ours, matching --cq-*, rather than Tailwind's @sm:, which
reads the --container-* namespace our scale deliberately moved off.
Decision.
