v1.15.0
A categorical (non-severity) colour vocabulary, added as a parallel set to
Tone rather than as more members on it. Additive and non-breaking: no existing
prop, token or rendered output changes, and Tone is still exactly five values.
Added
-
Category— a second colour vocabulary, for identity instead of severity.
Toneis five points on a severity ramp: how bad is this. But a lot of state
has no severity at all — a feature is on or off, a setting is inherited or
overridden, a delivery is the first or a duplicate, a record is draft or
published, a graph node is one kind of thing rather than another. With only a
severity ramp available, all of those borrowed one: "enabled" rendered as
success, "duplicate" and "overridden" asinfo, "viewing an older version"
aswarning. The UI then reports a problem where there is none.The deprecated
purplealias was the same story in miniature — it meant "seen
this before", was never a severity, and rodeinfoonly because there was
nowhere else to put it. There is somewhere now.variant="purple"still
resolves toinfoand still warns;category="purple"is what it meant.Categoryis deliberately not more members onTone. WideningTone
would break every consumer holding its own exhaustiveRecord<Tone, X>map —
a semver-major change to ship an additive feature — and it would also be
wrong: severity is ordered and categories are not. A category means only
"different from the other one". -
categoryprop onBadgeandStatusBadge, a sibling ofvariantand
mutually exclusive with it. Pass one or the other; passing both warns once in
development (throughwarnOnce) and renders the category. Use
variant="neutral"for "no category".resolveCategory()is exported
alongsideresolveTone()and warns once on an unknown value, for the same
reasonpick()exists: consuming apps write plain-JS SFCs where the typed
union is erased, so a typo otherwise indexes the palette toundefinedand
renders a badge with no colour classes at all.Neither
variantnorcategorycarries a prop default any more. That is
load-bearing rather than cosmetic: with a default, an unset prop is
indistinguishable from an explicitly passed one, and the exclusivity check
cannot be made at all. Theneutralfallback moved one level down into
resolveTone(), which already applied it — so rendered output is unchanged
for every existing call site. -
Nine categorical tokens, in the same base/soft/line shape as the status
block so the two are interchangeable at the call site:
--color-cat-indigo,--color-cat-purpleand--color-cat-magenta, each
with-softand-line. They are namespacedcat-rather than named
indigo/purple/magentaso they cannot be confused with, or shadow,
Tailwind's own colour scales in a consuming app.Three is a measured ceiling, not a starting point. A categorical hue has
to clear four gates at once: AA as text (≥4.5:1) on white, on--color-bg
and on its own-softtint; OKLCH chroma ≥0.10, below which a hue reads as
gray and stops doing identity work; and OKLab ΔE separation from both the
other categories and every severity token — ≥15 to normal vision and ≥8 under
protanopia/deuteranopia (Machado-Oliveira-Fernandes 2009, severity 1.0).Sweeping the whole Tailwind ramp against those gates rejects most of the
wheel, for reasons that are not guessable and are worth recording:rejected why red / rose / pink collapse into danger— ΔE 6.8 / 8.5 / 11.4orange / amber / yellow collapse into warning— ΔE 9.6 / 8.5 / 9.1lime / green / emerald collapse into success— ΔE 10.0 / 8.4 / 6.6teal / cyan not a colour at text weight — dark enough for AA, they measure chroma 0.059 / 0.066, under the 0.10 floor, i.e. they render gray sky collapses into --color-muted— ΔE 12.5The teal/cyan result is the same trap as the bright amber that could not reach
3:1 on white at any lightness (v1.14.0): some hues simply do not exist at the
weight the contrast rule demands. What survives is a single ~60° blue→magenta
arc, with room for exactly three mutually separable steps. A fourth drops the
worst pair to ΔE≈3.0 under simulated red-green colour blindness — that is
indistinguishable, not "close". More kinds than three need a channel that is
not colour: an icon, or the label itself.Measured for the shipped set:
token on white on --color-bgon own -soft--color-cat-indigo#4f46e56.29:1 5.87:1 5.62:1 --color-cat-purple#581c8710.88:1 10.16:1 10.14:1 --color-cat-magenta#a21caf6.32:1 5.91:1 5.89:1 Separation — indigo↔purple ΔE 17.3 normal / 16.6 CVD; indigo↔magenta 18.2 /
9.5; purple↔magenta 16.3 / 9.6; nearest severity pair 21.3 / 10.8
(magenta↔muted).softandlinestay decorative and are never the sole
carrier of meaning — the badge always carries its text label. -
Stories for both components, including a before/after that puts the same
four non-severity states on the severity ramp and then on the categorical one,
and a grid of all three categories beside all five tones (the pairing the
tokens are measured against). The tokens are registered intokenCatalog.ts,
so Foundations → Colors documents them rather than silently omitting them.
Notes
- Not changed here, but found while measuring: the existing
warningand
dangerbases are themselves close — ΔE 6.7 to normal vision and 2.9 under
simulated red-green colour blindness. Both always carry a text label, so
meaning is never colour-alone, but separating them would change existing
rendered output and is a decision for a major release.