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

@wtfalch/design

v0.25.2

Published

wtfalch's design system: themeable components on a fixed token vocabulary.

Readme

@wtfalch/design

wtfalch's design system: components on a fixed token vocabulary, so every product can look like itself without forking the stylesheet.

pnpm add @wtfalch/design

What is public

The components, and the tokens. Not the classes.

A class in this package is an implementation detail: .card, .set-row, .pill and the rest may be renamed or deleted whenever the component that draws them changes. 0.9.0 did exactly that to several of them, and 0.10.0 deleted 41 more: the components that drew them draw themselves now, so the rules had no reader left.

0.11.0 went the other way and removed the rules that reached through a class, which is the same mistake seen from the other side. .row > .switch-row styled a Toggle by the container a caller had written round it, so the caller had to know to write .row -- a class no component here renders. Those are props now: inRow on Toggle and on Field, tile on Checkbox.

If you need a card, render <Card>; if you need a row of your own, write it in the token vocabulary:

.app-row { display: flex; gap: var(--space-3); align-items: center; }

tokens.css ships separately for that, and every value a theme can change is in it. An app that wants utilities should run Tailwind over its own source -- this package's stylesheet is compiled and does not need it.

import '@wtfalch/design/tokens.css'   // the vocabulary and its base values
import '@wtfalch/design/styles.css'   // the components

The idea

A theme is a set of values for a fixed vocabulary — colour, surface, typography, shape, density, motion and interaction. It never ships a selector.

That constraint is not a limitation, it is what makes the package safe to change: the moment a theme can write a rule, every theme becomes a fork of the stylesheet and no change to a component is safe again. What makes a theme expressive instead is a vocabulary wide enough that the thing you want to vary is already a value — a screen background, a hover lift, how fast things move.

Where a theme genuinely needs a layer that values cannot reach — a paper grain, a vignette — the base CSS pre-declares the slot and the theme fills it. The rule is always ours.

Products

A product is declared in the app that wears it. The package supplies the shape and the machinery; the mark, the identity and the themes are the app's.

// src/design.ts, in the app
import { THEMES, bindProduct, defineProduct } from '@wtfalch/design'

export const product = defineProduct({
  name: 'acme',
  mark: { view: '0 0 24 24', d: 'M4 4H20V20H4Z', stroke: 2 },
  identity: { '--radius': '4px' },
  themes: { system: THEMES.system, dusk: { name: 'Dusk', note: 'Dark', scheme: 'dark', tokens: { /* ... */ } } },
  defaultTheme: 'system',
})

export const { Brand, THEMES: APP_THEMES, applyTheme } = bindProduct(product)
// the stylesheet, and the product's rules beside it
import '@wtfalch/design/tokens.css'
import '@wtfalch/design/styles.css'
import { productCss } from '@wtfalch/design'

<style dangerouslySetInnerHTML={{ __html: productCss(product) }} />   // in <head>

Three layers, each falling back to the one under it:

| | | |---|---| | the system | The components, the base values in tokens.css, the shared icons and illustrations, and one theme: system. | | the product | Its mark, and its identity: the tokens that make it itself under every theme — font, shape, density. Written under html:root by productCss, so it beats the base values wherever the app puts the string. | | the theme | A palette and a colour scheme, plus anything it deliberately changes. One :root[data-theme='<id>'] rule each, generated from the object. |

The middle layer is what lets a theme be shared between products: it names its colours and inherits the identity of whichever product wears it. Before it existed, valet's two palettes each restated valet's font and corners, and a palette written for two products would have shown the base font on valet wherever it kept quiet.

The product's default theme is also written on :root when no data-theme is set, so the first paint is right with no attribute at all; set the attribute before the bundle loads only to restore a theme somebody picked (see First paint). A product whose default is system, a prefers-color-scheme rule rather than a palette, still sets the attribute.

Why the products left. From 0.3.0 to 0.16.2 otf and valet were declared in this package, each with an entry (@wtfalch/design/otf) and a stylesheet (otf.css). Every new surface brought its own palette, and every palette was a release here and a pin bump in every app. 0.17.0 removed both entries, the PRODUCTS registry and the table of marks. THEMES is system alone.

