feat(color): deriveScheme + harmonize — Material 3 formula in OKLCH

Theme-builder core in uix.color: one brand seed -> hierarchy role seeds
(primary/secondary/tertiary/neutral/neutralVariant). Ports M3's HCT CorePalette
to OKLCH — secondary = same hue/low chroma, tertiary = hue+60deg, neutral =
near-gray; variants tonal/vibrant/monochrome (structured for more). harmonize()
nudges hues toward the brand (M3 blend.harmonize). The 6 canonical intents are
NOT derived (an error is always red); APCA replaces HCT's tone->contrast. Pure
+ isomorphic — produces values behind the frozen --color-{role}-{slot}
contract, so zero component impact.

Docs: COLOR_ENGINE_RFC.md §6.2 + color README theme-builder section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 71f97cc470
commit 60dd14ec74

@ -53,6 +53,25 @@ so the solid step lands on the seed **exactly**. Beats a naive `l − k` linear
(constant chroma → muddy mids) because the donor's curve is already perceptually
placed. The 31 Radix scales eidos already ships become the **template library**.
## Theme builder (scheme derivation)
`deriveScheme(seed, variant?)` ports **Material 3's HCT `CorePalette` to OKLCH**: one
brand seed → the hierarchy role seeds (`primary` / `secondary` / `tertiary` /
`neutral` / `neutralVariant`).
- **secondary** = same hue, low chroma · **tertiary** = hue **+ 60°**, moderate
chroma · **neutral** = same hue, near-zero chroma. (Structure is exact; chroma is
recalibrated HCT→OKLCH. Tone→contrast is NOT ported — APCA decides contrast.)
- **Variants** (`SchemeVariant`): `tonal` (default), `vibrant`, `monochrome`.
- The **6 canonical intents are NOT derived** (an error is always red).
`harmonize(color, toward, amount?)` (M3 `blend.harmonize`) nudges a hue toward the
brand for cohesion — opt-in, keeps L + C.
Feed each derived seed to `generateScale`. **Live builder**: seed → `deriveScheme` →
`generateScale` → `ActiveEidos.setCssVariables` — same code at build or runtime. It
produces only VALUES behind the frozen `--color-{role}-{slot}` contract, so it
touches **no component**. Full spec: `../../uix/eidos/COLOR_ENGINE_RFC.md` §6.2.
## Notes
- **APCA is a WCAG 3 draft**, not legal conformance. `pickOnSolid` decides by APCA

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
alphaOverBackground,
apcaLc,
deriveScheme,
harmonize,
gammaRgbToHex,
generateScale,
isInSrgbGamut,
@ -233,3 +235,50 @@ describe('alphaOverBackground — compositing inverse', () => {
expect(out).toBe(gammaRgbToHex(target));
});
});
describe('deriveScheme — Material-style role derivation (OKLCH)', () => {
const seed = parseColor('#1570ef'); // brand blue ~H259
const norm = (h: number) => ((h % 360) + 360) % 360;
it('tonal: primary is the seed; tertiary rotates +60°; secondary keeps the hue but desaturates', () => {
const s = deriveScheme(seed, 'tonal');
expect(s.primary).toEqual(seed);
expect(s.tertiary[2]).toBeCloseTo(norm(seed[2] + 60), 1);
expect(s.secondary[2]).toBeCloseTo(seed[2], 1);
expect(s.secondary[1]).toBeLessThan(seed[1]);
expect(s.neutral[1]).toBeLessThan(0.02);
});
it('vibrant: tertiary is more saturated than tonal', () => {
expect(deriveScheme(seed, 'vibrant').tertiary[1]).toBeGreaterThan(
deriveScheme(seed, 'tonal').tertiary[1]
);
});
it('monochrome: every role has zero chroma', () => {
const s = deriveScheme(seed, 'monochrome');
for (const role of [s.primary, s.secondary, s.tertiary, s.neutral, s.neutralVariant]) {
expect(role[1]).toBe(0);
}
});
it('defaults to the tonal variant', () => {
expect(deriveScheme(seed)).toEqual(deriveScheme(seed, 'tonal'));
});
});
describe('harmonize', () => {
it('nudges hue toward the target, preserving L and C', () => {
const color = parseColor('#e5484d');
const toward = parseColor('#1570ef');
const out = harmonize(color, toward, 0.2);
expect(out[0]).toBe(color[0]);
expect(out[1]).toBe(color[1]);
expect(out[2]).not.toBeCloseTo(color[2], 0);
});
it('harmonizing toward self leaves the hue unchanged', () => {
const c = parseColor('#8e4ec6');
expect(harmonize(c, c)[2]).toBeCloseTo(c[2], 5);
});
});

