--- title: RFC — Next-generation color engine (OKLCH · P3 · APCA · 1-seed generator) type: rfc audience: human + agent status: implemented — phases landed through 4-bis; slot tuning (phase 5) gated source: migrated from src/uix/eidos/COLOR_ENGINE_RFC.md (2026-07-02, docs-book F7.4) --- # RFC — Next-generation color engine (OKLCH · P3 · APCA · 1-seed generator) > **Status: PROPOSAL (2026-06-04), since implemented — see the per-phase state in §11.** > > This RFC is the **physical layer** of color. The **conceptual model** > (palette → hierarchy roles → auto-derived intents) is **closed** in > [`theming/reference.md §25`](../theming/reference.md) and is NOT touched. > Here we change **how the color variables are produced**, not which variables > exist or what they mean. > > Supersedes the deferred phases of [`rfc-color-model.md §5`](./rfc-color-model.md) > (phase 2 "expand the library" already done — 33 scales; phase 3 "1-hex > generator" plus the wide-gamut/APCA work is what this RFC concretizes and > elevates). --- ## 0. TL;DR — what changes and what does NOT **Changes (the physical / authoring layer):** 1. **OKLCH authoring.** A scale can be declared as **one seed** (`seed`) instead of 12 hand-written hex. The engine generates the functional 12-step ramp. 2. **1-seed generator → 12 steps × light/dark** by *template morph* (the Radix method): reuses the 33 tuned scales as **curve donors**, re-hues to the seed. Kills "author 12 steps × 2 modes by hand" and the `loss`-class bug. 3. **Wide-gamut output.** Every color token is emitted as **OKLCH with an sRGB hex fallback** (Tailwind v4 style) — automatic wide-gamut where the browser supports it, universal where not. Optional `@media (color-gamut: p3)` for max fidelity. 4. **APCA contrast.** The text-on-solid decision moves from WCAG2 (gamma-linearized) to **APCA (Lc)**, with a WCAG2 floor as a safety net (APCA is a WCAG3 draft). 5. **Cleanup**: `primary≡loss=purple` in the base gets fixed; conservative tuning of the slot→step map (audit P3-3). **Does NOT change (invariants — see §10):** - The **names** of all vars: `--scale-{name}-{step}`, `--scale-{name}-a{step}`, `--primitive-{role}-{step}`, `--color-{role}-{slot}`, `--color-{role}-surface`. - The **3-layer model** (§25), the **9 roles**, the **13 slots**, the **8 sema families**, the **variants canon** (§19). - The **TSC** (scope-as-data, `scopeCovers`, cross-axis), the **purge**, the **contrast introspection**, the **compositing-inverse alpha**. - **Downstream (recipes, eidos CSS, components, demos): zero changes.** - Everything computes at **build-time**; the output remains static CSS. **The generator is ISOMORPHIC, not build-only** (the "Holy Grail" of relative-colors *in CSS* as the mechanism was rejected). The axis that sacrifices introspection (the APCA pick) and the compositing-inverse alpha **is not build-vs-runtime** — it is **pure-CSS vs JS**. Those two properties only need the color's numeric value at decision time; the math (OKLCH↔sRGB↔P3 + gamut-map + APCA + inverse-alpha) is **pure, DOM-free**, so it runs identically at build **and** at runtime: - **build** → static themes (the `render-css.ts` path). - **runtime JS** → white-label / live theming: the user picks a hex, the engine generates the scale + dark (hex fallback + wide-gamut `oklch()`) and writes it via `ActiveEidos.applyColorScheme(seed)`. In **both** modes the output is **already-resolved static values** (contrast picked by APCA, alpha inverted) → **zero sacrifice in either mode**. The only thing that loses both is resolving the color *inside* CSS (`oklch(from …)`), which degrades to **optional sugar** for trivial derivations (§11.6), never the engine. See §6.1 (isomorphism) and §12 (cost). The generator is promoted to **`uix.color`**, an isomorphic *art* parallel to `uix.motion`. --- ## 1. Motivation The audit (`THEMING_AUDIT_2026-06-01.md`, since retired) confirmed that eidos's hole **is not bloat** (`eidos:purge` already gives a ~11 KB gzip floor; on-the-wire bloat is competitive) but **color fidelity and ergonomics**: - **P3-1** — Zero wide-gamut. Everything is sRGB hex. Radix ships P3 for its whole palette. **Not even on the backlog.** - **No OKLCH and no generator.** A brand with a hue outside the 33 scales has to **author 12 steps × 2 modes by hand** (`config-types.ts` `ColorScale` = 12 hex). That is what produced the `loss: indigo` bug in untitled-ui. - **WCAG2 contrast** (`render-css.ts` `wcagContrastRatio`), imprecise in mid-tones. Radix decides with APCA. - **Visible residue**: in the base theme `primary` and `loss` both map **to `purple`** (`themes/base.ts`) → indistinguishable, even though `plum`/`indigo` already exist. A framework aspiring to be a perceptual reference (the promise of the sema layer + two-moment motion) **cannot stay on 2020 sRGB**. This RFC raises the fidelity ceiling and, **as a by-product**, absorbs the legitimate wish for a "more efficient color model" (less authoring surface — see §13). --- ## 2. Design principles 1. **Isomorphic generator, static output.** The generator is pure math (DOM-free) and runs at build (static themes) **or** at JS runtime (white-label). In both it emits already-resolved values → keeps TSC, purge, introspection (APCA pick), compositing-inverse alpha and universal support **in every mode**. The sacrifice is only imposed by resolving color in pure CSS, which we don't use as the engine. 2. **Additive and backwards-compatible.** 12-hex `ColorScale`s remain valid verbatim. The seed is the *new, ergonomic* form, not the only one. 3. **Zero-downstream.** Var names don't change; recipes/CSS/components stay untouched (the same contract `rfc-color-model.md §6` promised). 4. **OKLCH as the authoring and source space.** sRGB+P3 are **output**, not authoring. 5. **Fidelity over formula.** The generator reuses the 33 tuned scales as curve templates (it does not invent a naive linear ramp — the mistake the runtime relative-colors proposal makes). 6. **Verifiable per phase.** Each phase delivers value and validates on its own (§11). --- ## 3. Current state (code anchors) | Piece | Today | File | |---|---|---| | Scale shape | `ColorScale = Record` (12 hex) | `config-types.ts:27` | | Palette | 33 hex scales (many seeded from Radix) | `themes/base.ts` + `color-scales.ts` | | Roles | explicit hierarchy + auto-derived intents | `config-types.ts:78-94` | | Slots | 13 slots `track1·bg2·element3·hover4·active5·separator6·border7·borderHover8·solid9·solidHover10·text11·textStrong12·contrast(on-solid)` | `render-css.ts` `DEFAULT_COLOR_ROLE_SLOT_STEPS` | | Color emission | `renderThemeCss` → `--scale-*`, `--primitive-*`, `--color-{role}-{slot}` | `render-css.ts:288-380` | | Contrast | gamma-lin WCAG2, swap to `onSolidContrast` if `<3:1` | `render-css.ts:343-363,1629` | | Alpha | compositing-inverse (generates `aN` that over the background reproduces solid N) | `render-css.ts:1647-1700` | | Output | sRGB hex in `[data-theme='…']` blocks (light+dark both shipped, one active) | `render-css.ts:379` | --- ## 4. Proposed architecture (color layers, revised) ``` ┌─ AUTHORING (new) build-time │ OKLCH seed | oklch() string | 12-hex verbatim (legacy) │ │ │ ▼ generator (template morph) §6 ├─ SOURCE: 12-step scale in OKLCH build-time │ │ │ ▼ gamut projection §7 ├─ OUTPUT: --scale-{name}-{step} = sRGB hex + oklch() override │ --scale-{name}-a{step} = compositing-inverse alpha (P3-aware) │ │ │ ▼ (UNCHANGED — layers 2..7 of §25) ├─ --primitive-{role}-{step} role→scale alias ├─ --color-{role}-{slot} slots (contrast pick = APCA) §8 └─ recipes / TSC / eidos CSS INTACT ``` Only one stage is inserted **in front** (OKLCH authoring → generation → gamut projection). From `--scale-*` down, everything is identical to today. --- ## 5. OKLCH authoring + types (additive) `config-types.ts` (existing shapes preserved; the new ones added): ```ts /** OKLCH triple: L (0..1), C (0..~0.4), H (0..360 deg). */ export type Oklch = readonly [l: number, c: number, h: number] /** * A scale authored as ONE seed. The generator expands the functional 12-step * ramp by morphing a template scale (Radix's generateRadixColors method) and * re-hueing it to the seed. Generated per-mode (the mode's surface anchors the * low steps). See §6. */ export interface ColorScaleSeed { /** Brand/source color: any CSS color (hex, rgb, `oklch(...)`) OR an Oklch triple. */ readonly seed: string | Oklch /** * Curve donor. Name of an existing scale whose perceptual L-curve and chroma * profile are borrowed and re-hued. Default: nearest template by step-9 * lightness + hue. Selecting the nearest template guarantees step 9 lands on * the seed. */ readonly template?: string /** Step the seed lands on exactly. Default `'9'` (the solid). */ readonly solidStep?: ColorScaleStep } /** A scale is EITHER 12 explicit entries (verbatim) OR a seed (generated). */ export type ColorScaleSource = ColorScale | ColorScaleSeed /** Map of scale name → source. Widens `ColorScales` for authoring. */ export type ColorScaleSourceMap = Record ``` `ThemeColorSet.scales` widens from `ColorScales` to `ColorScaleSourceMap` (backwards-compatible: a `Record` satisfies it). At render time the engine expands the seeds before the emission loop. ### Authoring example — before vs after ```ts // TODAY (untitled-ui / grafito): 12 hex × 2 modes by hand, per scale. violet: s('#fcfaff','#f9f5ff','#f4ebff','#e9d7fe','#d6bbfb','#c3a5f7', '#b692f6','#9e77ed','#7f56d9','#6941c6','#5b34b5','#42307d'), // PROPOSAL: one seed. light and dark generate from the SAME seed against // each mode's surface. violet: { seed: '#7f56d9' } // hex violet: { seed: [0.556, 0.196, 296.5] } // direct OKLCH violet: { seed: '#7f56d9', template: 'iris' } // force the curve donor ``` The per-component override (`