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