Kinetrix Design System
An overview of the Kinetrix design system, covering its tokens, its components, and the lint rules and tests that enforce them.
Kinetrix is the codebase behind Kilter. This post covers how its design system is set up and how it is enforced. The full design rules, with every token and component, are published at kilter.work/kinetrix.
In short: every visual value comes from a token, every control comes from the
component library in platform/src/ui, and lint rules and tests fail the build
when code goes around either one.
#Tokens
Tokens are defined in platform/src/styles/theme.css and
platform/src/ui/styles.ts.
| Area | Rule |
|---|---|
| Color | One accent blue (#056ffa) for interactive and selected states. Red, amber and green for status only. Everything else is gray. |
| Borders | 1px everywhere. The focus ring is the only 2px line. |
| Radius | Three values: rounded-control, rounded-container and rounded-full. |
| Shadow | Raised for panels, cards and buttons. Overlay for menus, popovers and dialogs. Hover never adds a shadow. |
| Type | One font, Open Sans. Code names a role (page, section, body, detail, micro) instead of a size. |
| Icons | Four sizes: glyph.meta, glyph.inline, glyph.control and glyph.tile. |
| Motion | 160ms for feedback, 240ms for a change in place, 340ms to enter, 440ms for a layout change, 220ms to exit. All curves decelerate. |
#Components
Screens are built from the components in platform/src/ui, such as Button,
Input, Card, Menu, Dialog and Callout. Each component handles its own
hover, focus, pressed, disabled and loading states, and its own motion.
When the same layout shows up in two feature files, it becomes a component. An
audit found the title and description header written eleven different ways
across the apps, and it produced nine new components, including Heading,
PaneHeader, SplitView and ListBand. recipes.test.ts now fails if a
feature file writes one of those layouts by hand, and names the component to
use instead.
#Lint Rules
Four custom ESLint rules enforce the system:
no-adhoc-stylesblocks invented values: arbitrary sizes liketext-[11px], literal colors, raw color ramp steps likebg-surface-900, and radii outside the three allowed values. Semantic tokens likebg-panelandtext-ink-mutedare allowed.use-ui-primitivesblocks raw<button>,<a>,<input>,<select>,<textarea>,<form>and<dialog>elements in feature and route files. The error names the component to use.icon-scaleblocks icon sizes written as numbers. Use theglyphscale.design-vocabularyblocks feature code from using the design system's internal class names, such aspress-fillorapp-selected. Use the exports inui/styles.ts.
Why they are set up this way:
- The first version of
no-adhoc-stylesbanned every appearance class and produced 439 errors, almost all of them correct uses of semantic tokens. It was narrowed to block only invented values. use-ui-primitiveswas added after an audit found 30 hand-written interactive elements, 17 of them buttons, with four different hover styles and five different focus rings between them.
#Color
Colors are not written by hand. scripts/palette.mjs generates every color
token from a small set of declared hues, and pnpm palette:check, which runs as
part of pnpm lint, fails if theme.css does not match what the generator
produces.
The generator also checks contrast:
- Every text color has a minimum contrast ratio, at least 4.5:1, and has to meet it on every background it is used on.
- Light and dark themes have to stay within 0.6 of a ratio of each other on the panel background, so one theme is not noticeably weaker than the other.
The accent's main step is pinned to #056ffa, the blue in the logo, and
scripts/brand-assets.mjs imports it so the two cannot drift apart.
Before the neutral colors were generated, they were picked by hand, and small detail text (timestamps, counts, hints) measured 4.17:1 in light mode and 3.95:1 in dark mode, below the WCAG AA minimum. It is now 5.0:1 in both.
#Pixel Rounding
Sizes are in rem so the layout scales with the user's text size setting. The
root font size is fluid, so a rem is usually a fractional number of pixels. That
caused two problems: 1px borders could render blurry, and elements shifted by a
pixel when an animation ended.
To fix this, theme.css redefines each spacing utility with CSS round().
Vertical lengths round to 2px so centered content lands on a whole pixel, and
horizontal lengths round to 1px. pixel-grid.test.ts fails if code uses a
spacing utility that has no rounded version.
#Motion
In the app, text is never animated with a transform. In Chromium, text on a layer that is animating a transform loses subpixel antialiasing until the animation ends, so it looks blurry and then snaps sharp. Fading an element with a transparent background causes the same problem.
So there are two ways for something to appear:
- Elements with their own background, like menus, dialogs, toasts and
callouts, use
motion-fade-in. - Text on an existing surface, like page content, validation messages and
empty states, uses
motion-veil-in. It puts an overlay in the surface's background color over the text and fades the overlay out. The text itself never moves or fades.
.motion-veil-in::after {
content: '';
position: absolute;
inset: 0;
z-index: 45;
border-radius: inherit;
background-color: var(--surface-fill);
pointer-events: none;
opacity: var(--veil);
}
@utility motion-veil-in {
&::after {
animation: app-unveil var(--duration-base) var(--ease-enter) both;
}
}Every background class sets --surface-fill, so the overlay always matches the
surface it is on. Button presses use press-fill, an 8% tint over the button's
background, instead of scaling. The only transforms left in the app are
animate:flip, used when items are reordered or dragged, and a small scale on
page crossfades.
Motion is turned off under prefers-reduced-motion, and an in-app setting can
override the operating system's setting in either direction.
#Copy
- Labels (buttons, headings, tabs, menu items, field labels) use Title Case.
Sentences use sentence case and end with a period.
labels.test.tschecks every label in feature and route files. - An error shows what failed, why it failed, and an error code with a request reference, with a way to copy all of it.
- All text uses American spelling and no dashes as punctuation. ESLint checks
code files, and
scripts/prose.mjschecks everything else, including Markdown.
#Known Gaps
The checks do not catch everything. For example, a flex child with
min-w-0 flex-1 next to a button can shrink to 0px wide on a phone. It passes
type checks, unit tests and the end-to-end suite, which runs at desktop width.
The fix is to give the flexible element a basis such as basis-48 and let the
row wrap. The only way to catch it is to test in a real browser at 375px.
#Full Design Rules
This post is a summary. The complete rules live in DESIGN.md in the Kinetrix
repository, and the rules part of it is published at
kilter.work/kinetrix. That page is generated from the file every
time the site is built, so it matches the rules the app is built with. It
covers:
- Tokens: every color, type role, radius, spacing step and component value, with swatches and samples.
- Colors, typography, elevation, layout, motion and components, including the named rules such as the One Signal Rule, the Pixel Grid Rule and the Crisp Text Rule.
- How the product writes, and the do's and don'ts.
#Decision Records
The reasoning behind each part is in docs/adr:
| Topic | ADR |
|---|---|
| Lint rules | 0004 |
| Color palette | 0007, 0033 |
| Motion timing | 0012 |
| Pixel rounding | 0014 |
| Shared components | 0016 |
| Text and transforms | 0019 |