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

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

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

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

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