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
parent
cc37bdcebe
commit
c4d2e34dbc
@ -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 };
|
||||||
|
}
|
||||||
Loading…
Reference in new issue