Theme Fit Report

Two things move a token away from the color a designer wrote down, and both are silent:

  • Contrast enforcement. Espalier shifts the lightness of a text token until it clears its APCA Lc target against its background pair. See the Accessibility guide for the targets.
  • Brand anchors. A mapping with source: 'anchor:rose.hover' samples that swatch's hue and chroma, then re-lights it from the mapping's lightness ramp stop. See the Theming guide.

The result is a mock that says one color and a render that says another. The fit report is the record of the step in between.

Taking a report

import {
  DEFAULT_LIGHT_THEME,
  mergeTheme,
  printThemeFitReport,
  themeFitReport,
} from '@taprootio/espalier';

const theme = mergeTheme(DEFAULT_LIGHT_THEME, brandPartial);
const report = themeFitReport(theme, 'light');

printThemeFitReport(report);   // console.table
report.adjustedTokens;         // ['linkHover']

themeFitReport() takes a resolved theme — the object mergeTheme() returns, not the encoded partial you hand to <esp-root>.

The report is dev-mode only and costs nothing when you do not ask for one. It is produced by running the real pipeline with an observer attached, so its values are the emitted custom properties; it never re-derives them.

A worked example

The SHY brand is four swatches — plum, rose gold, espresso, cream — declared as anchors, with linkHover mapped to the rose hover swatch:

{
  "anchors": {
    "rose": { "color": "oklch(0.362 0.0935 10.5)", "hover": "oklch(0.52 0.098 10.5)" }
  },
  "semanticMappings": {
    "linkHover": { "source": "anchor:rose.hover", "lightness": "accent" }
  }
}

printThemeFitReport() on the light scheme prints a summary line and one row per token. Abbreviated to the interesting rows:

Espalier theme fit — light scheme, seed oklch(0.469 0.0809 338.5). APCA moved linkHover.

token       source             requested                resolved                 apca                                                  anchor
headings    anchor:plum        oklch(0.469 0.0809 338.5) oklch(0.469 0.0809 338.5) Lc 60 vs background — met at Lc 77.8                plum ΔE 0
link        anchor:plum        oklch(0.469 0.0809 338.5) oklch(0.469 0.0809 338.5) Lc 75 vs background — met at Lc 77.8                plum ΔE 0
linkHover   anchor:rose.hover  oklch(0.52 0.098 10.5)    oklch(0.23 0.091959 10.5) Lc 75 vs linkHoverBg — pushed from Lc 50.6 to Lc 75.1 rose.hover ΔE 0.29
layer2      anchor:cream.tint  oklch(0.926 0.038 81.5)   oklch(0.926 0.038 81.5)                                                        cream.tint ΔE 0

Read across the linkHover row:

  • It asked for the rose hover swatch exactly — requested equals the declared anchor color.
  • Hovered links paint on the linkHoverBg wash, so that is the surface the ink is enforced against (not the page background). Against the rose wash the swatch scores only Lc 50.6, far below the Lc 75 the token requires.
  • Enforcement darkened it to oklch(0.23 0.091959 10.5), which scores Lc 75.1 on the wash.
  • The ΔE 0.29 is the whole cost of the fix: how far the rendered link hover sits from the swatch in the brand guide — a large move, and exactly the kind the report exists to make visible. A brand that wants the hover nearer its swatch retunes the wash lighter (or the swatch deeper) until the pair clears the tier on its own.

Every other anchor-sourced token reports ΔE 0 — the swatches were chosen to land on their ramp stops, so nothing else drifted. A row with an empty apca cell is a background token: enforcement does not guard those.

The JSON shape

interface ThemeFitReport {
  scheme: 'light' | 'dark';
  seedColor: string;
  /** Declared anchors flattened to reference strings: `rose`, `rose.hover`. */
  anchors: Record<string, string>;
  tokens: ThemeFitToken[];
  /** The tokens contrast enforcement moved. */
  adjustedTokens: SemanticColorName[];
}

interface ThemeFitToken {
  token: SemanticColorName;      // 'linkHover'
  property: string;              // '--esp-color-link-hover'
  source: MappingSource;         // 'anchor:rose.hover'
  lightness: LightnessReference; // 'accent' or 'tone:recessed'
  requested: string;             // before enforcement
  resolved: string;              // the emitted custom property value
  adjusted: boolean;             // requested !== resolved
  sourceResolved: boolean;       // false when the source fell back to the seed
  anchor?: {
    reference: string;           // 'rose.hover'
    color: string;               // the declared swatch
    deltaE: number;              // ΔE-OK from swatch to emitted token
  };
  apca?: {
    from: string;
    to: string;
    targetLc: number;            // 75
    against: SemanticColorName;  // 'background'
    againstColor: string;
    fromLc: number;              // 71.8
    toLc: number;                // 75.4
  };
}

