diff --git a/src/uix/eidos/COLOR_ENGINE_RFC.md b/src/uix/eidos/COLOR_ENGINE_RFC.md index e5c88b6ee..cef3b3c29 100644 --- a/src/uix/eidos/COLOR_ENGINE_RFC.md +++ b/src/uix/eidos/COLOR_ENGINE_RFC.md @@ -346,9 +346,23 @@ CSS ni el TSC cambian — una variante es "otro tema", como `base` ↔ `grafito` **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). +Vive en `uix.color` (`scheme.ts`): matemática pura, isomórfica. La COMPOSICIÓN de +`deriveScheme` + `generateScale` + APCA + alpha en el mapa de tokens +`--primitive-{role}-*` es `buildScheme(seed, opts)` (`eidos/lib/build-scheme.ts`, +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`. + +```ts +const result = eidos.applyColorScheme('#8e4ec6', { + variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome' + temper: 0.12, // cohesión de intents (mantiene hue) + overrides: { tertiary: '#3e63dd' } // fija un rol; el resto deriva +}) +``` --- diff --git a/src/uix/eidos/README.md b/src/uix/eidos/README.md index 570a83af2..ffabc3416 100644 --- a/src/uix/eidos/README.md +++ b/src/uix/eidos/README.md @@ -576,6 +576,7 @@ Resumen rápido de lo que cubre, para no duplicar aquí: | **Eje de `scaling` (zoom global), separado de densidad** | §23 | | **Correcciones P2: texto on-solid por luminancia + superficies translúcidas** | §24 | | **Modelo de color: paleta (31 escalas) + roles (alias) + intents (auto-derivados)** | §25 | +| **Theme builder en runtime: `eidos.applyColorScheme(seed)` (motor `uix.color`)** | §26 | Lo que sigue en este README son las decisiones operativas de la **capa visual como módulo** (typography sourcing, picker patterns, API diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index a02bd362b..0cef174af 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -54,6 +54,7 @@ 23. [Eje de `scaling` (zoom global)](#23-eje-de-scaling-zoom-global--2026-06-02) 24. [Correcciones P2 del engine (2026-06-02)](#24-correcciones-p2-del-engine-2026-06-02) 25. [Modelo de color — paleta + roles/intents derivados](#25-modelo-de-color--paleta--rolesintents-derivados-2026-06-02) +26. [Theme builder en runtime — `eidos.applyColorScheme`](#26-theme-builder-en-runtime--eidosapplycolorscheme-2026-06-04) --- @@ -2134,6 +2135,50 @@ El modelo final es **paleta rica + alias / auto-derivación**, no ancla. --- -**Última revisión**: 2026-06-02. Si algo en este doc no coincide con +## 26. Theme builder en runtime — `eidos.applyColorScheme` (2026-06-04) + +El RFC §6.2 (un seed → todo el sistema) está **implementado** como API de primera +clase. Un app re-tematiza desde UN color de marca con una llamada, sin tocar el CSS: + +```ts +const result = eidos.applyColorScheme('#8e4ec6', { + variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome' + temper: 0.12, // cohesión de intents (mantiene hue) + overrides: { tertiary: '#3e63dd' } // fija un rol; el resto deriva del seed +}) +eidos.clearColorScheme() // revierte a los primitives del tema +``` + +**Qué hace**: compone el motor `uix.color` — `deriveScheme` (Material 3 → jerarquía ++ neutral) → `generateScale` (12 pasos por rol) → APCA on-solid → alpha +compositing-inverse — en un override de la **capa de binding** `--primitive-{role}-*` +(+ `--color-{role}-contrast`). Override del binding **reproyecta** cada +`--color-{role}-{slot}` y el chrome neutral (surface/content/border) aguas abajo. La +**paleta de 31 escalas** y los slots NO se tocan. + +**Capas** (matemática pura → composición pura → aplicación DOM): + +| Pieza | Dónde | Qué | +| --- | --- | --- | +| matemática | `arts/color` (`$color`) | `deriveScheme` / `generateScale` / `temper` / APCA / alpha — pura, isomórfica | +| composición | `eidos/lib/build-scheme.ts` | `buildScheme(seed, opts)` → `{ variables, roles }` — pura, testeable | +| runtime | `ActiveEidos.applyColorScheme` | resuelve donantes + background del tema activo, escribe el bloque de estilo, **sigue light/dark** | + +**Sigue el modo**: las curvas-donantes + el background salen del tema activo, así que +el esquema se **re-deriva en cada `apply()`** (cambio de modo → ramp light vs dark). El +bloque `uix-eidos-scheme` se escribe **después** del de tema para ganar en orden de +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. + +Demo en vivo: `/temas/color` (el builder usa el mismo `buildScheme`). Tests: +`build-scheme.test.ts` + `active-eidos.test.ts`. + +--- + +**Última revisión**: 2026-06-04. Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc. diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index 031fae8b3..193735b4b 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -42,11 +42,14 @@ import { type RenderThemeCssOptions } from './lib/render-css'; import { createThemeBaseEidosConfig } from './lib/themes/base'; +import { buildScheme, type BuildSchemeOptions, type BuildSchemeResult } from './lib/build-scheme'; +import type { Oklch } from '$color'; import type { EngineMotion } from '$motion'; import type { EidosConfigPatch } from './lib/options'; import type { ColorRole, ColorScale, + ColorScales, EidosCssContract, EidosCssVariableMap, EidosValidationReport, @@ -97,6 +100,30 @@ export type ActiveEidosStyleHost = ActiveDomStyleHost; export type ActiveEidosThemeSource = 'auto' | 'config' | 'css'; export type ActiveEidosCssVariablesOptions = Omit; +/** + * Options for {@link ActiveEidos.applyColorScheme} — derive + apply a whole-system + * color scheme from one brand seed. Extends the pure {@link BuildSchemeOptions} but + * the runtime resolves `scales` (donor curves) + `background` from the active theme, + * so they are dropped here and replaced by `theme` / `mode` selectors. + */ +export interface ApplyColorSchemeOptions extends Omit { + /** Theme id to source donor scales from. @default the active theme id */ + readonly theme?: string; + /** Force light / dark donor + background. @default the active effective mode */ + readonly mode?: ThemeEffective; + /** CSS selector the override targets. @default ':root' */ + readonly selector?: string; + /** Background for the alpha steps (overrides the mode default). */ + readonly background?: string; +} + +interface ActiveEidosSchemeSpec { + readonly seed: string | Oklch; + readonly options: ApplyColorSchemeOptions; +} + +const DEFAULT_SCHEME_SELECTOR = ':root'; + export interface ActiveEidosOptions { readonly config?: EidosConfig | EidosConfigDocument; readonly themeBase?: EidosConfigPatch; @@ -155,6 +182,7 @@ export class ActiveEidos { #cssVariables: EidosCssVariableMap | undefined; #cssVariablesOptions: ActiveEidosCssVariablesOptions; + #schemeSpec: ActiveEidosSchemeSpec | undefined; #unsubscribe: (() => void) | undefined; #lastAttrs: ActiveEidosLastAttrs | undefined; #disposed = false; @@ -388,6 +416,65 @@ export class ActiveEidos { this.apply(); } + /** + * Derive a whole-system color scheme from one brand seed and apply it live. + * + * Composes the `uix.color` engine (Material-3 `deriveScheme` → `generateScale` + * → APCA on-solid → compositing-inverse alpha) into `--primitive-{role}-*` + * overrides written as a managed style block. Overriding the binding layer + * reprojects every `--color-{role}-*` slot (and the neutral-driven surface / + * content / border chrome) downstream — the 31-scale palette stays put. + * + * The donor curves + alpha background are resolved from the active theme, so + * the scheme follows light / dark automatically (it re-derives on mode change). + * The returned {@link BuildSchemeResult} exposes the generated steps / solids / + * on-solid picks for introspection. + */ + applyColorScheme(seed: string | Oklch, options: ApplyColorSchemeOptions = {}): BuildSchemeResult { + this.#schemeSpec = { seed, options }; + const result = this.#buildSchemeResult(); + this.apply(); + return result; + } + + /** Remove an applied color scheme, reverting to the theme's own primitives. */ + clearColorScheme(): void { + if (!this.#schemeSpec) return; + this.#schemeSpec = undefined; + this.apply(); + } + + #buildSchemeResult(): BuildSchemeResult { + const spec = this.#schemeSpec; + if (!spec) throw new ActiveEidosConfigError('no color scheme applied'); + this.assertValid(); + const themeId = spec.options.theme ?? this.getThemeId(); + const mode = spec.options.mode ?? this.getThemeContext().mode; + return buildScheme(spec.seed, { + ...spec.options, + scales: this.#resolveDonorScales(themeId), + background: spec.options.background ?? (mode === 'dark' ? '#111111' : '#ffffff') + }); + } + + #resolveDonorScales(themeId: string): ColorScales { + const themeScales = this.getTheme(themeId)?.color?.scales; + if (themeScales && Object.keys(themeScales).length > 0) return themeScales; + + // Fallback: assemble from the primitive palette (theme declared no scales). + const out: Record = {}; + for (const name of this.listColorScales()) { + const scale = this.getColorScale(name); + if (scale) out[name] = scale; + } + if (Object.keys(out).length === 0) { + throw new ActiveEidosConfigError( + `no color scales available to seed a scheme (theme '${themeId}')` + ); + } + return out; + } + apply(): void { if (this.#disposed || !this.#applyDom) return; @@ -421,6 +508,33 @@ export class ActiveEidos { } else { this.#removeStyle(host, variablesStyleId); } + + // The derived scheme (applyColorScheme) is written LAST so its + // `--primitive-{role}-*` overrides win over the theme block at equal + // specificity. Re-derived here on every apply() so it follows mode changes. + const schemeStyleId = `${this.#styleId}-scheme`; + const schemeCss = this.#renderSchemeCss(); + if (schemeCss) { + this.#writeStyle(host, schemeStyleId, schemeCss); + } else { + this.#removeStyle(host, schemeStyleId); + } + } + + #renderSchemeCss(): string { + if (!this.#schemeSpec) return ''; + let variables: EidosCssVariableMap; + try { + variables = this.#buildSchemeResult().variables; + } 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};`) + .join('\n'); + return body ? `${selector} {\n${body}\n}` : ''; } dispose(): void { diff --git a/src/uix/eidos/active-eidos.test.ts b/src/uix/eidos/active-eidos.test.ts index b7cdf01c3..c8fde0074 100644 --- a/src/uix/eidos/active-eidos.test.ts +++ b/src/uix/eidos/active-eidos.test.ts @@ -408,3 +408,73 @@ describe('ActiveEidos', () => { expect(eidos.getThemeId()).toBe('base-light'); }); }); + +describe('ActiveEidos color scheme', () => { + it('derives a whole-system scheme from a seed and returns its roles', () => { + const { preferences } = preferencesHarness(); + const eidos = createActiveEidos({ preferences, applyDom: false }); + + const result = eidos.applyColorScheme('#e5484d'); // red brand seed + + expect(result.roles.map((entry) => entry.role)).toEqual([ + 'primary', + 'secondary', + 'tertiary', + 'neutral' + ]); + expect(result.variables['--primitive-primary-9']).toMatch(/^#[0-9a-f]{6}$/); + expect(result.variables['--color-primary-contrast']).toMatch(/^#[0-9a-f]{6}$/); + }); + + it('temper > 0 folds the evaluative intents into the scheme', () => { + const { preferences } = preferencesHarness(); + const eidos = createActiveEidos({ preferences, applyDom: false }); + + const result = eidos.applyColorScheme('#3e63dd', { temper: 0.3 }); + const roles = result.roles.map((entry) => entry.role); + expect(roles).toContain('threat'); + expect(result.variables['--primitive-threat-9']).toMatch(/^#[0-9a-f]{6}$/); + }); + + it('writes a scheme style block after the theme block and clears it', () => { + const { preferences } = preferencesHarness(); + const dom = createActiveDom(); + const eidos = createActiveEidos({ preferences, dom, styleHost: document.head }); + + expect(document.getElementById('uix-eidos-scheme')).toBeNull(); + + eidos.applyColorScheme('#3e63dd'); + + const scheme = document.getElementById('uix-eidos-scheme'); + expect(scheme?.textContent).toContain(':root {'); + expect(scheme?.textContent).toContain('--primitive-primary-9'); + + // 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); + expect(ids.indexOf('uix-eidos-scheme')).toBeGreaterThan(ids.indexOf('uix-eidos-theme')); + + eidos.clearColorScheme(); + expect(document.getElementById('uix-eidos-scheme')).toBeNull(); + + eidos.dispose(); + dom.dispose(); + }); + + it('re-derives the scheme when the mode changes (follows light/dark)', () => { + const { preferences, setMode } = preferencesHarness(); + const dom = createActiveDom(); + const eidos = createActiveEidos({ preferences, dom, styleHost: document.head }); + + eidos.applyColorScheme('#3e63dd'); + const lightCss = document.getElementById('uix-eidos-scheme')?.textContent; + + setMode('dark'); + const darkCss = document.getElementById('uix-eidos-scheme')?.textContent; + + expect(darkCss).toBeTruthy(); + expect(darkCss).not.toBe(lightCss); // dark donor curves → different ramp + + eidos.dispose(); + dom.dispose(); + }); +}); diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 58e5b56d6..d44098364 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -5946,30 +5946,30 @@ --color-threat-contrast: var(--color-content-on-solid, var(--primitive-threat-12)); --color-threat-surface: var(--primitive-threat-a2); --color-threat-surface-hover: var(--primitive-threat-a3); - --primitive-loss-1: var(--scale-purple-1); - --primitive-loss-2: var(--scale-purple-2); - --primitive-loss-3: var(--scale-purple-3); - --primitive-loss-4: var(--scale-purple-4); - --primitive-loss-5: var(--scale-purple-5); - --primitive-loss-6: var(--scale-purple-6); - --primitive-loss-7: var(--scale-purple-7); - --primitive-loss-8: var(--scale-purple-8); - --primitive-loss-9: var(--scale-purple-9); - --primitive-loss-10: var(--scale-purple-10); - --primitive-loss-11: var(--scale-purple-11); - --primitive-loss-12: var(--scale-purple-12); - --primitive-loss-a1: var(--scale-purple-a1); - --primitive-loss-a2: var(--scale-purple-a2); - --primitive-loss-a3: var(--scale-purple-a3); - --primitive-loss-a4: var(--scale-purple-a4); - --primitive-loss-a5: var(--scale-purple-a5); - --primitive-loss-a6: var(--scale-purple-a6); - --primitive-loss-a7: var(--scale-purple-a7); - --primitive-loss-a8: var(--scale-purple-a8); - --primitive-loss-a9: var(--scale-purple-a9); - --primitive-loss-a10: var(--scale-purple-a10); - --primitive-loss-a11: var(--scale-purple-a11); - --primitive-loss-a12: var(--scale-purple-a12); + --primitive-loss-1: var(--scale-plum-1); + --primitive-loss-2: var(--scale-plum-2); + --primitive-loss-3: var(--scale-plum-3); + --primitive-loss-4: var(--scale-plum-4); + --primitive-loss-5: var(--scale-plum-5); + --primitive-loss-6: var(--scale-plum-6); + --primitive-loss-7: var(--scale-plum-7); + --primitive-loss-8: var(--scale-plum-8); + --primitive-loss-9: var(--scale-plum-9); + --primitive-loss-10: var(--scale-plum-10); + --primitive-loss-11: var(--scale-plum-11); + --primitive-loss-12: var(--scale-plum-12); + --primitive-loss-a1: var(--scale-plum-a1); + --primitive-loss-a2: var(--scale-plum-a2); + --primitive-loss-a3: var(--scale-plum-a3); + --primitive-loss-a4: var(--scale-plum-a4); + --primitive-loss-a5: var(--scale-plum-a5); + --primitive-loss-a6: var(--scale-plum-a6); + --primitive-loss-a7: var(--scale-plum-a7); + --primitive-loss-a8: var(--scale-plum-a8); + --primitive-loss-a9: var(--scale-plum-a9); + --primitive-loss-a10: var(--scale-plum-a10); + --primitive-loss-a11: var(--scale-plum-a11); + --primitive-loss-a12: var(--scale-plum-a12); --color-loss-track: var(--primitive-loss-1); --color-loss-element: var(--primitive-loss-3); --color-loss-hover: var(--primitive-loss-4); @@ -7035,30 +7035,30 @@ --color-threat-contrast: var(--color-content-on-solid, var(--primitive-threat-12)); --color-threat-surface: var(--primitive-threat-a2); --color-threat-surface-hover: var(--primitive-threat-a3); - --primitive-loss-1: var(--scale-purple-1); - --primitive-loss-2: var(--scale-purple-2); - --primitive-loss-3: var(--scale-purple-3); - --primitive-loss-4: var(--scale-purple-4); - --primitive-loss-5: var(--scale-purple-5); - --primitive-loss-6: var(--scale-purple-6); - --primitive-loss-7: var(--scale-purple-7); - --primitive-loss-8: var(--scale-purple-8); - --primitive-loss-9: var(--scale-purple-9); - --primitive-loss-10: var(--scale-purple-10); - --primitive-loss-11: var(--scale-purple-11); - --primitive-loss-12: var(--scale-purple-12); - --primitive-loss-a1: var(--scale-purple-a1); - --primitive-loss-a2: var(--scale-purple-a2); - --primitive-loss-a3: var(--scale-purple-a3); - --primitive-loss-a4: var(--scale-purple-a4); - --primitive-loss-a5: var(--scale-purple-a5); - --primitive-loss-a6: var(--scale-purple-a6); - --primitive-loss-a7: var(--scale-purple-a7); - --primitive-loss-a8: var(--scale-purple-a8); - --primitive-loss-a9: var(--scale-purple-a9); - --primitive-loss-a10: var(--scale-purple-a10); - --primitive-loss-a11: var(--scale-purple-a11); - --primitive-loss-a12: var(--scale-purple-a12); + --primitive-loss-1: var(--scale-plum-1); + --primitive-loss-2: var(--scale-plum-2); + --primitive-loss-3: var(--scale-plum-3); + --primitive-loss-4: var(--scale-plum-4); + --primitive-loss-5: var(--scale-plum-5); + --primitive-loss-6: var(--scale-plum-6); + --primitive-loss-7: var(--scale-plum-7); + --primitive-loss-8: var(--scale-plum-8); + --primitive-loss-9: var(--scale-plum-9); + --primitive-loss-10: var(--scale-plum-10); + --primitive-loss-11: var(--scale-plum-11); + --primitive-loss-12: var(--scale-plum-12); + --primitive-loss-a1: var(--scale-plum-a1); + --primitive-loss-a2: var(--scale-plum-a2); + --primitive-loss-a3: var(--scale-plum-a3); + --primitive-loss-a4: var(--scale-plum-a4); + --primitive-loss-a5: var(--scale-plum-a5); + --primitive-loss-a6: var(--scale-plum-a6); + --primitive-loss-a7: var(--scale-plum-a7); + --primitive-loss-a8: var(--scale-plum-a8); + --primitive-loss-a9: var(--scale-plum-a9); + --primitive-loss-a10: var(--scale-plum-a10); + --primitive-loss-a11: var(--scale-plum-a11); + --primitive-loss-a12: var(--scale-plum-a12); --color-loss-track: var(--primitive-loss-1); --color-loss-element: var(--primitive-loss-3); --color-loss-hover: var(--primitive-loss-4); diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index c39b96d75..d39c45036 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -12,6 +12,13 @@ export { EidosConfigValidationError } from './errors'; export { createEidosCssContract, flattenEidosCssContract } from './lib/contract'; +export { buildScheme } from './lib/build-scheme'; +export type { + BuildSchemeOptions, + BuildSchemeResult, + SchemeOnSolidPair, + SchemeRoleResult +} from './lib/build-scheme'; export { createEidosConfigDocumentFromConfig, getEidosColorRoleScale, @@ -82,7 +89,8 @@ export type { ActiveEidosThemeContext, ActiveEidosThemeResolver, ActiveEidosThemeSource, - ActiveEidosUserOptions + ActiveEidosUserOptions, + ApplyColorSchemeOptions } from './active-eidos.svelte'; export type { EidosConfigDocument, diff --git a/src/uix/eidos/lib/build-scheme.test.ts b/src/uix/eidos/lib/build-scheme.test.ts new file mode 100644 index 000000000..6f4ba8760 --- /dev/null +++ b/src/uix/eidos/lib/build-scheme.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { parseColor } from '$color'; +import { buildScheme } from './build-scheme'; +import { THEME_BASE_LIGHT_COLOR_SCALES } from './themes/base'; + +const SEED = '#8e4ec6'; // base primary (purple) +const scales = THEME_BASE_LIGHT_COLOR_SCALES; + +const roleOf = (result: ReturnType, role: string) => + result.roles.find((entry) => entry.role === role); + +describe('buildScheme', () => { + it('derives the hierarchy + neutral and emits a full primitive ramp per role', () => { + const result = buildScheme(SEED, { scales }); + + // temper defaults to 0 → only the 4 derived roles, no intents. + expect(result.roles.map((entry) => entry.role)).toEqual([ + 'primary', + 'secondary', + 'tertiary', + 'neutral' + ]); + + // Full 12-step ramp + alpha + on-solid for primary. + for (let step = 1; step <= 12; step++) { + expect(result.variables[`--primitive-primary-${step}`]).toMatch(/^#[0-9a-f]{6}$/); + } + expect(result.variables['--primitive-primary-a2']).toMatch(/^rgb\(/); + expect(result.variables['--primitive-primary-a3']).toMatch(/^rgb\(/); + expect(result.variables['--color-primary-contrast']).toMatch(/^#[0-9a-f]{6}$/); + }); + + it('anchors primary step 9 to the seed (brand fidelity)', () => { + const primary = roleOf(buildScheme(SEED, { scales }), 'primary'); + const [sl, sc, sh] = parseColor(SEED); + const [pl, pc, ph] = parseColor(primary!.solid); + expect(Math.abs(pl - sl)).toBeLessThan(0.02); + expect(Math.abs(pc - sc)).toBeLessThan(0.02); + expect(Math.abs(ph - sh)).toBeLessThan(2); + }); + + it('derives a +60° tertiary distinct from secondary (Material rule)', () => { + const result = buildScheme(SEED, { scales }); + const secondary = parseColor(roleOf(result, 'secondary')!.solid); + const tertiary = parseColor(roleOf(result, 'tertiary')!.solid); + // Hue separation around the +60° rotation (allowing gamut drift). + expect(Math.abs(tertiary[2] - secondary[2])).toBeGreaterThan(20); + }); + + it('temper > 0 adds the evaluative intents; 0 leaves them canonical (absent)', () => { + const plain = buildScheme(SEED, { scales }); + const tempered = buildScheme(SEED, { scales, temper: 0.3 }); + + expect(roleOf(plain, 'threat')).toBeUndefined(); + const threat = roleOf(tempered, 'threat'); + expect(threat).toBeDefined(); + // Red intent keeps its hue (still reads as "red"), only L/C shift toward the seed. + const canonical = parseColor(scales.red['9']); + const shifted = parseColor(threat!.solid); + const hueDelta = Math.abs(((shifted[2] - canonical[2] + 540) % 360) - 180); + expect(hueDelta).toBeLessThan(12); // hue ~kept + }); + + it('honours a per-role override and flags it pinned', () => { + const result = buildScheme(SEED, { scales, overrides: { secondary: '#e5484d' } }); + const secondary = roleOf(result, 'secondary'); + expect(secondary!.pinned).toBe(true); + const [, , hue] = parseColor(secondary!.solid); + const [, , redHue] = parseColor('#e5484d'); + expect(Math.abs(hue - redHue)).toBeLessThan(8); // override hue, not derived purple + }); + + it('monochrome collapses secondary chroma toward gray', () => { + const tonal = buildScheme(SEED, { scales, variant: 'tonal' }); + const mono = buildScheme(SEED, { scales, variant: 'monochrome' }); + const tonalC = parseColor(roleOf(tonal, 'secondary')!.solid)[1]; + const monoC = parseColor(roleOf(mono, 'secondary')!.solid)[1]; + expect(monoC).toBeLessThan(tonalC); + expect(monoC).toBeLessThan(0.03); + }); +}); diff --git a/src/uix/eidos/lib/build-scheme.ts b/src/uix/eidos/lib/build-scheme.ts new file mode 100644 index 000000000..6e6d76e65 --- /dev/null +++ b/src/uix/eidos/lib/build-scheme.ts @@ -0,0 +1,172 @@ +/** + * Scheme builder — composes the pure `$color` math (deriveScheme + generateScale + + * APCA on-solid + compositing-inverse alpha) into the eidos `--primitive-{role}-*` + * token override map that re-themes the whole system from ONE brand seed. + * + * This is the pure core of `ActiveEidos.applyColorScheme()`: no DOM, no Svelte — + * `seed → variables`. The runtime method resolves the donor scales from the active + * theme and writes the variables as a managed style block; this function does the + * math and is independently testable. + * + * What it overrides: only the BINDING layer (`--primitive-{role}-*`) plus each + * role's on-solid (`--color-{role}-contrast`). The 31-scale palette (`--scale-*`) + * and the semantic slots (`--color-{role}-{slot}`) stay put — overriding the + * primitives reprojects every slot downstream (and the neutral-driven + * surface / content / border chrome). + */ +import { + alphaOverBackground, + deriveScheme, + generateScale, + oklchToHex, + parseColor, + pickNearestTemplate, + pickOnSolid, + safeParseColor, + scaleToTemplate, + temper, + type Oklch, + type ScaleSteps, + type SchemeVariant +} from '$color'; +import { + CANONICAL_INTENT_SCALES, + COLOR_SCALE_STEPS, + type ColorScales, + type EidosCssVariableMap +} from './config-types'; + +/** On-solid text pair — the two candidates APCA picks between per role. */ +export interface SchemeOnSolidPair { + readonly onSolid: string; + readonly onSolidContrast: string; +} + +export interface BuildSchemeOptions { + /** Material-style derivation flavor. @default 'tonal' */ + readonly variant?: SchemeVariant; + /** + * Intent coherence, 0..1. How far the evaluative intents (affirm / fulfill / + * risk / threat / loss) are pulled toward the seed's PERCEPTUAL TEMPERATURE + * (chroma + lightness) while KEEPING their hue — so they feel of the brand's + * family without losing meaning (red stays red). 0 = pure canonical intents, + * left untouched. @default 0 + */ + readonly temper?: number; + /** Per-role pinned hex overrides (role → hex). Unpinned roles derive. */ + readonly overrides?: Readonly>; + /** + * Donor curve scales — provide the lightness / chroma SHAPE the generator + * morphs. Pass the active theme's scales so generated steps match its + * light / dark feel. + */ + readonly scales: ColorScales; + /** + * Background for the compositing-inverse alpha (the a2 / a3 translucent steps). + * @default '#ffffff' + */ + readonly background?: string; + /** On-solid candidates. @default white / near-black */ + readonly onSolid?: SchemeOnSolidPair; + /** APCA |Lc| floor under which on-solid flips to `onSolidContrast`. @default 60 */ + readonly apcaFloor?: number; +} + +export interface SchemeRoleResult { + readonly role: string; + /** 12 hex steps (1..12). */ + readonly steps: readonly string[]; + /** Step 9 — the solid identity. */ + readonly solid: string; + /** The APCA-picked on-solid hex. */ + readonly onSolid: string; + /** Whether this role used a designer override (vs derived). */ + readonly pinned: boolean; +} + +export interface BuildSchemeResult { + /** `--primitive-{role}-*` + `--color-{role}-contrast` overrides. */ + readonly variables: EidosCssVariableMap; + /** Per-role introspection (steps + solid + on-solid + pinned). */ + readonly roles: readonly SchemeRoleResult[]; +} + +const DEFAULT_ON_SOLID: SchemeOnSolidPair = { onSolid: '#ffffff', onSolidContrast: '#1c1917' }; +const DEFAULT_APCA_FLOOR = 60; + +// Roles derived from the seed (M3 CorePalette). `neutralVariant` is M3-only; eidos +// has no such primitive role, so it is intentionally not emitted. +const DERIVED_ROLES = ['primary', 'secondary', 'tertiary', 'neutral'] as const; +// Evaluative intents (book-defined hues). `neutral` is derived above, not here. +const INTENT_ROLES = ( + Object.keys(CANONICAL_INTENT_SCALES) as (keyof typeof CANONICAL_INTENT_SCALES)[] +).filter((intent) => intent !== 'neutral'); + +const rgbaStr = (rgb: readonly number[], alpha: number): string => + `rgb(${Math.round(rgb[0] * 255)} ${Math.round(rgb[1] * 255)} ${Math.round(rgb[2] * 255)} / ${alpha.toFixed(3)})`; + +/** + * Build the `--primitive-{role}-*` override map (+ on-solid) from one brand seed. + * Pure: `seed → variables + per-role introspection`. See the module header. + */ +export function buildScheme(seed: string | Oklch, options: BuildSchemeOptions): BuildSchemeResult { + const seedOklch: Oklch = typeof seed === 'string' ? parseColor(seed) : seed; + const variant = options.variant ?? 'tonal'; + const temperAmount = options.temper ?? 0; + const overrides = options.overrides ?? {}; + const onSolid = options.onSolid ?? DEFAULT_ON_SOLID; + const floor = options.apcaFloor ?? DEFAULT_APCA_FLOOR; + const bg = parseColor(options.background ?? '#ffffff'); + + // Donor templates: each palette scale → an OKLCH curve the generator morphs. + const templates: Record = {}; + for (const [name, scale] of Object.entries(options.scales)) { + templates[name] = scaleToTemplate(COLOR_SCALE_STEPS.map((step) => scale[step])); + } + + const variables: Record = {}; + const roles: SchemeRoleResult[] = []; + + const emit = (role: string, roleSeed: Oklch, pinned: boolean): void => { + const template = templates[pickNearestTemplate(roleSeed, templates)]; + const stepsOklch = generateScale(roleSeed, template); + const stepsHex = stepsOklch.map(oklchToHex); + stepsHex.forEach((hex, index) => { + variables[`--primitive-${role}-${index + 1}`] = hex; + }); + const a2 = alphaOverBackground(stepsOklch[1], bg); + const a3 = alphaOverBackground(stepsOklch[2], bg); + variables[`--primitive-${role}-a2`] = rgbaStr(a2.rgb, a2.alpha); + 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 }); + }; + + const overrideSeed = (role: string): Oklch | null => { + const hex = overrides[role]; + return hex ? safeParseColor(hex) : null; + }; + + // Hierarchy + neutral from the M3 derivation (override wins per role). + const derived = deriveScheme(seedOklch, variant); + for (const role of DERIVED_ROLES) { + const override = overrideSeed(role); + emit(role, override ?? derived[role], !!override); + } + + // Intents: pinned override → temper toward the seed → (amount 0) leave canonical. + for (const intent of INTENT_ROLES) { + const override = overrideSeed(intent); + if (override) { + emit(intent, override, true); + continue; + } + if (temperAmount <= 0) continue; + const baseHex = options.scales[CANONICAL_INTENT_SCALES[intent]]?.['9']; + if (!baseHex) continue; + emit(intent, temper(parseColor(baseHex), seedOklch, temperAmount), false); + } + + return { variables, roles }; +} diff --git a/web/routes/temas/color/+page.svelte b/web/routes/temas/color/+page.svelte index 3ae6fd571..ab1b13205 100644 --- a/web/routes/temas/color/+page.svelte +++ b/web/routes/temas/color/+page.svelte @@ -34,7 +34,6 @@ } from '$uix/eidos/lib/config-types' import { INTENTS } from '$uix/intent' import { - alphaOverBackground, apcaLc, deriveScheme, generateScale, @@ -51,6 +50,7 @@ type ScaleSteps, type SchemeVariant } from '$color' + import { buildScheme } from '$uix/eidos/lib/build-scheme' let theme = $state<'light' | 'dark'>('light') @@ -210,38 +210,22 @@ }) ) - // Live apply: write the derived hierarchy + neutral (and, with harmonize on, the - // intents) as CSS-var overrides on `.root` — overriding `--primitive-{role}-*` - // reproyects every `--color-{role}-*` (and the neutral-driven surface/content/ - // border chrome) downstream. The 31-scale library + canonical intents stay put. - const rgbaStr = (rgb: readonly number[], alpha: number): string => - `rgb(${Math.round(rgb[0] * 255)} ${Math.round(rgb[1] * 255)} ${Math.round(rgb[2] * 255)} / ${alpha.toFixed(3)})` - const emitRole = (parts: string[], role: string, roleSeed: Oklch, bg: Oklch): void => { - const steps = generateScale(roleSeed, activeTemplates[pickNearestTemplate(roleSeed, activeTemplates)]) - steps.forEach((st, i) => parts.push(`--primitive-${role}-${i + 1}:${oklchToHex(st)}`)) - const a2 = alphaOverBackground(steps[1], bg) - const a3 = alphaOverBackground(steps[2], bg) - parts.push(`--primitive-${role}-a2:${rgbaStr(a2.rgb, a2.alpha)}`) - parts.push(`--primitive-${role}-a3:${rgbaStr(a3.rgb, a3.alpha)}`) - const pick = pickOnSolid(steps[8], ON_SOLID_PAIR, 60) - parts.push(`--color-${role}-contrast:${oklchToHex(pick.color)}`) - } + // Live apply: the SAME `buildScheme` an app calls via `eidos.applyColorScheme` — + // derive `--primitive-{role}-*` from the seed and write them on `.root`. Overriding + // the binding layer reprojects every `--color-{role}-*` (and the neutral-driven + // surface / content / border chrome) downstream. The 31-scale library stays put. const themeOverride = $derived.by((): string => { if (!seedOklch) return '' - const bg = parseColor(theme === 'dark' ? '#111111' : '#ffffff') - const parts: string[] = [] - for (const { role, seed } of schemeSeeds) { - if (role !== 'neutralVariant') emitRole(parts, role, seed, bg) - } - if (temperAmount > 0) { - for (const intent of INTENTS) { - if (intent === 'neutral') continue - const baseHex = activeScales[CANONICAL_INTENT_SCALES[intent]]?.['9'] - if (baseHex) - emitRole(parts, intent, temper(parseColor(baseHex), seedOklch, temperAmount), bg) - } - } - return parts.join(';') + const { variables } = buildScheme(seedOklch, { + scales: activeScales, + variant: builderVariant, + temper: temperAmount, + overrides, + background: theme === 'dark' ? '#111111' : '#ffffff' + }) + return Object.entries(variables) + .map(([name, value]) => `${name}:${value}`) + .join(';') }) // Set a color input's value imperatively (client-only action). Avoids a reactive