Skip to content

v1.15.0 — categorical (non-severity) tone set - #23

Merged
aicodebar merged 1 commit into
mainfrom
release/v1.15.0
Aug 7, 2026
Merged

v1.15.0 — categorical (non-severity) tone set#23
aicodebar merged 1 commit into
mainfrom
release/v1.15.0

Conversation

@aicodebar

Copy link
Copy Markdown
Collaborator

Adds a categorical colour vocabulary alongside the existing severity ramp, so a component that means "this is a kind of thing" stops borrowing success/warning/danger, which mean "this is good / needs attention / broken".

Why

A survey of the consuming app found ~70 call sites across 11 semantic axes borrowing the severity ramp with no severity intended — on/off state (9 definitions, ~25 render sites, uniformly enabled → success), lifecycle stage, entity kind, field traits, duplicate/replay, provenance. The existing escape hatch made it worse rather than better: info in this design system is monochrome ink, so the variant="purple" → info workaround rendered an essentially grey badge. The workaround was a visual no-op.

The set: indigo | purple | magenta — and why only three

The set was not chosen by eye; candidates were swept across the full Tailwind ramp and gated on four measurements simultaneously:

  • AA as text (≥4.5:1) on white, on --color-bg, and on its own -soft background
  • OKLCH chroma ≥0.10 (i.e. actually reads as a colour, not a grey)
  • OKLab ΔE ≥15 from both other categories and every severity token
  • ΔE ≥8 under simulated protanopia and deuteranopia (Machado–Oliveira–Fernandes 2009, severity 1.0)

Most of the wheel fails:

rejected why
red / rose / pink collapse into danger — ΔE 6.8 / 8.5 / 11.4
orange / amber / yellow collapse into warning — ΔE 9.6 / 8.5 / 9.1
lime / green / emerald collapse into success — ΔE 10.0 / 8.4 / 6.6
teal / cyan not colours at text weight — dark enough for AA they measure chroma 0.059 / 0.066, under the floor; they render grey
sky collapses into --color-muted — ΔE 12.5

What survives is a single ~60° blue→magenta arc holding exactly three separable steps; only 5 valid 3-sets exist in the entire space. A fourth category drops the worst pair to ΔE ≈ 3.0 under simulated red-green colour blindness — which is why the set is three and the ceiling is documented in the tokens, the catalog and the CHANGELOG rather than left for someone to discover by adding a fourth.

The first hand-picked guess, for the record, would have shipped three bugs: azure↔violet at ΔE 0.31, teal-700 reading as grey, and lime-700 colliding with success.

Measured contrast (white / --color-bg / own -soft):

  • cat-indigo #4f46e5 — 6.29 / 5.87 / 5.62
  • cat-purple #581c87 — 10.88 / 10.16 / 10.14
  • cat-magenta #a21caf — 6.32 / 5.91 / 5.89

Measured separation: indigo↔purple 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. Verified in the browser that the computed colours resolve to exactly these hexes.

Breaking changes: none

Tone is untouched at five values, and palette: Record<Tone, string> was not widened — categoryPalette: Record<Category, string> is a separate map, so no consumer's own Record<Tone, X> breaks.

One deliberate subtlety: the variant prop defaults were removed from Badge and StatusBadge. That is load-bearing, not cosmetic — with a default in place, an unset prop is indistinguishable from an explicit variant="neutral", which makes the tone/category mutual-exclusivity check impossible to write. resolveTone(undefined, 'neutral') already applied the identical fallback one level down, so rendered output is byte-identical for every existing call site.

Tokens are namespaced --color-cat-* rather than --color-indigo, so they cannot shadow Tailwind's own scales in a consuming app.

Known limit, recorded not fixed

The consuming app's 15-kind graph palette is not salvageable as colour alone — several of its hues (amber-500, orange-500, emerald-500, rose-500) sit directly on warning/success/danger, which is the reported bug itself. Finer kind distinction needs a non-colour channel.

Separately: the existing warning and danger bases are themselves close — ΔE 6.7 normal, 2.9 under CVD. Both always carry a text label, so meaning is never colour-alone, but separating them would change existing rendered output. Recorded in the CHANGELOG as a decision for a major rather than acted on here.

Gates

typecheck ✓ · lint ✓ (0 errors, 5 warnings — exactly the v1.14.0 baseline; the 4 vue/require-default-prop warnings this change introduces are suppressed inline with the reason, since the omission is deliberate) · build ✓ (incl. verify:externals and verify:dev-warnings) · build-storybook ✓ · test:ci226 passed, including 7 new stories.

No dark: variants and no import.meta.env in the diff; both new warnings use warnOnce. package.json bumped to 1.15.0 — the release workflow hard-fails on a tag/manifest mismatch — and the workflow's awk extraction was confirmed to pull the ## v1.15.0 body.

🤖 Generated with Claude Code

`Tone` is five points on a SEVERITY ramp. A lot of app state has no severity
at all — on/off, inherited/overridden, first/duplicate, draft/published, and
"which kind of thing is this" — and with only a severity ramp available it all
borrowed one: "enabled" rendered as `success`, "duplicate" and "overridden" as
`info`, "older version" as `warning`. The UI reports a problem where there is
none. The deprecated `purple` alias was the same story in miniature.

Adds `Category` as a PARALLEL set, not more members on `Tone`. Widening `Tone`
would break every consumer holding its own exhaustive `Record<Tone, X>` map — a
semver-major change to ship an additive feature — and severity is ordered while
categories are not.

- `Category` + `resolveCategory()` mirroring `resolveTone()`'s warnOnce shape
- `category` prop on Badge and StatusBadge, sibling to and mutually exclusive
  with `variant`; passing both warns once and renders the category
- nine `--color-cat-*` base/soft/line tokens, registered in tokenCatalog
- stories for both, including the before/after and the against-severity grid

Three is a MEASURED ceiling. A categorical hue must clear AA as text (>=4.5:1)
on white, on bg and on its own soft tint; OKLCH chroma >=0.10; and OKLab dE
separation from both the other categories and every severity token (>=15 normal,
>=8 under protan/deutan, Machado-Oliveira-Fernandes 2009 at severity 1.0).

Sweeping the full Tailwind ramp against those gates rejects most of the wheel:
red/rose/pink collapse into danger (dE 6.8/8.5/11.4), orange/amber/yellow into
warning (9.6/8.5/9.1), lime/green/emerald into success (10.0/8.4/6.6), sky into
muted (12.5), and teal/cyan are not colours at text weight at all — dark enough
for AA they measure chroma 0.059/0.066, under the floor, so they render gray.
Same trap as the bright amber that could not reach 3:1 on white at any lightness
in v1.14.0. What survives is one ~60 degree blue->magenta arc holding exactly
three separable steps; a fourth drops the worst pair to dE ~3.0 under simulated
red-green colour blindness. More kinds than three need a non-colour channel.

Shipped: cat-indigo #4f46e5 (6.29:1 white / 5.87:1 bg / 5.62:1 own soft),
cat-purple #581c87 (10.88 / 10.16 / 10.14), cat-magenta #a21caf (6.32 / 5.91 /
5.89). Worst pair dE 16.3 normal / 9.5 CVD; nearest severity 21.3 / 10.8.

Non-breaking: `variant` and `category` lose their prop defaults so an unset prop
is distinguishable from an explicitly passed one (the exclusivity check needs
it); the `neutral` fallback already lived in `resolveTone()`, so every existing
call site renders exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aicodebar
aicodebar merged commit 2a317b0 into main Aug 7, 2026
2 checks passed
@StanBarrows
StanBarrows deleted the release/v1.15.0 branch August 8, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant