dhaga.blog
Engineering

One token cannot be both

Light mode was audited to WCAG AA and the palette itself turned out to be the bug. On a light ground a colour's contrast as a fill and its contrast as text multiply to a fixed constant — ours is 16.78 — so no single value can clear 4.5:1 at both jobs. Why every accent in the system is now a pair.

The short version

Users told us light mode was hard to read and the logo was half missing. Both were true. The obvious hypothesis was that colour had leaked out of the design system into components — it hadn't. Colour was already about 90% centralised; the light values were wrong, not the architecture.

The deepest of those wrong values could not be fixed by picking a better one. For a colour on our light ground, its contrast ratio as text and its contrast ratio as a fill under near-black multiply to a constant. We measured ours at 16.78. Two ratios of 4.5:1 need a product of 20.25. The best a single value can reach is the square root of the constant — 4.096:1, a shade under 4.1.

So the fix wasn't a better hex. Every accent became a pair: a base token that is a FILL, and an -ink sibling that is the TEXT. In dark mode each sibling aliases its base, so one class name is correct in both themes with no dark: variant anywhere.

This was not one sitting. The WCAG audit came first and fixed the values it could; the pair system arrived just over two weeks later, once it was clear that one of those values had no better hex to be fixed to.

The rest of this post is the deep dive: the logo bug that made the whole class of failure visible, the arithmetic that makes the pair forced rather than chosen, why the systemic fix beat the per-call-site one by a wide margin, and the two bugs that only a systemic fix could ever have reached. File paths refer to the real code.


The logo that wasn't there

Start with the smallest bug, because it is the whole story in miniature.

Our mark is a thread: an amber stroke with a dot at each end. In light mode, users saw the stroke and no dots — apps/web/src/components/brand/ThreadMark.tsx:

// Before. Two hard-coded hexes.
<path d="M2 16 C 7 16, …" stroke="#e2a44c" strokeWidth="1.8" />
<circle cx="2" cy="16" r="2" fill="#f3ede2" />
<circle cx="22" cy="4" r="2" fill="#f3ede2" />

#f3ede2 is the dark-mode cream — the correct colour, on the near-black ground it was written for. On the light ground it is 1.03:1, which is not a low-contrast dot. It is an invisible one.

// After.
<path d="M2 16 C 7 16, …" stroke="var(--brand-amber)" strokeWidth="1.8" />
<circle cx="2" cy="16" r="2" fill="currentColor" />
<circle cx="22" cy="4" r="2" fill="currentColor" />

currentColor inherits whatever text colour the mark sits in, which is already theme-aware everywhere. One line each, and the logo was correct at all 11 of its render sites at once.

That is the shape of every bug in this post. A value that was right for one ground was written down as if it were right for all of them, and light mode was where the assumption came due.

The audit, and the numbers it produced

We measured every light value against the WCAG AA targets — 4.5:1 for body text, 3:1 for non-text things like control boundaries and focus indicators. Most of the haul was nearly right, which is the hardest kind to notice:

  • --brand-fog, the universal secondary text colour, sat at 4.03:1. The text-fog utility that resolves to it appears 563 times in apps/web/src. It went to 5.69:1.
  • The focus ring was amber, at about 1.4:1 against the ground. A ring that cannot be seen is not a styling defect, it is a keyboard user losing their place. It moved onto ember at 6.6:1.
  • Prose links across /docs and /blog inherited a fumadocs primary that was also amber — 1.83:1, shipped on every documentation page.
  • Errors used Tailwind reds. A red at that lightness is about 2.4:1 on light. They all became the semantic destructive token, whose light value clears AA both as plain text and under the tinted focus state.

Notice what all four have in common. Amber, on light, is about 2.35:1. It kept being used as text because in dark mode — where it clears comfortably — using it as text is right. Nobody made a bad decision. They made a dark-mode-shaped decision in a file that both modes read.

Two borders, on purpose

