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