28 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| Gradient Finish — Design and Architecture | reference | human + agent | E1 — the finish doctrine («la rampa huye de la tinta») and its decision record | current — v1 shipped 2026-07-15 (Button + Badge; anchored ramp + guard) | 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. Live lab:web/routes/temas/gradientes. Chronicle:changelog.md §42.
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:
- Nobody computes a legible ink over a multi-stop background. They force white, delegate, constrain the gradient to same-hue subtlety, or abstain.
- 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-onsurface-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:
forced-colors: the UA strips every non-url()background-imageand force-paintsbackground-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.)- The state layer (§38) composes veils in
background-image; base color and finish never fight it for the same longhand value slot. - Hover transition:
transition: background …interpolates the COLOR layer (9→10) exactly as today; gradients don't interpolate and don't need to. - 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).
Alpha / transparency (audited 2026-07-15, by computed values). The layered fill is what makes transparency correct across the whole system:
- Opaque identities (every
solidslot in the palette — all 9 roles and 33 scales) → the ramp/spread stops are opaque; a finished button is fully opaque, matching today's solid. (Measured: opaque teal →oklch(…)with no alpha channel.) - Alpha finishes are the point, not a problem. The
auroranamed finish is deliberately translucent — role blobscolor-mix(…, transparent)fading totransparent— layered over the identity's opaque solid base, so the base shows through the gaps. (Measured: blobs at α 0.62 over the solid.) - Translucent tint slots (
surface/track=a2/a3) and thesoft/surfacevariants are untouched — the finish is inert on non-solid, so their translucency is preserved by not being read. data-oninks are intentionally translucent (color-mix(ink, transparent)for the secondary/muted/border hierarchy).
The ONE edge, and why it is a non-issue: feeding a translucent value into
the solid slot makes the anchor color-mix (toward opaque #000/#fff)
drift the alpha UP and non-uniformly (α 0.5 → 0.545 weak / 0.631 deep for the
ramp; 0.545 uniform for spread). But solid is the opaque variant by
definition — translucency lives in the tint slots the finish never touches —
and a translucent solid is not reachable through any component prop (only by
manually overriding --{c}-palette-solid inline). The base background-color
keeps its alpha regardless, so the element stays translucent overall. The
alpha drift is a consequence of an out-of-contract input, not a defect in the
intended path — documented here rather than left tacit.
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#d1afecis 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
#8e4ec6in 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-angleare 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-angledecided by the samelightSolidScalesmembership that decidespalette-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
pins the invariant executable:
- coverage — the full color axis (≥ 84 combos);
- 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);
- 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}(configgradients, the documented "open cage",buildGradientfactories) stays a decorative / scenography axis:Image's shimmer today; theSurfaceprimitive withgradient="aurora" on="light|dark"in v2. Mesh materials live there — never in component fills. - Not gradient text.
TextGradientclips a single<image>through glyphs; disjoint mechanism, unchanged. - The subtree ink context is MINIMAL by design (D12).
data-onre-binds the global content/border roles for plain content only; nested components with their own tokens and portaled content are NOT re-inked — by construction, and documented. The FULL surface-inversion system stays an independent initiative (Card's solid variant needs it today, without gradients). - Roadmap — COMPLETE (2026-07-15). v1 ramp · v1.x (guard, Badge, theme
dial override) · v1.5
spread(D10) · v2.1Surface· v2.2 named finishes (D11) +on/data-onminimal (D12). Beyond the plan: the full subtree-inversion initiative, per-component demo pages, and model-based validation of named inks (see D11's honesty note).
D11 — Named finishes: the open cage as a finish, with AUTHORED ink
gradient="aurora" (any name opted in via gradientFinish.named) paints an
open-cage gradient as the finish layer. Three structural points:
- The base stays the identity's solid (
background-color, the layered fill D2) — which is why the shippedaurorais pure alpha blobs with NO trailing base color: it composes over ANY identity's solid and staysbackground-image-valid (the mesh caveat dissolved by architecture, not by exception). - The ink is AUTHORED, required, and themeable: a multi-role image
cannot inherit a single derived ink — the config declares it
(
named: { aurora: { ink: 'var(--color-content-on-solid)' } }→ emitted as--gradient-{name}-ink). It travels as the--_{c}-finish-inkvar and each recipe's SOLID slice consumes it with its contrast as the fallback — the D7 split again (generator fabricates, recipe paints). Audited 2026-07-15: the first shape (a direct--_{c}-fgoverride) TIED Surface's own slice at (0,3,0) and left the winner to stylesheet order — not a contract; the var indirection wins by existing, never by specificity, and non-solid variants stay inert by never reading it. This is the rejected design A salvaged as an EXPLICIT opt-in: the derived kinds keep the constructive guarantee; the named kind trades it for expressiveness and says so. - An UNDECLARED name degrades to the ramp (measured):
gradient="foo"matches the presence block (which sets the ramp) and no named block overrides it — graceful fallback, never a broken render. - Honesty note — validation: the guard pins config sanity (named ⊆
gradients, ink present). Numeric worst-stop validation of an arbitrary CSS
gradient string is not implementable (stops unknown); it becomes possible
only for model-built gradients (
buildGradient) — deferred, documented.
D12 — on: the minimal subtree ink context
<Surface on="dark"> stamps data-on='dark'; the foundation re-binds the
GLOBAL content roles for the subtree (--color-content-primary → the
on-solid ink; secondary/muted/border → oklch mixes of it; 'light' uses the
dark on-solid-contrast ink). Verified live: a muted paragraph inside an
aurora Surface computes the context ink at 64 % — the Radix
appearance/MD3 on-surface move as custom-property re-binding. Hard
limits, by construction and documented at every consumer: nested components
resolve their OWN tokens; portaled content escapes ancestor inheritance.
Under forced-colors the context resolves to CanvasText/GrayText
(appended to the foundation's forced-colors block). No color-scheme on
purpose: it flips UA chrome only, and would mismatch inside an opposite
theme.
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 |
| D11 | Named finishes = open-cage gradients opted in via gradientFinish.named, each with required authored ink; base stays the identity's solid |
a multi-role image can't inherit a derived ink; alpha-blob materials need no trailing base (layered fill) → background-image-valid by architecture; numeric validation only possible for model-built gradients (deferred, documented) |
2026-07-15 · standing |
| D12 | on prop → data-on re-binds global content roles for the subtree; MINIMAL by design |
plain content re-inks (verified live: muted = context ink @64 %); nested components + portals excluded by construction; forced-colors → CanvasText; no color-scheme (UA chrome only) |
2026-07-15 · standing |
12. Artifacts
| What | Where |
|---|---|
| Doctrine (standing summary) | theming/reference.md §39 |
| Chronicle (dated, incl. the rectification) | theming/changelog.md §42 |
| Operational plan + phase queue | docs/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 |