One fix in that pass looks like duplication and isn't. We split the border token in two:

TokenJobTargetMeasured at the split
--brand-seamDecorative separators, card edges, hairlinesnone — it is ornament1.65:1, deliberately restrained
--brand-lineControl and input boundaries: where does this field end?3:1 (WCAG non-text)3.41:1
--ringFocus indicator — where am I?3:1, and it must beat both6.6:1 on ember

Before the split, one token did both of the first two jobs, and whichever way you tuned it something broke. Push it to clear 3:1 and every decorative hairline turns into a heavy grey rule. Leave it restrained and form fields lose their edges. The resolution is the same one the rest of this post is about, arrived at a different way: a token's contrast target is a property of its job, not of its appearance.

The arithmetic, which is the actual point

Contrast ratio between two colours is (L₁ + 0.05) / (L₂ + 0.05), where L is relative luminance and the lighter colour is on top. Take one accent colour C. It has two jobs in a light theme:

  • As text, it sits on the light ground G. Since G is lighter: R_text = (L_G + 0.05) / (L_C + 0.05)
  • As a fill, near-black label ink B sits on top of it. Since C is lighter: R_fill = (L_C + 0.05) / (L_B + 0.05)

Multiply them:

R_text × R_fill = (L_G + 0.05)/(L_C + 0.05) × (L_C + 0.05)/(L_B + 0.05)
                = (L_G + 0.05)/(L_B + 0.05)

C cancels. The product does not depend on the accent at all. It is a property of the theme — precisely the contrast between the light ground and the ink you put on top of accents. On our light ground with our --brand-on-accent, that number is 16.78.

Which settles it. For both jobs to clear 4.5:1 you need a product of at least 4.5 × 4.5 = 20.25. You have 16.78. No hex, no oklch tweak, no saturation trade gets there, because the accent is not in the equation. Push the colour darker and it reads better as text by exactly the factor it loses as a fill. It is a see-saw with a fixed length.

The best a single value can do is balance the two — √16.78, which is 4.096:1 at both ends, a shade under 4.1. Good enough to look fine in a screenshot. Not good enough to pass.

The empirical version arrived before the algebra did. When --brand-calm was softened to a sage green that made the on-accent label clear AA on the fill, text-calm fell to 3.35:1. The fill got better and the text got worse, in lockstep. That is the see-saw, observed.

Dark mode has no such conflict, and it is worth being precise about why rather than saying "dark is easier". On a near-black ground the accent is the lighter colour in both comparisons — lighter than the ground it sits on as text, and lighter than the near-black ink placed on top of it as a fill. Both ratios therefore increase together as the colour gets lighter. They are not reciprocally coupled, so one value satisfies both.

The pair is forced by light mode and free in dark.

What the pair looks like

/* Before. One token, two jobs. `bg-calm` and `text-calm` both resolved to
   this teal, and only one of them was right: 4.81:1 as text on the light
   ground, but only 3.49:1 under the on-accent ink as a fill. */
:root {
  --brand-calm: #287a6c;
}
/* After. palette-light.css — the FILL and the TEXT are separate values. */
:root {
  --brand-calm: #5d9370;      /* fill: on-accent ink clears 4.5:1 on it */
  --brand-calm-ink: #3a7150;  /* text: clears 4.5:1 on the light ground */
}
/* After. palette-dark.css — the sibling simply aliases its base. */
.dark {
  --brand-calm: #8bc19d;
  --brand-calm-ink: var(--brand-calm);
}

That third block is what pays for the whole design. Because the ink sibling exists in both palettes, text-calm-ink is the correct class in dark mode too. A component writes it once — no dark: variant, no conditional, no knowledge of the current theme at the call site.

