Skip to Content
The Blog

Kinetrix Design System

An overview of the Kinetrix design system, covering its tokens, its components, and the lint rules and tests that enforce them.

The Kilter Team 7 min read

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-styles blocks invented values: arbitrary sizes like text-[11px], literal colors, raw color ramp steps like bg-surface-900, and radii outside the three allowed values. Semantic tokens like bg-panel and text-ink-muted are allowed.
  • use-ui-primitives blocks raw <button>, <a>, <input>, <select>, <textarea>, <form> and <dialog> elements in feature and route files. The error names the component to use.
  • icon-scale blocks icon sizes written as numbers. Use the glyph scale.
  • design-vocabulary blocks feature code from using the design system's internal class names, such as press-fill or app-selected. Use the exports in ui/styles.ts.

Why they are set up this way:

  • The first version of no-adhoc-styles banned 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-primitives was 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.
css
.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.ts checks 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.mjs checks 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