Chapters

Part 3 — How the greats did it · Chapter 24

Adobe Spectrum & Leonardo

Case study #4: the solver. Leonardo inverts every other system's model — declare key colors, a background, and target contrast ratios, and it works backward to the swatches. Contrast becomes an input instead of an audit, themes re-solve when the background moves, the meter is a config option — and the ratio decides the lightness whether the gamut has chroma left there or not.

Radix answered Part 2 with a hand, Tailwind with a convention, Material with a space. Adobe's answer is a solver. Leonardo — the open-source engine under Spectrum's palette — is chapter 11's second philosophy shipped whole: you don't pick colors and check them; you declare what the contrast must be, and the colors are whatever satisfies the declaration. The project's stated goal is exactly that inversion — to use "contrast ratio as the starting point, rather than a post-color-selection auditing process." Nate Baldwin, who built it, had named the need a year earlier: an approach to color selection that begins with accessibility, instead of auditing its way there.

The model, inverted

Every other generator in this course takes colors in and emits colors. Leonardo's Color is a stranger thing — it contains no shippable values at all. Three declarations make a theme:

  • A Color is a name, a list of colorKeys — "specific colors to interpolate between in order to generate a full lightness scale of the color" — and a list of target contrast ratios. The key colors are the art: they steer what hue and chroma the scale passes through. They are not the output.
  • A BackgroundColor is the same declaration, marked as the thing everything else is measured against.
  • A Theme binds them, with global dials: a lightness from 0–100 for the background, a contrast multiplier over every ratio, a saturation dial, and — hold this one — a formula.

The machinery underneath is unglamorous and worth knowing. The scale is built as literally as the description says: key colors sorted by lightness, pure white and pure black appended as endpoints ([white, ...sortedColor, black] in the source), the whole path interpolated — sRGB by default — and sampled into 3,000 swatches. Then, for each target ratio, a binary search walks the samples until it finds the one whose measured contrast against the background hits the target. Ratios can be negative: -2 means "2:1 on the other side of the background," which is how a dark theme asks for washes below its surface.

Target ratio
4.5:1
solved: #2a65ffmeasured against #f8f8f8: 4.49:1
Leonardo's scale for colorKeys: ['#5CDBFF', '#0000FF'] — white and black appended, keys sorted by lightness, interpolated in sRGB. The key colors (small rings) are anchors on the path; the swatch you get (the large ring) is wherever the target ratio lives against the background. Drag the target: the output moves, the keys never do.

That's the whole trick, and it lands exactly where chapter 14 filed it: the seed policy at its loosest. Your key colors ship only if a target ratio happens to land on one. The swatches are solver output; the declaration is the product. Ask Leonardo "what's our blue?" and the honest answer is a list of ratios and a promise.

Adaptive by construction

Now the consequence that makes this a system and not a trick. The theme's lightness isn't a cosmetic setting — the background color is itself a scale, and lightness indexes into it (backgroundColorScale[this._lightness], verbatim). Move it, and every color in the theme re-solves against the new background, because every color was only ever defined relative to the background:

lightness: 98 → background #f8f8f8 (L* 97.6)
blue1001.41.41
blue2002.02.00
blue3003.03.01
blue4004.54.53
blue5008.08.01
lightness: 20 → background #303030 (L* 19.9)
blue1001.41.40
blue2002.02.00
blue3003.03.01
blue4004.54.50
blue5008.07.99
lightness: 11 → background #1d1d1d (L* 10.8)
blue1001.41.41
blue2002.02.00
blue3003.03.01
blue4004.54.48
blue5008.08.00
Verbatim output of @adobe/leonardo-contrast-colors v1.1.0: the same blue declaration solved at theme lightness 98, 20, and 11. Read blue400 across the rows — three different hexes, the same 4.5:1, re-measured live under each swatch. And read blue100: lighter than the ramp's end on the light background, darker than it on the dark ones. The ramp direction flipped; nobody wrote a dark palette.

