The Lightness Ramp
Anchors and roles declare which colors a theme uses. The lightness ramp decides where those colors sit — every semantic token takes its hue and chroma from a color source, then normally takes its lightness from a named ramp stop. Fitting a brand is therefore two separate acts: declare the swatches (anchors), and seat them on the ramp. This page documents the second act, which is where the judgement lives. A one-off explicit mapping may use an isolated custom tone instead.
The eleven stops
Each scheme carries one ramp: eleven named stops, each a lightness value from 0 to 1. The defaults:
| Stop | Light default | Dark default | Feeds (default mappings) |
|---|---|---|---|
surface |
0.99 | 0.15 | background — the page canvas |
raised1 |
0.97 | 0.20 | layer1 — cards, boxes |
raised2 |
0.90 | 0.30 | layer2, linkHoverBg, inputSelectionBg |
raised3 |
0.80 | 0.40 | layer3, actionBackground — filled buttons |
raised4 |
0.72 | 0.36 | layer4 |
accent |
0.50 | 0.60 | link |
muted |
0.45 | 0.75 | headings |
text |
0.35 | 0.85 | text, linkHover, headingsHover, dangerText |
border |
0.30 | 0.70 | border |
ink |
0.25 | 0.95 | actionText, inputCaret — the most extreme step |
shadow |
0.20 | 0.60 | shadow |
Two structural facts to keep in mind:
- The ramp inverts between schemes.
surfaceis near-white in light and near-black in dark;textandinkdo the opposite. The stop names describe function, not brightness. - Stops are a shared vocabulary, not a private detail. Every default mapping, every role compilation, and every context resolves its built-in lightness through these names — move a stop and everything seated on it moves together. That coupling is the point: it is what keeps a theme coherent when you re-tune one number. Explicit mappings can opt out of that coupling with a custom tone when one value truly is local.
The procedure: seat the brand book on the ramp
A brand book arrives as a handful of swatches, not a ramp. The mechanical
steps below are automated — themeFromSwatches({ swatches }) returns a
paired partial-theme starter (anchors, seated ramps for both schemes, and
the role guesses lightness alone justifies), and deriveLightnessRamp
exposes the seating step on its own. The judgement calls the walk-through
points out remain yours: the starter is the input to this procedure's
thinking, not a substitute for it. The fitting procedure that produced the
worked example below:
1. Convert every swatch to OKLCH and sort by lightness
Use any converter, or let Espalier do it — seedColor and anchors accept
#rrggbb and convert internally (see OKLCH Notation).
What matters is the L channel:
cream #FFF1D9 → L 0.963
rose-200 #E3C7CB → L 0.854
rose-500 #B76E79 → L 0.621
rose-700 #9B4D59 → L 0.520
plum #78486A → L 0.469
espresso #4A3327 → L 0.344
2. Seat each swatch on the stop it naturally occupies
Ask, for each swatch: what job does a color at this lightness do? A 0.96 swatch is a canvas. A 0.85 swatch is a raised surface or a soft border. A 0.47 swatch is mid-band — headings, links, filled actions. A 0.34 swatch is body text. Then set those stops to the swatch's exact measured lightness, so the anchor-sourced tokens seated there reproduce the declared color instead of a relative of it:
surface 0.963 ← cream, exactly
raised3 0.854 ← rose-200, exactly (also border: same swatch, same stop value)
accent 0.520 ← rose-700, exactly
muted 0.469 ← plum, exactly
text 0.344 ← espresso, exactly
This is the step that turns "an anchor is a relative of its swatch" into "an anchor lands on its swatch": the mapping takes hue and chroma from the anchor, lightness from the stop — when the stop is the swatch's lightness, the token reproduces the swatch.
3. Fill the gaps between seated stops
The remaining stops interpolate between their seated neighbors, preserving the ramp's ordering (surfaces stay lighter than text in a light scheme). From the same fit:
lightness: {
surface: 0.963, // cream
raised1: 0.988, // a paper white above the cream canvas
raised2: 0.926, // between canvas and rose-200
raised3: 0.854, // rose-200
raised4: 0.78, // stepping down toward the mid band
border: 0.854, // rose-200 again — soft borders on cream
accent: 0.52, // rose-700
muted: 0.469, // plum
text: 0.344, // espresso
ink: 0.362, // near espresso — filled-action labels
shadow: 0.25,
}
Note two judgement calls the numbers show: raised1 was seated above
surface (a paper white floating over a cream canvas — the default ramp
assumes the opposite), and border reuses raised3's value because the
brand's softest swatch does both jobs. The ramp is a description of the
brand, not a formula.
4. Derive the dark ramp with the same relationships
Repeat the procedure against the dark scheme's inverted expectations: deep
values for surfaces, high values for text, and the same brand swatches seated
where they still work. A dark ramp is usually not 1 − light — compressed
surface steps (0.21 → 0.42) with widely spaced text steps read better than a
mirror image:
lightness: {
surface: 0.21, raised1: 0.25, raised2: 0.3, raised3: 0.36, raised4: 0.42,
border: 0.38, accent: 0.75, muted: 0.82, text: 0.9, ink: 0.95, shadow: 0.1,
}
5. Verify with the fit report
Run a theme fit report for both schemes. The report shows, token by token, the color the theme asked for and the color that rendered — a seated anchor should show a near-zero delta, and any APCA adjustment is listed by name. If a seated swatch moved, its stop is fighting the contrast floor; move the stop or accept the enforced value deliberately.
The stop the action lives on
One stop deserves special attention: the effective actionBackground stop
(raised3 by default). Filled buttons render there, and the engine chooses
an action surface targeting a mid-band lightness (an internal target near
L 0.45) so labels can carry contrast in both directions. Two consequences:
- Keep the action stop distinguishable from the canvas it sits on. A zone whose canvas lightness lands near the action's own band swallows its filled buttons — see contexts and the action surface for the zone-side rule.
- Status colors relight to this stop too. The collision warning audits statuses as rendered at the effective action stop, so an extreme stop value (near 0 or 1) is itself a findable theming defect: everything relit there converges.
Contexts carry partial ramps
A context may override part
of the ramp for its zone only — the usual reason is a genuinely dark band
inside a light scheme, whose surface stop must drop from ~0.96 to ~0.36
while the root ramp stays put. Seat a context's ramp with exactly the same
procedure, against the zone's own canvas.
Custom tones for one-off values
Do not re-seat a shared stop merely to create one recessed, pressed, or hovered ground. Declare a custom tone and reference it from the specific semantic mapping instead:
contexts: {
inverted: {
lightness: { surface: 0.32, raised1: 0.36, raised2: 0.40 },
tones: { recessed: 0.24 },
semanticMappings: {
linkHoverBg: { source: 'anchor:plum', lightness: 'tone:recessed' },
},
},
},
tone:recessed still supplies only lightness; the mapping source supplies hue
and chroma, and normal gamut and APCA processing still runs. The difference is
scope: the tone changes only mappings that name it. It never joins the eleven
stops, emits --esp-l-recessed, or becomes a candidate for roles and automatic
action placement. Tone names are lowercase slugs that may not reuse one of the
eleven built-in stop names, their values are finite numbers from 0 to 1, and a
reference to an undeclared tone is a validation error.