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. surface is near-white in light and near-black in dark; text and ink do 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 raised3 seated at gold's own lightness. A pin overrides the engine's placement; nothing else does. The fit report's action-anchor-inversion lint 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-unmet lint 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.

Components API Guides Getting started Styling Espalier Browser support GitHub npm package Taproot I/O