Two of our card colors were the same color
It started as “the palette feels muddy.” It ended as a measured rebuild, a color-blindness fix in a notation we’d shipped three weeks earlier, and a test that fails with a number instead of a screenshot.
- Colliding pairs before
- 13Of 120, below the threshold where two card-sized areas read as different colors.
- Tightest pair
- 3.7blue and skyblue. Four degrees of hue apart.
- Toolbar minimum now
- 20.6Was 7.4 — and that pair sat side by side in the toolbar.
- Places the palette lived
- 5Four hand-synced copies plus one orphan that called itself canonical.
Muddy is a feeling. ΔE is a number.
The complaint was that colors on a board were hard to tell apart. That’s the kind of thing you can argue about forever, so the first move was to stop arguing and measure. ΔE2000 is the standard perceptual distance between two colors: roughly, ΔE 1 is a just-noticeable difference in ideal lab conditions, and for flat card-sized areas viewed side by side you want 15 or more before people reliably name two colors as different — across a room, through a projector, on a cheap monitor.
Of the 120 pairs in a sixteen-color palette, thirteen came in under 15. Three were severe. Here they are butted edge to edge, which is the honest test — it’s what your eye does when two cards sit next to each other on a board.
blueandskybluediffered by four degrees of hue and three points of lightness. That isn’t a second color. It’s a rounding error with a name.
The orange/red pair at ΔE 7.4 was the expensive one, because those two sat
adjacent in the six-swatch toolbar — the colors people reach for most.
What people actually complain about in Miro and FigJam
Before picking anything, we read what users of the two biggest whiteboard tools say about their palettes. The pattern is consistent, and it is not “give us prettier colors.”
They run out
Miro’s most-voted color request is simply more of them: people mapping several data types onto one board exhaust the set. Event Storming is the recurring example — it needs seven or eight distinct categories at once. One commenter notes that accommodating color-blind colleagues further shrinks the usable set, which turned out to be the most important sentence we read all week.
Neighbouring colors are indistinguishable
The specific pair Miro users flag — #ADD1F6 against #7CDCFA — is the same
failure as our blue/skyblue. Both communities ask for a decent grey and
don’t get one.
Pastel drift reads as “washed out”
This one settled a decision for us. Figma lightened FigJam’s stickies for text-contrast reasons and got a backlash — users called the result pale and low-contrast. Miro moved the other way in its 2024 design-language update, explicitly raising vibrancy and contrast for visibility against the board. Two competitors ran the same experiment in opposite directions, and the market preferred more saturation.
Chrome changes break color as a signal
When Miro reduced sticky-note shadow, users immediately reported that same-color notes overlapping with a slight offset blended into one mass. Worth recording because our pastels had the same dependency: three of our colors sat at 1.00, 1.08 and 1.15 contrast against a white canvas. Take the shadow away and those cards have no edge at all.
Everyone wants custom colors; nobody ships them freely
Both tools have long-running requests for it. Figma’s answer was custom palettes at the team level rather than per-sticky freedom — a constrained set applied broadly. That distinction shaped what we did next.
Material Design has the right mechanism and the wrong shape
Material 3 doesn’t publish a sticky-note palette. It publishes a generator, and the mechanism is genuinely useful.
It works in HCT — hue, chroma, tone — a space built so that tone maps to measured perceptual lightness. The consequence is the whole point: you can change a color’s hue and chroma without changing its tone. From a key color it derives a tonal palette of 13 tones numbered 0–100, and contrast becomes arithmetic rather than judgement — a fixed tone distance guarantees a contrast ratio regardless of hue.
Container roles sit at tone 90 in light theme. Tone 90 is precisely the pastel sticky-note register. Material had, in effect, already specified our pastel pack: eight hues, chroma held constant, all pinned to one tone.
So we built it to check. Eight hues at fixed lightness, textbook tone-90:
Text contrast is a uniform 14.8:1 across all eight — genuinely excellent, exactly as advertised. And under simulated red-green color blindness the minimum separation collapses to ΔE 0.4. Purple, blue and teal become one color.
Material’s tonal system is right for UI surfaces, where colors are never asked to be a legend. It is wrong for categorical encoding, which is exactly what sticky notes are. Constant tone is the feature and the failure.
The reason is structural, and it governed every decision afterwards. Red-green color blindness collapses two of the three color channels, so hue alone cannot carry a distinction — only lightness and blue-yellow survive. A row of colors tuned to identical lightness therefore cannot be made color-blind-safe at any hue. That’s a theorem, not a tuning problem.
Which put a number on that Miro commenter’s aside. Lightness spread predicts color-blind survival almost perfectly:
| Palette | n | L* spread | Min ΔE, deuteranopia |
|---|---|---|---|
| Ours, before | 16 | 100 | 1.2 |
| FigJam | 10 | 21 | 2.8 |
| Miro | 12 | 36 | 4.0 |
| Okabe–Ito (the color-blind-safe reference) | 8 | 89 | 11.5 |
Nobody’s sticky-note palette comes close to Okabe–Ito, because pastel paper is by definition all one lightness. But Miro’s wider spread is measurably why it holds up better than FigJam’s.
You don’t configure a pack of Post-its. You buy one.
The organising idea came from the physical object. Different packs of sticky notes at the store are different registers — a pack is a set that shares tone and chroma and varies only in hue. That’s a coherent design object, because sameness of tone is what the eye reads as “same material.” Post-it’s own product line has always worked this way: Beachside Café, Playful Primaries, Poptimistic, Supernova Neons. Each is a register with a hue rotation inside it.
It also named what was wrong with our sixteen. Nine were pastels crammed into a
13-point lightness band; three were saturated mid-tones; one (mustard)
belonged to neither. Those were two registers shuffled into one list with an
orphan — not one palette.
The first version of this made “pack” a per-board setting. That was wrong for the same reason the analogy is right: a setting turns a purchase into a decision, and it’s a decision every board would face before it had a single card on it. So the pack isn’t a mode. It’s what’s on the toolbar, with the rest one click behind it.
Two registers or three
Convention says two. Notion offers each color twice; FigJam and Miro are effectively single-register; nobody in this category ships three. And two registers gets color blindness to ΔE 9.5, against three registers’ 10.9 — most of the benefit for half the tray.
We shipped three anyway, and the reason was a bug we’d find two sections from now: one map type where color isn’t a preference at all.
Eight hues, three registers, five on the rail
Every value generated in OKLCH with per-hue gamut clamping, hue angles chosen to maximise the minimum separation within a register. The maths doesn’t ship — these are its output.
The rail carries five, not six, and the reason is a color that was already there: a card with no color set renders white. Five swatches is six card appearances on a board — which is where the retail packs land (Playful Primaries is four pads, Beachside Café five) and where facilitation practice caps a legend before people stop reading it.
Their order is the rainbow reversed from yellow — strictly descending hue, wrapping past 0° once. Not decoration: it’s a rule a reviewer can check, where a rail in arbitrary order reads as an inventory rather than a spectrum. The hue angles are now the stored data, and the five are provably a subsequence of the full rotation, so the rail can’t drift into being a second ordering.
The most important card on the wall was invisible
Three weeks before this, we shipped Event Storming — the first map type whose notation is chromatic. On an Event Storming board the sticky colors aren’t a preference, they’re a published grammar the domain-driven-design community already reads. Color is the meaning.
Measured against the old palette, that notation’s minimum separation was ΔE 7.4, and the worst pair was the one that mattered most:
hotspot— the marked disagreement, the thing our own code calls “the workshop’s real output” — was ΔE 7.4 fromdomain_event, the most common card on the wall. On a projector, the output of the workshop was invisible against its own background.
This is what the third register bought. Because the notation is a projection rather than a user’s choice, it can be specified across registers — and that’s what takes it from broken to genuinely color-blind-safe, rather than merely prettier:
| Event Storming notation | Before | After |
|---|---|---|
| Minimum separation, normal vision | 7.4 | 23.6 |
| Minimum under deuteranopia | 1.6 | 10.9 |
| Minimum under protanopia | — | 12.7 |
Four things measurement didn’t catch
Then we put it on a real board, and the interesting part started. Every one of these passed the tests we had.
Bold yellow looked brown
And it wasn’t a picking mistake — it was the gamut. Yellow’s chroma peaks near OKLCH lightness 0.87. Holding it at the bold register’s 0.775 capped it at chroma 0.160, and a yellow with that little lightness has nowhere to go but olive. No hue angle fixes that; only lightness does. So yellow became the register’s one documented exception, lifted as far as it can go before the deep yellow beneath it stops clearing the floor.
Using our brand yellow directly was tempting and measures worse: at its hue the deep row falls to ΔE 12.3 and bold yellow lands 16.4 from orange. The shipped value sits ΔE 4.8 from the brand color, against 7.3 before.
The tray reached into the canvas
Eight hues across, three registers down, opening sideways out of a 44-pixel vertical rail — it reached 16% of the way across the board. Wrong axis: the palette’s long dimension should match the rail’s. Transposed to three registers across and eight hues down, it went from 236 pixels to 96, and a row became one color at three weights.
A grey too close to the board
stone measured ΔE 5.6 from the default board canvas and 6.1 from white —
squeezed between two things it was nearly identical to, which is the exact
defect this work existed to remove. Cutting it also put the neutral row at
three, aligning it under the three register columns.
Text changed color when you stopped typing
The in-place edit field had a hardcoded dark text color, with a comment explaining it was there to stop the page’s inherited color producing white-on-yellow. A real problem — but pinning it dark meant the nine dark fills typed black on black and only flipped to white on blur. It reads the palette’s own text pairing now, caret included.
The palette used to live in five places
This is the part that actually caused the original bug. The values were duplicated across four hand-synced files — the Ruby model told you to update the stylesheet by hand, the stylesheet said its own generator was switched off, and a JavaScript array held a third copy that arrow-key color cycling really used. Then we found a fifth: an orphaned set of tokens describing itself as canonical, with zero consumers.
Six near-identical loops in the stylesheets each did the same two things — a background and a border, keyed on a class name. They collapsed into single rules reading custom properties, which let the duplicates be deleted rather than kept in sync. The change removed more code than it added.
One duplication is unavoidable: the stylesheet compiler runs ahead of time and can’t read Ruby, which is exactly why that disabled generator never worked. So the two remaining declarations are bonded by a test that fails if they disagree. No generator, no build step, no rake task anyone has to remember.
And the invariants are now assertions rather than comments:
- Separation floors per register, plus the toolbar set and the Event Storming notation — the last one also under simulated color blindness.
- No non-white fill within ΔE 10 of the board canvas — the measurement that
condemned
stone, now permanent. - Hue order descends, wrapping exactly once, and the toolbar is a subsequence of the full rotation.
- Every fill pairs with text it can actually carry, and every color’s edge is distinguishable from its own fill.
The per-register floor exists because of a near-miss. Retuning yellow toward our brand color quietly dropped the deep row to ΔE 12.3 — and nothing failed, because we’d only ever asserted the toolbar and the notation.
That check would have caught the original hotspot bug, and it fails with a
measurement instead of someone squinting at a projector.
Measurements: sRGB → CIELAB with ΔE2000 for perceptual distance, WCAG relative luminance for contrast, and Machado et al. transfer matrices for color-vision simulation. Palette values generated in OKLCH with per-hue sRGB gamut clamping. Numbers in this post are the ones the test suite asserts.
Sources: Miro: additional sticky note colours · Miro: notes hard to distinguish after update · Miro: accessibility and default colors · Miro: new design language · FigJam: palette-change feedback · FigJam: canonical sticky colors · Material 3: how the color system works · Okabe–Ito palette · Post-it color collections