3.5 KiB
$libs/gradient — the canonical gradient model
A pure, zero-dependency gradient model + CSS serializer, shared by two consumers so a gradient is one thing in both places:
- the eidos theming axis (
uix/eidos/lib/build-gradient.ts) — derives named--gradient-*tokens from color roles; - the GradientBuilder component (soma, Phase 2) — the interactive editor.
A gradient built in the editor is therefore also a themeable token, and a token re-opens in the editor. No other system unifies "gradient derived from a color system" with "hand-edited gradient" — that unification is the whole point of keeping the model in a lib both layers can import (below soma, no layer violation).
The model (model.ts)
type Gradient = LinearGradient | RadialGradient | ConicGradient | MeshGradient
interface GradientStop {
color: StopColor // role ref | OKLCH literal | raw css
position?: number // 0..1 along the line
alpha?: number // 0..1
}
type StopColor =
| { kind: 'role'; role: string; slot?: string; step?: number } // → var(--color-…) — RE-TINTS
| { kind: 'oklch'; value: Oklch } // stored perceptual literal
| { kind: 'css'; value: string } // transparent / currentColor
Geometry is explicit per kind (LinearGradient.angle, RadialGradient.shape/size/at,
ConicGradient.from/at, MeshGradient.points) so it round-trips losslessly to
CSS and maps trivially to a future on-canvas gizmo + keyboard editing — no opaque
affine matrix. Colors are stored as Oklch (not hex): the "store OKLCH, not
hex" property that keeps gradients gamut-honest and perceptually interpolated.
Why role stops are the headline
A { kind: 'role', … } stop serializes to var(--color-{role}-{slot}) rather
than a frozen color. So a single --gradient-{name} token re-tints with the
brand seed and flips light/dark for free — Tailwind / Open Props / Panda blend
two literal endpoints; Material 3 has no gradient axis at all. Nobody derives the
stops themselves from a color system.
The serializer (to-css.ts)
gradientToCss(g: Gradient): string // model → a CSS <gradient> (or stacked-radial mesh) string
meshToCss(m: MeshGradient): string
stopColorToCss(c: StopColor, alpha?): string
- Role stops →
var(--color-…); OKLCH →oklch(L% C H / a); css → verbatim. - Interpolation defaults to
in oklch(perceptual; not the muddy sRGB midpoint dead-zone) — emitted geometry-first thenin <space> [<hue>], the ordering Tailwind v4 ships in production. Hue paths (longer/shorter) drive aurora / iridescent sweeps from two role endpoints. - Mesh serializes to layered
radial-gradients over a base — the only portable CSS mesh today (native CSS mesh is unshipped). Re-tints because each blob is a role/OKLCH color in the model.
The OKLCH→hex fallback sibling (wide-gamut honesty for pinned literals) is a
consumer concern (eidos has $color); this lib stays pure and emits the
oklch() form.
Status
- ✅ model +
gradientToCss(this lib) — tested into-css.test.ts. - ✅ eidos axis (
buildGradient/applyGradients) — seeuix/eidos. - ⏳
parseGradient(CSS → model, lossless round-trip) — Phase 2, what the GradientBuilder needs (paste-to-edit). The model is shaped for it (hints, explicit geometry) but it is not implemented yet.