The design system behind Kool

Design

One set of tokens drives the app, the website, the help centre and the status page. When the accent colour changed, it changed everywhere in an afternoon. That is the whole point, and it is worth explaining how it is actually enforced, because "we have a design system" usually means "we have a document nobody opens".

Ours is a file: design/tokens.json. Thirty-five colours, seven spacing steps, six radii, seven type styles, five motion tokens and one two-layer shadow. Nothing else is allowed to be the place a value is first typed.

A generator, design/generate-tokens.py, compiles that file into per-platform source. Today it emits Kotlin, into the Android design module. That is where the interesting problem starts, because a generator only helps while nobody edits its output, and editing the output is easy to do by accident. The generated file is ordinary Kotlin. The IDE offers it in autocomplete. A one-hex-digit change there compiles, ships, and looks completely right on the phone in front of you.

What it does not do is reach the other platform. A token edited on one side and not the other is the exact failure the pipeline exists to prevent: two apps drifting a shade apart, a card two points narrower on Android, a spring half a step slower. None of that is visible until somebody holds the two phones side by side, which is to say it is invisible for weeks.

So the generated file is treated as build output. A check in CI re-runs the generator into memory and fails if what is on disk differs, whether the cause was a hand edit of the Kotlin or a change to the JSON that nobody regenerated. The fix is always the same one sentence: put the value in tokens.json and run the generator.

The part we are proudest of is the motion. SwiftUI describes a spring as a response and a damping fraction. Compose describes the same physical model as a stiffness and a damping ratio. The conversion is exact for a unit mass, stiffness being (2*pi/response) squared, so we carry both numbers in the token rather than making either platform re-derive the other's. Our standard spring is a response of 0.5 at 0.85 damping, which is a stiffness of 157.91 on the other side. Two numbers, one motion, no arguing about which one is correct.

An honest note, because a design system post that claims total victory is lying. Only the Kotlin emitter is wired up. Theme.swift on the iOS side is still hand-written, and while other people are actively editing it, generating over it would throw away work in progress. So an edit to tokens.json is mirrored into Swift by hand, and the JSON says so in its own metadata: anything here that disagrees with Theme.swift is a bug in one of the two. Everything an emitter needs lives on a base class rather than being baked into the Kotlin one, so adding Swift later is a subclass and a line in a table, not a rewrite.

That is the state of it. One file, one generator, one check that refuses to let the output drift, and one known gap we can point at.

All posts