You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/theming/gradient-finish.md

34 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:

  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).

Alpha / transparency (audited 2026-07-15, by computed values). The layered fill is what makes transparency correct across the whole system:

  • Opaque identities (every solid slot 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 aurora named finish is deliberately translucent — role blobs color-mix(…, transparent) fading to transparent — 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 the soft/surface variants are untouched — the finish is inert on non-solid, so their translucency is preserved by not being read.
  • data-on inks 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 #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 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.
  • The subtree ink context is MINIMAL by design (D12). data-on re-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.1 Surface · v2.2 named finishes (D11) + on/data-on minimal (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 shipped aurora is pure alpha blobs with NO trailing base color: it composes over ANY identity's solid and stays background-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-ink var 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}-fg override) 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

Powered by TurnKey Linux.