From 60dd14ec7439ed225fe4fc010d102f5a7d343fda Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 4 Jun 2026 19:59:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(color):=20deriveScheme=20+=20harmonize=20?= =?UTF-8?q?=E2=80=94=20Material=203=20formula=20in=20OKLCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/arts/color/README.md | 19 +++++ src/arts/color/color.test.ts | 49 +++++++++++++ src/arts/color/index.ts | 3 + src/arts/color/scheme.ts | 115 ++++++++++++++++++++++++++++++ src/uix/eidos/COLOR_ENGINE_RFC.md | 56 +++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 src/arts/color/scheme.ts diff --git a/src/arts/color/README.md b/src/arts/color/README.md index f82b94a75..0cbab2f0f 100644 --- a/src/arts/color/README.md +++ b/src/arts/color/README.md @@ -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 diff --git a/src/arts/color/color.test.ts b/src/arts/color/color.test.ts index 0ff3c200c..bc78c839f 100644 --- a/src/arts/color/color.test.ts +++ b/src/arts/color/color.test.ts @@ -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); + }); +}); diff --git a/src/arts/color/index.ts b/src/arts/color/index.ts index 84b66604a..85d36ffa2 100644 --- a/src/arts/color/index.ts +++ b/src/arts/color/index.ts @@ -18,6 +18,9 @@ export { export { apcaLc, wcagContrastRatio } from './apca'; +export { deriveScheme, harmonize } from './scheme'; +export type { DerivedScheme, SchemeVariant } from './scheme'; + export { alphaOverBackground, generateScale, diff --git a/src/arts/color/scheme.ts b/src/arts/color/scheme.ts new file mode 100644 index 000000000..c2e57a06b --- /dev/null +++ b/src/arts/color/scheme.ts @@ -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 = { + 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)]; +} diff --git a/src/uix/eidos/COLOR_ENGINE_RFC.md b/src/uix/eidos/COLOR_ENGINE_RFC.md index 6ccece0a1..0bd91b4fc 100644 --- a/src/uix/eidos/COLOR_ENGINE_RFC.md +++ b/src/uix/eidos/COLOR_ENGINE_RFC.md @@ -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)