import { applyTheme, productTheme } from '@wtfalch/design'

applyTheme(productTheme(product, 'dusk'))          // dusk, on the product's identity
applyTheme(productTheme(product), myEl)            // the product's default, on a subtree

Writing a theme

Start from a brand accent rather than a blank object:

node tools/create-theme.mjs --name="Northwind" --scheme=light --accent=#2B6CB0

Every other themeable token it writes is fixed and pre-verified (Good Company's own neutrals and status colours for light, Afterhours's for dark — neither depends on the accent, the same way this package's own shipped themes keep status colour and brand accent unrelated); only the accent's lightness moves, and only if it has to, nudged and re-checked against the real contrastFailures until it clears or the attempt budget runs out — printed either way, never written silently. --out=path writes the result to a file instead of stdout. This is a starting point, not the whole system: everything below still applies to a token you want to hand- tune afterward.

A theme is a Partial<ThemeTokens> with a name, a note and a scheme: name the tokens you change, the rest inherit from the product's identity and then from tokens.css. A theme naming three tokens is valid. A product's themes are keyed by the id applyTheme derives from the name (lowercased, spaces to hyphens); defineProduct throws on a key that disagrees, a palette that restates its product's identity, and a default that is not one of the product's themes.

A typo is a compile error. tokens is a Partial<ThemeTokens>, so '--densty' fails to build rather than silently doing nothing — which is the failure a string-keyed map produces at run time, invisibly.

A product names its font and does not ship it. An identity can set --font to read a --font-sans variable the app defines with whatever loads its fonts, and fall back to the family by name.

The three kinds of token

| | | |---|---| | themeable (50) | A theme may set it. TOKEN_KEYS, and the keys of ThemeTokens. | | derived (16) | calc() off a themeable token, and not settable. --text-* follow --font-size; --space-* follow --density. Move the input, not the output — a step written as a literal is a scale that stopped scaling. | | fixed (11) | Not themeable. --tile-control is geometry other things are measured against, --nudge is optical alignment rather than spacing, --tick-mask is a glyph — an arbitrary SVG from a theme is a theme shipping markup — --z-tooltip through --z-toast are the stacking order the package's overlays sit in, and --measure, --measure-wide and --measure-narrow are how wide a line of content gets before it stops being readable. |

tokens.test.ts holds all three lists to tokens.css in both directions. A token added to the stylesheet and to no list fails the suite rather than becoming a fourth, undocumented category.

The measurement ships

import { contrastFailures, productTheme, ratio } from '@wtfalch/design'
ratio('#6d28d9', '#ffffff') // 6.30 -- the label on your primary button

// in the app's test suite, once per theme
expect(contrastFailures(productTheme(product, 'dusk').tokens)).toEqual([])

contrastFailures measures a palette, pair by pair, with the WCAG 2 formula: text on the page and on both panels, hints, the label on the primary button, all four status colours, and the two boundaries that want 3:1 (CONTRAST_PAIRS). A sparse theme is laid over BASE_PALETTE, the colours tokens.css states, the way the page lays it over the stylesheet. On its first run the measurement found the built-in information blue at 3.96:1 on white. The package measures system in both schemes; every other theme is written in an app, and that app's tests are the only place it will be measured.

It also measures CONTRAST_TINTS: Card, Callout and Pill tint a tone into --panel with color-mix() rather than naming a flat token, and the text they draw sits on that tint, not on --panel itself. contrastFailures mixes the same colours the same way and checks the text against the result, which is how it catches a status card whose body copy fails on its own tone.

Two rules that outrank any theme

prefers-reduced-motion wins. The motion tokens collapse to 0s under it, whatever the theme says, and the hover and press transforms go with them. Motion is an accessibility setting before it is a style, so a theme may set the durations and may not decide whether they apply.

Contrast is measured, not judged. 4.5:1 for text and 3:1 for a non-text boundary, in every theme. --on-accent exists because a hardcoded white button label vanishes under a pale accent, and --border-strong exists because a hairline divider and a control's outline have different thresholds and cannot share a value.

First paint

Your product's stylesheet paints its default theme with no attribute set, so this is for restoring a choice. React mounts after the stylesheet, so a theme applied in an effect flashes the default. Cache the name and apply it from a blocking script before the bundle loads:

<script>
  try {
    var t = localStorage.getItem('theme')
    if (t) document.documentElement.dataset.theme = t
  } catch (e) {}
</script>

Your server stays the source of truth. localStorage only beats the paint.

Marks

A product's mark is a Mark: one path in its own ink box, stroked (stroke, the weight it was drawn at) or filled (fill, the rule that decides which subpaths are holes). Brand draws it in currentColor, so the stylesheet decides the colour and a theme can move it.

import { Brand } from '@wtfalch/design'

<Brand mark={product.mark} title="acme" />   // anywhere; a bound Brand defaults to both

Icons and illustrations are the system's, shared by every product the way Button is. A product wanting its own inside the package's components is a case nobody has had; when it comes, the product entry is where to bind it.

Rich text

Prose, written and drawn, added in 0.11.0 for the CMS and the forum.

import { RichText, isEmptyRichText } from '@wtfalch/design'      // drawing it
import { RichTextEditor } from '@wtfalch/design/editor'          // writing it
import { richTextSchema } from '@wtfalch/design/rich-text'       // storing it

Three entries and not one, because they cost different things. Drawing prose is a server component with no dependencies. Writing it loads TipTap, which is ProseMirror, and a site that only reads should not download an editor. Validating it needs zod. TipTap and zod are optional peer dependencies: install them if you import those entries, and the front door works without either.

The restriction is the component. The toolbar offers two heading levels, bold, italic, a link and two kinds of list. richTextSchema admits exactly those and RichText draws exactly those, so a document cannot contain something a page cannot render — and a consumer that validates on the way into its database gets that guarantee against a crafted request too, not just against the toolbar. Tables, colours, fonts, code blocks and quotes are off, each one a line in the component with the reason beside it. A seventh thing is added in all three places, on purpose.

Nothing here produces an HTML string: RichText walks the value into React elements, so there is no sanitiser to configure and none to get wrong. That is the difference from Markdown, which parses and must sanitise.

Blocks

The CMS's palette, added in 0.25.0: a document is a list of blocks, and the palette that can appear in one is declared in code, not configured in an interface (app-template's ADR 0018).

