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.
230 lines
8.8 KiB
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;
|
|
}
|