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

467 lines
28 KiB

eidos(gradient-finish): acabado anclado a la sombra de la tinta - v1 Button+Badge + dial temable + guard El gradiente entra al sistema como ACABADO (material) del fill, jamas como valor del eje de color: prop `gradient` -> attr eidos-only `data-gradient` (familia data-variant; NO morfo - el runtime lo resolveria desde props de soma y mergeProps clobberea el stamp del wrapper). - Fill por capas: background-color = base solida (degrada sola en forced-colors) + background-image = rampa derivada de los slots de LA instancia (roles + 33 escalas + custom gratis, tinta heredada). - Dial unico de tema: primitives.gradientFinish.lift (26%) -> --gradient-finish-lift (0% = apagado; override por instancia via cascada). - RAMPA ANCLADA a la sombra de la tinta ("la rampa huye de la tinta"): tinta blanca -> #000 fuerte abajo (CTA sombreado); tinta oscura -> #fff fuerte arriba (glossy). Ancla+angulo por color x modo con el MISMO flip del slot contrast. Rectificacion MEDIDA: el lift global hacia blanco rompia la tinta heredada en 52/84 combos a 26% (techo global 0%) - el contraste ahora solo puede mejorar: dial sin topes. - Guard ejecutable gradient-finish-guard.test.ts (3/3): no-regresion <=40% sobre 84 combos + set flat-fail clavado (cyan/orange, deuda on-solid preexistente). - Generador emite la VAR (--_{c}-fill-finish); el recipe pinta (solid + re-assert en hover: su shorthand background resetea el longhand). - Lab temas/gradientes: 9 casos con componentes reales sobre el token real (dial en vivo, polaridad observable, evidencia de pasos 7/11, forced-colors). - Docs: capitulo theming/gradient-finish.md (registro de decisiones D1-D9, alternativas rechazadas con evidencia, leccion de proceso: medir ANTES de fijar defaults) + reference.md paragrafo 39 + changelog paragrafo 42 + plan docs/process/gradient-finish-plan-2026-07.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
---
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).
**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.
eidos(gradient-finish): acabado anclado a la sombra de la tinta - v1 Button+Badge + dial temable + guard El gradiente entra al sistema como ACABADO (material) del fill, jamas como valor del eje de color: prop `gradient` -> attr eidos-only `data-gradient` (familia data-variant; NO morfo - el runtime lo resolveria desde props de soma y mergeProps clobberea el stamp del wrapper). - Fill por capas: background-color = base solida (degrada sola en forced-colors) + background-image = rampa derivada de los slots de LA instancia (roles + 33 escalas + custom gratis, tinta heredada). - Dial unico de tema: primitives.gradientFinish.lift (26%) -> --gradient-finish-lift (0% = apagado; override por instancia via cascada). - RAMPA ANCLADA a la sombra de la tinta ("la rampa huye de la tinta"): tinta blanca -> #000 fuerte abajo (CTA sombreado); tinta oscura -> #fff fuerte arriba (glossy). Ancla+angulo por color x modo con el MISMO flip del slot contrast. Rectificacion MEDIDA: el lift global hacia blanco rompia la tinta heredada en 52/84 combos a 26% (techo global 0%) - el contraste ahora solo puede mejorar: dial sin topes. - Guard ejecutable gradient-finish-guard.test.ts (3/3): no-regresion <=40% sobre 84 combos + set flat-fail clavado (cyan/orange, deuda on-solid preexistente). - Generador emite la VAR (--_{c}-fill-finish); el recipe pinta (solid + re-assert en hover: su shorthand background resetea el longhand). - Lab temas/gradientes: 9 casos con componentes reales sobre el token real (dial en vivo, polaridad observable, evidencia de pasos 7/11, forced-colors). - Docs: capitulo theming/gradient-finish.md (registro de decisiones D1-D9, alternativas rechazadas con evidencia, leccion de proceso: medir ANTES de fijar defaults) + reference.md paragrafo 39 + changelog paragrafo 42 + plan docs/process/gradient-finish-plan-2026-07.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
---
## 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.
eidos(gradient-finish): acabado anclado a la sombra de la tinta - v1 Button+Badge + dial temable + guard El gradiente entra al sistema como ACABADO (material) del fill, jamas como valor del eje de color: prop `gradient` -> attr eidos-only `data-gradient` (familia data-variant; NO morfo - el runtime lo resolveria desde props de soma y mergeProps clobberea el stamp del wrapper). - Fill por capas: background-color = base solida (degrada sola en forced-colors) + background-image = rampa derivada de los slots de LA instancia (roles + 33 escalas + custom gratis, tinta heredada). - Dial unico de tema: primitives.gradientFinish.lift (26%) -> --gradient-finish-lift (0% = apagado; override por instancia via cascada). - RAMPA ANCLADA a la sombra de la tinta ("la rampa huye de la tinta"): tinta blanca -> #000 fuerte abajo (CTA sombreado); tinta oscura -> #fff fuerte arriba (glossy). Ancla+angulo por color x modo con el MISMO flip del slot contrast. Rectificacion MEDIDA: el lift global hacia blanco rompia la tinta heredada en 52/84 combos a 26% (techo global 0%) - el contraste ahora solo puede mejorar: dial sin topes. - Guard ejecutable gradient-finish-guard.test.ts (3/3): no-regresion <=40% sobre 84 combos + set flat-fail clavado (cyan/orange, deuda on-solid preexistente). - Generador emite la VAR (--_{c}-fill-finish); el recipe pinta (solid + re-assert en hover: su shorthand background resetea el longhand). - Lab temas/gradientes: 9 casos con componentes reales sobre el token real (dial en vivo, polaridad observable, evidencia de pasos 7/11, forced-colors). - Docs: capitulo theming/gradient-finish.md (registro de decisiones D1-D9, alternativas rechazadas con evidencia, leccion de proceso: medir ANTES de fijar defaults) + reference.md paragrafo 39 + changelog paragrafo 42 + plan docs/process/gradient-finish-plan-2026-07.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
**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.
eidos(gradient-finish): v2.2 named finishes + on/data-on - EL PLAN COMPLETO D11 - Named finishes (el diseno A rescatado como opt-in explicito): - gradientFinish.named opta gradientes del open cage como acabado, cada uno con su tinta AUTORADA obligatoria (--gradient-{name}-ink, override de la convencion --_{c}-fg a especificidad (0,3,0) sobre el slice de variante). - La base sigue siendo el solid de la identidad (fill por capas, D2) -> el aurora shipped son blobs de rol con alfa SIN color base final: background-image-valido POR ARQUITECTURA (el caveat del mesh disuelto, no exceptuado), y re-tine por tema y modo via referencias de rol. - Honestidad documentada: la validacion numerica al peor stop de un string CSS arbitrario no es implementable (stops desconocidos); posible solo para gradientes de modelo (buildGradient) - diferida. El guard clava la sanidad del config (named subconjunto de gradients + ink presente). D12 - on/data-on minimo (Surface on="light|dark"): - La foundation re-vincula --color-content-*/--color-border-default para el subarbol (dark -> tinta on-solid; light -> on-solid-contrast; mixes oklch 82/64/32%). Verificado EN VIVO: un parrafo muted dentro del aurora computa la tinta del contexto al 64%. - Limites POR CONSTRUCCION y documentados en cada consumidor: componentes anidados con tokens propios y contenido portaleado NO se re-entintan (la inversion completa sigue siendo iniciativa independiente). forced-colors: el contexto resuelve a CanvasText/GrayText (bloque extendido). Sin color-scheme a proposito (solo chrome UA). Ademas: prop gradient ampliada a (string & {}) para named en Button/Badge/ Surface; poda del orphan-guard (border/text de Surface: 3 slots x 8 colores, lo que las variantes consumen); guard 5/5; audit 145/145; lab CASO 07 con el aurora REAL (<Surface gradient="aurora" on="dark">) verificado por valores. Docs: capitulo D11+D12 + registro D1-D12 + roadmap COMPLETO; reference p39; changelog p42; plan cerrado (quedan declaradas: inversion completa, demo propia de Surface, validacion de modelo para tintas named). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
- **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`), and the finish block overrides the recipe's
`--_{c}-fg` convention at (0,3,0). 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.
- **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.*
eidos(gradient-finish): acabado anclado a la sombra de la tinta - v1 Button+Badge + dial temable + guard El gradiente entra al sistema como ACABADO (material) del fill, jamas como valor del eje de color: prop `gradient` -> attr eidos-only `data-gradient` (familia data-variant; NO morfo - el runtime lo resolveria desde props de soma y mergeProps clobberea el stamp del wrapper). - Fill por capas: background-color = base solida (degrada sola en forced-colors) + background-image = rampa derivada de los slots de LA instancia (roles + 33 escalas + custom gratis, tinta heredada). - Dial unico de tema: primitives.gradientFinish.lift (26%) -> --gradient-finish-lift (0% = apagado; override por instancia via cascada). - RAMPA ANCLADA a la sombra de la tinta ("la rampa huye de la tinta"): tinta blanca -> #000 fuerte abajo (CTA sombreado); tinta oscura -> #fff fuerte arriba (glossy). Ancla+angulo por color x modo con el MISMO flip del slot contrast. Rectificacion MEDIDA: el lift global hacia blanco rompia la tinta heredada en 52/84 combos a 26% (techo global 0%) - el contraste ahora solo puede mejorar: dial sin topes. - Guard ejecutable gradient-finish-guard.test.ts (3/3): no-regresion <=40% sobre 84 combos + set flat-fail clavado (cyan/orange, deuda on-solid preexistente). - Generador emite la VAR (--_{c}-fill-finish); el recipe pinta (solid + re-assert en hover: su shorthand background resetea el longhand). - Lab temas/gradientes: 9 casos con componentes reales sobre el token real (dial en vivo, polaridad observable, evidencia de pasos 7/11, forced-colors). - Docs: capitulo theming/gradient-finish.md (registro de decisiones D1-D9, alternativas rechazadas con evidencia, leccion de proceso: medir ANTES de fijar defaults) + reference.md paragrafo 39 + changelog paragrafo 42 + plan docs/process/gradient-finish-plan-2026-07.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
---
## 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 |
eidos(gradient-finish): v2.2 named finishes + on/data-on - EL PLAN COMPLETO D11 - Named finishes (el diseno A rescatado como opt-in explicito): - gradientFinish.named opta gradientes del open cage como acabado, cada uno con su tinta AUTORADA obligatoria (--gradient-{name}-ink, override de la convencion --_{c}-fg a especificidad (0,3,0) sobre el slice de variante). - La base sigue siendo el solid de la identidad (fill por capas, D2) -> el aurora shipped son blobs de rol con alfa SIN color base final: background-image-valido POR ARQUITECTURA (el caveat del mesh disuelto, no exceptuado), y re-tine por tema y modo via referencias de rol. - Honestidad documentada: la validacion numerica al peor stop de un string CSS arbitrario no es implementable (stops desconocidos); posible solo para gradientes de modelo (buildGradient) - diferida. El guard clava la sanidad del config (named subconjunto de gradients + ink presente). D12 - on/data-on minimo (Surface on="light|dark"): - La foundation re-vincula --color-content-*/--color-border-default para el subarbol (dark -> tinta on-solid; light -> on-solid-contrast; mixes oklch 82/64/32%). Verificado EN VIVO: un parrafo muted dentro del aurora computa la tinta del contexto al 64%. - Limites POR CONSTRUCCION y documentados en cada consumidor: componentes anidados con tokens propios y contenido portaleado NO se re-entintan (la inversion completa sigue siendo iniciativa independiente). forced-colors: el contexto resuelve a CanvasText/GrayText (bloque extendido). Sin color-scheme a proposito (solo chrome UA). Ademas: prop gradient ampliada a (string & {}) para named en Button/Badge/ Surface; poda del orphan-guard (border/text de Surface: 3 slots x 8 colores, lo que las variantes consumen); guard 5/5; audit 145/145; lab CASO 07 con el aurora REAL (<Surface gradient="aurora" on="dark">) verificado por valores. Docs: capitulo D11+D12 + registro D1-D12 + roadmap COMPLETO; reference p39; changelog p42; plan cerrado (quedan declaradas: inversion completa, demo propia de Surface, validacion de modelo para tintas named). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
| 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 |
eidos(gradient-finish): acabado anclado a la sombra de la tinta - v1 Button+Badge + dial temable + guard El gradiente entra al sistema como ACABADO (material) del fill, jamas como valor del eje de color: prop `gradient` -> attr eidos-only `data-gradient` (familia data-variant; NO morfo - el runtime lo resolveria desde props de soma y mergeProps clobberea el stamp del wrapper). - Fill por capas: background-color = base solida (degrada sola en forced-colors) + background-image = rampa derivada de los slots de LA instancia (roles + 33 escalas + custom gratis, tinta heredada). - Dial unico de tema: primitives.gradientFinish.lift (26%) -> --gradient-finish-lift (0% = apagado; override por instancia via cascada). - RAMPA ANCLADA a la sombra de la tinta ("la rampa huye de la tinta"): tinta blanca -> #000 fuerte abajo (CTA sombreado); tinta oscura -> #fff fuerte arriba (glossy). Ancla+angulo por color x modo con el MISMO flip del slot contrast. Rectificacion MEDIDA: el lift global hacia blanco rompia la tinta heredada en 52/84 combos a 26% (techo global 0%) - el contraste ahora solo puede mejorar: dial sin topes. - Guard ejecutable gradient-finish-guard.test.ts (3/3): no-regresion <=40% sobre 84 combos + set flat-fail clavado (cyan/orange, deuda on-solid preexistente). - Generador emite la VAR (--_{c}-fill-finish); el recipe pinta (solid + re-assert en hover: su shorthand background resetea el longhand). - Lab temas/gradientes: 9 casos con componentes reales sobre el token real (dial en vivo, polaridad observable, evidencia de pasos 7/11, forced-colors). - Docs: capitulo theming/gradient-finish.md (registro de decisiones D1-D9, alternativas rechazadas con evidencia, leccion de proceso: medir ANTES de fijar defaults) + reference.md paragrafo 39 + changelog paragrafo 42 + plan docs/process/gradient-finish-plan-2026-07.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
---
## 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 |
eidos(gradient-finish): acabado anclado a la sombra de la tinta - v1 Button+Badge + dial temable + guard El gradiente entra al sistema como ACABADO (material) del fill, jamas como valor del eje de color: prop `gradient` -> attr eidos-only `data-gradient` (familia data-variant; NO morfo - el runtime lo resolveria desde props de soma y mergeProps clobberea el stamp del wrapper). - Fill por capas: background-color = base solida (degrada sola en forced-colors) + background-image = rampa derivada de los slots de LA instancia (roles + 33 escalas + custom gratis, tinta heredada). - Dial unico de tema: primitives.gradientFinish.lift (26%) -> --gradient-finish-lift (0% = apagado; override por instancia via cascada). - RAMPA ANCLADA a la sombra de la tinta ("la rampa huye de la tinta"): tinta blanca -> #000 fuerte abajo (CTA sombreado); tinta oscura -> #fff fuerte arriba (glossy). Ancla+angulo por color x modo con el MISMO flip del slot contrast. Rectificacion MEDIDA: el lift global hacia blanco rompia la tinta heredada en 52/84 combos a 26% (techo global 0%) - el contraste ahora solo puede mejorar: dial sin topes. - Guard ejecutable gradient-finish-guard.test.ts (3/3): no-regresion <=40% sobre 84 combos + set flat-fail clavado (cyan/orange, deuda on-solid preexistente). - Generador emite la VAR (--_{c}-fill-finish); el recipe pinta (solid + re-assert en hover: su shorthand background resetea el longhand). - Lab temas/gradientes: 9 casos con componentes reales sobre el token real (dial en vivo, polaridad observable, evidencia de pasos 7/11, forced-colors). - Docs: capitulo theming/gradient-finish.md (registro de decisiones D1-D9, alternativas rechazadas con evidencia, leccion de proceso: medir ANTES de fijar defaults) + reference.md paragrafo 39 + changelog paragrafo 42 + plan docs/process/gradient-finish-plan-2026-07.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
| **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.