import { BlockEditor, BlockView, blocks, defineBlocks } from '@wtfalch/design/blocks' // the studio
import { BlockView, blocks, defineBlocks } from '@wtfalch/design/blocks/view' // a public site
import { blockSchema, documentBodySchema } from '@wtfalch/design/blocks/schema' // a server, validating only

Three entries. BlockEditor loads RichTextEditor for the text block, so it needs TipTap; validating a document needs zod; drawing one needs neither. @wtfalch/design/blocks/schema is the zod-only half, importable on a server that validates without rendering anything. @wtfalch/design/blocks/view is BlockView plus that same schema — the palette a view needs to make sense of data, with no BlockEditor in the module and so no TipTap in the graph — for a public site that only draws blocks (ADR 0018: "Reading a page loads no editor"). @wtfalch/design/blocks keeps both, for the studio that also lets somebody write them.

blocks ships four: text, image, card, callout — the four every site needs. defineBlocks is how an app adds its own beside them, the same call the four are declared with:

const ours = defineBlocks({
  ...blocks,
  pricing: { label: 'Pricing', hint: 'Three tiers.', schema: pricingSchema, empty: () => ({ tiers: [] }) },
})

A block type lives in three places and needs all three, the rule ADR 0018 states: its declaration and schema (@wtfalch/design/blocks/schema), BlockEditor's case (the form) and BlockView's case (what a page draws). A block declared and given no case in the other two is a type that can be added and never opened or rendered, and blocks.test.ts in this package's own suite is what a consumer's equivalent test should check its own palette against.

