Design & engineering

the component layer kern defines

Thirty-eight React components over two tiers of tokens, consumed as TypeScript source at a pinned tag.

  • React
  • TypeScript
  • Storybook
  • Design Tokens
  • CVA

The problem

The small tools on this site are separate apps. Each has its own repository, its own Vite build, and its own Tailwind v4 setup. That separation is deliberate. It also meant every surface they share, the sidebar, the chips, the focus rings, the type scale, was written once per app, and the copies drifted.

kern is one layer every tool consumes: thirty-eight components across four layers and a single token file, defined once. It holds only what the apps share, and nothing app-specific.

specifiOverview#F78D2C · 4px
gray-scottOverview#15AD70 · 16px
hexiconOverview#7193ED · 10px
Overviewvar(--primary) · var(--radius-card)
one kern definition

The token layer

tokens.css has two tiers. Primitives are the raw values: a ten-step void neutral scale and eleven named accent palettes, each with a dark, base and light stop. Semantics are the roles a component reads. A component never names a hue. It reads --primary and --ring, and those alias a primitive.

a component.nav-row.is-active { color: var(--primary) }
tier 2 · semantic roles
--primary: var(--color-orbit)alias, no raw value
tier 1 · primitives
--color-orbit: #73BDE7
a component reads the role. the role aliases a primitive. only the primitive holds a raw value.

The seam is two roles. --primary and --ring both alias --color-orbit by default, so an app retints its whole accent vocabulary by pointing those two roles somewhere else. That mechanism is section four.

Type is role-based on the same split. Each role carries its own size, weight, line-height and tracking rather than inheriting a default, so --text-h1 resolves to weight 300 and --text-display to 800 without either restating the others. Motion is a fixed set: five easings and six durations, one of them --duration-reduced for the reduced-motion path.

/* tokens.css, tier 1: primitives, raw values */
--color-void-0:  #121213;   /* … through --color-void-90 */
--color-orbit:  #73BDE7;   /* … 11 accents, each dark / base / light */

/* tier 2: semantics, the roles a component reads */
:root {
  --background: var(--color-void-0);
  --foreground: var(--color-void-90);
  --primary:    var(--color-orbit);   /* ← the seam */
  --ring:       var(--color-orbit);   /* ← the seam */
}

That CSS is not hand-written. src/tokens/tokens.ts is the source, and npm run tokens generates the stylesheets from it. They are committed rather than built on install, because kern ships as source with no build step, and tokens:check is what keeps the committed copy honest: it regenerates into memory and diffs against what is on disk. It runs first in CI, ahead of lint, typecheck and the tests, so a token edit that skipped the generator fails the build instead of shipping a stale stylesheet to every app.

Six wizards
token
--text-display
weight
800
line-height
0.95
tracking
-0.03em
every role carries its own four axes. nothing is inherited.

Motion is tokenised on the same terms. Five easing curves and six durations, each one carrying the case it is for rather than a number alone: standard for a transition between two resting states, decelerate for something entering, accelerate for something leaving. A curve is unreadable as four numbers, so the demo runs one. --duration-reduced is 0s, substituted wholesale under prefers-reduced-motion, which is what makes honouring it a token swap rather than a rewrite.

Reduced motion is on, so the runner is holding still rather than crossing. The tokens still answer: pick a curve or a duration and the declarations below change. It is what --duration-reduced does to every other animation on the site.

--ease-accelerate
cubic-bezier(0.4, 0, 1, 1)
--duration-enter
0.4s
the same trip, on every curve the system owns.

Atomic structure

The library is organised by how large a component is and what it may depend on. An atom is the smallest useful part, a chip or a button or an icon, and reads tokens and nothing else. A molecule arranges a few atoms to do one job and owns layout but no application state. An organism is a full region of interface that may own local state. A template is the frame those regions sit in, the shell an app mounts once and fills. The library is sixteen atoms, sixteen molecules, three organisms and three templates.

One rule holds the split together. Dependency runs down that list and never back up. An atom cannot import a molecule, which is what keeps the bottom of the stack safe to change.

organisma full region of interface, mounts everything below it

dependency runs down only. an atom cannot import a molecule.

Consumed and retinted

Every accent surface in kern reads --primary or --ring: the active nav row, a filled track, a selected chip, every focus ring. A consumer redefines those two properties in its own index.css and the whole shared vocabulary retints. Nothing forks. One tool runs solstice, another nebula, another pulsar.

OverviewParameters
TonalExpressive
/* specifi/src/index.css */
--primary: var(--color-solstice);
--ring:    var(--color-solstice);
same markup, same classes. only these two lines change.

