Migrating To Espalier 3

Espalier 3 removes the variant attribute. It conflated two unrelated ideas — what an element means and which hue family a subtree borrowed — and its re-seeding mechanism broke down under fitted brand themes: measured under a real client theme, variant="danger" painted a danger callout's background mint green. Version 3 splits the two jobs into attributes that each do one thing:

  • intent — the element's meaning: neutral, success, warning, danger, info. On token-emitting controls (buttons and other action chrome) a non-neutral intent pins the filled-action pair to the intent's own fixed status family — blue for info since 3.1; each family is retunable per theme via intents — contrast-enforced. Class-styled chrome (badge, info, status-indicator) renders its treatment from CSS classes and emits nothing inline. An intent never repaints the zone around it.
  • context — a theme-defined zone that rebinds designer-facing roles and re-emits the complete, contrast-enforced token table for its subtree. See the theming guide.

Find every usage first

The attribute form is only one of five ways variant reached an element. Run the full audit before rewriting anything — a sweep that only greps variant=" leaves the imperative and template forms behind to fail silently:

rg -n 'variant\s*=\s*["'"'"']' src            # attributes, double or single quoted
rg -n 'setAttribute\(\s*["'"'"']variant' src  # imperative attribute writes
rg -n '\.variant\s*=' src                     # property assignment and Lit .variant=${...} bindings
rg -n '\bvariant\s*:' src                     # object keys — showToast payloads, config objects
rg -n 'EspalierVariant' src                   # type imports

The mechanical pass

Intent values rename one-for-one — this is sed-able:

sed -i '' -E 's/variant="(danger|success|warning|info|neutral)"/intent="\1"/g' $(rg -l 'variant="' src)

variant="primary" was the default; delete the attribute:

sed -i '' -E 's/ ?variant="primary"//g' $(rg -l 'variant="primary"' src)

The imperative forms rename the same way: setAttribute("variant", …) becomes setAttribute("intent", …), el.variant = "danger" becomes el.intent = "danger", and a Lit .variant=${…} binding becomes .intent=${…} — for status values only; geometric values go through the judgment pass below. Toast payloads are object keys, not attributes — showToast({ variant: "success" }) becomes showToast({ intent: "success" }).

The judgment pass — geometric variants

Every remaining supported variant value is geometric (complementary, triadic-left, analogous-right, …); unknown or application-invented values (ghost, normal, …) also survive the mechanical pass and must be audited and deleted or mapped explicitly. A leftover variant attribute is inert and silent in v3 — the attribute no longer exists, so nothing reads it; only a value blindly carried over into intent (e.g. intent="ghost") warns and renders as neutral. The geometric values have no rename, because the mechanism is gone: audit each usage for what it meant, then express that meaning once, in the theme, as a named context:

The usage meant… Express it as
"this band inverts to the brand color" contexts: { inverted: { canvas: "anchor:…", … } }, then context="inverted"
"this section is a quieter tint" contexts: { quiet: { canvas: "anchor:…" } }, then context="quiet"
nothing anyone can articulate delete the attribute

A handful of context names typically covers every geometric usage a product accumulated — the audit is the work, the rewrite is a line.

Behavior changes to expect

  • Unknown values warn. variant="ghost" silently no-oped; intent="ghost" logs a console warning once per element and renders as neutral. The attribute reflects the normalized value.
  • No zone repainting from intent. Elements that relied on variant="danger" tinting their entire subtree should either move that tint into a theme context or accept the corrected behavior — the status hue now lives only on the action pair.
  • Intent is zone-aware. A non-neutral intent inside a context zone derives its action pair over the zone's theme — its own context, or the nearest ancestor zone's — so it lands in the same tonal system the zone already delivers to its neutral siblings.
  • EspalierVariant is gone. Type imports move to EspalierIntentVariant from the package root.

Subclasses of EspalierElementBase

The protected variant hooks are removed with the attribute. If you subclassed the base:

  • variantBackerintentBacker (an EspalierIntentVariant; sets the element's default intent).
  • applyVariantTokens()applyScopedColorTokens() (now covers both the context table and the intent overlay; overriding it is rarely the right move).
  • getVariantColorSource() → removed with no replacement. The intent → color-family mapping is fixed engine policy; a subclass that overrode the source to a geometric family should express that styling as a theme context instead. A subclass whose treatment is class-based (badge-like chrome) sets intentEmitsTokens = false and defines its own .intent-* styling from public tokens — the status families (--esp-color-danger / -success / -warning / -info) and the --esp-l-* ramp.

Component-specific notes

  • esp-badge, esp-status-indicator: variant was already intent-shaped and their treatment was already class-based; the attribute renames and nothing else changes.
  • esp-info changes appearance by default. In v2 it subclassed the base directly with variantBacker = "complementary", so every callout re-seeded the full inline token table from the complementary family — a tinted zone of its own. In v3 it defaults to intent="info" with the class-based surface treatment and emits no inline tokens: slotted content keeps its inherited colors, and the treatment is themeable via the --esp-info-* custom properties.
  • esp-toaster: ToastConfig.variantToastConfig.intent. The old .variant=${toast.variant ?? "complementary"} fallback matched esp-info's own default, so toasts without an explicit variant rendered the complementary re-seed — those toasts now render the class-based info treatment described above.
Components API Guides Getting started Styling Espalier Browser support GitHub npm package Taproot I/O