---
title: Gradient Finish — Design and Architecture
type: reference
audience: human + agent
authority: E1 — the finish doctrine («la rampa huye de la tinta») and its decision record
status: current — v1 shipped 2026-07-15 (Button + Badge; anchored ramp + guard)
source: distilled from the 2026-07-14/15 design cycle (three analyses + coherence review + reference-framework research + measured rectification); operational plan at docs/process/gradient-finish-plan-2026-07.md
---
# Gradient Finish — Design and Architecture
> **Status: shipped v1 (2026-07-15).** `<Button color="affirm" gradient>` and
> `<Badge variant="solid" gradient>` paint a same-hue ramp DERIVED from the
> instance's own palette slots, ANCHORED to the ink's shadow side, scaled by
> ONE theme dial (`--gradient-finish-lift`). The a11y invariant is executable:
> [`gradient-finish-guard.test.ts`](../../src/uix/eidos/gradient-finish-guard.test.ts).
> Live lab: `web/routes/temas/gradientes`. Chronicle: [`changelog.md §42`](./changelog.md).
This chapter records **what** the gradient finish is, **why** every piece is
shaped the way it is, and **which alternatives were rejected with what
evidence**. It exists because the design went through a measured rectification
mid-flight — the kind of episode a reference framework documents rather than
buries (see D6).
---
## 1. The problem
Gradient-filled CTAs are one of the most common visual treatments in modern
product UI — and one of the least safely shipped. The reference landscape,
researched against official docs (2026-07-14):
| System | Mechanism | Ink over the gradient |
| --- | --- | --- |
| Mantine | `variant="gradient"` + `gradient={{from,to,deg}}` (open pair) | **Hardcoded white** — its own `autoContrast` engine is explicitly bypassed in the gradient branch |
| Bootstrap | `.bg-gradient` — one global white-alpha overlay, off by default | Inherits the solid's pairing *because* the overlay is same-hue and capped at 15 % |
| Chakra / Tailwind | background props / utilities, author-composed | Author's problem, no guarantee |
| Material 3 / Radix / Spectrum / Fluent / Apple | **Refuse gradients on components** | Their contrast guarantees are defined against ONE flat fill — the assumption a gradient breaks |
Two structural facts fall out of that table and shaped everything below:
1. **Nobody computes a legible ink over a multi-stop background.** They force
white, delegate, constrain the gradient to same-hue subtlety, or abstain.
2. The only architecture that *avoids* the ink problem (Bootstrap) does it by
making the gradient a **treatment over the base fill** rather than a color
in its own right — keeping the base solid's ink pairing valid.
UIX generalizes insight #2 into doctrine, with the guarantee made
constructive and executable instead of hoped-for.
---
## 2. D1 — A gradient is a FINISH (material), never a color identity
**Decision.** The gradient enters the system as a **finish of the saturated
fill**, opted into via a `gradient` prop that stamps `data-gradient` . It is
NOT a value of the `color` prop / `data-color` axis, NOT a variant, and NOT a
separate `background` prop.
**Why.** The `color` axis carries the strongest invariant in the theming
system: every value (9 roles, 33 donor scales, custom) resolves **all ten
palette slots mechanically** — fill, tints, border, text, hover, contrast —
and **every variant can express every value** . A gradient cannot honor that
contract by type: it is an `<image>` , valid only where backgrounds paint;
`border-color` , `color` , tint slots cannot take it. The first design (a
`color="gradient-aurora"` value riding the `data-color` axis like the 33
scales) was fully developed and then rejected in a coherence review because
every consequence of the type mismatch needed a patch:
- hand-authored companion tokens (`-ink`, `-edge` , `-soft` ) bolted onto a
prop whose other values derive everything mechanically;
- variants that could not express the value (`variant="outline"` rendering
no trace of the chosen "color" — same pixels as the finish model, but there
they *broke* the axis contract; here they are honest inertness);
- a collision with the state layer (§38) needing special-casing;
- per-gradient worst-stop ink tooling per mode;
- the `data-on` surface-inversion machinery dragged in as a dependency.
The framework already has the correct taxonomy, and the finish slots into it:
| Axis | Question it answers |
| --- | --- |
| `color` | **who** the instance is (identity) |
| `variant` | **how it wears** the identity |
| `data-depth` / frost / **gradient finish** | **what material** its surface is |
| state layer | **how it responds** to the pointer |
Two precedents complete the argument. Mantine models gradient as a *variant*
only because its gradient is an **open** `{from,to}` pair that cannot live in
an enum; a treatment with a **derived** recipe doesn't need a variant branch —
and Mantine's variant branch is precisely where its ink guarantee dies. And
the named gradients (`--gradient-{name}`, the config "open cage") remain a
**separate decorative axis** (scenography, `Image` 's shimmer, the future
`Surface` ), untouched by this feature — see §10.
**Rejected alternatives.** (a) color-axis value — type mismatch, patches
enumerated above; (b) variant — needs a resolver branch, duplicates the 6
variants × finish matrix, and follows Mantine into the ink trap; (c) separate
`bg` /`background` prop — two identity axes competing on one element
(`color="teal" bg="gradient-x"`: whose text? whose hover?), plus a new morfo
attr + forward + TSC axis for no gain.
---
## 3. D2 — The layered fill
**Decision.** A finished fill is **base + layers** :
```
background-color = the solid base (--_{c}-bg — unchanged, always a < color > )
background-image = the finish layer (--_{c}-fill-finish, generator-emitted)
```
**Why.** Keeping the base as a *color* in `background-color` and the finish
as an *image layer* dissolves four problems that plagued the color-value
design, by construction instead of by patch:
1. ** `forced-colors` **: the UA strips every non-`url()` `background-image`
and force-paints `background-color` — a finished button degrades to
exactly today's solid button. Zero fallback tokens, zero HCM blocks.
(The initial analysis had this backwards — assuming the gradient would
survive; MDN-verified during the reference research and corrected.)
2. **The state layer (§38)** composes veils in `background-image` ; base color
and finish never fight it for the same longhand value slot.
3. **Hover transition** : `transition: background …` interpolates the COLOR
layer (9→10) exactly as today; gradients don't interpolate and don't need
to.
4. **The mesh caveat** (a mesh token's trailing base color is invalid as
`background-image` ) stops mattering for components — and mesh never enters
component fills anyway (§10).
---
## 4. D3 — The ramp derives from the instance's own palette slots
**Decision.** There is **no per-color gradient catalog** . The ramp is
composed in CSS from the slots the identity already resolves —
`--{c}-palette-solid` / `--{c}-palette-solid-hover` — via `color-mix(in
oklch, …)`, emitted once per opted-in recipe by `renderRecipeGradientFinish`
(`render-css.ts`).
**Why.** The THM-2 shared palette layer already routes **every** identity —
9 roles, 33 donor scales, `data-color-custom` raw brand colors — into those
per-instance slots. Deriving from them means the finish re-tints with roles,
scales, custom colors, themes and light/dark **automatically** , with zero new
rows in the shared layer and zero maintenance per color. The ink is the same
inherited `--{c}-palette-contrast` the flat fill uses. Non-interactive
recipes (Badge) declare no `solid-hover` ; the deep stop falls back to `solid`
— the ramp still deepens via the anchor mix.
Interpolation is `in oklch` (perceptual) — consistent with the named-gradient
builder and with Tailwind v4's default; it keeps midpoint luminance smooth and
predictable, which the guard's sampling relies on.
---
## 5. D4 — Why the ramp endpoints are NOT other scale steps (9→7, 9→11)
**Decision.** The ramp never reaches for other steps of the 12-step scale.
Its endpoints live in the solid's own neighborhood (solid / solid-hover,
mixed toward the anchor).
**Why.** The obvious idea — "want a wider ramp? span two steps" — fails on
the scale's own semantics, verified against the generated theme values:
- The 12 steps are **functional, mode-relative slots** , not a luminance
ruler: 7 = border, 9/10 = solid, 11 = text. In light mode purple-11 is
`#8145b5` (ΔL ≈ 0.04 from solid — an even FLATTER ramp than 9→10); in dark
mode purple-11 is `#d19dff` — a **light** text tone: the 9→11 ramp inverts
direction and kills white ink. 9→7 dies symmetrically (7-light `#d1afec` is
a pastel: white ink ~1.9:1; 7-dark is darker than 9 — inverted again).
- The luminance cliff of the scale sits at 8→9 and 11→12, not inside the
solid region. In slot language the proposal reads "fill from *solid* to
*border* " or "to *text* " — the names themselves flag the category error.
- The **only mode-stable anchor** in the entire scale is the solid itself
(purple-9 is `#8e4ec6` in BOTH modes). Anything anchored elsewhere imports
per-mode direction flips and per-scale polarity exceptions.
This analysis is demonstrated live in the lab's «¿y por qué no saltar 2
pasos?» panel, with the real theme values.
---
## 6. D5 — ONE theme dial: `--gradient-finish-lift`
**Decision.** The finish intensity is **config data** :
`primitives.gradientFinish.lift` (default `26%` ) → emitted as the
`--gradient-finish-lift` token. One dial scales every ramp in the catalog;
`0%` degenerates the ramp to ≈flat (the dial is also the global off-switch).
Per-instance override is free via the custom-property cascade; a **theme**
overrides it by declaring `gradientFinish.lift` in its `ThemeDefinition` —
the theme block re-emits the token at the same specificity, later in the
cascade, so light and dark can run different intensities (or `0%` to turn
the finish off per theme). The compound `gradient-finish-*` prefix reserves
the namespace against the named-gradient open cage exactly like
`--gradient-angle-*` does.
**Why.** This is the state-layer precedent (§38/§40) applied again: visual
magnitudes are **config data emitted by the generator** , never hand-written
CSS constants. A theme retunes one number; the whole catalog follows. The
alternative — per-kind or per-component knobs — multiplies surface without a
consumer, and the pure 9→10 ramp (no knob at all) was measured imperceptible
at button size in the interactive mockup that preceded implementation.
---
## 7. D6 — The ANCHORED ramp: «la rampa huye de la tinta»
This is the load-bearing decision, and the one that was **rectified under
measurement** mid-implementation. Both halves — the mistake and the fix —
are part of the record.
**The mistake (documented deliberately).** The first ratified form was a
global *lift toward white* : light end = `solid + lift` , deep end =
`solid-hover − lift/3` . It looked right on the mockup — which was calibrated
on **purple** , one of the highest-headroom colors in the palette (sampling
bias), and it shipped with its validation queued instead of gating. The probe
(84 combos = 9 roles + 33 scales × light/dark; floors mirroring
`on-solid.ts` : APCA |Lc| ≥ 60 ∧ WCAG ≥ 3) then measured:
- **52/84 combos broke the inherited ink at 26 %** at the ramp extremes
(37/84 even sampling only the text band);
- per-color safe ceilings: teal **1 %** , gray 15 %, dark-mode blues/greens
**0 %** — the scale's step 9 simply has **no headroom toward white** on
half the palette, because the on-solid floors are calibrated exactly there;
- the **global safe ceiling was 0 %** — no perceptible global lift exists.
Process lesson, recorded as doctrine: **measure before fixing a default; a
default calibrated visually on one color is a sampling artifact.** The guard
must gate the number, not trail it.
**The fix — the doctrine.** The ramp **anchors to the ink's shadow side** :
- **White-ink colors** (most roles/scales): anchor `#000` , strong stop DOWN —
the fill deepens from the solid into shade. The classic shaded CTA.
- **Dark-ink colors** (light solids: amber, yellow, lime, mint, sky…):
anchor `#fff` , strong stop UP — the fill lifts into highlight. Glossy.
Formally: `weak = mix(solid, anchor, lift/3)` under the label,
`strong = mix(solid-hover, anchor, lift)` falling away, with the gradient
angle flipping with the polarity (`180deg` / `0deg` via the existing
`--gradient-angle-*` tokens). Because **both stops move toward the ink's
safe side**, the label's contrast can only **improve** over the base solid
pairing that the component already ships. That is *constructive safety* :
- the dial is **unbounded** (probe: 0 regressions up to 40 %, the dial's UI
range);
- intensity is **uniform** across the whole palette — no per-color caps, no
colors where the feature silently vanishes;
- the physical reading is coherent: the light source sits on the label's
side; the material falls away toward shade.
**How the polarity resolves.** By the SAME flip the `contrast` slot already
rides — no second source of truth:
- per role, at theme time: `--color-{role}-finish-anchor` / `-finish-angle`
are emitted next to the role's contrast decision (`onSolidClearsFloors`);
- per donor scale, at build time: the THM-2 shared rows carry
`--palette-finish-anchor` / `-finish-angle` decided by the same
`lightSolidScales` membership that decides `palette-contrast` ;
- per recipe, with the presence guard mirroring the palette forward: the
host block pins **primary's** anchor so a colorless nested instance never
inherits an ancestor's polarity over its own primary fill (the nesting
gap, same defense as THM-2).
**The guard.** [`gradient-finish-guard.test.ts` ](../../src/uix/eidos/gradient-finish-guard.test.ts )
pins the invariant executable:
1. coverage — the full color axis (≥ 84 combos);
2. **no-regression** — every combo whose base solid clears the floors with
its own ink still clears them at any dial value ≤ 40 % (worst-sample of
the anchored ramp);
3. the pre-existing **flat-fail set is pinned** — `{role:risk(orange),
scale:cyan, scale:orange}` are mid-tone solids where NO single ink clears
APCA 60 even flat (an industry-wide on-solid reality, out of the finish's
scope); the set must never grow silently.
**Rejected alternatives, structurally.** (a) global lift + lower default —
ceiling is 0 %, no default exists; (b) per-color caps (`min(dial, cap)`) —
uniformity dies, the feature vanishes on teal/grays and to 0 on
orange/cyan; (c) computing a separate ramp ink per color — flat and finished
siblings would carry different inks for the same identity, and it reopens
the very ink-over-gradient problem the ecosystem cannot solve.
---
## 8. D7 — The generator emits the VAR; the recipe paints
**Decision.** `renderRecipeGradientFinish` emits only the ramp variable
(`--_{c}-fill-finish`, plus the anchor/angle pins) under
`[data-{c}][data-gradient]` . The **recipe** paints it on its saturated fill
(`[data-variant='solid']`) and re-asserts on `:hover` , next to its own hover
rule with its own guards.
**Why.** Button's hover is `[data-button]:hover:not([data-disabled]):not([data-loading])`
— specificity (0,4,0) — and uses the `background:` **shorthand** , which
resets the `background-image` longhand. A generic generator re-assert at
(0,3,0) silently loses; matching each recipe's guard shape from the generator
would couple it to every recipe's selector conventions. The split follows the
system's grain: *the generator fabricates from data; the recipe decides where
paint lands* — exactly how variants consume palette slots. The finish applies
to the saturated fill and is **inert** on soft/outline/ghost: no fill to
finish, and no broken contract, because `gradient` never claimed to be the
identity (contrast with the rejected color-value design, where the same
pixels violated the axis).
---
## 9. D8 — `data-gradient` is an eidos-only WRAPPER attr (not morfo-declared)
**Decision.** The attr is stamped by the eidos wrapper (Button: via the soma
provider's passthrough props; Badge: directly on its element) and does NOT
appear in any morfo declaration — the `data-variant` / `data-size` family.
**Why (found live, the bug is instructive).** Declaring it in the morfo (on
the `data-color-custom` precedent) made the attr silently vanish: a
morfo-declared attr resolves in the **soma provider's prop space** , where the
pure-visual `gradient` prop does not exist → the runtime emits `undefined`
for it → soma's `mergeProps(restProps, state.props)` gives the runtime's
`undefined` precedence and **clobbers the wrapper's stamp** . The corrected
rule of thumb, now doctrine: **the morfo declares an attr only when its
driving prop crosses the soma boundary** (`data-color-custom` qualifies —
Card's `colorCustom` is a soma prop; `gradient` does not). eidos-lint
classifies these as "wrapper attrs" (eidos-only) — that category *is* the
convention.
---
## 10. Boundaries — what the finish is NOT
- **Not the named gradients.** `--gradient-{name}` (config `gradients` , the
documented "open cage", `buildGradient` factories) stays a decorative /
scenography axis: `Image` 's shimmer today; the `Surface` primitive with
`gradient="aurora" on="light|dark"` in v2. Mesh materials live there —
never in component fills.
- **Not gradient text.** `TextGradient` clips a single `<image>` through
glyphs; disjoint mechanism, unchanged.
- **Not a surface-content inversion system.** Large gradient surfaces under
arbitrary content need the `data-on` subtree ink context — an independent
initiative (Card's solid variant needs it today, without gradients), with
known hard limits (nested components resolve their own tokens; portaled
content escapes ancestor inheritance).
- **Roadmap.** v1.x: ✅ complete (guard · Badge · per-theme/mode dial
override). v1.5: ✅ shipped — the `spread` kind (§D10). v2.1: ✅ shipped —
the ** `Surface` primitive** (Box + treatment: the themeable canvas riding
the color axis, soft/solid variants and both derived finish kinds; the
hero as a first-class primitive — `src/uix/eidos/components/surface` ).
v2 remaining: named finishes with authored, worst-stop-validated inks (the
rejected design A salvaged as an explicit, opt-in extension) + `data-on`
(subtree ink inversion, independent initiative).
### D10 — The `spread` kind (v1.5): anchored constant-L hue rotation
`gradient="spread"` rotates the fill's hue ±`--gradient-finish-spread`
(default `30` , a **unitless** number: the relative-color `h` channel is a
`<number>` — `calc(h ± 30deg)` computes to `none` , measured in Chromium)
around the instance's solid, at constant L/C, horizontally. **Measured, not
assumed**: OKLCH L is *not* relative luminance — a pure constant-L rotation
broke grass at ±4° and gold at ±27°. The rescue is the doctrine itself
applied lightly: **both rotated stops take the ramp's weak anchor mix**
(`lift/3` toward the ink's shadow side), which buys back the hue-induced
luminance drift — 0 regressions up to ±45° across the 78 base-passing
combos, pinned in the guard. On achromatic identities (the gray family,
C ≈ 0) the rotation is imperceptible by nature: spread renders ≈flat there —
documented behavior, not a special case. Ink, border, hover: inherited,
unchanged. One doctrine, two kinds: *the ramp flees the ink strongly; the
spread flees it lightly while it plays with hue.*
---
## 11. Decision register
| # | Decision | Why (short) | Status |
| --- | --- | --- | --- |
| D1 | Gradient = **finish** (material), not a color value, variant, or bg prop | `<image>` cannot honor the color axis' derive-all-slots contract; taxonomy: identity × wearing × material × feedback; Mantine's variant = its ink trap | 2026-07-15 · standing |
| D2 | **Layered fill** : base `background-color` + finish `background-image` | forced-colors degrades free; state layer composes; hover transition intact; mesh caveat moot | standing |
| D3 | Ramp **derives from the instance's palette slots** (no catalog) | THM-2 rails: roles + 33 scales + custom re-tint automatically; ink inherited | standing |
| D4 | Endpoints never reach steps 7/11 | steps are functional & mode-relative (11-dark is light TEXT `#d19dff` ); only the solid is mode-stable | standing |
| D5 | ONE dial `--gradient-finish-lift` = config data (`26%`, `0%` ≈ off) | state-layer §40 precedent; theme retunes one number; pure 9→10 measured imperceptible | standing |
| D6 | **Anchored ramp** — «la rampa huye de la tinta» (white ink → deepen ↓, dark ink → lift ↑) | measured: global lift-toward-white breaks 52/84 at 26 %, global ceiling 0 %; anchored = contrast can only improve, dial unbounded, 0 regressions ≤ 40 % | 2026-07-15 · standing (rectified same day it was measured) |
| D6b | Process rule: **measure before fixing a default** ; guards gate, not trail | the 26 % default was calibrated visually on purple (top-headroom color) — sampling bias caught by the probe | doctrine |
| D7 | Generator emits the **var** ; the **recipe paints** (+ hover re-assert) | recipe hover shorthand at (0,4,0) resets the longhand; generator can't know per-recipe guards; matches variant-consumption grain | standing |
| D8 | `data-gradient` = eidos-only **wrapper attr** ; morfo declares an attr only if its prop crosses soma | morfo-declared attr resolved in soma's prop space → `undefined` → `mergeProps` clobbers the stamp (found live) | doctrine |
| D9 | Pre-existing flat-fail set (cyan/orange mid-tones) is **pinned** , not fixed here | no ink clears APCA 60 on those solids even flat — on-solid scope, industry-wide; the guard forbids silent growth | standing |
| D10 | `spread` kind = constant-L hue rotation **with the weak anchor mix** on both stops; the spread token is **unitless** | pure constant-L broke grass ±4°/gold ±27° (L ≠ luminance, measured); anchored: 0 fails ≤ ±45°; RCS `h` is a `<number>` — `deg` computes `none` (measured in Chromium); ≈flat on grays (C≈0), documented | 2026-07-15 · standing |
---
## 12. Artifacts
| What | Where |
| --- | --- |
| Doctrine (standing summary) | [`theming/reference.md §39` ](./reference.md ) |
| Chronicle (dated, incl. the rectification) | [`theming/changelog.md §42` ](./changelog.md ) |
| Operational plan + phase queue | [`docs/process/gradient-finish-plan-2026-07.md` ](../process/gradient-finish-plan-2026-07.md ) |
| Generator | `src/uix/eidos/lib/render-css.ts` — `renderRecipeGradientFinish` , `GRADIENT_FINISH_COMPONENTS` gate; anchor/angle emission in the role loop + THM-2 shared rows |
| Config + default | `src/uix/eidos/lib/config-types.ts` (`gradientFinish`) · `lib/primitives/static.ts` (`STATIC_GRADIENT_FINISH`) |
| Consumers (finish gate) | `components/button/*` · `components/badge/*` · `components/surface/*` (prop `gradient` , paint rules) |
| The themeable canvas | `components/surface/*` — Box + treatment (v2.1); ficha with its own Decisiones/Gaps |
| **Executable invariant** | `src/uix/eidos/gradient-finish-guard.test.ts` — coverage / no-regression ≤ 40 % / pinned flat-fail set |
| Live lab (9 cases, real components, real token) | `web/routes/temas/gradientes` |