Blog · Engineering ·
The agent painted it white. The white was pink.
Our brand agent wrote a colour it could not read back. The fix was not to change colour spaces — it was to show the model the same colour twice, in the space it manipulates and the space it recognises.
A tester asked Atelier's brand agent for a white background. The agent wrote this:
oklch(0.98 0.01 0)
Which is pink. , to be exact — a tint you notice immediately on a page and would never ship. The tester saw it at once. The agent, told four separate times that the background was pink, could not. Each turn it re-read its own value, confirmed that a lightness of 0.98 was near-white, and changed something else.#FFF6F8
That is not a reasoning failure. Every number in is correct for the colour it produces. It is a recognition failure, and it taught us something we now apply everywhere the product puts colour in front of a model.oklch(0.98 0.01 0)
Two colour spaces, two different jobs
Atelier's brand studio is a conversation. You say "make the primary a bit darker", "warmer", "less shouty", and the agent edits the palette. That workload is the reason we author in oklch rather than hex:
- Relative edits are one axis. "Darker" is a subtraction on
L. "More muted" is a subtraction onC. In hex, both are three-channel arithmetic with a round trip through a colour model the model has to perform in its head. - Lightness is perceptually honest. In HSL, two colours at the same
Lcan differ wildly in how light they look. In oklch they don't. A palette built on oklch'sLstays readable when you rotate the hue.
So oklch earns its place. The problem is that it is only good at the manipulation half of the job.
Hex is where models have recognition priors. appears in training data in a context — a stylesheet, a design token, a bug report — surrounded by human beings calling it a pink. #FFF6F8
mostly does not appear at all. Asked what it looks like, a model computes, and a small chroma next to a large lightness rounds, in its narration, to "white". Then it defends that reading, because nothing in the prompt contradicts it.oklch(0.98 0.01 0)
Two spaces, two jobs: models manipulate cleanly in oklch and recognise reliably in hex. Picking one means giving up the other.
So we stopped picking
The fix was not a better prompt and not a colour-space migration. It was to stop echoing colour into a prompt in a single notation.
Every place Atelier shows the model brand state — the live preview state, the saved-palette brief, the token grounding block — now runs through one renderer that appends the hex equivalent to anything that isn't already hex:
brand-primary:
oklch(0.55 0.15 35) (≈
#AB5637)
The model still edits in oklch. It just can no longer hold a belief about what the colour is that the next line of its own prompt contradicts. In the pink case, (≈ ends the argument before it starts — the same way it ended instantly for the human, who was looking at a rendered page rather than a notation.#FFF6F8)
One renderer, not three, and deliberately so: we had colour-only walkers in two places already and they drifted. A single colorEcho means the live-draft lines and the saved-palette line cannot disagree about how a colour is spelled.
The second bug, which was the same bug
Grounding a literal was only half of it. Atelier's studio lets you pick a colour from a Tailwind swatch grid, and a picked swatch rides the wire as a reference, not a value:
var(--color-yellow-100)
Echoed into the prompt bare, that is a name with no number attached. When the user then said "make primary darker", the model had nothing to subtract from — so it anchored on the only concrete primary anywhere in its context, which was the saved palette from before the pick, and dutifully darkened that. The user watched a yellow they had just chosen turn into a darker brown.
The same rule fixed it: a var() reference is followed, through the token registry and then the Tailwind palette, and echoed with both what it resolves to and what that looks like.
brand-primary: var(--color-yellow-100) (=
oklch(97.3% 0.071 103.193) ≈
#FEF9C2)
References resolve against the draft on screen, not the saved brand. A model reasoning about a colour should be reasoning about the one the user is looking at. That sounds obvious written down. It was not obvious in the code, where the draft lived in the browser and the saved brand lived in the database, and the prompt was assembled from whichever was closer to hand.
What generalises
The specific bug is about colour. The shape of it is not, and it is worth stating plainly, because we keep finding it elsewhere:
- A model's blind spots follow the training data, not the logic. Nothing about oklch is hard. It is simply rarer, and rare notation is where a model will state something false with total confidence and then defend it. When you pick a representation for its computational properties, check separately whether it is one the model can read.
- Redundant encoding is cheap. Two notations for the same value costs a handful of tokens per line and removes an entire class of failure. We were reluctant at first, on prompt-budget grounds. That was the wrong thing to economise on.
- Never make the model recall what you can hand it. The yellow-turned-brown bug was a model filling a gap from memory because the prompt left one. Close the gap and the behaviour disappears — no prompt engineering needed.
- Advisories you send every turn stop being read. The same session turned up a contrast warning that graded the live draft against the saved brand, so it fired on every incremental edit with a complaint about a palette nobody was using any more. Noise like that trains both the agent and the human to ignore the one warning that matters. It now grades against the staged draft.
We also added a prompt rule that follows directly from all of this: a neutral has chroma exactly 0. Not 0.01, not "close to zero". If it isn't perfectly neutral, it has a hue, and the model should have to say what that hue is rather than round it away.
Behaviours like these don't stay fixed on their own, so the brand agent now has a regression net of real turns — actual model calls against a live site, graded on the resulting palette — with known-open behaviours tracked as expected failures rather than left quietly red.
The tester got their white. It is .#FFFFFF
Atelier is an AI-first website builder built on Drupal that runs as an appliance on your own machine, with your own provider key. The source is on GitHub; install is one command.