One declaration produced all three rows. Nobody designed a dark palette — lightness 11 is the dark palette, and blue400 holds 4.5:1 in every row because 4.5:1 is what blue400 means. Even the ramp's direction flipped by itself: solved against near-white, higher contrast means darker; against near-black, lighter. Spectrum's naming carries the same logic — Adobe's own account: "our system is relative to contrast, rather than brightness," so blue-100 is lighter than blue-400 in the light theme, darker than it in the dark theme, and lower-contrast in both. And the contrast multiplier turns chapter 9's prefers-contrast into arithmetic — it scales each ratio's distance from 1, so a 1:1 wash stays a wash while 4.5 becomes 5.2 at 1.2× — which is how Leonardo's demo lets "end users have the flexibility and control that they need while still upholding the design team's color choices."

Chapter 16 spent a whole chapter convicting the arithmetic flip and demanding a second design. Be honest about how much of that demand the solver meets, because it's a lot — and not all of it. Ask this blue for 8:1 on the dark background and you get #4fbcff, oklch(0.760 0.138) — Radix's hand-designed blueDark11 sits at oklch(0.764 0.126). The glow fix, chapter 16's lift-L-shed-C move, rediscovered by search: the dark room demands light colors, and the sRGB path toward white sheds chroma on the way. Yellow does chapter 16's reversal on its own, too — the 4.5 solve against near-white is a C 0.115 olive, while the dark theme's 8:1 solve is #cab300 at C 0.159, chroma up, because light vivid yellow exists and dark vivid yellow doesn't:

blue solved at 8:1 on this background
#4fbcff · oklch(0.760 0.138 239.4)
Radix blueDark11, hand-tuned
#70b8ff · oklch(0.764 0.126 249.5)
yellow solved at 4.5:1 on near-white
#827300 · oklch(0.552 0.115 100.2)
yellow solved at 8:1 on dark
#cab300 · oklch(0.763 0.159 100.0)
Left: the blue declaration solved at 8:1 on the dark background, beside Radix's hand-designed blueDark11 — the solver's search lands ΔEok 0.026 from the hand's answer, chapter 16's lift-L-shed-C move rediscovered. Right: the same yellow key solved twice — a C 0.115 olive at 4.5:1 on near-white, C 0.159 at 8:1 on the dark background. Chroma went up in the dark theme, because light vivid yellow exists and dark vivid yellow doesn't.

What the solver does not re-derive: an elevation policy (which lightness indices make a surface ladder is still a design decision), per-job chroma budgets beyond what one key-color path encodes, and chapter 15's tint budgets. The contract half of dark mode falls out of the construction; the taste half is still a human's. Spectrum's team says as much — they built "a single fully adaptive theme" and then generated light, dark, and darkest "by simply optimizing color and contrast." Generated, then optimized.

The meter is a parameter

Chapter 8 ended with a promise: Leonardo's scoring switch "used to look like a nerd option; it's actually the entire subject of this chapter, exposed as a parameter." Here it is in the constructor: formula = 'wcag2' by default, and 'wcag3' swaps the search's ruler to APCA — the actual apca-w3 implementation, the same constants as this course's own meter — with targets read as Lc instead of ratios.

formula: wcag2, target 4.5:1

Body text solved to a target — #2a65ff

4.49:1Lc 68.1
formula: wcag3, target Lc 75

Body text solved to a target — #1e48ff

5.77:1Lc 75.1

Distance between the two picks: ΔEok 0.061

The same scale solved twice — once to WCAG 4.5:1, once to APCA Lc 75, chapter 8's rough bridge pair. On the light background the picks sit ΔEok 0.061 apart: close, arguable. Switch to the dark background and they're 0.258 apart — the WCAG pick scores Lc −36, deep below APCA's body-text floor, because the ratio's flare term over-credits dark pairs. The solver guarantees exactly what its ruler measures.

A contrast-anchored system doesn't get to be vague about its meter, because the meter isn't checking the output — it is the generator. On the light background the two formulas nearly agree. On the dark one they hand you different palettes: the WCAG solve dutifully hits 4.5:1 and scores around Lc −35 — the demo's mini-solver picks #357fff, one hex tick from the real package's #357eff at Lc −35.3 — chapter 8's flare-term over-credit baked into every swatch of every dark theme solved under the default formula. The solver guarantees exactly what its ruler measures — nothing more. Anchor to wcag2 and you've industrialized its blind spots along with its floor; anchor to wcag3 and you've built compliance-critical output on a candidate, not a standard. Chapter 8's posture doesn't dissolve here; it gets compiled in.

Spectrum: the deployment

