Design & engineering · 2026

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

specifi, gray-scott and hexicon are three 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 three times, and the three copies drifted.

kern is one layer those three apps consume: 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-md)
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 100 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;   /* … eleven 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 three apps.

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.

--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. specifi runs solstice, gray-scott runs nebula, hexicon runs 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, aliased, never built

kern is not compiled, not bundled, and not published to npm. It ships as TypeScript source. Each consumer resolves @kern/* through a Vite alias pointed straight at the source tree, with a matching tsconfig paths entry for the type checker. There is no build artefact to fall out of step with the source, and no publish step between an edit and the three apps seeing it. The git ref is the version: each consumer pins a tag, github:hipuku/kern#v1.2.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.2.0"the tag is the version, no npm publish
node_modules/kern/srcTypeScript source, no build artefact
Vite alias · tsconfig paths@kern/* → node_modules/kern/src
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 three apps. 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 the load-bearing components in CI.

// 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, kept apart. haus is a full design system: W3C DTCG tokens, published to npm, built to be consumed by anyone. kern is a lab layer, github-pinned and source-only, and it holds the specific set of parts three in-house tools actually share. Merging them would mean one contract serving two audiences, and the smaller one would lose: kern can make a breaking change on a Tuesday because it knows all three of its consumers by name, and a published package cannot.

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:, not a matchMedia read held in state: no resize listener to leak, no flash of the wrong branch on first paint, and it is correct before hydration, because the browser simply paints whichever branch the current width selects.

A tag, not a branch. 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 three apps had been hand-rolling, released as v1.2.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 three independently deployed subdomains into one repository would force them to move together.

Where it stands

kern is defined once and consumed by three apps that each pin a tag, so a change to a shared component reaches all three when each one moves its pin. v1 was a breaking rebuild, and all three migrated onto it. What source-only distribution still costs is a consumer outside the three that share the alias, and the accessibility checks cover only the load-bearing components rather than the whole library. For three in-house apps those costs are 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 →