the component layer kern defines
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.
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.
.nav-row.is-active { color: var(--primary) }--primary: var(--color-orbit)alias, no raw value--color-orbit: #73BDE7The 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.
- token
- --text-display
- weight
- 800
- line-height
- 0.95
- tracking
- -0.03em
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
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
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.
/* specifi/src/index.css */
--primary: var(--color-solstice);
--ring: var(--color-solstice);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.
"kern": "github:hipuku/kern#v1.3.0"the tag is the version, no npm publishTypeScript source, no build artefact"." → ./src/index.ts@source "../node_modules/kern/src"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.
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 →