You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lib/build-scheme.ts

230 lines
8.8 KiB

/**
* 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,
oklchToCss,
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) — the gamut-mapped sRGB fallback. */
readonly steps: readonly string[];
/** 12 `oklch()` steps (1..12) — the wide-gamut value (may exceed sRGB). */
readonly stepsOklch: readonly string[];
/** Step 9 — the solid identity (hex). */
readonly solid: string;
/** 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}-*` (hex / rgba) + `--color-{role}-contrast` — the universal
* sRGB-fallback layer + introspection-friendly values.
*/
readonly variables: EidosCssVariableMap;
/**
* `--primitive-{role}-{1..12}` as `oklch()` — the wide-gamut siblings (only the
* opaque steps; keyed identically to `variables`). Pair with `variables` to emit
* a hex-fallback + oklch-override stack; see {@link schemeDeclarations}.
*/
readonly wideGamut: EidosCssVariableMap;
/** Per-role introspection (steps + solid + on-solid + pinned). */
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 wideGamut: Record<string, string> = {};
const roles: SchemeRoleResult[] = [];
const emit = (role: string, roleSeed: Oklch, pinned: boolean): void => {
const template = templates[pickNearestTemplate(roleSeed, templates)];
// generateScale keeps RAW OKLCH (no gamut clamp); the wide-gamut chroma
// survives into `oklchToCss`, while `oklchToHex` is the gamut-mapped fallback.
const stepsOklch = generateScale(roleSeed, template);
const stepsHex = stepsOklch.map(oklchToHex);
const stepsWide = stepsOklch.map(oklchToCss);
stepsHex.forEach((hex, index) => {
const key = `--primitive-${role}-${index + 1}`;
variables[key] = hex;
wideGamut[key] = stepsWide[index];
});
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, stepsOklch: stepsWide, 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, wideGamut, roles };
}
export interface SchemeDeclarationsOptions {
/**
* Emit the hex value as a fallback line BEFORE the `oklch()` override (a CSS
* stack: pre-OKLCH browsers take the hex, the rest take the wider `oklch()`).
* Set `false` to emit a single `oklch()` line per step — required where the host
* keeps only one value per property (inline `style=`). @default true
*/
readonly fallback?: boolean;
}
/**
* Flatten a {@link BuildSchemeResult} into CSS declaration strings (`name: value;`).
*
* For each opaque primitive step it stacks the **hex fallback** then the wide-gamut
* **`oklch()`** (unless `fallback: false`, which emits only the `oklch()`); alpha
* and contrast tokens emit once. Wrap the result in a selector for a stylesheet, or
* `join('')` it for an inline `style=` attribute.
*/
export function schemeDeclarations(
result: BuildSchemeResult,
options: SchemeDeclarationsOptions = {}
): string[] {
const fallback = options.fallback ?? true;
const lines: string[] = [];
for (const [name, value] of Object.entries(result.variables)) {
if (value == null) continue;
const wide = result.wideGamut[name];
if (wide == null) {
lines.push(`${name}: ${value};`);
} else if (fallback) {
lines.push(`${name}: ${value};`);
lines.push(`${name}: ${wide};`);
} else {
lines.push(`${name}: ${wide};`);
}
}
return lines;
}

Powered by TurnKey Linux.