What does this look like run at Adobe scale — "Adobe's portfolio of more than 100 applications," in their design team's words?

The 2019 system was built background-first: "We started by generating our gray system based on target contrast ratios," then every UI color solved against those grays — 4 tints and 11 background shades per theme, 59 swatches, regenerated per theme. The team's stated reason for going adaptive is a perception fact this course met in chapter 16: for contrast to appear consistent across themes, "the actual contrast ratios had to change depending on the brightness of the theme background."

The current system — version 6.0, rebuilt in Leonardo — hardened that into semantics. Each color ships 14 tints and shades, and the index numbers are contracts, not brightness labels: colors at the 700 index meet or exceed 3:1 against gray-100, and colors at the 900 index meet or exceed 4.5:1 — chapter 10's job table, denominated in ratios, with the background named. The generation details are the tells of a team that's been burned: contrast targets specified as percentages "so that generated colors wouldn't default to pure black or white," lightness stops synchronized with ratio targets so designers can steer aesthetics without breaking contracts, and an automatic UI kit generator bolted onto Leonardo that emits the labeled swatch grid as an SVG so design kits regenerate with every revision instead of being redrawn. This is the largest contrast-anchored deployment in the industry, and it ships as chapter 20 demands — the continuum is a build-time capability, and what reaches products is a handful of frozen theme outputs.

The lab

A working mini-Leonardo. The solver is this chapter's own culori implementation of the real mechanics — white → keys → black, sampled, searched — and it reproduces the real package's output to within ΔEok 0.004:

PlaygroundName the background and the target ratios — what does the solver hand back, and what does it trade away?
Background L*
98 · #f9f9f9
100 target
1.4:1
200 target
2.0:1
300 target
3.0:1
400 target
4.5:1
500 target
8.0:1
100
200
300
400
500

Body text set in the step-400 solve — whatever color that currently is.

steptargetsolvedmeasuredoklch L / Cof chroma ceiling
1001.4:1#7ee2ff1.40:10.862 / 0.102100%
2002.0:1#4fbcff2.00:10.760 / 0.138100%
3003.0:1#3c90ff3.01:10.659 / 0.184100%
4004.5:1#2b66ff4.49:10.570 / 0.235100%
5008.0:1#040aff8.00:10.456 / 0.311100%

Key colors carry chroma up to 0.313; the 400 solve delivers 0.235 — riding the gamut ceiling: the ratio dictated the lightness, and the tent decided the rest.

This solver reproduces @adobe/leonardo-contrast-colors v1.1.0 within ΔEok 0.004 across 25 checked swatches — three backgrounds, both scoring formulas.

Worth doing, in order:

  • Read the defaults. Leonardo's own README blue, five ratios, background L* 98 — the same declaration as the three-row demo above, and the same swatches come out within a hex tick (the mini theme's background dial is plain CIELAB L*, so 98 lands on #f9f9f9 where Leonardo's own gray scale gives #f8f8f8).
  • Slide the background to 20, then 11. Every swatch re-solves; the measured column never moves. Somewhere mid-slide the ramp turns inside out — the solver crosses from picking darker-than-background to lighter-than.
  • Park the background at L* 50. The background is #777777 — chapter 8's almost-passing gray — and the "out of reach" flags light up: against a true mid-gray, no color on either side reaches even 5:1. The solver clamps at white or black and reports the miss. A contrast-anchored system is honest about impossibility in a way an anchored skeleton never has to be.
  • Switch to the vivid yellow. Watch the 4.5 and 8 targets: the solved swatches ride the chroma-ceiling column at 100% — the ratio dictated the lightness, and chapter 6's tent decided everything else.
  • Try the brand purple. #635bff, chapter 14's recurring seed. It ships only if some target's solve happens to land on it — nudge the 400 target and watch the solver walk straight past the brand.

The critiques, honestly

Solved colors land on aesthetic dead spots. The solver optimizes one number. At the lightness a text-grade ratio demands, some hues have almost no chroma to offer — and the solver takes what's there and calls it done:

key color, the art
#ffe100 · oklch(0.907 0.188 99.5)
solved at 4.5:1 (4.49 measured)
#827300 · oklch(0.552 0.115 100.2)
A vivid yellow key color, and what the solver returns when asked for 4.5:1 against near-white. The solved swatch sits at 100% of the sRGB chroma ceiling at its own lightness and hue — the solver spent everything the gamut had. The dead spot isn't a bug in the search; it's chapter 6's tent: at the lightness the ratio demands, this hue has no chroma left to give.

