@wtfalch/design
v0.25.2
Published
wtfalch's design system: themeable components on a fixed token vocabulary.
Maintainers
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/designWhat 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 componentsThe 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 subtreeWriting a theme
Start from a brand accent rather than a blank object:
node tools/create-theme.mjs --name="Northwind" --scheme=light --accent=#2B6CB0Every 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 bothIcons 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 itThree 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 onlyThree 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.
