feat(eidos): wide-gamut-true generator — buildScheme/applyColorScheme emit oklch()

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 909ab7f944
commit 0bb03559a9

@ -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) pura). El método runtime **`eidos.applyColorScheme(seed, opts)`** (ActiveEidos)
resuelve las escalas-donantes + background del tema activo, escribe el bloque de 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 estilo y **sigue light/dark** (re-deriva al cambiar de modo); devuelve un
`BuildSchemeResult` (steps / solid / on-solid por rol) para introspección. `BuildSchemeResult` (steps hex + `stepsOklch` + solid / on-solid por rol) para
`eidos.clearColorScheme()` revierte. **Estado: implementado** (Fase 4) — ver introspección. `eidos.clearColorScheme()` revierte. El bloque apila **hex + `oklch()`**
THEMING.md §26; tests en `build-scheme.test.ts` + `active-eidos.test.ts`. 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 ```ts
const result = eidos.applyColorScheme('#8e4ec6', { const result = eidos.applyColorScheme('#8e4ec6', {

@ -2172,8 +2172,12 @@ cascada.
**Override por rol** + **temper** = doctrina de §25.4 / RFC §6.2: la jerarquía deriva **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 (override per-rol opcional), los intents **mantienen su hue** y solo afinan
temperatura. `applyColorScheme` devuelve `BuildSchemeResult` (steps / solid / on-solid temperatura. `applyColorScheme` devuelve `BuildSchemeResult` (steps hex + `stepsOklch`
/ pinned por rol) para introspección de UI. + 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: Demo en vivo: `/temas/color` (el builder usa el mismo `buildScheme`). Tests:
`build-scheme.test.ts` + `active-eidos.test.ts`. `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. 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. - **Default-on, sin flag**: es el comportamiento del framework.
`render-css.ts > appendColorScaleDeclarations`. `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).
--- ---

@ -42,7 +42,12 @@ import {
type RenderThemeCssOptions type RenderThemeCssOptions
} from './lib/render-css'; } from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base'; 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 { Oklch } from '$color';
import type { EngineMotion } from '$motion'; import type { EngineMotion } from '$motion';
import type { EidosConfigPatch } from './lib/options'; import type { EidosConfigPatch } from './lib/options';
@ -523,16 +528,16 @@ export class ActiveEidos {
#renderSchemeCss(): string { #renderSchemeCss(): string {
if (!this.#schemeSpec) return ''; if (!this.#schemeSpec) return '';
let variables: EidosCssVariableMap; let result: BuildSchemeResult;
try { try {
variables = this.#buildSchemeResult().variables; result = this.#buildSchemeResult();
} catch { } catch {
return ''; return '';
} }
const selector = this.#schemeSpec.options.selector ?? DEFAULT_SCHEME_SELECTOR; const selector = this.#schemeSpec.options.selector ?? DEFAULT_SCHEME_SELECTOR;
const body = Object.entries(variables) // Dual stack per opaque step: hex fallback + oklch() wide-gamut override.
.filter(([, value]) => value != null) const body = schemeDeclarations(result)
.map(([name, value]) => `\t${name}: ${value};`) .map((line) => `\t${line}`)
.join('\n'); .join('\n');
return body ? `${selector} {\n${body}\n}` : ''; return body ? `${selector} {\n${body}\n}` : '';
} }

@ -448,6 +448,8 @@ describe('ActiveEidos color scheme', () => {
const scheme = document.getElementById('uix-eidos-scheme'); const scheme = document.getElementById('uix-eidos-scheme');
expect(scheme?.textContent).toContain(':root {'); expect(scheme?.textContent).toContain(':root {');
expect(scheme?.textContent).toContain('--primitive-primary-9'); 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. // 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); const ids = [...document.querySelectorAll('style[data-uix-eidos]')].map((el) => el.id);

@ -12,10 +12,11 @@ export {
EidosConfigValidationError EidosConfigValidationError
} from './errors'; } from './errors';
export { createEidosCssContract, flattenEidosCssContract } from './lib/contract'; export { createEidosCssContract, flattenEidosCssContract } from './lib/contract';
export { buildScheme } from './lib/build-scheme'; export { buildScheme, schemeDeclarations } from './lib/build-scheme';
export type { export type {
BuildSchemeOptions, BuildSchemeOptions,
BuildSchemeResult, BuildSchemeResult,
SchemeDeclarationsOptions,
SchemeOnSolidPair, SchemeOnSolidPair,
SchemeRoleResult SchemeRoleResult
} from './lib/build-scheme'; } from './lib/build-scheme';

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseColor } from '$color'; import { parseColor } from '$color';
import { buildScheme } from './build-scheme'; import { buildScheme, schemeDeclarations } from './build-scheme';
import { THEME_BASE_LIGHT_COLOR_SCALES } from './themes/base'; import { THEME_BASE_LIGHT_COLOR_SCALES } from './themes/base';
const SEED = '#8e4ec6'; // base primary (purple) const SEED = '#8e4ec6'; // base primary (purple)
@ -78,4 +78,27 @@ describe('buildScheme', () => {
expect(monoC).toBeLessThan(tonalC); expect(monoC).toBeLessThan(tonalC);
expect(monoC).toBeLessThan(0.03); 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\(/);
});
}); });

@ -18,6 +18,7 @@ import {
alphaOverBackground, alphaOverBackground,
deriveScheme, deriveScheme,
generateScale, generateScale,
oklchToCss,
oklchToHex, oklchToHex,
parseColor, parseColor,
pickNearestTemplate, pickNearestTemplate,
@ -74,9 +75,11 @@ export interface BuildSchemeOptions {
export interface SchemeRoleResult { export interface SchemeRoleResult {
readonly role: string; readonly role: string;
/** 12 hex steps (1..12). */ /** 12 hex steps (1..12) — the gamut-mapped sRGB fallback. */
readonly steps: readonly string[]; 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; readonly solid: string;
/** The APCA-picked on-solid hex. */ /** The APCA-picked on-solid hex. */
readonly onSolid: string; readonly onSolid: string;
@ -85,8 +88,17 @@ export interface SchemeRoleResult {
} }
export interface BuildSchemeResult { 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; 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). */ /** Per-role introspection (steps + solid + on-solid + pinned). */
readonly roles: readonly SchemeRoleResult[]; readonly roles: readonly SchemeRoleResult[];
} }
@ -125,14 +137,20 @@ export function buildScheme(seed: string | Oklch, options: BuildSchemeOptions):
} }
const variables: Record<string, string> = {}; const variables: Record<string, string> = {};
const wideGamut: Record<string, string> = {};
const roles: SchemeRoleResult[] = []; const roles: SchemeRoleResult[] = [];
const emit = (role: string, roleSeed: Oklch, pinned: boolean): void => { const emit = (role: string, roleSeed: Oklch, pinned: boolean): void => {
const template = templates[pickNearestTemplate(roleSeed, templates)]; 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 stepsOklch = generateScale(roleSeed, template);
const stepsHex = stepsOklch.map(oklchToHex); const stepsHex = stepsOklch.map(oklchToHex);
const stepsWide = stepsOklch.map(oklchToCss);
stepsHex.forEach((hex, index) => { 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 a2 = alphaOverBackground(stepsOklch[1], bg);
const a3 = alphaOverBackground(stepsOklch[2], 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); variables[`--primitive-${role}-a3`] = rgbaStr(a3.rgb, a3.alpha);
const onSolidHex = oklchToHex(pickOnSolid(stepsOklch[8], onSolid, floor).color); const onSolidHex = oklchToHex(pickOnSolid(stepsOklch[8], onSolid, floor).color);
variables[`--color-${role}-contrast`] = onSolidHex; 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 => { 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); 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;
} }

@ -37,6 +37,7 @@
apcaLc, apcaLc,
deriveScheme, deriveScheme,
generateScale, generateScale,
isInSrgbGamut,
oklchToGammaRgb, oklchToGammaRgb,
oklchToHex, oklchToHex,
parseColor, parseColor,
@ -50,7 +51,7 @@
type ScaleSteps, type ScaleSteps,
type SchemeVariant type SchemeVariant
} from '$color' } 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') let theme = $state<'light' | 'dark'>('light')
@ -151,16 +152,28 @@
// hue) so they feel of the same family without losing meaning (red stays red). // 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. // 0 = pure canonical. Per Gemini's note: match perceptual temperature, not hue.
let temperAmount = $state(0.12) 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. // Per-role overrides the designer pinned (role → hex). Empty = fully derived.
let overrides = $state<Record<string, string>>({}) let overrides = $state<Record<string, string>>({})
const seedOklch = $derived.by((): Oklch | null => { const seedBase = $derived.by((): Oklch | null => {
try { try {
return parseColor(seedHex) return parseColor(seedHex)
} catch { } catch {
return null 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 } 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. // surface / content / border chrome) downstream. The 31-scale library stays put.
const themeOverride = $derived.by((): string => { const themeOverride = $derived.by((): string => {
if (!seedOklch) return '' if (!seedOklch) return ''
const { variables } = buildScheme(seedOklch, { const result = buildScheme(seedOklch, {
scales: activeScales, scales: activeScales,
variant: builderVariant, variant: builderVariant,
temper: temperAmount, temper: temperAmount,
overrides, overrides,
background: theme === 'dark' ? '#111111' : '#ffffff' background: theme === 'dark' ? '#111111' : '#ffffff'
}) })
return Object.entries(variables) // oklch-only (fallback:false): the inline style attr keeps one value per prop,
.map(([name, value]) => `${name}:${value}`) // and oklch carries the wide-gamut chroma a hex fallback would clip.
.join(';') return schemeDeclarations(result, { fallback: false }).join('')
}) })
// Set a color input's value imperatively (client-only action). Avoids a reactive // Set a color input's value imperatively (client-only action). Avoids a reactive
@ -329,6 +342,19 @@
</button> </button>
{/each} {/each}
</div> </div>
<label class="builder-vivacity">
vivacidad P3
<input
type="range"
min="1"
max="1.8"
step="0.05"
value={vivacity}
oninput={(e) => (vivacity = +e.currentTarget.value)}
/>
<code>×{vivacity.toFixed(2)}</code>
{#if seedOutOfGamut}<small class="p3-badge">fuera de sRGB → P3</small>{/if}
</label>
</div> </div>
<div class="builder-out"> <div class="builder-out">
{#each builderRoles as r} {#each builderRoles as r}
@ -911,6 +937,25 @@
background: none; background: none;
cursor: pointer; 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 { .roles-temper {
display: flex; display: flex;
align-items: center; align-items: center;

Loading…
Cancel
Save

Powered by TurnKey Linux.