documentBodySchema validates a whole document — { blocks: Array<{ id, type, data }> } — against the declared palette, and refuses a block whose type nobody declared as readily as it refuses one whose data fails its own schema. Refusing "well-formed but undeclared" is the point: a type not in the palette cannot be saved this way even by a crafted request, not only by the editor's own add menu.

BlockView draws React elements only, the same rule RichText follows and for the same reason — no dangerouslySetInnerHTML anywhere in the block views, so there is no sanitiser to get wrong. An unknown block type renders nothing rather than throwing, so a block deleted from the code leaves a page working; BlockEditor's case for it says which type it could not open, which is where an author sees the problem.

The image block stores { assetId?, src?, alt }. assetId is for a later task that wires it to an asset store; src stays alongside it so a document written before that task ships still validates and still draws.

Icons

import {
  generateFavicon,
  generateAppIcons,
  generateOgImage,
  generateManifest,
} from '@wtfalch/design/icons'

Node-only, for a build script rather than a page — a favicon, the two PWA icon sizes, an apple-touch-icon and an OG image, all rasterized from a product's Mark (the same vector Brand draws) onto a page colour and an ink colour of your choosing:

const [favicon, appIcons, ogImage] = await Promise.all([
  generateFavicon(DOTS, { page: '#F6F3EC', ink: '#B23A0C' }),
  generateAppIcons(DOTS, { page: '#F6F3EC', ink: '#B23A0C' }),
  generateOgImage(DOTS, { page: '#F6F3EC', ink: '#B23A0C' }),
])

const manifest = generateManifest({
  name: 'Northwind',
  themeColor: '#B23A0C',
  backgroundColor: '#F6F3EC',
  icon192Path: '/icon-192.png',
  icon512Path: '/icon-512.png',
})

sharp is an optional peer dependency: install it and you can rasterize; generateManifest alone needs nothing but the values above, since it is JSON, not an image.

The OG image carries the mark alone, not the product's name — compositing text needs a font-layout engine, which is a second, heavier dependency this package has no opinion about the wording to spend on. A Next.js app already has next/og's ImageResponse for text; this gives it the art to put a name beside.

react-hook-form

import { useControllerField } from '@wtfalch/design/react-hook-form'

Its own entry for the same reason ./editor is: react-hook-form is an optional peer dependency, installed only if you import this. Field's own wiring (useFieldWiring) reads aria-describedby/aria-invalid off context; RHF's useController reads a field's value and validation state off control. Neither knows the other exists, so wiring both onto a control by hand is copied at every call site that adopts RHF. useControllerField reads both and hands back one object:

const { error, ...field } = useControllerField({
  name: 'email',
  rules: { required: 'An email is required' },
})

<Field label="Email" error={error}>
  <Input {...field} />
</Field>

useForm/FormProvider stay the caller's, unchanged — this only replaces what one field does with control.

Status

0.20.0. Forty-seven components, every one of the 125 gallery specimens photographed in four themes, the open windows photographed too, and the contrast, reduced-motion and keyboard rules are tests rather than sentences. It came out of otf, which is its first consumer; valet is the second. A product is a layer, declared in the app that wears it: its mark, its identity under its themes, and the rules productCss writes.

Requires React 19. Behaviour comes from React Aria Components; every pixel is the stylesheet's, styled through data-* attributes off the token vocabulary. Components draw themselves with Tailwind utilities bound to that vocabulary, compiled into @wtfalch/design/styles.css at build: you install no Tailwind to use this package, and none of Tailwind's own defaults reach you. An app that wants utilities for its own layout binds its @theme to the tokens here and imports no preflight, because this package styles button, input, select and textarea by element and the reset would unstyle them.

Source and issues

github.com/wtfalch/design — the package under packages/design, the gallery under gallery, and the visual, axe and keyboard suites under gallery-e2e. The repository's CLAUDE.md carries the rules the components follow, each with the bug that produced it.

Licence

MIT.