@ -18,6 +18,9 @@ export {
export { apcaLc, wcagContrastRatio } from './apca';
export { deriveScheme, harmonize } from './scheme';
export type { DerivedScheme, SchemeVariant } from './scheme';
export {
alphaOverBackground,
generateScale,

@ -0,0 +1,115 @@
/**
* Scheme derivation — one brand seed → the hierarchy role seeds, ported from
* Material 3's HCT `CorePalette` to OKLCH. This is the core of a theme builder:
*
* seed → deriveScheme(seed, variant) → { primary, secondary, tertiary, … }
* → generateScale(eachSeed, template) → 12-step scales → tokens
*
* Material's rule (HCT): secondary = same hue, low chroma; tertiary = hue + 60°,
* moderate chroma; neutral = same hue, near-zero chroma. The STRUCTURE is
* model-agnostic, so it ports cleanly; only the absolute chroma numbers are
* recalibrated (HCT chroma 0..~120 ≠ OKLCH chroma 0..~0.37). Tone→contrast (HCT's
* other trick) is NOT ported — the framework decides contrast with APCA instead.
*
* The 6 canonical intents (affirm/risk/threat/…) are NOT derived here — they are
* book-defined hues (an error is always red). Use `harmonize` to nudge them toward
* the brand if a builder wants cohesion.
*/
import type { Oklch } from './types';
/**
* Material-style scheme flavors. More can be added (expressive / neutral /
* content) as `{ secondary, tertiary, neutral, neutralVariant }` rule sets.
*/
export type SchemeVariant = 'tonal' | 'vibrant' | 'monochrome';
export interface DerivedScheme {
/** The brand seed (verbatim, or desaturated under `monochrome`). */
readonly primary: Oklch;
/** Same hue as primary, desaturated — a quiet sibling. */
readonly secondary: Oklch;
/** Primary's hue rotated ~+60° — a harmonious accent. */
readonly tertiary: Oklch;
/** Near-gray with a hint of the brand hue — drives surfaces. */
readonly neutral: Oklch;
/** Slightly more tinted gray — drives borders / variant surfaces. */
readonly neutralVariant: Oklch;
}
const norm360 = (h: number): number => ((h % 360) + 360) % 360;
/** Per-role rule: hue delta from the seed + an absolute OKLCH chroma target. */
interface RoleRule {
readonly dh: number;
readonly c: number;
}
interface VariantRules {
readonly secondary: RoleRule;
readonly tertiary: RoleRule;
readonly neutral: RoleRule;
readonly neutralVariant: RoleRule;
}
// OKLCH-calibrated ports of M3's CorePalette chroma targets (HCT secondary 16 ·
// tertiary +60/24 · neutral 4 · neutral-variant 8). Tertiary's +60° rotation is the
// canonical Material tertiary rule.
const VARIANTS: Record<SchemeVariant, VariantRules> = {
tonal: {
secondary: { dh: 0, c: 0.04 },
tertiary: { dh: 60, c: 0.09 },
neutral: { dh: 0, c: 0.008 },
neutralVariant: { dh: 0, c: 0.016 }
},
vibrant: {
// More colorful: secondary picks up a small rotation + chroma; tertiary stays
// at +60° but saturated; the neutrals carry a touch more brand tint.
secondary: { dh: 20, c: 0.1 },
tertiary: { dh: 60, c: 0.16 },
neutral: { dh: 0, c: 0.02 },
neutralVariant: { dh: 0, c: 0.03 }
},
monochrome: {
// Grayscale ink (Vercel / Linear look): the hierarchy collapses to one neutral
// by design — differentiate via tone steps + emphasis, not hue.
secondary: { dh: 0, c: 0 },
tertiary: { dh: 0, c: 0 },
neutral: { dh: 0, c: 0 },
neutralVariant: { dh: 0, c: 0 }
}
};
/**
* Derive the hierarchy role seeds from one brand seed (Material 3 `CorePalette`
* ported to OKLCH). All roles share the seed's lightness (so their solids are
* tonally consistent); they vary by hue rotation + chroma per the variant. Primary
* is the seed verbatim (brand fidelity), except `monochrome` desaturates it.
*
* Output seeds are raw OKLCH (may be wide-gamut); `generateScale` / `oklchToHex`
* gamut-map them at emit time. Feed each seed to `generateScale` for the 12 steps.
*/
export function deriveScheme(seed: Oklch, variant: SchemeVariant = 'tonal'): DerivedScheme {
const [l, c, h] = seed;
const rules = VARIANTS[variant];
const role = (rule: RoleRule): Oklch => [l, rule.c, norm360(h + rule.dh)];
return {
primary: variant === 'monochrome' ? [l, 0, h] : [l, c, h],
secondary: role(rules.secondary),
tertiary: role(rules.tertiary),
neutral: role(rules.neutral),
neutralVariant: role(rules.neutralVariant)
};
}
/**
* Nudge `color`'s hue toward `toward`'s hue by `amount` (0..1) of the angular
* distance — Material 3's `blend.harmonize`. Use it to pull the canonical intents
* a little toward the brand for cohesion (opt-in; keeps lightness + chroma).
*/
export function harmonize(color: Oklch, toward: Oklch, amount = 0.15): Oklch {
const [l, c, h] = color;
let dh = norm360(toward[2]) - norm360(h);
if (dh > 180) dh -= 360;
if (dh < -180) dh += 360;
return [l, c, norm360(h + dh * amount)];
}

@ -281,6 +281,62 @@ del documento de bloat **sin** la regresión de CSS-relative-colors.
> alpha-inverse **no hay primitiva CSS** — JS es el único camino. Por eso el motor es
> JS isomórfico, no CSS.
### 6.2 — Derivación de esquema (theme builder · fórmula Material 3)
Encima de `generateScale` (un seed → 12 pasos) vive **`deriveScheme`** (un seed → los
SEEDS de los roles de jerarquía). Es el núcleo de un theme builder:
```
seed de marca → deriveScheme(seed, variant) → { primary, secondary, tertiary, neutral, neutralVariant }
→ generateScale(cada seed) → escalas de 12 pasos
→ setCssVariables(...) → tema en vivo
```
**La fórmula** es la de Material 3 (`CorePalette` HCT) portada a OKLCH:
| rol | hue | chroma (OKLCH calibrado) | regla M3 (HCT) |
| --- | --- | --- | --- |
| primary | H (seed) | el del seed (verbatim) | `max(C, 48)` |
| secondary | H | `0.04` (bajo) | `16` |
| **tertiary** | **H + 60°** | `0.09` | `+60 / 24` |
| neutral | H | `0.008` (casi gris) | `4` |
| neutral-variant | H | `0.016` | `8` |
La **estructura** (mismo-hue-desaturado para secondary · +60° para tertiary) es
model-agnóstica, así que porta exacta; solo los números de croma se recalibran (HCT
`0..120` ≠ OKLCH `0..0.37`). El truco **tone→contraste** de HCT NO se porta — el
contraste lo decide **APCA** (§8).
**Variantes** (`SchemeVariant`) — el "estilo" del builder:
- `tonal` (default) — la tabla (look M3 clásico).
- `vibrant` — más croma + pequeña rotación en secondary; tertiary saturado.
- `monochrome` — croma 0 en todo: la jerarquía **colapsa a una tinta neutra** (look
Vercel / Linear); se diferencia por tono + énfasis, no por hue (colisión *por
diseño*, a diferencia del bug del base).
Estructurado para añadir `expressive` / `neutral` / `content` como ~15 líneas de
reglas, sin tocar nada más.
**Los 6 intents NO se derivan** — son hues canónicos del libro (un error es rojo
siempre). `harmonize(color, toward, amount)` (M3 `blend.harmonize`) los empuja hacia
la marca un % si el builder quiere cohesión — opt-in, conserva L y C.
**Caveat del +60°**: la rotación de Material puede caer cerca de un intent según el
primary (p. ej. `purple + 60° = H6 ≈ red/threat`). Por eso el tema base afinó su
`tertiary` a `indigo` (−60°, frío, libre de intents) **a mano**. Un builder debería
ofrecer override del hue del tertiary o esquivar la banda de los intents (red 25° ·
orange 55° · amber 75° · green 158° · teal 182° · plum 330°).
**Blast radius: cero sobre componentes.** `deriveScheme` produce VALORES que entran
por el contrato congelado `--color-{role}-{slot}` (§10). Ningún componente, recipe,
CSS ni el TSC cambian — una variante es "otro tema", como `base` ↔ `grafito`. El
**número de variantes es decisión de catálogo del builder, no coste arquitectónico**
(no se shippean N CSS; se computa un tema a la vez, build o runtime).
Vive en `uix.color` (`scheme.ts`): matemática pura, isomórfica. En runtime, el seed
del usuario → `deriveScheme` → `generateScale` → `setCssVariables` = theme builder en
vivo, conservando pick APCA + alpha (§6.1).
---
## 7. Salida wide-gamut (P3 + sRGB)

Loading…
Cancel
Save

Powered by TurnKey Linux.