|
|
---
|
|
|
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.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. D3 — The ramp derives from the instance's own palette slots
|
|
|
|
|
|
**Decision.** There is **no per-color gradient catalog**. The ramp is
|
|
|
composed in CSS from the slots the identity already resolves —
|
|
|
`--{c}-palette-solid` / `--{c}-palette-solid-hover` — via `color-mix(in
|
|
|
oklch, …)`, emitted once per opted-in recipe by `renderRecipeGradientFinish`
|
|
|
(`render-css.ts`).
|
|
|
|
|
|
**Why.** The THM-2 shared palette layer already routes **every** identity —
|
|
|
9 roles, 33 donor scales, `data-color-custom` raw brand colors — into those
|
|
|
per-instance slots. Deriving from them means the finish re-tints with roles,
|
|
|
scales, custom colors, themes and light/dark **automatically**, with zero new
|
|
|
rows in the shared layer and zero maintenance per color. The ink is the same
|
|
|
inherited `--{c}-palette-contrast` the flat fill uses. Non-interactive
|
|
|
recipes (Badge) declare no `solid-hover`; the deep stop falls back to `solid`
|
|
|
— the ramp still deepens via the anchor mix.
|
|
|
|
|
|
Interpolation is `in oklch` (perceptual) — consistent with the named-gradient
|
|
|
builder and with Tailwind v4's default; it keeps midpoint luminance smooth and
|
|
|
predictable, which the guard's sampling relies on.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. D4 — Why the ramp endpoints are NOT other scale steps (9→7, 9→11)
|
|
|
|
|
|
**Decision.** The ramp never reaches for other steps of the 12-step scale.
|
|
|
Its endpoints live in the solid's own neighborhood (solid / solid-hover,
|
|
|
mixed toward the anchor).
|
|
|
|
|
|
**Why.** The obvious idea — "want a wider ramp? span two steps" — fails on
|
|
|
the scale's own semantics, verified against the generated theme values:
|
|
|
|
|
|
- The 12 steps are **functional, mode-relative slots**, not a luminance
|
|
|
ruler: 7 = border, 9/10 = solid, 11 = text. In light mode purple-11 is
|
|
|
`#8145b5` (ΔL ≈ 0.04 from solid — an even FLATTER ramp than 9→10); in dark
|
|
|
mode purple-11 is `#d19dff` — a **light** text tone: the 9→11 ramp inverts
|
|
|
direction and kills white ink. 9→7 dies symmetrically (7-light `#d1afec` is
|
|
|
a pastel: white ink ~1.9:1; 7-dark is darker than 9 — inverted again).
|
|
|
- The luminance cliff of the scale sits at 8→9 and 11→12, not inside the
|
|
|
solid region. In slot language the proposal reads "fill from _solid_ to
|
|
|
_border_" or "to _text_" — the names themselves flag the category error.
|
|
|
- The **only mode-stable anchor** in the entire scale is the solid itself
|
|
|
(purple-9 is `#8e4ec6` in BOTH modes). Anything anchored elsewhere imports
|
|
|
per-mode direction flips and per-scale polarity exceptions.
|
|
|
|
|
|
This analysis is demonstrated live in the lab's «¿y por qué no saltar 2
|
|
|
pasos?» panel, with the real theme values.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. D5 — ONE theme dial: `--gradient-finish-lift`
|
|
|
|
|
|
**Decision.** The finish intensity is **config data**:
|
|
|
`primitives.gradientFinish.lift` (default `26%`) → emitted as the
|
|
|
`--gradient-finish-lift` token. One dial scales every ramp in the catalog;
|
|
|
`0%` degenerates the ramp to ≈flat (the dial is also the global off-switch).
|
|
|
Per-instance override is free via the custom-property cascade; a **theme**
|
|
|
overrides it by declaring `gradientFinish.lift` in its `ThemeDefinition` —
|
|
|
the theme block re-emits the token at the same specificity, later in the
|
|
|
cascade, so light and dark can run different intensities (or `0%` to turn
|
|
|
the finish off per theme). The compound `gradient-finish-*` prefix reserves
|
|
|
the namespace against the named-gradient open cage exactly like
|
|
|
`--gradient-angle-*` does.
|
|
|
|
|
|
**Why.** This is the state-layer precedent (§38/§40) applied again: visual
|
|
|
magnitudes are **config data emitted by the generator**, never hand-written
|
|
|
CSS constants. A theme retunes one number; the whole catalog follows. The
|
|
|
alternative — per-kind or per-component knobs — multiplies surface without a
|
|
|
consumer, and the pure 9→10 ramp (no knob at all) was measured imperceptible
|
|
|
at button size in the interactive mockup that preceded implementation.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. D6 — The ANCHORED ramp: «la rampa huye de la tinta»
|
|
|
|
|
|
This is the load-bearing decision, and the one that was **rectified under
|
|
|
measurement** mid-implementation. Both halves — the mistake and the fix —
|
|
|
are part of the record.
|
|
|
|
|
|
**The mistake (documented deliberately).** The first ratified form was a
|
|
|
global _lift toward white_: light end = `solid + lift`, deep end =
|
|
|
`solid-hover − lift/3`. It looked right on the mockup — which was calibrated
|
|
|
on **purple**, one of the highest-headroom colors in the palette (sampling
|
|
|
bias), and it shipped with its validation queued instead of gating. The probe
|
|
|
(84 combos = 9 roles + 33 scales × light/dark; floors mirroring
|
|
|
`on-solid.ts`: APCA |Lc| ≥ 60 ∧ WCAG ≥ 3) then measured:
|
|
|
|
|
|
- **52/84 combos broke the inherited ink at 26 %** at the ramp extremes
|
|
|
(37/84 even sampling only the text band);
|
|
|
- per-color safe ceilings: teal **1 %**, gray 15 %, dark-mode blues/greens
|
|
|
**0 %** — the scale's step 9 simply has **no headroom toward white** on
|
|
|
half the palette, because the on-solid floors are calibrated exactly there;
|
|
|
- the **global safe ceiling was 0 %** — no perceptible global lift exists.
|
|
|
|
|
|
Process lesson, recorded as doctrine: **measure before fixing a default; a
|
|
|
default calibrated visually on one color is a sampling artifact.** The guard
|
|
|
must gate the number, not trail it.
|
|
|
|
|
|
**The fix — the doctrine.** The ramp **anchors to the ink's shadow side**:
|
|
|
|
|
|
- **White-ink colors** (most roles/scales): anchor `#000`, strong stop DOWN —
|
|
|
the fill deepens from the solid into shade. The classic shaded CTA.
|
|
|
- **Dark-ink colors** (light solids: amber, yellow, lime, mint, sky…):
|
|
|
anchor `#fff`, strong stop UP — the fill lifts into highlight. Glossy.
|
|
|
|
|
|
Formally: `weak = mix(solid, anchor, lift/3)` under the label,
|
|
|
`strong = mix(solid-hover, anchor, lift)` falling away, with the gradient
|
|
|
angle flipping with the polarity (`180deg` / `0deg` via the existing
|
|
|
`--gradient-angle-*` tokens). Because **both stops move toward the ink's
|
|
|
safe side**, the label's contrast can only **improve** over the base solid
|
|
|
pairing that the component already ships. That is _constructive safety_:
|
|
|
|
|
|
- the dial is **unbounded** (probe: 0 regressions up to 40 %, the dial's UI
|
|
|
range);
|
|
|
- intensity is **uniform** across the whole palette — no per-color caps, no
|
|
|
colors where the feature silently vanishes;
|
|
|
- the physical reading is coherent: the light source sits on the label's
|
|
|
side; the material falls away toward shade.
|
|
|
|
|
|
**How the polarity resolves.** By the SAME flip the `contrast` slot already
|
|
|
rides — no second source of truth:
|
|
|
|
|
|
- per role, at theme time: `--color-{role}-finish-anchor` / `-finish-angle`
|
|
|
are emitted next to the role's contrast decision (`onSolidClearsFloors`);
|
|
|
- per donor scale, at build time: the THM-2 shared rows carry
|
|
|
`--palette-finish-anchor` / `-finish-angle` decided by the same
|
|
|
`lightSolidScales` membership that decides `palette-contrast`;
|
|
|
- per recipe, with the presence guard mirroring the palette forward: the
|
|
|
host block pins **primary's** anchor so a colorless nested instance never
|
|
|
inherits an ancestor's polarity over its own primary fill (the nesting
|
|
|
gap, same defense as THM-2).
|
|
|
|
|
|
**The guard.** [`gradient-finish-guard.test.ts`](../../src/uix/eidos/gradient-finish-guard.test.ts)
|
|
|
pins the invariant executable:
|
|
|
|
|
|
1. coverage — the full color axis (≥ 84 combos);
|
|
|
2. **no-regression** — every combo whose base solid clears the floors with
|
|
|
its own ink still clears them at any dial value ≤ 40 % (worst-sample of
|
|
|
the anchored ramp);
|
|
|
3. the pre-existing **flat-fail set is pinned** — `{role:risk(orange),
|
|
|
scale:cyan, scale:orange}` are mid-tone solids where NO single ink clears
|
|
|
APCA 60 even flat (an industry-wide on-solid reality, out of the finish's
|
|
|
scope); the set must never grow silently.
|
|
|
|
|
|
**Rejected alternatives, structurally.** (a) global lift + lower default —
|
|
|
ceiling is 0 %, no default exists; (b) per-color caps (`min(dial, cap)`) —
|
|
|
uniformity dies, the feature vanishes on teal/grays and to 0 on
|
|
|
orange/cyan; (c) computing a separate ramp ink per color — flat and finished
|
|
|
siblings would carry different inks for the same identity, and it reopens
|
|
|
the very ink-over-gradient problem the ecosystem cannot solve.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. D7 — The generator emits the VAR; the recipe paints
|
|
|
|
|
|
**Decision.** `renderRecipeGradientFinish` emits only the ramp variable
|
|
|
(`--_{c}-fill-finish`, plus the anchor/angle pins) under
|
|
|
`[data-{c}][data-gradient]`. The **recipe** paints it on its saturated fill
|
|
|
(`[data-variant='solid']`) and re-asserts on `:hover`, next to its own hover
|
|
|
rule with its own guards.
|
|
|
|
|
|
**Why.** Button's hover is `[data-button]:hover:not([data-disabled]):not([data-loading])`
|
|
|
— specificity (0,4,0) — and uses the `background:` **shorthand**, which
|
|
|
resets the `background-image` longhand. A generic generator re-assert at
|
|
|
(0,3,0) silently loses; matching each recipe's guard shape from the generator
|
|
|
would couple it to every recipe's selector conventions. The split follows the
|
|
|
system's grain: _the generator fabricates from data; the recipe decides where
|
|
|
paint lands_ — exactly how variants consume palette slots. The finish applies
|
|
|
to the saturated fill and is **inert** on soft/outline/ghost: no fill to
|
|
|
finish, and no broken contract, because `gradient` never claimed to be the
|
|
|
identity (contrast with the rejected color-value design, where the same
|
|
|
pixels violated the axis).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. D8 — `data-gradient` is an eidos-only WRAPPER attr (not morfo-declared)
|
|
|
|
|
|
**Decision.** The attr is stamped by the eidos wrapper (Button: via the soma
|
|
|
provider's passthrough props; Badge: directly on its element) and does NOT
|
|
|
appear in any morfo declaration — the `data-variant` / `data-size` family.
|
|
|
|
|
|
**Why (found live, the bug is instructive).** Declaring it in the morfo (on
|
|
|
the `data-color-custom` precedent) made the attr silently vanish: a
|
|
|
morfo-declared attr resolves in the **soma provider's prop space**, where the
|
|
|
pure-visual `gradient` prop does not exist → the runtime emits `undefined`
|
|
|
for it → soma's `mergeProps(restProps, state.props)` gives the runtime's
|
|
|
`undefined` precedence and **clobbers the wrapper's stamp**. The corrected
|
|
|
rule of thumb, now doctrine: **the morfo declares an attr only when its
|
|
|
driving prop crosses the soma boundary** (`data-color-custom` qualifies —
|
|
|
Card's `colorCustom` is a soma prop; `gradient` does not). eidos-lint
|
|
|
classifies these as "wrapper attrs" (eidos-only) — that category _is_ the
|
|
|
convention.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 10. Boundaries — what the finish is NOT
|
|
|
|
|
|
- **Not the named gradients.** `--gradient-{name}` (config `gradients`, the
|
|
|
documented "open cage", `buildGradient` factories) stays a decorative /
|
|
|
scenography axis: `Image`'s shimmer today; the `Surface` primitive with
|
|
|
`gradient="aurora" on="light|dark"` in v2. Mesh materials live there —
|
|
|
never in component fills.
|
|
|
- **Not gradient text.** `TextGradient` clips a single `<image>` through
|
|
|
glyphs; disjoint mechanism, unchanged.
|
|
|
- **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`](./reference.md) |
|
|
|
| Chronicle (dated, incl. the rectification) | [`theming/changelog.md §42`](./changelog.md) |
|
|
|
| Operational plan + phase queue | [`docs/process/gradient-finish-plan-2026-07.md`](../process/gradient-finish-plan-2026-07.md) |
|
|
|
| Generator | `src/uix/eidos/lib/render-css.ts` — `renderRecipeGradientFinish`, `GRADIENT_FINISH_COMPONENTS` gate; anchor/angle emission in the role loop + THM-2 shared rows |
|
|
|
| Config + default | `src/uix/eidos/lib/config-types.ts` (`gradientFinish`) · `lib/primitives/static.ts` (`STATIC_GRADIENT_FINISH`) |
|
|
|
| Consumers (finish gate) | `components/button/*` · `components/badge/*` · `components/surface/*` (prop `gradient`, paint rules) |
|
|
|
| The themeable canvas | `components/surface/*` — Box + treatment (v2.1); ficha with its own Decisiones/Gaps |
|
|
|
| **Executable invariant** | `src/uix/eidos/gradient-finish-guard.test.ts` — coverage / no-regression ≤ 40 % / pinned flat-fail set |
|
|
|
| Live lab (9 cases, real components, real token) | `web/routes/temas/gradientes` |
|