Chapter 11 called this output "a dark olive that dutifully passes"; chapter 13 named the rescue (bend the hue toward gold) and chapter 23 showed Material accepting the olive on principle. Leonardo's answer is the key colors — a human can add a mid-dark gold key to steer the path around the dead spot. Which is the honest limit of the whole model: the solver industrialized the contract, not the taste. The art moved into colorKeys and stayed human.

The default formula inherits WCAG 2's flaws at generation time. The meter demo above is the receipt: dark-theme palettes solved under wcag2 carry chapter 8's over-credited dark pairs in their construction, not just in their audit. The switch exists; the default is the standard, and the standard is the flawed one — chapter 8's trade, unavoidable either way.

Background-relative colors complicate static export. Chapter 11 flagged it and it's worth saying precisely: the palette is a function, and CSS ships values (chapter 20). "What's our step 400?" has no answer until a background is named; the L skeleton itself is solver output that slides when the background does. The resolution is Spectrum's own: evaluate the function at a few named points — light, dark, darkest — and freeze. You keep adaptive generation; you give up adaptive runtime, unless you ship the solver to the client and re-derive chapter 20's entire pre-verification story.

The steal list

What dotUI's rewrite takes, judged against Part 2's spec:

  1. Steal contrast-as-input — for the steps that carry text. The hybrid, spec'd: the lightness-anchored skeleton keeps the surface and component steps (chapter 11's cross-hue consistency, tokens as constants), and the text-grade slots — steps 11–12 and every fg-on-* pairing — are solved, per mode, against that mode's named backgrounds, at build time. That single move turns chapter 17's pairing table from measured-after into true-by-construction, retires the autocontrast bug class outright, and costs nothing chapter 20 cares about: the solve happens before the CSS is written. Chapter 20 already concluded every gate needs a build-time solver — this is that solver, given a second job.
  2. Steal the switchable meter, with chapter 8's posture pinned as config. Solve text steps to the WCAG floor (the auditable promise), report APCA alongside, and let a formula option exist for the day the candidate becomes a standard. Leonardo proves the architecture: the meter is one function behind an interface, not an assumption smeared across the pipeline.
  3. Skip the adaptive-continuum runtime. dotUI ships static CSS to apps that will never grow a background-lightness slider. Spectrum itself freezes three points off the continuum; dotUI freezes two (chapter 16's light and dark passes) plus chapter 9's contrast variants — same architecture, smaller N. Adaptivity lives in the generator, not the bundle.
  4. Skip contrast-relative naming. Spectrum's blue-100-flips-meaning-per-theme is coherent because everything at Adobe rides the solver. dotUI's primitive tier already committed to Tailwind's grammar (chapter 22), where names track the ramp; the contrast contracts live one tier up, in chapter 17's semantic names, which is where mode-flipping already happens.

Next, the last case study: Vercel's Geist — what a design system's color story looks like when it's young, opinionated, and shipped by a company whose whole aesthetic is restraint.

Before you move on

Further reading

  • adobe/leonardo — the source this chapter's mechanics were read from: Color, BackgroundColor, and Theme in contrast-colors, the white-keys-black scale, the 3,000 swatches, the binary search, and formula: 'wcag2' | 'wcag3'. The live tool is leonardocolor.io.
  • Nate Baldwin — Adaptive Color in Design Systems — part one of the three-part series that motivates the whole model (part two, "Introducing Adaptive Color Palettes," walks the same solve mechanics this chapter's demos run live — skipped here for scope): why audit-then-fix fails, and what "begins with accessibility" means.
  • Nate Baldwin — Adaptive Color in Spectrum, Adobe's Design System — part three: the 2019 deployment, grays generated first, and the ratios-must-change-per-theme argument quoted here.
  • Reinventing Adobe Spectrum's colors — the version 6.0 rebuild: 14 indices per color, the 700/3:1 and 900/4.5:1 contracts against gray-100, and the single-adaptive-theme-to-three-themes pipeline.
  • Spectrum — Color fundamentals — the shipped doctrine: contrast-relative indices and per-theme color values, as Adobe documents them for product teams.