Design

Contrast forced a token split

I found this by computing it, not by looking at it. That is the only honest way this gets found.

The problem

Every app in the portfolio inherits a design family, and every family carries a palette: bg, surface, ink, muted, accent, accentInk. The house rules also require 4.5:1 contrast for text on every surface in the palette, which is WCAG 2.2 AA for body text.

Nobody had ever checked whether those two things agreed.

What the numbers said

I wrote the ratio calculation, ran it over all ten palettes, and got this:

FamilyWorst failing pairRatio
Papermuted on bg3.62:1
InstrumentaccentInk on accent3.96:1
Instrumentaccent on surface4.37:1
VoidaccentInk on accent4.18:1
Inspectoraccent on surface3.68:1
Inspectoraccent on bg3.98:1
InspectoraccentInk on accent4.23:1
Inspectormuted on surface4.32:1

Ledger, Arcade, Bench, Drill, Field and Console pass. Paper, Instrument, Void and Inspector do not, and Inspector fails four different ways. Field and Console pass at 4.64 and 4.55, which is passing by a coat of paint.

Paper is the one that stings. It is the most restrained family in the set, the one I would have bet on, and its muted text sits at 3.62:1 on its own background. Restraint reads as low contrast because that is literally what it is.

The options

Repaint the failing palettes. Darken Paper's muted, deepen Inspector's violet, and be done. Rejected: the palettes are catalog data, mirrored from the app-inventory sheet, and the families already ship in live apps. Changing accent changes every filled button in every app that uses it, and Inspector's violet at 4.5:1 against a near-white surface is not violet any more. The fix would have cost the families their character to solve a problem that only exists for text.

Add an override per app. Let each app nudge its own muted colour. Rejected outright: that is how ten families become twenty-three, and the audits would have nothing left to check against.

Raise the text size instead. WCAG allows 3:1 for large text. Rejected: it would pass the letter of the rule by making every muted caption a headline, and the captions are captions for a reason.

Split the tokens. Keep the family values for what they are good at, and derive separate values for text. This is the one I took.

What I did

Each app now carries nine tokens instead of six:

--bg: #FDFBF7;        /* the family's own value, unchanged */
--surface: #FDFBF7;
--ink: #211E1A;
--muted: #8B8378;     /* hairlines, dividers, decorative fills */
--accent: #6D5BD0;    /* filled buttons, markers, curve strokes */

--muted-ink: #787167;      /* muted, as text. 4.66:1 */
--accent-strong: #6D5BD0;  /* accent, as text or a link. 5.01:1 */
--on-accent: #FFFFFF;      /* text sitting on an accent fill. 5.18:1 */

The derivatives are computed, not chosen: each one walks from the family value toward black or white in one percent steps until it clears 4.6:1 against both bg and surface. Where the family value already clears the bar, the derivative is the family value, unchanged. Ledger's --muted-ink is exactly Ledger's muted. Only the families that needed moving moved, and only for text.

Each app also carries the counterpart theme, which was the second half of the problem. A light family needed a dark one derived from it and a dark family needed a light one, and every pair in both themes gets the same treatment.

The part that makes it stick

A rule nobody checks is a preference. So every app ships scripts/check-contrast.mjs, which reads the token file, walks every theme block in it, and fails the build if any of seven text pairs drops under 4.5:1. It runs in CI on every pull request.

That is 28 checks per app across the four theme blocks, and it costs about 40 milliseconds. There is no version of this where a palette tweak ships a 3.6:1 caption again without someone deliberately deleting the check.

What I would still change

The split means an author has to know that --muted is for lines and --muted-ink is for words. That is a real cost, and it is the kind of thing that gets confused at 11pm. I documented it at the top of every token file and in every AGENTS.md, which helps, but the honest answer is that a naming scheme where the wrong choice is merely ugly would be better than one where the wrong choice is inaccessible.

The second thing: --accent as a fill and --accent-strong as text are the same colour in six of the seven families I have shipped this to. Six tokens that are usually identical is a smell. I kept it because the two that differ, Instrument and Inspector, are exactly the ones where collapsing them would reintroduce the bug.

The evidence

families.json in the calabrodesign skill library holds the ten palettes. Every app repo built in this wave has src/tokens.css with the nine tokens and both themes, and scripts/check-contrast.mjs next to it. The ratios in the table above reproduce from families.json with the standard WCAG relative luminance formula.