Back

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.

blue / skyblueΔE 3.7
green / cyanΔE 5.9
orange / redΔE 7.4
pink / purpleΔE 10.4
white / blueΔE 11.1
yellow / mustardΔE 13.0

blue and skyblue differed 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:

A textbook constant-tone row — 14.8:1 text contrast, and ΔE 0.4 under deuteranopia

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:

PalettenL* spreadMin ΔE, deuteranopia
Ours, before161001.2
FigJam10212.8
Miro12364.0
Okabe–Ito (the color-blind-safe reference)88911.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.

Before — 16 in a flat list, 13 colliding pairs
After — 8 hues × 3 registers, bold row ΔE 18.7

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 rail — five, descending hue, plus the white card you start with
yellow#fac700
red#ff9384
purple#cd9cff
blue#8ab7ff
green#39d678
whiteno color set

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:

domain_event / hotspotΔE 7.4
policy / external_systemΔE 10.4
aggregate / actorΔE 13.0

hotspot — the marked disagreement, the thing our own code calls “the workshop’s real output” — was ΔE 7.4 from domain_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 notationBeforeAfter
Minimum separation, normal vision7.423.6
Minimum under deuteranopia1.610.9
Minimum under protanopia12.7
After — the notation spans all three registers, which is what makes it survive simulation
domain event#ff993f
command#ccdfff
aggregate#f8dc90
actor#007275
policy#cd9cff
read model#00783a
external system#ff8abc
hotspot#ba0c09

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.

Before — #d6b300, held at the register's lightness
yellow#d6b300 · ΔE 7.3 from brand
orangeits neighbour
After — #fac700, lifted out of the register
yellow#fac700 · ΔE 4.8 from brand
orangeΔE 20.9 apart

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