Distribution: source, pinned

kern ships as TypeScript source. Its package.json exports point at src/index.ts, so a consumer imports from kern and its own Vite build and type checker compile the source directly. There is no build artefact to fall out of step with the source, and no npm publish step. The git ref is the version: each consumer pins a tag, github:hipuku/kern#v1.3.0, so a breaking change lands when an app moves its own pin. One @source line registers kern with Tailwind, without which none of its utility classes are generated.

package.json"kern": "github:hipuku/kern#v1.3.0"the tag is the version, no npm publish
node_modules/kern/srcTypeScript source, no build artefact
kern package.json exports"." → ./src/index.ts
Tailwind scan@source "../node_modules/kern/src"
specifigray-scotthexicon
a git ref is the version. the source is the artefact.

Accessibility in the shared layer

Accessibility is cheaper as a property of the shared layer than as a pass over every app. Semantic HTML comes first, and every interactive element carries a focus-visible ring drawn from --ring. Where a rule can move into the type system, it does. A control that renders as an icon alone takes a required aria-label, so a nameless icon button does not compile. The Storybook a11y addon runs on every story, and vitest-axe asserts zero violations on all 38 components and the ErrorBoundary in CI, which is every test file in the repository.

// kern/src/atoms/IconButton.tsx
interface IconButtonProps {
  onClick: () => void
  // required, not optional: an icon has no text node to name it,
  // so a nameless icon button is a compile error, not a lint warning.
  'aria-label': string
  children: ReactNode
}

<IconButton onClick={close}><X /></IconButton>                  // ✗ no accessible name
<IconButton onClick={close} aria-label="Close"><X /></IconButton> // ✓

Colour is held to the same standard, and by the same means. kern had a contrast matrix that measured every pairing beautifully and prevented nothing, so the arithmetic moved into src/lib/colour.ts and contrast.test.ts now asserts on it: every ink role against every surface it is permitted on, built from the roles' own declarations rather than from a sample, so adding a role extends the suite automatically. A pairing below its floor fails the build.

hue & chroma, in OKLCH

angle is hue, radius is chroma, outer ring 0.20. The three stops of one accent sit on a line out from the centre: same hue, more colour.

set on the surface it is drawn on

  • Aanebula5.7
  • Aaaurora8.9
  • Aatidal9.0
  • Aapulsar5.6
  • Aaquasar7.4
  • Aacorona10.8
  • Aaflare4.6
  • Aasolstice6.9
  • Aasupernova10.5

4.5 clears AA for body text, 3.0 clears AA for large text and UI, below clears neither. Base stops, measured on surface, #1f1f20, the tighter of the two surfaces: the same colour scores a little higher on background.

Lightness strip

L from 0.51 to 0.96. The dark stops of one accent and the base stops of another overlap, which is why a role names a stop rather than a shade.

every figure computed from the palette itself.

Design decisions

Two systems, consumed differently. haus publishes W3C DTCG tokens and React components to npm, for consumers it does not know. kern is github-pinned and source-only, and holds the parts the in-house tools share. The split is what lets kern change an API: it knows every one of its consumers by name and each pins a tag, so a breaking change lands when an app moves its own pin.

Desktop only, gated in CSS. These are wide-canvas tools, so rather than have each one degrade its own layout, or have kern carry a responsive collapse it does not want, v1 gates the whole app. A ViewportGate template renders the interface at lg and above and a short notice below it. The switch is lg: / max-lg: in CSS, so there is no resize listener to leak and no flash of the wrong branch on first paint. It is correct before hydration, because the browser paints whichever branch the current width selects.

Each app pins a tag. Until v1 the experiments depended on an unpinned github:hipuku/kern, which resolves to whatever main is at install time. An unrelated change could break an app on a routine reinstall, so no breaking change to kern was ever really safe. Pinning a tag is what made v1 possible: a breaking rebuild that added the templates layer and the organisms the apps had been hand-rolling, released as v1.3.0, with each app adopting it by bumping one line on its own schedule. That is also why there is no monorepo. The git ref is already the version, and coupling independently deployed subdomains into one repository would force them to move together.

Where it stands

kern is defined once and consumed by tools that each pin a tag, so a change to a shared component reaches each one when it moves its pin. v1 was a breaking rebuild, and every tool migrated onto it. What source-only distribution still costs is a consumer whose build cannot compile TypeScript source and scan it with Tailwind. For in-house apps that cost is acceptable. kern is not published to npm because nothing outside this portfolio consumes it; if something does, that decision changes.

npm, a monorepo, CSS-in-JS: what was weighed, in DESIGN.md →