· 43 min read
Forty-Seven Palettes
I gave my assistant fifty skins and shipped thirty-nine I couldn't actually read. Why the generator produced them, why my first fix passed every contrast check and still looked worse, and what a design system written for agents did for the repair.
For about a year I've been building Jared, a personal assistant that runs on my own hardware — a local-first multi-agent system with long-term memory, a proactive daemon, and opinions. This year it got a new face. This is a short account of how that went, including the part where I gave it fifty colour schemes and couldn't read most of them.
Jared got a second face
The first interface was an ordinary app: a sidebar, a chat column, twenty-nine React components accumulated one feature at a time. It worked. It also quietly decided what Jared was — a chat window with panels bolted on — because the shape of an interface argues for a shape of thinking.
The second one starts from a different premise. Seven domains — surveillance, home, vitals, ledger, recall, cadence, dialogue — and a canvas that composes itself per turn. Ask about training and the canvas puts up training panels. The layout is a response, not a destination you navigate to. Composition is data, and Jared emits it.
That reframing is the actual improvement. The chat is still there, but it stopped being the product.
The design system did the half I'm bad at
The other change was that V2 doesn't own its look. It installs one — @sesebon/ui, a brutalist vocabulary I'd been extracting alongside it: pure-black surfaces, monospace numerals, hairline borders, zero radius, one amber accent, red reserved strictly for alerts.
The part worth mentioning is how it's packaged. Thirty-four components, and each ships a .prompt.md next to its code saying what it's for and when to reach for it — plus a skill file describing the token contract and the rules of the house. It's a design system written to be read by an agent as much as by me.
The effect is hard to overstate for someone working alone. I stopped describing what a panel should look like. I'd name the domain and the data, and get something already in the house style, because the constraints were legible to the thing doing the work. Making Jared fashionable stopped being a design task and became a composition task.
Which is exactly the kind of leverage that makes it easy to ship fifty of something without looking at any of them.
I hoarded colour like a magpie
Somewhere in there I started collecting palettes. Anyone who has fallen into this knows the pattern — you find a five-swatch palette with a nice name, you save it, and it costs nothing, so you do it forty-seven more times.
Then I fed the hoard into a generator, got forty-seven themes out, added them to the skin picker next to the three I'd made by hand, and admired the grid. Fifty skins. Lovely.
What I never did was read one. A few seconds each in the picker, judging the swatch rather than the page behind it — and a swatch is four squares of colour with no words on it.
I had been collecting colours, not reading them.
When I finally measured, thirty-nine of the forty-seven were below the readable threshold. Eighteen were so far below that body text was effectively invisible — one sat at 1.13 against a bar of 4.5.
Five colours, nine jobs
The cause turned out to be mechanical rather than a matter of taste, which was a relief and also a little embarrassing.
Each palette had five colours. The generator assigned them to roles by position — first colour is the background, second is the heading, and so on — and never measured what came out. Three things followed:
The system has five levels of text emphasis. Five colours, no spare. So "body" and "supporting" got the same hex, and "caption" and "placeholder" got the same hex, in forty-two of forty-seven themes. A five-step hierarchy quietly collapsed into three.
Nothing sorted by lightness. In twenty-four of forty-seven, the palette's brightest colour landed on the accent — the one role whose entire job is standing out from the background.
And only seventeen of thirty-eight tokens were ever re-bound, so light themes were drawing chart colours still tuned for a black ground.
The palettes were never the problem. Assigning colour by position and not looking was the problem.
Every check passed and it looked worse
The fix is to stop taking ink from a palette slot and start computing it against each theme's own background. Walk each role away from the surface until it reaches the contrast it owes.
My first attempt held hue and saturation and moved only lightness. Every check passed. The themes looked worse, and it took me an embarrassing minute to see why.
HSL saturation isn't perceptual. A pale pink reports "100% saturation" at 90% lightness, because in that model saturation means distance from grey at this lightness, not how vivid this is. Hold that 100% while you darken it and #FFCAD4 becomes #E3002B — a dusty pink turns into a fire engine. Redone in OKLCh, which separates lightness from perceptual chroma properly, the same pink lands on #94656F. Same colour, just darker.
Both versions scored identically. The numbers could not tell them apart, because I'd asked them the wrong question. Only looking caught it.
The swatch, before and after
Here is the whole set — all forty-nine, filterable, with every ratio the audit produced. Each card paints itself with that theme's real values rather than describing them, which is the point: a contrast figure is easy to skim past and an unreadable caption isn't.
Every skin, painted in its own tokens
Each card renders with that theme's real values — the legibility is the evidence, not the number beside it. Bars are contrast against the panel; green clears the bar for its role, red does not. Filter by how each theme scored before, so the groups keep meaning when you switch views.
midnight-depthsunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#312244 · #0064665/5 ladder steps
#312244 · #55A4A65/5 ladder steps
soft-frostunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#F0EFEB · #FFF1E65/5 ladder steps
#F0EFEB · #72665D5/5 ladder steps
powder-cloudsunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#F0EFEB · #FFF1E65/5 ladder steps
#F0EFEB · #72665D5/5 ladder steps
almond-skyunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#F9F9F7 · #F8D5C25/5 ladder steps
#F9F9F7 · #8667565/5 ladder steps
pure-porcelainunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #F3D8C73/5 ladder steps
#FFFFFF · #856D5E5/5 ladder steps
earthy-minimalistunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#ECF8F8 · #E6BEAE3/5 ladder steps
#ECF8F8 · #8965575/5 ladder steps
lilac-hazeunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#E1DEE9 · #B6A6CA3/5 ladder steps
#E1DEE9 · #6657785/5 ladder steps
ocean-orchidunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#D7B9D5 · #F4CAE03/5 ladder steps
#DEC6DD · #6A475B5/5 ladder steps
pacific-laceunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #1B9AAA3/5 ladder steps
#FFFFFF · #007D8B5/5 ladder steps
spring-blossomunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FEEAFA · #DEE2FF3/5 ladder steps
#FEEAFA · #62657E5/5 ladder steps
desert-roseunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#C5BAAF · #EBCFB23/5 ladder steps
#D3CBC2 · #654D345/5 ladder steps
candy-onyxunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#2E0219 · #FBFFB95/5 ladder steps
#2E0219 · #8688465/5 ladder steps
olive-ashunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#D5D0CD · #F8F4E33/5 ladder steps
#D5D0CD · #5955475/5 ladder steps
dusty-lilacunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#E8C2CA · #F7D1CD3/5 ladder steps
#E9C6CD · #6A4B485/5 ladder steps
frosted-limeunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#F1F7EE · #E7F59E3/5 ladder steps
#F1F7EE · #6772165/5 ladder steps
cherry-ashunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #FFCAD43/5 ladder steps
#FFFFFF · #94656F5/5 ladder steps
monolithic-greyunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#323031 · #C1BDB33/5 ladder steps
#2F2E2E · #A4A0965/5 ladder steps
deep-onyx-tealunusableAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#1A1D1A · #03120E3/5 ladder steps
#1A1D1A · #7C908A5/5 ladder steps
rustic-trufflepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#AB947E · #C3A9953/5 ladder steps
#D5CABF · #644D3B5/5 ladder steps
stormy-rosepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#C9C5BA · #EBBAB93/5 ladder steps
#CFCBC1 · #6F46465/5 ladder steps
rich-burgundypoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#81171B · #5408043/5 ladder steps
#5C1013 · #DD877A5/5 ladder steps
golden-olivepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#A59132 · #FFFBDB3/5 ladder steps
#D9CA82 · #5652375/5 ladder steps
pine-lilacpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#DFD9E2 · #2A7F623/5 ladder steps
#DFD9E2 · #04684C5/5 ladder steps
vintage-steelpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#76949F · #86BBBD3/5 ladder steps
#C1CFD4 · #265A5D5/5 ladder steps
wisteria-nightpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #4122343/5 ladder steps
#FFFFFF · #8B667A5/5 ladder steps
camel-graphitepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#C0B7B1 · #C69C723/5 ladder steps
#D1CBC6 · #6F49205/5 ladder steps
industrial-dunepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#93A8AC · #D7CEB23/5 ladder steps
#C3CED1 · #5951385/5 ladder steps
blackberry-silkpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#D5B9B2 · #ECE2D03/5 ladder steps
#DEC8C3 · #5850415/5 ladder steps
shadowed-thistlepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#C4B2BC · #D8D4F23/5 ladder steps
#D6C9D0 · #514D665/5 ladder steps
twilight-indigopoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#0D1321 · #F0EBD83/5 ladder steps
#0D1321 · #8883725/5 ladder steps
coral-slatepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#BDD5EA · #F7F7FF3/5 ladder steps
#BDD5EA · #55545B5/5 ladder steps
muted-amethystpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#D6CFCB · #CCB7AE3/5 ladder steps
#D6CFCB · #614F485/5 ladder steps
moody-lavenderpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#C9ADA7 · #F2E9E43/5 ladder steps
#DBC9C5 · #574F4B5/5 ladder steps
deep-maritimepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#1B263B · #0D1B2A3/5 ladder steps
#1B263B · #8496A95/5 ladder steps
grape-mintpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#BBCBCB · #E5FFDE3/5 ladder steps
#C1CFCF · #41573B5/5 ladder steps
sunlit-walnutpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#DCAB6B · #FFFFB33/5 ladder steps
#E7C598 · #5754005/5 ladder steps
muted-fernpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#040303 · #BEB0A73/5 ladder steps
#040303 · #84776F5/5 ladder steps
vintage-grapepoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#C0C5C1 · #EAF0CE3/5 ladder steps
#C9CECA · #5054375/5 ladder steps
berry-blushpoorAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#F9DBBD · #FFA5AB3/5 ladder steps
#F9DBBD · #96474F5/5 ladder steps
olive-cherryAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#31081F · #6B0F1A3/5 ladder steps
#31081F · #CD6B6A5/5 ladder steps
prussian-dimAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #0116383/5 ladder steps
#FFFFFF · #57739C5/5 ladder steps
teal-steelAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#B7D5D4 · #4882863/5 ladder steps
#B7D5D4 · #1F5B5F5/5 ladder steps
mocha-blushAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#D1CCDC · #F5EDF03/5 ladder steps
#D1CCDC · #5750535/5 ladder steps
corporate-slateAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #284B633/5 ladder steps
#FFFFFF · #5276905/5 ladder steps
obsidianreferencereference
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#000000 · #FFB0005/5 ladder steps
#000000 · #FFB0005/5 ladder steps
graphitereferencereference
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#0C0D10 · #FFB0005/5 ladder steps
#0C0D10 · #FFB0005/5 ladder steps
shadowed-boneAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#F1F0EA · #E0DDCF3/5 ladder steps
#F1F0EA · #6C695D5/5 ladder steps
urban-sunAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#E8EDDF · #F5CB5C3/5 ladder steps
#E8EDDF · #7F62005/5 ladder steps
modern-contrastAAAA
resting hridle
58bpm
Body copy sits at this weight.
Secondary — supporting values
Tertiary caption, micro-label size
Ghost / placeholder text
commitalert
#FFFFFF · #FCA3113/5 ladder steps
#FFFFFF · #9F64005/5 ladder steps
Every palette kept its five source colours; they're now the generator's input rather than its output. What changed is that lightness is computed against each theme's own ground, in OKLCh, so a palette keeps its character and loses its illegibility.
Seventeen backgrounds also had to move, because a mid-grey simply hasn't the room to carry five distinguishable steps — one had 6.5:1 of total range to work with. Those shift toward whichever end the palette had already put its ink, so a light theme stays a light theme.
| before | after | |
|---|---|---|
| unusable | 18 | 0 |
| poor | 21 | 0 |
| readable | 10 | 49 |
| full five-step hierarchy | 7 | 49 |
What I'd tell myself
Three things, none of them about colour.
A generated file whose generator isn't in the repo can't be reviewed. Mine said "generated by generate-themes.js — do not edit by hand." That file wasn't there. Nobody could see what it did, so nobody did, for as long as it took me to notice. It exists now, next to the palettes it reads.
Measure the thing, then look at it. Those are two different checks and neither substitutes for the other. My contrast audit found thirty-nine broken themes the eye had missed; my eye found a fire engine the audit was perfectly happy with.
Then make the check permanent. There's a script now that reads the shipped stylesheet and fails the build on any of eight hundred and thirty-three contrast pairs, on any collapsed hierarchy, on series colours that aren't distinct from one another. Run against the old file it reports seven hundred and seven problems. It won't catch a fire engine — nothing automated will — but it means the next batch can't ship blind in the way this one did.
Fifty skins, and I can read all of them now. It only took admitting I'd never actually looked.