The Lightness Ramp
Anchors declare brand swatches; roles assign them to UI purposes. A semantic color usually takes hue and chroma from its source and lightness from a named ramp stop. Setting a stop to a swatch's measured lightness helps reproduce that swatch, subject to gamut mapping and contrast enforcement.
This guide calls that process seating a swatch. Use an isolated custom tone when one mapping needs a value that should not move the shared ramp.
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 in the dark scheme — filled buttons |
raised4 |
0.72 | 0.36 | layer4 |
accent |
0.50 | 0.60 | link |
muted |
0.45 | 0.75 | headings; actionBackground in the light scheme — filled buttons |
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 |
actionBackground is the one default mapping named per scheme rather than
once: no single stop clears the Lc 75 a button label needs in both ramps
while staying clear of the canvas and of the status collision audit. See
the stop the action lives on.
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.
Two deltas do not answer to that remedy, and the report lints them instead of leaving you to spot them: the filled action's surface and its paired ink sit on a stop the engine chooses, which no re-seating reaches. See the stop the action lives on.
The stop the action lives on
One stop does not follow the procedure above, and it is the one that surprises people: the filled action's.
Every other token in this guide sits on a stop you name. Seat that stop to your swatch's measured lightness and the token reproduces the swatch. The action surface is different — the engine chooses its stop, targeting a mid-band lightness (an internal target near L 0.45) among the stops whose finished surface can actually hold a legible label. It picks by lightness, not by name, so re-seating the ramp cannot move it. A theme that declares
roles: { action: { color: "anchor:gold", ink: "anchor:night" } }
with a light gold swatch gets a filled action at the engine's band, not at gold's own lightness, no matter how carefully the ramp is seated. Its paired ink follows the surface the engine chose, which is how a light-gold-on-navy mock renders as navy-on-brown.
Four consequences:
-
A filled action is a reversed band, by design. Espalier has one orientation for it, in both schemes. If your brand's action swatch is light — a gold, a pastel, a soft yellow — the roles layer will not paint it that way. Pin the token instead:
semanticMappings: { actionBackground: { source: "anchor:gold", lightness: "raised3" }, }with
raised3seated at gold's own lightness. A pin overrides the engine's placement; nothing else does. The fit report'saction-anchor-inversionlint exists to tell you when this is happening, because the report's per-token deltas alone look like ordinary anchor re-lighting. -
Check that your swatch can hold a label before you pin it. Under APCA there is a mid band where no ink — not even pure black or pure white — reaches the Lc 75 a button label needs. It runs from roughly L 0.55 to L 0.83; the exact edges move a few hundredths with hue and chroma, so measure your own colour rather than trusting the round numbers. A gold at L 0.809 sits inside it and tops out around Lc 70 with black text, so pinning the surface there buys a swatch-faithful button with an illegible label — the
apca-target-unmetlint says so. Three ways out, in order of how little they cost you: lighten the stop past the band (that same gold pinned at L 0.84 carries a dark label at Lc 75.1, and at 0.85 at Lc 76.2 — a change most brand books can live with), deepen the swatch below the band, or accept the engine's own reversed band. -
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.