@charcuterie/tokens

中文文档
1.10.2发布于 2026/09/29MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2840

Design tokens for the Charcuterie component library. Zero dependencies, no React.

原始使用文档
文档目录16 节
  1. Running it
  2. The fonts
  3. The contrast gate is now a test
  4. The two tiers
  5. intent means something; categorical deliberately does not
  6. Why intents carry a solid as well as a surface
  7. The three axes, and the one profile
  8. Six is what one pixel can be, not what the panel can show
  9. The first-paint rule ships from here — copy it, never <link…
  10. Gate the copy in your app
  11. colour in TypeScript, --color-* in CSS
  12. Logical properties only
  13. Contrast is a test, not a guideline
  14. Adding a variant
  15. Closed at M3: the structural namespaces now reach Tailwind …
  16. Container-query variants are generated here too

@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 board

That 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 gate

build 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:

GroupRoles
surfacebase, raised, sunken, overlay, inverse
contentprimary, secondary, muted, disabled, onAccent
bordersubtle, default, strong, focus
intent{neutral, accent, success, warning, danger, info} × {surface, surfaceHover, border, content, solid, solidHover, onSolid}
categorical{1 … 10} × the same seven roles
focusring, 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):

whatwhat it may usecount on Spectra 6
photographsthe full dithered gamut — not this profile's businessnear-continuous
flat fills, large areasinks + blends19
borders, small text, iconsinks only6
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
  : undefined

Two 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.

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.strong is 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:

PublishedReadsUtility 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
--spacingspace[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.

版本与发布记录

@charcuterie/tokens 共发布 18 个版本,首次发布于 2026/07/31;过去 12 个月发布了 18 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.10.2latest2026/09/291 个月前
1.10.12026/09/211 个月前
1.10.02026/09/181 个月前
1.9.02026/09/092 个月前
1.8.02026/09/082 个月前
1.7.02026/09/032 个月前

关于 @charcuterie/tokens 的常见问题

@charcuterie/tokens 是什么?

Design tokens for the Charcuterie component library. Zero dependencies, no React.

如何安装 @charcuterie/tokens?

在项目目录运行 npm install @charcuterie/tokens,或使用 pnpm add / yarn add / bun add。

@charcuterie/tokens 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@charcuterie/tokens 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@charcuterie/tokens 可以商用吗?

@charcuterie/tokens 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@charcuterie/tokens 的下载量有多少?

根据 npm 官方下载统计,@charcuterie/tokens 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,840 次,日均约 95 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@charcuterie/tokens 还在维护吗?

最新版本 1.10.2 发布于 2026/09/29(1 个月前);至今共发布 18 个版本,其中过去 12 个月发布了 18 个;首次发布于 2026/07/31。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。