From 0bb03559a9b1e6552b9ea573917c9268528eef2b Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 00:13:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20wide-gamut-true=20generator=20?= =?UTF-8?q?=E2=80=94=20buildScheme/applyColorScheme=20emit=20oklch()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme builder now carries REAL wide-gamut, not just sRGB reformatted. generateScale keeps raw OKLCH (no clamp), so a seed whose chroma exceeds sRGB renders more saturated on P3 than its hex fallback. - build-scheme.ts: result gains `wideGamut` (oklch() per opaque step) + `roles[].stepsOklch`; `variables` stays hex (fallback + introspection). New `schemeDeclarations(result, {fallback})` flattens to CSS lines — dual hex+oklch stack (default) or oklch-only (fallback:false, for inline style where the CSSOM keeps one value per prop). - ActiveEidos.#renderSchemeCss: emits the dual stack via schemeDeclarations → the applied scheme block is wide-gamut on P3, sRGB-safe everywhere. - index: export schemeDeclarations + SchemeDeclarationsOptions. - temas/color demo: new "vivacidad P3" slider pushes the seed chroma past sRGB + a "fuera de sRGB -> P3" badge (isInSrgbGamut). themeOverride now applies oklch (wide-gamut). Verified: vivacity x1.70 -> primary-9 chroma 0.18 -> 0.31, badge on. - tests: wide-gamut chroma retention (stepsOklch > hex fallback) + schemeDeclarations dual/single; active-eidos scheme block asserts oklch(). 31/31 green. - docs: THEMING §26/§27 + RFC §6.2. Honest scope unchanged: the AUTHORED Radix palette stays exact sRGB (no regression). Wide-gamut lives in the generator path (vivid seeds / OKLCH-authored themes). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/COLOR_ENGINE_RFC.md | 8 +-- src/uix/eidos/THEMING.md | 14 +++++- src/uix/eidos/active-eidos.svelte.ts | 17 ++++--- src/uix/eidos/active-eidos.test.ts | 2 + src/uix/eidos/index.ts | 3 +- src/uix/eidos/lib/build-scheme.test.ts | 25 +++++++++- src/uix/eidos/lib/build-scheme.ts | 69 +++++++++++++++++++++++--- web/routes/temas/color/+page.svelte | 57 ++++++++++++++++++--- 8 files changed, 170 insertions(+), 25 deletions(-) diff --git a/src/uix/eidos/COLOR_ENGINE_RFC.md b/src/uix/eidos/COLOR_ENGINE_RFC.md index 2c7e51dc0..aaff71294 100644 --- a/src/uix/eidos/COLOR_ENGINE_RFC.md +++ b/src/uix/eidos/COLOR_ENGINE_RFC.md @@ -352,9 +352,11 @@ Vive en `uix.color` (`scheme.ts`): matemática pura, isomórfica. La COMPOSICIÓ pura). El método runtime **`eidos.applyColorScheme(seed, opts)`** (ActiveEidos) resuelve las escalas-donantes + background del tema activo, escribe el bloque de estilo y **sigue light/dark** (re-deriva al cambiar de modo); devuelve un -`BuildSchemeResult` (steps / solid / on-solid por rol) para introspección. -`eidos.clearColorScheme()` revierte. **Estado: implementado** (Fase 4) — ver -THEMING.md §26; tests en `build-scheme.test.ts` + `active-eidos.test.ts`. +`BuildSchemeResult` (steps hex + `stepsOklch` + solid / on-solid por rol) para +introspección. `eidos.clearColorScheme()` revierte. El bloque apila **hex + `oklch()`** +por paso (wide-gamut, §7) y `generateScale` retiene el OKLCH raw sin clamp, así que un +seed vívido (croma > sRGB) sale P3 (demo: slider *vivacidad*). **Estado: implementado** +(Fase 4) — ver THEMING.md §26; tests en `build-scheme.test.ts` + `active-eidos.test.ts`. ```ts const result = eidos.applyColorScheme('#8e4ec6', { diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 5dd8e8848..92f8eed8c 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -2172,8 +2172,12 @@ cascada. **Override por rol** + **temper** = doctrina de §25.4 / RFC §6.2: la jerarquía deriva (override per-rol opcional), los intents **mantienen su hue** y solo afinan -temperatura. `applyColorScheme` devuelve `BuildSchemeResult` (steps / solid / on-solid -/ pinned por rol) para introspección de UI. +temperatura. `applyColorScheme` devuelve `BuildSchemeResult` (steps hex + `stepsOklch` ++ solid / on-solid / pinned por rol) para introspección de UI. + +**Wide-gamut**: el bloque apila **hex fallback + `oklch()`** por paso (vía +`schemeDeclarations`), y `generateScale` retiene el OKLCH raw sin clamp — un seed +vívido (croma > sRGB) sale wide-gamut en P3. Ver §27. Demo en vivo: `/temas/color` (el builder usa el mismo `buildScheme`). Tests: `build-scheme.test.ts` + `active-eidos.test.ts`. @@ -2202,6 +2206,12 @@ navegador lo soporta (Chrome 111+ / Safari 15.4+ / Firefox 113+). paleta Radix shipped es sRGB → idéntica hoy; wide-gamut **visible** de la paleta = Fase 3. - **Default-on, sin flag**: es el comportamiento del framework. `render-css.ts > appendColorScaleDeclarations`. +- **El generador SÍ produce wide-gamut REAL**: `buildScheme` / `applyColorScheme` + (§26) retienen el OKLCH raw de `generateScale` (sin clamp), así que un seed cuyo + croma excede sRGB renderiza más saturado en P3 que su hex fallback — el bloque apila + **hex + `oklch()`** por paso vía `schemeDeclarations(result, { fallback })`. El demo + `/temas/color` lo demuestra con el slider **vivacidad P3** (badge «fuera de sRGB → P3» + al cruzar el gamut; verificado: croma 0.18 → 0.31). --- diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index 193735b4b..05e385fb8 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -42,7 +42,12 @@ import { type RenderThemeCssOptions } from './lib/render-css'; import { createThemeBaseEidosConfig } from './lib/themes/base'; -import { buildScheme, type BuildSchemeOptions, type BuildSchemeResult } from './lib/build-scheme'; +import { + buildScheme, + schemeDeclarations, + type BuildSchemeOptions, + type BuildSchemeResult +} from './lib/build-scheme'; import type { Oklch } from '$color'; import type { EngineMotion } from '$motion'; import type { EidosConfigPatch } from './lib/options'; @@ -523,16 +528,16 @@ export class ActiveEidos { #renderSchemeCss(): string { if (!this.#schemeSpec) return ''; - let variables: EidosCssVariableMap; + let result: BuildSchemeResult; try { - variables = this.#buildSchemeResult().variables; + result = this.#buildSchemeResult(); } catch { return ''; } const selector = this.#schemeSpec.options.selector ?? DEFAULT_SCHEME_SELECTOR; - const body = Object.entries(variables) - .filter(([, value]) => value != null) - .map(([name, value]) => `\t${name}: ${value};`) + // Dual stack per opaque step: hex fallback + oklch() wide-gamut override. + const body = schemeDeclarations(result) + .map((line) => `\t${line}`) .join('\n'); return body ? `${selector} {\n${body}\n}` : ''; } diff --git a/src/uix/eidos/active-eidos.test.ts b/src/uix/eidos/active-eidos.test.ts index c8fde0074..eaff5eed3 100644 --- a/src/uix/eidos/active-eidos.test.ts +++ b/src/uix/eidos/active-eidos.test.ts @@ -448,6 +448,8 @@ describe('ActiveEidos color scheme', () => { const scheme = document.getElementById('uix-eidos-scheme'); expect(scheme?.textContent).toContain(':root {'); expect(scheme?.textContent).toContain('--primitive-primary-9'); + // Wide-gamut: each opaque step stacks a hex fallback + an oklch() override. + expect(scheme?.textContent).toContain('oklch('); // Source order: the scheme block must follow the theme block so it wins. const ids = [...document.querySelectorAll('style[data-uix-eidos]')].map((el) => el.id); diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index d39c45036..5a8ee65a9 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -12,10 +12,11 @@ export { EidosConfigValidationError } from './errors'; export { createEidosCssContract, flattenEidosCssContract } from './lib/contract'; -export { buildScheme } from './lib/build-scheme'; +export { buildScheme, schemeDeclarations } from './lib/build-scheme'; export type { BuildSchemeOptions, BuildSchemeResult, + SchemeDeclarationsOptions, SchemeOnSolidPair, SchemeRoleResult } from './lib/build-scheme'; diff --git a/src/uix/eidos/lib/build-scheme.test.ts b/src/uix/eidos/lib/build-scheme.test.ts index 6f4ba8760..fc2db7607 100644 --- a/src/uix/eidos/lib/build-scheme.test.ts +++ b/src/uix/eidos/lib/build-scheme.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { parseColor } from '$color'; -import { buildScheme } from './build-scheme'; +import { buildScheme, schemeDeclarations } from './build-scheme'; import { THEME_BASE_LIGHT_COLOR_SCALES } from './themes/base'; const SEED = '#8e4ec6'; // base primary (purple) @@ -78,4 +78,27 @@ describe('buildScheme', () => { expect(monoC).toBeLessThan(tonalC); expect(monoC).toBeLessThan(0.03); }); + + it('retains wide-gamut chroma in stepsOklch beyond the sRGB hex fallback', () => { + // Vivid green seed whose chroma exceeds sRGB at this lightness/hue. + const result = buildScheme([0.65, 0.3, 160], { scales }); + const primary = roleOf(result, 'primary')!; + const wideChroma = parseColor(primary.stepsOklch[8])[1]; + const hexChroma = parseColor(primary.solid)[1]; // gamut-mapped fallback + expect(wideChroma).toBeGreaterThan(0.25); // raw chroma preserved + expect(wideChroma).toBeGreaterThan(hexChroma + 0.05); // hex clipped to sRGB + }); + + it('schemeDeclarations stacks hex + oklch (fallback:false → oklch only)', () => { + const result = buildScheme(SEED, { scales }); + const dual = schemeDeclarations(result); + expect(dual).toContain(`--primitive-primary-9: ${roleOf(result, 'primary')!.solid};`); + expect(dual.some((line) => /^--primitive-primary-9: oklch\(/.test(line))).toBe(true); + + const single = schemeDeclarations(result, { fallback: false }).filter((line) => + line.startsWith('--primitive-primary-9:') + ); + expect(single).toHaveLength(1); + expect(single[0]).toMatch(/^--primitive-primary-9: oklch\(/); + }); }); diff --git a/src/uix/eidos/lib/build-scheme.ts b/src/uix/eidos/lib/build-scheme.ts index 6e6d76e65..b1acada9d 100644 --- a/src/uix/eidos/lib/build-scheme.ts +++ b/src/uix/eidos/lib/build-scheme.ts @@ -18,6 +18,7 @@ import { alphaOverBackground, deriveScheme, generateScale, + oklchToCss, oklchToHex, parseColor, pickNearestTemplate, @@ -74,9 +75,11 @@ export interface BuildSchemeOptions { export interface SchemeRoleResult { readonly role: string; - /** 12 hex steps (1..12). */ + /** 12 hex steps (1..12) — the gamut-mapped sRGB fallback. */ readonly steps: readonly string[]; - /** Step 9 — the solid identity. */ + /** 12 `oklch()` steps (1..12) — the wide-gamut value (may exceed sRGB). */ + readonly stepsOklch: readonly string[]; + /** Step 9 — the solid identity (hex). */ readonly solid: string; /** The APCA-picked on-solid hex. */ readonly onSolid: string; @@ -85,8 +88,17 @@ export interface SchemeRoleResult { } export interface BuildSchemeResult { - /** `--primitive-{role}-*` + `--color-{role}-contrast` overrides. */ + /** + * `--primitive-{role}-*` (hex / rgba) + `--color-{role}-contrast` — the universal + * sRGB-fallback layer + introspection-friendly values. + */ readonly variables: EidosCssVariableMap; + /** + * `--primitive-{role}-{1..12}` as `oklch()` — the wide-gamut siblings (only the + * opaque steps; keyed identically to `variables`). Pair with `variables` to emit + * a hex-fallback + oklch-override stack; see {@link schemeDeclarations}. + */ + readonly wideGamut: EidosCssVariableMap; /** Per-role introspection (steps + solid + on-solid + pinned). */ readonly roles: readonly SchemeRoleResult[]; } @@ -125,14 +137,20 @@ export function buildScheme(seed: string | Oklch, options: BuildSchemeOptions): } const variables: Record = {}; + const wideGamut: Record = {}; const roles: SchemeRoleResult[] = []; const emit = (role: string, roleSeed: Oklch, pinned: boolean): void => { const template = templates[pickNearestTemplate(roleSeed, templates)]; + // generateScale keeps RAW OKLCH (no gamut clamp); the wide-gamut chroma + // survives into `oklchToCss`, while `oklchToHex` is the gamut-mapped fallback. const stepsOklch = generateScale(roleSeed, template); const stepsHex = stepsOklch.map(oklchToHex); + const stepsWide = stepsOklch.map(oklchToCss); stepsHex.forEach((hex, index) => { - variables[`--primitive-${role}-${index + 1}`] = hex; + const key = `--primitive-${role}-${index + 1}`; + variables[key] = hex; + wideGamut[key] = stepsWide[index]; }); const a2 = alphaOverBackground(stepsOklch[1], bg); const a3 = alphaOverBackground(stepsOklch[2], bg); @@ -140,7 +158,7 @@ export function buildScheme(seed: string | Oklch, options: BuildSchemeOptions): variables[`--primitive-${role}-a3`] = rgbaStr(a3.rgb, a3.alpha); const onSolidHex = oklchToHex(pickOnSolid(stepsOklch[8], onSolid, floor).color); variables[`--color-${role}-contrast`] = onSolidHex; - roles.push({ role, steps: stepsHex, solid: stepsHex[8], onSolid: onSolidHex, pinned }); + roles.push({ role, steps: stepsHex, stepsOklch: stepsWide, solid: stepsHex[8], onSolid: onSolidHex, pinned }); }; const overrideSeed = (role: string): Oklch | null => { @@ -168,5 +186,44 @@ export function buildScheme(seed: string | Oklch, options: BuildSchemeOptions): emit(intent, temper(parseColor(baseHex), seedOklch, temperAmount), false); } - return { variables, roles }; + return { variables, wideGamut, roles }; +} + +export interface SchemeDeclarationsOptions { + /** + * Emit the hex value as a fallback line BEFORE the `oklch()` override (a CSS + * stack: pre-OKLCH browsers take the hex, the rest take the wider `oklch()`). + * Set `false` to emit a single `oklch()` line per step — required where the host + * keeps only one value per property (inline `style=`). @default true + */ + readonly fallback?: boolean; +} + +/** + * Flatten a {@link BuildSchemeResult} into CSS declaration strings (`name: value;`). + * + * For each opaque primitive step it stacks the **hex fallback** then the wide-gamut + * **`oklch()`** (unless `fallback: false`, which emits only the `oklch()`); alpha + * and contrast tokens emit once. Wrap the result in a selector for a stylesheet, or + * `join('')` it for an inline `style=` attribute. + */ +export function schemeDeclarations( + result: BuildSchemeResult, + options: SchemeDeclarationsOptions = {} +): string[] { + const fallback = options.fallback ?? true; + const lines: string[] = []; + for (const [name, value] of Object.entries(result.variables)) { + if (value == null) continue; + const wide = result.wideGamut[name]; + if (wide == null) { + lines.push(`${name}: ${value};`); + } else if (fallback) { + lines.push(`${name}: ${value};`); + lines.push(`${name}: ${wide};`); + } else { + lines.push(`${name}: ${wide};`); + } + } + return lines; } diff --git a/web/routes/temas/color/+page.svelte b/web/routes/temas/color/+page.svelte index ab1b13205..379c42dfa 100644 --- a/web/routes/temas/color/+page.svelte +++ b/web/routes/temas/color/+page.svelte @@ -37,6 +37,7 @@ apcaLc, deriveScheme, generateScale, + isInSrgbGamut, oklchToGammaRgb, oklchToHex, parseColor, @@ -50,7 +51,7 @@ type ScaleSteps, type SchemeVariant } from '$color' - import { buildScheme } from '$uix/eidos/lib/build-scheme' + import { buildScheme, schemeDeclarations } from '$uix/eidos/lib/build-scheme' let theme = $state<'light' | 'dark'>('light') @@ -151,16 +152,28 @@ // hue) so they feel of the same family without losing meaning (red stays red). // 0 = pure canonical. Per Gemini's note: match perceptual temperature, not hue. let temperAmount = $state(0.12) + // Chroma multiplier on the seed. >1 pushes its chroma PAST sRGB into the P3 gamut — + // headroom only an `oklch()` value (not a hex) can carry. The scheme then paints + // more saturated on P3 displays than its sRGB hex fallback. + let vivacity = $state(1) // Per-role overrides the designer pinned (role → hex). Empty = fully derived. let overrides = $state>({}) - const seedOklch = $derived.by((): Oklch | null => { + const seedBase = $derived.by((): Oklch | null => { try { return parseColor(seedHex) } catch { return null } }) + // Fold vivacity into the seed so every derivation (scheme, chips, applied theme) + // shares one wide-gamut-aware seed. + const seedOklch = $derived.by((): Oklch | null => + seedBase ? [seedBase[0], seedBase[1] * vivacity, seedBase[2]] : null + ) + // True once vivacity pushes the seed past sRGB — wide-gamut headroom that only + // shows on a P3 display (the hex fallback clips it back to sRGB). + const seedOutOfGamut = $derived(seedOklch ? !isInSrgbGamut(seedOklch) : false) const ON_SOLID_PAIR = { onSolid: ON_SOLID, onSolidContrast: ON_SOLID_DARK } @@ -216,16 +229,16 @@ // surface / content / border chrome) downstream. The 31-scale library stays put. const themeOverride = $derived.by((): string => { if (!seedOklch) return '' - const { variables } = buildScheme(seedOklch, { + const result = buildScheme(seedOklch, { scales: activeScales, variant: builderVariant, temper: temperAmount, overrides, background: theme === 'dark' ? '#111111' : '#ffffff' }) - return Object.entries(variables) - .map(([name, value]) => `${name}:${value}`) - .join(';') + // oklch-only (fallback:false): the inline style attr keeps one value per prop, + // and oklch carries the wide-gamut chroma a hex fallback would clip. + return schemeDeclarations(result, { fallback: false }).join('') }) // Set a color input's value imperatively (client-only action). Avoids a reactive @@ -329,6 +342,19 @@ {/each} +
{#each builderRoles as r} @@ -911,6 +937,25 @@ background: none; cursor: pointer; } + .builder-vivacity { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-sm); + color: var(--color-content-secondary); + } + .builder-vivacity input[type='range'] { + max-inline-size: 120px; + accent-color: var(--color-primary-solid); + } + .p3-badge { + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-primary-contrast); + background: var(--color-primary-solid); + } .roles-temper { display: flex; align-items: center;