Skip to content
Resources

Engineering

We pointed the colour-debt linter at ourselves

Building a tool that finds duplicate colours and then running it on your own stylesheet is a fair test of whether it works. It found 14% removable, and the reason was interesting.

4 min read

We built a linter that finds colours a codebase cannot tell apart, then pointed it at our own compiled stylesheet. That felt like the minimum honest test: a tool for finding duplicate colours which has never been run on real input is a tool that works on the examples in its own test file.

49 distinct colours, 14% of them removable. Not a disaster, and not nothing — and the reason turned out to be more interesting than the number.

The duplicates were all the same bug

  • #0F172A and #0F172B
  • #334155 and #314158
  • #94A3B8 and #90A1B9
  • #64748B and #62748E

Every pair is one slate step in two eras. Tailwind v4 rebuilt its default palette in OKLCH, which moved every shade by a fraction — and every hex we had hardcoded during the v3 years stayed exactly where it was. So the codebase now holds two slate-900s, two slate-700s, two slate-400s and two slate-500s, each pair under a ΔE of 2.3: literally indistinguishable, and each one a value someone has to keep in sync forever.

Nobody did anything wrong. This is what colour debt actually looks like — not carelessness, but a dependency moving underneath values that were correct when they were written.

Running it on real input found two bugs in the linter

Both would have shipped, and both were the kind that look fine in a unit test:

Every line number was wrong. We reported the offset of a rule’s selector, but that offset pointed at the whitespacebefore the selector — so each rule landed on the line its predecessor ended, and the first rule in every file was always line 1. In a test fixture written as a single line, that is invisible. In a real stylesheet it is every annotation pointing one line off.

One pair was reported as both “merge these” and “these are suspiciously close”. Clustering uses union-find, so a chain of individually imperceptible steps can span a perceptible gap: #FFFFFF and #F1F5F9 got joined through #F8FAFC even though the two ends are 3.21 apart. Our near-miss filter excluded pairs by distance, which correctly let that pair through — into a report where it now contradicted itself.

The fix for the second was not to break the chain. Transitive merging is usually right, and the members really are pairwise indistinguishable. What was wrong was doing it silently, so a chained cluster now reports its widest ΔE and lets the reader decide with the number in front of them.

What we actually changed

Nothing yet, deliberately. The finding is real but it is a dependency migration rather than a cleanup — the right fix is to stop hardcoding slate hexes and read the v4 tokens, not to hand-edit eight values into agreement and collect the same debt again next major version.

Which is the more useful lesson from this exercise than the percentage: a debt number tells you where to look, not what to do. Ours pointed at four pairs and the answer was one decision about where colours come from.

What colour debt is →