← All writing
29 Aug 2026 · Design systems

Light mode broke 279 violet usages. Renaming them by meaning fixed it.

We built this site dark-first. Near-black background, near-white text, violet accent. Then we added a light theme. The violet ladder alone accounted for 279 usages across the site, and they went washed-out or unreadable all at once — the grey ladder had already needed the same treatment.

The instinct is to go fix them. That instinct is wrong, and it is worth explaining why, because the same trap shows up in every design system that starts with one theme.

The actual bug was in the names

Our colour scale was Tailwind's default shape: gray-950, gray-900, gray-800, violet-300, violet-700. Those names describe lightness. gray-900 is a dark grey. That is all the name tells you.

But look at how each name was actually being used:

  • bg-gray-900 — always a card sitting on the page background
  • bg-gray-950 — always the raised surface on top of that card
  • text-violet-300 — always accent text
  • border-violet-700 — always a subtle accent border

Every one of those has a meaning that survives a theme change. A card is still a card on a white page. What does not survive is the lightness: a card on a near-black page is lighter than its background, and a card on a white page is darker.

So the tokens were named after the one property that had to invert, and silent about the property that had to stay fixed.

The fix

Instead of touching the 279 call sites, we remapped the ladder itself — per theme, by meaning:

:root, [data-theme='light'] {
  --gray-950: #ffffff;   /* raised surface */
  --gray-900: #f6f5f8;   /* card */
  --gray-800: #e4e3e8;   /* border */
  --gray-400: #686870;   /* secondary text */

  --v-300: #4c25d8;      /* accent text — darkens on white */
  --v-700: #c9bcff;      /* accent tint — lightens on white */
}

Note that the violet ladder does not simply reverse. violet-300 and violet-700 move in opposite directions, because one is text and one is a tint. Sorting by lightness would have produced a theme that passed a naive check and still looked wrong.

Every bg-gray-900 in the codebase became correct in both themes without being edited. The diff was one CSS block, not three hundred component changes.

Two things we got wrong on the way

We trusted an automated contrast audit too early. It reported failures three separate times that were not real — once because it measured a stale dev server still serving the previous CSS bundle, once because it could not see through a gradient, and once because it defaulted to a white page background when every ancestor happened to be translucent. Each time the tool was confident. A screenshot caught what the numbers missed, every time.

Not every page wants a light mode. Our two research reports were designed as dark technical documents, with blue and pink pastels tuned for a dark background. On white those measured 1.3–1.7:1 — nowhere near the 4.5:1 that body text needs. The answer was not to recolour them; it was to pin them:

<main data-theme="dark">

Because the token selectors are [data-theme='dark'] on any element rather than scoped to :root, a single attribute pins a subtree to dark while the rest of the site follows the reader's preference. That is worth designing for from the start.

The takeaway

If you might ever add a second theme, name your tokens after what they do, not what they look like. --surface-raised survives a theme flip. --gray-950 does not — it only looks like it does until the day you flip it.