One Color Stop, Two Contrast Values
The sed replaced 409 accent classes, yet the button fill stayed at stop 600. WCAG thresholds per role, not taste, explain the two anchors.
TL;DR
The sed swapping 409 emerald text classes wasn't busywork; it enforced a design contract mapping WCAG contrast roles to Tailwind stops. One blue stop can't serve both roles: text needs 4.5:1 (#0369a1 hits 5.93), button fill only needs 3:1 (#0080c9 hits 4.26). Verification paired a luminance script with a compiled-CSS census, plus two documented contrast exceptions.
The morning task in KotaPortal was mechanical. Replace every text-emerald-600 and text-emerald-700 with text-emerald-800 across the frontend, one sed, done. The counter printed 409 occurrences in 115 component and page files. Accent text now renders #0369a1 at stop 800, the value the token config declares.
Then I looked at the primary button. Its fill stayed at blue-600, value #0080c9. Same blue family as the accent text, two different stops side by side, on purpose.
The sed that looked like duplication
When the palette was first drafted, my assumption was simple: one brand color equals one Tailwind stop, used everywhere. Accent text, button fill, chart strokes, all anchored at stop 600. Under that assumption the morning change reads like pointless duplication. Two stops for one hue? A maintenance smell.
The sed ran anyway, because the ratified design contract said so. The contract was doing work I could not see yet.
Two anchors in the same hue
The contract anchors two values in a single hue, one per contrast role. White text on the CTA fill #0080c9 measures 4.26:1. That clears the 3:1 threshold that governs a button fill [2], but it sits below the 4.5:1 required for text [1]. Accent text at #0369a1 measures 5.93:1 on white, comfortably past that text threshold [1].
A single stop cannot serve both roles. Move the accent text down to stop 600 and it fails the text threshold. Push the fill up to stop 800 and it overshoots the ratified design. So each contrast role kept its own anchor stop, and the remaining stops became declared derivatives. The cool-gray family is labeled that way in the config, so nobody later mistakes a derived value for an anchor.
Why roles map to stops
WCAG sets different minimum ratios per role: 4.5:1 for body text, 3:1 for large text, and 3:1 for user interface components and graphical objects against adjacent colors. The AA versus AAA ladder is exactly this spread of thresholds [4].
Tailwind's default palette has 11 steps per color, from 50 as the lightest to 950 as the darkest, and the same palette feeds every color utility: text, background, border, ring [3]. A role-to-stop mapping is therefore the natural contract form. Each role anchors at the stop that satisfies its own threshold. Stops that no role anchors become derivatives, declared once and reused.
That framing is what the sed was enforcing mechanically. The 409 replacements were the tail end of a decision, not the decision itself.
Checking both layers
The verification runs in two layers, and both are worth keeping.
First, a small script computes WCAG relative-luminance ratios for every required pair:
const lum = (hex) => {
const c = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
.map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
};
const ratio = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
console.log(ratio("#0369a1", "#ffffff").toFixed(2)); // 5.93Output for the required pairs: accent text 5.93:1, CTA fill 4.26:1, CTA hover at #006ba6 5.75:1, dark-mode accent at #38bdf8 7.22:1, ink text 16.88:1.
Second layer is a census of the compiled CSS bundle, searching for the retired rgb triplets:
grep -oE 'rgb\(37, 99, 235\)|rgb\(59, 130, 246\)' dist/assets/*.css | wc -l # expected: 0Source classes can lie. Dead classes sit in the tree, layer overrides rewrite values downstream. The compiled bundle cannot; it is exactly what the browser receives. The census found zero occurrences of the old hex values in source and bundle, and the test suite ran 602 of 602 passing.
One mechanical-sed lesson: before replacing 409 occurrences, audit the same patterns for dark variants, bare, hover, group-hover, and large sizes. Zero existed here, verified before the command ran. In a codebase where those variants were present, a blind replacement would have clobbered dark-mode styling.
Bookkeeping stays honest. Two low-contrast pairs remain as ratified exceptions with usage constraints: meta text at 2.85:1 and body text on the band background at 4.28:1. Both are recorded in the contract rather than quietly ignored.
The line "old hex values: zero" is the real finish line, not the passing test count. One trade-off is still open: two chart series tones now share a single hue family, so visual differentiation between the series degrades. That is recorded as a pending owner decision, and I have not changed it silently.
Sources
- W3C WAI, Understanding WCAG 2.2 SC 1.4.3 Contrast (Minimum)
- W3C WAI, Understanding WCAG 2.2 SC 1.4.11 Non-text Contrast
- Tailwind CSS Docs, Colors
- MDN, Color contrast