feat(eidos): runtime theme builder API — eidos.applyColorScheme(seed)

RFC Phase 4: derive a whole-system color scheme from ONE brand seed at runtime.
Packages the demo-only builder into a first-class, tested API.

- build-scheme.ts (pure): buildScheme(seed, opts) composes the uix.color engine
  (deriveScheme -> generateScale -> APCA on-solid -> compositing-inverse alpha)
  into the `--primitive-{role}-*` (+ `--color-{role}-contrast`) override map.
  seed -> { variables, roles }. No DOM. 6 tests.
- ActiveEidos.applyColorScheme(seed, opts) / clearColorScheme(): resolves donor
  scales + background from the active theme, writes a managed `uix-eidos-scheme`
  style block AFTER the theme block (wins the cascade), and RE-DERIVES on mode
  change (follows light/dark). Returns BuildSchemeResult for introspection. opts:
  variant (tonal|vibrant|monochrome) + temper (intent coherence, keeps hue) +
  per-role overrides + selector. 4 tests (return value, intents, DOM block
  ordering + clear, mode re-derivation).
- index.ts: export buildScheme + ApplyColorSchemeOptions + BuildScheme* types.
- temas/color demo: themeOverride now dogfoods buildScheme (drops the duplicated
  emitRole/rgbaStr; identical output verified in-browser).
- generated/base.css: regenerated for the loss->plum role fix (binding layer
  --primitive-loss-* now points at --scale-plum-*; keeps the contract test green).
- docs: THEMING.md SS26 + COLOR_ENGINE_RFC SS6.2 (status: landed) + README ref row.

Overriding the binding layer reprojects every --color-{role}-{slot} + the neutral
chrome downstream; the 31-scale palette stays put. Math in $color, composition in
eidos/lib (pure), DOM application in ActiveEidos.

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

@ -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
})
```
---

@ -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

@ -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.

@ -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<RenderCssVariablesOptions, 'contract'>;
/**
* 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<BuildSchemeOptions, 'scales' | 'background'> {
/** 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<string, ColorScale> = {};
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 {

@ -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();
});
});

@ -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);

@ -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,

@ -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<typeof buildScheme>, 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);
});
});

@ -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<Record<string, string>>;
/**
* 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<string, ScaleSteps> = {};
for (const [name, scale] of Object.entries(options.scales)) {
templates[name] = scaleToTemplate(COLOR_SCALE_STEPS.map((step) => scale[step]));
}
const variables: Record<string, string> = {};
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 };
}

@ -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

Loading…
Cancel
Save

Powered by TurnKey Linux.