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 forinfosince 3.1; each family is retunable per theme viaintents— 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 asneutral. 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
contextzone 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. EspalierVariantis gone. Type imports move toEspalierIntentVariantfrom the package root.
Subclasses of EspalierElementBase
The protected variant hooks are removed with the attribute. If you subclassed the base:
variantBacker→intentBacker(anEspalierIntentVariant; 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) setsintentEmitsTokens = falseand 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:variantwas already intent-shaped and their treatment was already class-based; the attribute renames and nothing else changes.esp-infochanges appearance by default. In v2 it subclassed the base directly withvariantBacker = "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 tointent="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.variant→ToastConfig.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.