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

v0.2.0

Published

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

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.css, dist/theme.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

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.

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

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 a fixed six-colour (or two-colour) palette. Modelling it as data-scheme="epaper" would imply a data-variant still applies to it, which it cannot.

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

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 carries all seven roles, every swatch is opaque 6-digit hex, 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.