Amber was already half of the way there. --brand-ember had long been its light-mode ink sibling — but dark carried a third hex of its own, #c37731, so the two themes still disagreed about what the accent was and a call site still had to choose. The audit collapsed that: dark now sets --brand-ember: var(--brand-amber), so the accent returns to exact brand amber on a near-black ground and one class name is right in both themes. Then the alias became the rule rather than an amber quirk, and trust, calm, magic and human all grew siblings. Adding an accent now means adding both halves, or it isn't an accent.

The fix we didn't ship

The tempting fix, at every one of those call sites, is a variant:

// The wrong fix.
<span className="text-ember dark:text-amber">

It works. It is also 87 independent chances to forget, since that is exactly how many text-amber call sites the pass converted — 87 in apps/web/src before, none after. Each one encodes the palette's arithmetic into a component with no business knowing it, and the day a value is retuned all 87 are wrong in a way no type checker sees.

The systemic version moves the conditional into the layer that already knows about themes. One declaration in palette-dark.css decides the accent for both modes; call sites say text-ember and are correct everywhere.

The less obvious payoff is that it makes the rule checkable. "Is every -ink sibling declared in both palettes" is a question you can ask of two files. "Did all 87 call sites remember their dark: variant" is a question you can only ask of a screenshot.

Two bugs only the systemic fix could reach

The centralisation pass surfaced two failures no amount of per-component tuning would have found.

The forced-dark subtree. The shadcn semantic layer — --background, --foreground, --border, --ring — is a pure pass-through onto brand tokens, and it was declared on :root alone. Two surfaces force a dark subtree mid-page regardless of theme: the camera capture view and the photo cropper. They re-resolved --brand-* locally, but a custom property whose value contains var() is substituted at computed-value time on the element that declares it. Declared only at :root, --ring was already frozen to the light value inherited from html, so re-declaring brand tokens on a descendant could never reach it.

/* Before. */
:root { --ring: var(--brand-amber); /* … */ }

/* After. */
:root, .dark { --ring: var(--brand-ember); /* … */ }

The value moved in that same commit too — that is the amber-to-ember ring fix from the audit above, a separate bug that happened to land in the same line. For this bug, the selector is the entire fix: the symptom, for a light-mode user, was that focus rings vanished the moment they entered the camera overlay, and adding .dark to the selector is what restored them.

The graph canvas. Our knowledge graph renders to a canvas, so it reads colours from JavaScript rather than inheriting CSS — and it had one palette. On the light canvas the dark node fills measured 1.8–2.7:1, and the edge mesh, the thing that makes it a graph rather than a scatter of dots, sat at 1.28:1 and effectively disappeared. It now carries a per-theme node and edge palette.

Both bugs live in the seam between the token system and something that opts out of it. That is where a design system's guarantees stop, and where you should look after fixing the easy 90%.

The takeaways

  1. On a light ground, a colour's fill contrast and its text contrast multiply to a constant you cannot change by picking a different colour. Compute it once for your theme — it is the contrast between your ground and your on-accent ink. If it is under 20.25, one value provably cannot clear 4.5:1 at both jobs, and every hour spent tuning that value is wasted.
  2. Ship accents as pairs — a fill token and a text token. In dark mode the text sibling can simply alias the fill, which is what lets one class name be correct in both themes. An accent with only one half is a bug that hasn't rendered yet.
  3. A token's contrast target comes from its job, not its look. Decorative hairlines and control boundaries want opposite things from the same grey. That is two tokens, not one compromise.
  4. Prefer one declaration to N variants. A dark: at every call site is a correct fix that scales linearly with your chances of forgetting. Push the conditional into the layer that already knows the theme and the rule becomes verifiable by reading two files.
  5. var() in a custom property resolves on the element that declares it. Declare your semantic alias layer on every theme root, not just :root, or a forced-theme subtree silently inherits the wrong half of your palette.
  6. Audit the surfaces that opt out of CSS last, and hardest. Canvases, generated images and hard-coded SVG fills never inherit your fix. They are where a value written for one theme survives longest.
Share

Discussion

On this page