Blog

qlint hardcoded colors design tokens build check

Three lint guards that stopped our design system from rotting

A token migration only holds if the build refuses the next hardcoded colour, off-scale spacing or ad-hoc font size. Three guards, one exception marker, and how we got to zero.

4 min readEngineering practice

by Ares Networks

rec 96254e9 records
  1. session
  2. badge
  3. lookup
  4. score· client identifiers
  5. notify· sent for review
  6. lookup
  7. score
  8. lookup
  9. draft

We moved a large web app, around a hundred screens, onto design tokens. That part was easy. Keeping it there was the hard part. This post is about how we lint hardcoded colors and enforce design tokens with a build check, and the two guards that followed for spacing and type.

Migrations rot without a guard

A token migration has a half-life. The week after it lands, someone fixes a bug under a deadline, reaches for a raw palette class, and ships. Nobody notices, because it looks right in the theme they tested. A month later there are dozens of them, and the light theme has a grey-on-grey panel nobody can read.

A style guide does not stop this. People read it once. A build step that fails stops it every time.

Guard one: colour

The first guard is a script that runs in the build and fails on any hardcoded colour in component code: raw palette utility classes, hex values, rgb literals. It allows only semantic tokens.

check-colours.sh
1#!/usr/bin/env bash2# Fail the build on raw palette classes or hex colours in components.3set -euo pipefail4
5pattern='(bg|text|border|ring|fill|stroke)-(red|green|blue|amber|cyan|slate|gray)-[0-9]{2,3}|#[0-9a-fA-F]{3,8}'6
7if grep -rEn "$pattern" src/components | grep -v 'tokens-allow:'; then8  echo "Hardcoded colour. Use a semantic token, or add tokens-allow: <reason> on the line."9  exit 110fi

Exceptions exist. A brand illustration may need an exact value. Those go through an explicit comment marker with a reason, on the same line. The marker makes every exception greppable, and the reason makes a reviewer read it.

The tokens themselves are semantic, not descriptive. We name what a colour means (a status, a category, a runtime state) rather than what it looks like. Refused is a token. A shade number is not. When each meaning has one token, a theme change is a variable swap.

We also replaced a dozen copies of the same colour switch, scattered across components, with one status map in one module. Every badge, chip and table row now asks the map. A new status is one line in one place.

Finally, a contrast check runs in CI against both themes. It checks every token pair we use for text and fails on anything below the threshold. Most of our light-theme bugs were found here, not by eye.

Guard two: spacing

Spacing rotted the same way, more quietly. One-off margins and paddings crept in to nudge a layout by a few pixels. Each one was harmless. Together they meant no two cards lined up.

The guard fails on off-scale spacing values in components. The fix it pushes you toward is not "use the scale" in the abstract. It is size variants on the primitives: a card has small, medium and large padding, and you pick one. You do not override it with a utility at the call site.

Guard three: type

Type was the last one. Ad-hoc font sizes and weights appeared wherever a heading felt slightly wrong. The guard fails on raw size utilities in components and allows only the type roles: display, the heading levels, lead, body, caption, overline and mono. A role is size, line height, weight and tracking together, so you cannot get three of the four right and miss the last.

How we got to zero

The guards only work if the codebase passes them. We got there in batches, one feature folder at a time, each batch its own commit. An audit file, regenerated on each run, listed what was left by folder. The count went down every day, which kept the work visible and easy to review. When it reached zero, we switched each guard from a warning to a failure.

Order mattered. Colour went first because it had the most visible bugs and the clearest semantic names. Spacing and type followed once the primitives had size variants to point people at. A guard with no good alternative only teaches people to use the exception marker.

What to take with you

  • A guard that fails the build beats a style guide.
  • Allow exceptions only through an explicit, greppable marker with a reason.
  • Name tokens by meaning, keep status colours in one map, and check contrast in both themes in CI.
  • Treat spacing and type like colour: variants on primitives, roles instead of raw sizes.
  • Migrate by folder with an audit file, then switch the guard from warn to fail.

The docs describe the semantic colours we use in the product, and why amber only ever means permission.