BlogWritingdesign systems at scale
Pastels are surfaces, ink is content
A pastel palette fails quietly. Every pair looks fine on its own and the text turns out to be 3:1 on the tint. The fix is one rule, one pairing, and a 130-line script that refuses to let either slip.
- Date
- Reading time
- 6 min
- Status
- Draft
01 The failure mode
Three apps in one monorepo, three palettes picked by eye. Each one looked friendly in a screenshot. Measured, the same mistake appeared in all three: a tint that worked as a background behind an icon had been used as text, and a tint that worked as a chip fill had been used as a button label. Sage on paper, lavender on white, peach on cream. Each pair read as acceptable in isolation. Each one failed the 4.5:1 ratio that WCAG 2.1 asks for body text, several by a wide margin.
This is what pastel does. Dark palettes fail loudly: black on navy is obviously wrong. Pastel fails one pair at a time, and nobody notices until a user with ordinary eyesight on an ordinary laptop in daylight cannot read the label.
02 The rule
The system that replaced the three palettes rests on one sentence:
Pastels are surfaces. Ink is content.
Every tint ships with an ink dark enough to write on it, and the two are only ever used as a pair. A tint is a background. An ink is the text or the icon on that background. Body text is never a pastel. A large fill is never an ink.
In tokens:
--brand-sage: 158 32% 87%; /* a surface */
--brand-sage-ink: 164 46% 21%; /* what may be written on it */
Six tints, six inks: sage, lavender, peach, rose, sky, sand. Content is near-black (--brand-ink: 226 22% 15%) on warm paper (--brand-paper: 40 24% 98%). The paper is deliberately not pure white; under long reading white is harsh, and a cold grey makes pastels look grubby. Colour marks category. It never carries meaning on its own, and it never carries text.
The values are bare HSL channels rather than hex so Tailwind can wrap them in hsl() and apply opacity modifiers: bg-primary/10 works without a second token.
03 One ramp shape
The three apps still need to be told apart. Each points --primary at one signature ramp: indigo for the portfolio, violet for the German learning app, magenta for the health tracker. Every ramp has the same five parts: a fill, a hover, a foreground for text on the fill, an ink for text on the tint, and the tint itself.
--brand-indigo: 258 42% 48%;
--brand-indigo-hover: 258 44% 40%;
--brand-indigo-foreground: 0 0% 100%;
--brand-indigo-ink: 258 46% 34%;
--brand-indigo-tint: 258 40% 95%;
Because the shape is identical, a component written against --primary works in any of the three apps. The neutrals, type scale, elevation and motion are shared outright. The result is one family with three faces, rather than three apps that happen to share a repo.
04 The gate
A rule in a README is a guideline. The thing that makes it a system is check-contrast.mjs, a Node script with no dependencies that runs as pnpm check:contrast.
It reads tokens.css with a regular expression, so it checks the real values rather than a copy of them. It converts each HSL triple to relative luminance the way WCAG 2.1 defines it, and computes the contrast ratio for every pair in a list. The list names how each pair is actually used, and sets the threshold accordingly: 4.5 for text, 3 for large text, icons and UI marks, 1.4 for a hairline that has to stay visible. It runs the list against both themes and exits non-zero if anything is under target.
Today that is 24 pairs, 48 checks. The current numbers:
| Pair | Light | Dark | Floor |
|---|---|---|---|
| Body text on the page | 15.27:1 | 16.90:1 | 4.5 |
| Secondary text on the page | 5.97:1 | 7.77:1 | 4.5 |
| Captions and disabled text | 3.55:1 | 4.92:1 | 3 |
| Sage ink on the sage tint | 7.40:1 | 7.28:1 | 4.5 |
| Label on an indigo button | 6.83:1 | 5.79:1 | 4.5 |
| A hairline on paper | 1.51:1 | 1.99:1 | 1.4 |
The tightest pair is the caption at 3.55:1 in light mode. That is the one to watch when the next colour is added.
Adding a colour is three steps: add it to tokens.css in both :root and .dark; add its pair to the list with the threshold for its use; run the check. A colour that has not been through that script is not in the system. The check is a gate, not a guideline, because a pastel system gives no other signal that something has gone wrong.
05 Dark mode by inverting the roles
Dark mode is the second place pastel goes wrong. The usual move, darkening every tint, produces mud. The system inverts the roles instead: in .dark the tint becomes a deep wash and the ink becomes a light tone.
.dark {
--brand-sage: 164 22% 19%; /* the surface, now deep */
--brand-sage-ink: 158 44% 72%; /* the ink, now light */
}
The same component keeps the same colour meaning in both themes: a sage chip is still a sage chip, with sage writing on it. And because the script reads both blocks, every pair is checked in both themes. Dark mode is not allowed to be the theme nobody measured.
06 What it costs and what it buys
Cost: one CSS file of 193 lines, one script of about 130, a convention that every tint has an ink, and the discipline to add the pair to the list. Nothing to install.
What it buys: a soft palette that does not read as a toy, three apps that are visibly one family, and a failure mode that announces itself in the terminal instead of in a user's inbox.
Three things to take from it:
- Name the pair, not the colour. A tint without an ink is a bug waiting for text.
- Set thresholds by use, not by colour. The same ink needs 4.5 as text and 3 as an icon.
- Read the source of truth. The script parses the stylesheet; it does not keep a parallel list of values that can drift.
The repo is public at github.com/Akash-M/portfolio; the tokens and the script are under packages/brand.