Notes on reading it:

  • apca is present for the thirteen enforced text tokens and absent for the background tokens. It is reported whether or not the token moved, so a passing check is visible too — from === to and adjusted === false.
  • anchor is present only for a mapping whose source is a resolved anchor reference. deltaE is the perceptual distance (ΔE-OK) between the declared swatch and the token that was emitted. A non-zero value on a token that was never adjusted is normal when the mapping's built-in stop or custom tone differs from the swatch's own lightness: lightness always comes from that mapping reference.
  • sourceResolved: false means the mapping named an anchor that the theme never declared. The pipeline stands the seed in and warns; validateTheme() reports the same thing as a hard error before it gets that far.
  • themeFitRows(report) returns the console.table rows on their own, for hosts that want to render them somewhere other than the console.

Every surface at once: the suite

A themed site is not one surface — every context re-derives the full token table for its zone. themeFitReport takes a context option to report a single zone, and themeFitReportSuite reports all of them, both schemes, in one stable JSON shape:

import { themeFitReport, themeFitReportSuite } from "@taprootio/espalier";

const zone = themeFitReport(resolvedTheme, "light", { context: "inverted" });
const suite = themeFitReportSuite(lightPartial, darkPartial);
suite.surfaces;              // [":root", "footer", "inverted", "wash"]
suite.light[2].lints;        // cross-token findings for light/inverted
suite.dataPalette.light;     // eight resolved series slots
suite.paletteLints.light;    // scheme-level CVD findings, not repeated per context

The suite takes the same partials an esp-root takes and compiles each context through the exact zone path a context attribute uses, so it cannot drift from what a zone host renders. Each surface report carries two additions:

  • explicit marks on tokens pinned by semanticMappings rather than compiled from roles — "declared" when this surface's own declaration pinned it, "inherited" when a root-level pin survived into the zone. Inherited pins are by design and silent at runtime; the mark is how a zone's surprising value traces back to the root line that caused it. A context can correct one by declaring its own semanticMappings.
  • lints — cross-token findings computed from the emitted values, all warnings phrased as retunes. action-canvas-separation fires when a filled action sits within ΔE 0.045 of the surface's own background — it would vanish into it. link-hover-ordering judges the hover state on the surfaces the states actually render and has two branches: when the linkHoverBg wash is visibly distinct from the background (ΔE ≥ 0.045), the wash itself signals the state, and the lint requires only that the hover ink clear the Lc 75 text tier on that wash — a few Lc under the resting link is fine there, and the stock light theme (resting 79.6, hover 75.4 on its wash) is the deliberate example. When the wash is indistinguishable from the background, there is no surface signal, so hover must not read weaker than the resting link.

The suite also carries dataPalette.light / dataPalette.dark and matching paletteLints arrays at scheme scope. Palette identities do not change across theme contexts, so one collision is recorded once per scheme rather than on every surface report. Anchor references are resolved in the saved palette, making a slot edit visible in expectation review.

The CLI: fit tables as a reviewable convention

npx espalier theme check ./theme.json runs the pair validator and the suite from the command line — no DOM, no browser:

npx espalier theme check ./theme.json
npx espalier theme check ./theme.json --json
npx espalier theme check ./theme.json --update-expect ./fit-tables.json
npx espalier theme check ./theme.json --expect ./fit-tables.json

theme.json is { "light": { … }, "dark": { … } } — the partials you hand your esp-root — or a single partial applied to both schemes. Commit the --update-expect output next to the theme and gate with --expect in CI. The exit code is non-zero on validation errors, any suite lint, or expectation drift. A palette collision therefore fails with or without a matching expectation, and --update-expect still writes the candidate table for review before returning 1. The human summary counts every gated finding; --json keeps stdout to the suite alone.

Drift output localizes moved tokens and palette slots — for example, light/inverted/actionBackground: resolved oklch(…) → oklch(…) or light/dataPalette/series2: oklch(…) → oklch(…). A theme change then reviews as a diff of resolved values instead of a screenshot argument. Refreshing the committed tables is a deliberate act, exactly like a visual baseline.

The comparison includes the provenance that explains those values, not only the colors: token source, lightness, requested, adjustment and resolution marks, explicit-pin provenance, anchor fields, and APCA action fields all get their own compact line. Suite surfaces, scheme palettes and palette lints, and each report's seed, anchors, adjusted-token summary, surface lints, and token membership are localized the same way. A color-identical repoint therefore still reads clearly — for example, light/inverted/linkHoverBg: lightness shadow → tone:recessed — even beside token additions or resolved-color changes. The final difference count is the number of review lines printed; one kind of drift never suppresses another.

Expectation files are shape-checked through every nested field the reporter reads. A truncated token, malformed anchor or APCA action, invalid lint, or wrongly typed palette value remains an exit-2 file error with the regeneration guidance instead of throwing or appearing as ordinary drift. The same exit-2 guidance identifies tables written by Espalier 4.2.0 or earlier, which need one --update-expect refresh to add the scheme palette fields.

What it is not

The report describes resolved themes. It says nothing about colors you write by hand outside the token system — those are still yours to check. Lints are design-quality warnings, not an accessibility certification.

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