docs(book): F7.4 (4/4) — color-engine RFC translated; rfcs/ batch COMPLETE
COLOR_ENGINE_RFC (619 L, the largest RFC) -> rfcs/rfc-color-engine.md,
Spanish to English, same section numbering (s0 TL;DR, s6 generator +
s6.1 isomorphism + s6.2 deriveScheme/buildScheme, s7 wide-gamut
strategies, s8 APCA, s10 frozen invariants, s11 phases with 4-bis
implemented, s13 bloat-doc absorption, s14 comparison). Its
THEMING_AUDIT citation notes the audit is retired; the s25/s26 canon
citations point into theming/reference.md. decisions.md row repointed +
Status aligned (implemented through phase 4-bis); the index's naming
note rewritten — the deferred eidos-RFC rename is now DONE via stubs,
while the arts DESIGN_* docs keep their legacy names in place (their
citations were not swept).
F7.4 complete: 7 RFCs live at docs/rfcs/rfc-{scaling, structure, depth,
shape, typography, color-model, color-engine}.md; MOTION_SERVICE_RFC
stays in src (foreign/concurrent). docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
---
|
|
|
|
|
|
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<ColorScaleStep, string>` (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<string, ColorScaleSource>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`ThemeColorSet.scales` widens from `ColorScales` to `ColorScaleSourceMap`
|
|
|
|
|
|
(backwards-compatible: a `Record<string, ColorScale>` 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 (`<Button color="grass">`) and the roles
|
|
|
|
|
|
(`primary: 'violet'`) **do not change** — they keep referencing scales by name.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 6. The 1-seed → 12-step generator (algorithm)
|
|
|
|
|
|
|
|
|
|
|
|
**Method: template morph** (what `@radix-ui/colors`' `generateRadixColors`
|
|
|
|
|
|
does). Not a naive parametric ramp — it reuses the perceptual shape of a tuned
|
|
|
|
|
|
scale. Lives at build-time, e.g. `lib/themes/generate-scale.ts`.
|
|
|
|
|
|
|
|
|
|
|
|
Given `seed → OKLCH (Ls, Cs, Hs)`, mode `m`, and its background
|
|
|
|
|
|
`bg = mode.surface.default`:
|
|
|
|
|
|
|
|
|
|
|
|
1. **Template selection.** If no `template` is given, pick the library scale
|
|
|
|
|
|
minimizing `|L9_template − Ls|` weighted by hue proximity. *Key*: by
|
|
|
|
|
|
choosing the template whose step-9 lightness is closest to the seed,
|
|
|
|
|
|
preserving the template's L-curve **lands step 9 on the seed**.
|
|
|
|
|
|
2. **Re-hueing.** For each step `i`, take `H_i := Hs` (+ optionally the
|
|
|
|
|
|
template's relative hue drift: `H_i := Hs + (H_i_tpl − H9_tpl)`).
|
|
|
|
|
|
3. **Lightness.** Keep the template's L-curve **verbatim** (`L_i := L_i_tpl`).
|
|
|
|
|
|
These are the perceptual milestones (1-2 near-surface background, 9 solid,
|
|
|
|
|
|
11-12 text). Since the template was chosen by `L9 ≈ Ls`, step 9 already
|
|
|
|
|
|
falls on the seed.
|
|
|
|
|
|
4. **Chroma.** Re-scale the chroma profile so step 9 reaches `Cs`:
|
|
|
|
|
|
`C_i := C_i_tpl × (Cs / C9_tpl)`, gamut-capped (§7). Steps 1-2, of
|
|
|
|
|
|
near-zero chroma in the template, stay near-gray after re-hueing → the
|
|
|
|
|
|
backgrounds stay glued to the surface **without** special cases.
|
|
|
|
|
|
5. **Exact anchoring.** At `solidStep` (9 by default) force the result = the
|
|
|
|
|
|
exact seed (`L9:=Ls, C9:=Cs, H9:=Hs`) for pure brand fidelity on the button.
|
|
|
|
|
|
6. **Alpha.** Reuse the existing compositing-inverse over `bg` (already
|
|
|
|
|
|
implemented, `render-css.ts:1647`) — now with OKLCH→sRGB input, P3-aware
|
|
|
|
|
|
output (§7).
|
|
|
|
|
|
|
|
|
|
|
|
Result: 12 OKLCH per mode, brand-faithful at step 9, harmonic elsewhere,
|
|
|
|
|
|
gamut-safe. **The 33 scales stop being "768 dead vars" (the bloat doc's
|
|
|
|
|
|
complaint) and become the generator's template library** — their value
|
|
|
|
|
|
multiplies.
|
|
|
|
|
|
|
|
|
|
|
|
> **Calibration (mandatory before merging, §11.4).** The generator must
|
|
|
|
|
|
> reproduce the original Radix scales within tolerance (per-step ΔE2000 +
|
|
|
|
|
|
> APCA delta of the contrast pair). Where it falls short, the scale stays
|
|
|
|
|
|
> **verbatim** (the current 31 hex are ground-truth). The generator is for
|
|
|
|
|
|
> *new brands*, not for regenerating what is already tuned.
|
|
|
|
|
|
|
|
|
|
|
|
### 6.1 — The generator is isomorphic (build + runtime, same code)
|
|
|
|
|
|
|
|
|
|
|
|
Introspection (the contrast pick) and the compositing-inverse alpha **only
|
|
|
|
|
|
need the color's numeric value at decision time** — they don't require
|
|
|
|
|
|
build-time, they require **JS, not CSS**. The color math
|
|
|
|
|
|
(OKLCH↔sRGB↔Display-P3, gamut-map, APCA, inverse-alpha) is **pure and
|
|
|
|
|
|
deterministic, DOM-free**, so the same module runs in both places:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
// lib/color/engine.ts — PURE, isomorphic. No DOM imports.
|
|
|
|
|
|
export function generateScale(seed: Oklch, mode: ModeAnchors): ResolvedScale
|
|
|
|
|
|
export function pickOnSolid(solid: Oklch, candidates: OnSolidPair): string // APCA
|
|
|
|
|
|
export function alphaOverBackground(solid: Oklch, bg: Oklch): string // inverse
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| Mode | Caller | What it does with the result |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| **Build** | `render-css.ts` (framework + app static themes) | concatenates CSS strings (the current path) |
|
|
|
|
|
|
| **Runtime JS** | `buildScheme(seed)` (pure composition; white-label / theme editor) | `ActiveEidos.applyColorScheme(seed)` writes the scheme block (hex + `oklch()`) |
|
|
|
|
|
|
|
|
|
|
|
|
In **both** the output is **resolved static values** (the `contrast` already
|
|
|
|
|
|
picked by APCA, the `aN` already inverted). That is why **neither
|
|
|
|
|
|
introspection nor alpha is sacrificed in either mode** — they were computed in
|
|
|
|
|
|
JS *before* writing.
|
|
|
|
|
|
|
|
|
|
|
|
**Cost of keeping both at runtime**: on a live brand change the **resolved
|
|
|
|
|
|
role slots** are rewritten too (`--color-{role}-contrast`, `-surface`,
|
|
|
|
|
|
`-surface-hover`), not just the raw scale — so the APCA pick and the alpha
|
|
|
|
|
|
stay correct with the new luminance. That is ~24-48 vars/color, sub-ms math.
|
|
|
|
|
|
The only added weight is **shipping the color-math module to the client, and
|
|
|
|
|
|
only if the app uses runtime generation** (tree-shakeable; apps with static
|
|
|
|
|
|
themes don't load it).
|
|
|
|
|
|
|
|
|
|
|
|
**Promotion to `uix.color`**: the generator stops being a build script and
|
|
|
|
|
|
becomes an isomorphic *art* (parallel to `uix.motion`): a pure service
|
|
|
|
|
|
consumed by the build (`render-css`) and the runtime (`ActiveEidos`). This
|
|
|
|
|
|
absorbs the bloat doc's white-label / live dream **without** the
|
|
|
|
|
|
CSS-relative-colors regression.
|
|
|
|
|
|
|
|
|
|
|
|
**State 2026-06-29 — done.** `uix.color` exists as a *stateless* accessor on
|
|
|
|
|
|
`ActiveUix` (type `EngineColor` — stateless, hence `Engine*` not `Active*`),
|
|
|
|
|
|
discoverable next to `uix.motion` / `uix.timers`; `eidos` keeps importing
|
|
|
|
|
|
`$color` directly for build/SSR. The reciprocal consumer —resolving a theme
|
|
|
|
|
|
token to a concrete color in JS, without a `getComputedStyle` probe— is
|
|
|
|
|
|
`eidos.resolveToken(token)` (config + `$color`).
|
|
|
|
|
|
|
|
|
|
|
|
> **CSS-native frontier (watch, not a solution).** `contrast-color()` (CSS
|
|
|
|
|
|
> Color 5) would do the contrast pick in pure CSS someday — but it is not
|
|
|
|
|
|
> ready (Safari 18 prototype, nothing in Chrome/Firefox in 2026), it only
|
|
|
|
|
|
> picks pure white/black (not your `onSolid`/`onSolidContrast` tokens) and you
|
|
|
|
|
|
> don't control the criterion. For the alpha-inverse **there is no CSS
|
|
|
|
|
|
> primitive** — JS is the only path. That is why the engine is isomorphic JS,
|
|
|
|
|
|
> not CSS.
|
|
|
|
|
|
|
|
|
|
|
|
### 6.2 — Scheme derivation (theme builder · the Material 3 formula)
|
|
|
|
|
|
|
|
|
|
|
|
On top of `generateScale` (one seed → 12 steps) lives **`deriveScheme`** (one
|
|
|
|
|
|
seed → the SEEDS of the hierarchy roles). It is the core of a theme builder:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
brand seed → deriveScheme(seed, variant) → { primary, secondary, tertiary, neutral, neutralVariant }
|
|
|
|
|
|
→ generateScale(each seed) → 12-step scales (all inside buildScheme)
|
|
|
|
|
|
→ applyColorScheme(seed) → live theme (hex + oklch block)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**The formula** is Material 3's (`CorePalette` HCT) ported to OKLCH:
|
|
|
|
|
|
|
|
|
|
|
|
| role | hue | chroma (calibrated OKLCH) | M3 rule (HCT) |
|
|
|
|
|
|
| --- | --- | --- | --- |
|
|
|
|
|
|
| primary | H (seed) | the seed's (verbatim) | `max(C, 48)` |
|
|
|
|
|
|
| secondary | H | `0.04` (low) | `16` |
|
|
|
|
|
|
| **tertiary** | **H + 60°** | `0.09` | `+60 / 24` |
|
|
|
|
|
|
| neutral | H | `0.008` (near gray) | `4` |
|
|
|
|
|
|
| neutral-variant | H | `0.016` | `8` |
|
|
|
|
|
|
|
|
|
|
|
|
The **structure** (same-hue-desaturated for secondary · +60° for tertiary) is
|
|
|
|
|
|
model-agnostic, so it ports exactly; only the chroma numbers recalibrate (HCT
|
|
|
|
|
|
`0..120` ≠ OKLCH `0..0.37`). HCT's **tone→contrast** trick does NOT port —
|
|
|
|
|
|
contrast is decided by **APCA** (§8).
|
|
|
|
|
|
|
|
|
|
|
|
**Variants** (`SchemeVariant`) — the builder's "style":
|
|
|
|
|
|
- `tonal` (default) — the table (classic M3 look).
|
|
|
|
|
|
- `vibrant` — more chroma + a small rotation on secondary; saturated tertiary.
|
|
|
|
|
|
- `monochrome` — chroma 0 everywhere: the hierarchy **collapses to a neutral
|
|
|
|
|
|
ink** (Vercel / Linear look); differentiated by tone + emphasis, not hue (a
|
|
|
|
|
|
collision *by design*, unlike the base's bug).
|
|
|
|
|
|
|
|
|
|
|
|
Structured so `expressive` / `neutral` / `content` can be added as ~15 lines
|
|
|
|
|
|
of rules, touching nothing else.
|
|
|
|
|
|
|
|
|
|
|
|
**Per-role override** — `deriveScheme` gives DEFAULTS, not a cage. The
|
|
|
|
|
|
designer can **pin** any role to an exact color (replaces that role's seed;
|
|
|
|
|
|
the rest keeps deriving from the base seed, and changing the seed re-derives
|
|
|
|
|
|
only the unpinned). It is the Radix/M3 pattern (custom per-role colors) and
|
|
|
|
|
|
the hand-authored path (grafito maps every role explicitly:
|
|
|
|
|
|
`secondary: 'violet'`). The `/temas/color` builder exposes it with a color
|
|
|
|
|
|
input per row + "auto" to return to derived.
|
|
|
|
|
|
|
|
|
|
|
|
**The 6 intents are NOT derived** — they are canonical hues of the book (an
|
|
|
|
|
|
error is red, always). So they don't **clash** with the brand they are
|
|
|
|
|
|
**tempered** with `temper(color, reference, amount)`: keeps the **hue** (red
|
|
|
|
|
|
stays red) and only pulls **chroma + lightness** toward the brand's profile —
|
|
|
|
|
|
the *perceptual temperature*. That is what coheres a palette; **rotating the
|
|
|
|
|
|
hue erodes meaning** (a red stops reading as error). A **subtle (~10-15%)**
|
|
|
|
|
|
amount is enough; the `/temas/color` builder uses it as the default (slider in
|
|
|
|
|
|
*Canonical roles*, 0 = pure canonical → strong).
|
|
|
|
|
|
`harmonize(color, toward, amount)` (M3 `blend.harmonize`, **rotates hue**)
|
|
|
|
|
|
stays in the engine for custom **brand accents**, NOT for semantic intents.
|
|
|
|
|
|
|
|
|
|
|
|
**The +60° caveat**: Material's rotation can land near an intent depending on
|
|
|
|
|
|
the primary (e.g. `purple + 60° = H6 ≈ red/threat`). That is why the base
|
|
|
|
|
|
theme pinned its `tertiary` to `indigo` (−60°, cool, intent-free) **by hand**.
|
|
|
|
|
|
A builder should offer a tertiary-hue override or dodge the intent bands
|
|
|
|
|
|
(red 25° · orange 55° · amber 75° · green 158° · teal 182° · plum 330°).
|
|
|
|
|
|
|
|
|
|
|
|
**Blast radius: zero on components.** `deriveScheme` produces VALUES that
|
|
|
|
|
|
enter through the frozen `--color-{role}-{slot}` contract (§10). No component,
|
|
|
|
|
|
recipe, CSS or the TSC changes — a variant is "another theme", like `base` ↔
|
|
|
|
|
|
`grafito`. The **number of variants is a builder-catalog decision, not an
|
|
|
|
|
|
architectural cost** (N CSS files are not shipped; one theme computes at a
|
|
|
|
|
|
time, build or runtime).
|
|
|
|
|
|
|
|
|
|
|
|
Lives in `uix.color` (`scheme.ts`): pure, isomorphic math. The COMPOSITION of
|
|
|
|
|
|
`deriveScheme` + `generateScale` + APCA + alpha into the
|
|
|
|
|
|
`--primitive-{role}-*` token map is `buildScheme(seed, opts)`
|
|
|
|
|
|
(`eidos/lib/build-scheme.ts`, pure). The runtime method
|
|
|
|
|
|
**`eidos.applyColorScheme(seed, opts)`** (ActiveEidos) resolves the donor
|
|
|
|
|
|
scales + the active theme's background, writes the style block and **follows
|
|
|
|
|
|
light/dark** (re-derives on mode change); returns a `BuildSchemeResult` (hex
|
|
|
|
|
|
steps + `stepsOklch` + solid / on-solid per role) for introspection.
|
|
|
|
|
|
`eidos.clearColorScheme()` reverts. The block stacks **hex + `oklch()`** per
|
|
|
|
|
|
step (wide-gamut, §7) and `generateScale` keeps the raw unclamped OKLCH, so a
|
|
|
|
|
|
vivid seed (chroma > sRGB) comes out P3 (demo: the *vividness* slider).
|
|
|
|
|
|
**Status: implemented** (Phase 4) — see
|
|
|
|
|
|
[`theming/reference.md §26`](../theming/reference.md); tests in
|
|
|
|
|
|
`build-scheme.test.ts` + `active-eidos.test.ts`.
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const result = eidos.applyColorScheme('#8e4ec6', {
|
|
|
|
|
|
variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome'
|
|
|
|
|
|
temper: 0.12, // intent cohesion (keeps hue)
|
|
|
|
|
|
overrides: { tertiary: '#3e63dd' } // pins a role; the rest derives
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 7. Wide-gamut output (P3 + sRGB)
|
|
|
|
|
|
|
|
|
|
|
|
**Status: strategy A implemented, default-on** (2026-06-04). `render-css`
|
|
|
|
|
|
emits for every palette step the **hex (universal fallback)** + a sibling
|
|
|
|
|
|
**`oklch()`** that wins where supported (`appendColorScaleDeclarations`). NO
|
|
|
|
|
|
config flag: it is the default behavior. Strategy B (explicit P3 via `@media`)
|
|
|
|
|
|
is NOT implemented (it would be added as an option if an app needs to
|
|
|
|
|
|
hand-tune P3 values).
|
|
|
|
|
|
|
|
|
|
|
|
**Honesty about the visible effect**: the default palette (Radix) is authored
|
|
|
|
|
|
in **sRGB hex**, so its `oklch()` is **sRGB-equivalent** — there is no P3 data
|
|
|
|
|
|
to recover from an sRGB, it looks identical today (verified:
|
|
|
|
|
|
`--scale-purple-9` → `oklch(0.5556 0.1829 305.86)` paints `#8e4ec6`). The
|
|
|
|
|
|
value is that the token layer is now **OKLCH-native and wide-gamut-ready**: a
|
|
|
|
|
|
theme authored in OKLCH, or a scheme generated from a vivid seed, renders more
|
|
|
|
|
|
saturated on P3 **with no extra work**. Making the SHIPPED palette visibly
|
|
|
|
|
|
wide-gamut is Phase 3 (authoring/generating the palette in OKLCH).
|
|
|
|
|
|
|
|
|
|
|
|
Two strategies (design history):
|
|
|
|
|
|
|
|
|
|
|
|
### A. Direct-OKLCH + hex fallback (recommended · Tailwind v4 style)
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-theme='brand-light'] {
|
|
|
|
|
|
--scale-violet-9: #7f56d9; /* universal fallback (gamut-mapped sRGB) */
|
|
|
|
|
|
--scale-violet-9: oklch(0.556 0.196 296.5); /* wins where OKLCH exists → AUTOMATIC wide-gamut */
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- **Free wide-gamut**: `oklch()` uses the display's gamut; on P3 screens the
|
|
|
|
|
|
color comes out more saturated than the hex without a `@media`.
|
|
|
|
|
|
- **Universal**: pre-OKLCH browsers (rare in 2026) use the hex.
|
|
|
|
|
|
- **Byte-light**: two lines per token, no duplicated `@media` blocks. Gzip
|
|
|
|
|
|
eats them.
|
|
|
|
|
|
- OKLCH support: Chrome 111+ / Safari 15.4+ / Firefox 113+ (broad since 2023).
|
|
|
|
|
|
|
|
|
|
|
|
### B. Explicit P3 via `@media (color-gamut: p3)` (max fidelity · Radix style)
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-theme='brand-light'] { --scale-violet-9: #7f56d9; }
|
|
|
|
|
|
@supports (color: color(display-p3 0 0 0)) {
|
|
|
|
|
|
@media (color-gamut: p3) {
|
|
|
|
|
|
[data-theme='brand-light'] { --scale-violet-9: color(display-p3 0.45 0.34 0.83); }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
More control (distinct P3 values per step) at the cost of more bytes. For
|
|
|
|
|
|
apps that hand-tune the gamut.
|
|
|
|
|
|
|
|
|
|
|
|
### Gamut-mapping the sRGB fallback
|
|
|
|
|
|
|
|
|
|
|
|
The fallback hex is obtained by **CSS Color 4 gamut-mapping** (chroma
|
|
|
|
|
|
reduction preserving L and H until inside sRGB), **not** by channel clipping
|
|
|
|
|
|
(which shifts the hue). Implementation: iteratively reduce `C` until
|
|
|
|
|
|
OKLCH→sRGB is in-gamut. (Also reusable for the anchored step 9 when the seed
|
|
|
|
|
|
is P3-only.)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 8. APCA contrast
|
|
|
|
|
|
|
|
|
|
|
|
Replace `wcagContrastRatio` (`render-css.ts:1629`) with **APCA (Lc)** for the
|
|
|
|
|
|
text-on-solid pick (`render-css.ts:343-363`):
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
/** APCA lightness contrast, −108..+106. Polarity-aware (text vs bg). */
|
|
|
|
|
|
function apcaLc(text: Srgb, bg: Srgb): number { /* APCA-W3 0.1.9 */ }
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- **Pick**: for each role, compute `Lc(onSolid, solid9)` and
|
|
|
|
|
|
`Lc(onSolidContrast, solid9)`; choose the larger `|Lc|`.
|
|
|
|
|
|
- **Floor**: require `|Lc| ≥ 60` (normal text) / `≥ 45` (UI / large text). If
|
|
|
|
|
|
neither reaches it, fall back to step-12 (as today) + a validation warning.
|
|
|
|
|
|
- **Why APCA**: WCAG2 over/under-estimates contrast in mid-tones (the exact
|
|
|
|
|
|
`risk`=orange case that slipped through, audit P2-2). APCA models real
|
|
|
|
|
|
perception.
|
|
|
|
|
|
- **Honesty**: APCA is a **WCAG3 draft**, not legal conformance. Keep a
|
|
|
|
|
|
**WCAG2 ≥ 3:1 cross-check** as a safety net and document it; if APCA and
|
|
|
|
|
|
WCAG2 disagree strongly, the more conservative wins.
|
|
|
|
|
|
|
|
|
|
|
|
Effect: the pick improves on light solids (amber/yellow/lime/mint) with no
|
|
|
|
|
|
regression on the dark ones (purple/red/blue keep white).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 9. Cleanup of the current model (included in the sweep)
|
|
|
|
|
|
|
|
|
|
|
|
| Defect | Fix | Anchor |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `primary` ≡ `loss` = purple in the base | Migrate the base to seeds; `loss` → its own seed (plum). Auto-derives if omitted. | `themes/base.ts:18,31` |
|
|
|
|
|
|
| `tertiary` ≡ `neutral` = gray | `tertiary` → a distinct hue (e.g. indigo) or drop it from the default. | `themes/base.ts:20,21` |
|
|
|
|
|
|
| Doc-drift `primary: indigo` (doc) vs `purple` (code) | Align doc + base after deciding the base's primary. | theming reference §4/§9 |
|
|
|
|
|
|
| **P3-3** slot→step tight at the bottom, 7-8 underused, `border=6` washed out | *Conservative*: `border` 6→7; evaluate `border-strong`=8. **Gated behind a visual probe** — don't break harmony. | `render-css.ts:71-81` |
|
|
|
|
|
|
|
|
|
|
|
|
The model cleanup is not the heart of the RFC but comes "for free" when
|
|
|
|
|
|
migrating the base to seeds.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 10. Compatibility / invariants (what does NOT change)
|
|
|
|
|
|
|
|
|
|
|
|
**Name contract — frozen.** The generator and the gamut projection produce
|
|
|
|
|
|
exactly the same vars as today:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
--scale-{name}-{1..12} --scale-{name}-a{1..12}
|
|
|
|
|
|
--primitive-{role}-{1..12} --primitive-{role}-a{1..12}
|
|
|
|
|
|
--color-{role}-{slot} --color-{role}-surface --color-{role}-surface-hover
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Therefore **unchanged**: recipes (`lib/recipes/base.ts`), the TSC, component
|
|
|
|
|
|
eidos CSS, demos, the purge, the public contract (`contract.ts`), and the
|
|
|
|
|
|
component API (`data-color`, the `color` prop). The same shielding
|
|
|
|
|
|
`rfc-color-model.md §6` promised: "downstream, zero changes; only *how* those
|
|
|
|
|
|
vars are produced changes".
|
|
|
|
|
|
|
|
|
|
|
|
**Sema canon — intact.** The 6 intents, the 8 families, the doctrine "color
|
|
|
|
|
|
expresses the intent, it doesn't define it". Color keeps contributing hue
|
|
|
|
|
|
identity only.
|
|
|
|
|
|
|
|
|
|
|
|
**Variants canon — intact** (§19). The theme retints; it adds no variants and
|
|
|
|
|
|
redefines no cascades.
|
|
|
|
|
|
|
|
|
|
|
|
**Persistence** — the versioned envelope (`toDocument()`) gets a `version`
|
|
|
|
|
|
bump if the authored `scales` shape changes; old 12-hex scales read the same.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 11. Phase plan (each verifiable and mergeable alone)
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 0 — Types + generator (build-time lib), no behavior change
|
|
|
|
|
|
- Add `Oklch`, `ColorScaleSeed`, `ColorScaleSource`, widen `ThemeColorSet.scales`.
|
|
|
|
|
|
- `lib/themes/generate-scale.ts` (template morph) + OKLCH↔sRGB↔P3 conversion +
|
|
|
|
|
|
gamut-mapping. Not consumed yet — the existing scales stay verbatim.
|
|
|
|
|
|
- **Verify**: generator unit tests (round-trip, gamut, step-9 anchoring).
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 1 — APCA contrast
|
|
|
|
|
|
- Swap `wcagContrastRatio` → `apcaLc` in the on-solid pick; floor + WCAG2
|
|
|
|
|
|
cross-check.
|
|
|
|
|
|
- **Verify**: re-assert AA/Lc for the 9 roles × 2 modes (covers blind spot
|
|
|
|
|
|
P1-6); browser probe of every role's solid button/badge.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 2 — Wide-gamut output (strategy A)
|
|
|
|
|
|
- Emit every color token as `hex; oklch()` override. No `@media` (default A).
|
|
|
|
|
|
- **Verify**: hex identical to current (zero sRGB regression); on a P3 display
|
|
|
|
|
|
the color saturates. Test that the fallback always exists.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 3 — Migrate base + grafito to seeds
|
|
|
|
|
|
- Re-author `themes/base.ts` and `_lib/grafito.ts` with `seed`. Fix
|
|
|
|
|
|
`primary≡loss`.
|
|
|
|
|
|
- **Verify**: per-step ΔE2000 vs the current hex within tolerance; visual
|
|
|
|
|
|
probe of the `/uix/components/*` pages + `/temas/grafito` (light+dark).
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 4 — Public 1-seed generator + demo
|
|
|
|
|
|
- Expose in `ActiveEidos` / `defineEidosConfig` and document.
|
|
|
|
|
|
- Demo under `/temas` (or `/uix/lib`): a color input → full scale + dark + P3
|
|
|
|
|
|
live (build-time via an endpoint or precomputed).
|
|
|
|
|
|
- **Verify**: an arbitrary brand generates an AA scale without authoring hex.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 5 — slot→step tuning (P3-3), optional, gated behind a probe
|
|
|
|
|
|
- Only if the visual probe supports it. Conservative (`border` 6→7).
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 6 — Docs
|
|
|
|
|
|
- Theming reference: §25 gains a "physical layer" subsection pointing here;
|
|
|
|
|
|
§22 marks P3-1 resolved. Mark `rfc-color-model.md §5` phase 3 as resolved by
|
|
|
|
|
|
this RFC.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 4-bis — Runtime generation (white-label / live theming) — ✅ IMPLEMENTED
|
|
|
|
|
|
- `ActiveEidos.applyColorScheme(seed, opts)` runs the **same isomorphic
|
|
|
|
|
|
generator** (§6.1) in JS (via the pure `buildScheme` helper) and writes the
|
|
|
|
|
|
scale + **resolved role slots** (`--color-{role}-contrast`) as a **managed
|
|
|
|
|
|
style block** (hex fallback + wide-gamut `oklch()`). Keeps the APCA pick +
|
|
|
|
|
|
alpha (computed before writing) and **follows light/dark** (re-derives on
|
|
|
|
|
|
mode change). Covers white-label **without** CSS-relative. See §6.2 +
|
|
|
|
|
|
[`theming/reference.md §26`](../theming/reference.md).
|
|
|
|
|
|
- **Verify**: a live-picked brand hex produces coherent AA scale + dark + P3;
|
|
|
|
|
|
switching brands rewrites only the affected role's vars.
|
|
|
|
|
|
|
|
|
|
|
|
### Optional sugar (no fixed phase) — relative colors in CSS
|
|
|
|
|
|
- **Only** for trivial derivations where the numeric value isn't needed
|
|
|
|
|
|
(`oklch(from var(--color-x-solid) calc(l - .05) c h)` for a hover). Always
|
|
|
|
|
|
with `@supports` + fallback to the static token. **Never** generates scales
|
|
|
|
|
|
or decides contrast/alpha (the JS engine does that, not the CSS).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 12. Risks and open questions
|
|
|
|
|
|
|
|
|
|
|
|
| Risk | Mitigation |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| **Generator fidelity < hand-tuned** | Calibrate against Radix (ΔE + APCA); where short, keep verbatim. The 31 hex are ground-truth, not deleted. |
|
|
|
|
|
|
| **APCA is a draft** | Keep the WCAG2 ≥3:1 floor as cross-check; the conservative wins. |
|
|
|
|
|
|
| **OKLCH floor (pre-2023)** | The hex fallback is universal — zero loss. |
|
|
|
|
|
|
| **P3 doubles bytes (strategy B)** | Default = strategy A (direct-OKLCH, no `@media`); B opt-in only. |
|
|
|
|
|
|
| **Bad gamut-map shifts hue** | Use CSS Color 4 chroma reduction, not channel clipping. |
|
|
|
|
|
|
| **Generator build cost** | Memoize per seed; it is build-time, not runtime. |
|
|
|
|
|
|
| **Doc/code drift** | Phase 6 closes it; §10 freezes the name contract. |
|
|
|
|
|
|
|
|
|
|
|
|
**Open questions** (to decide before Phase 0):
|
|
|
|
|
|
|
|
|
|
|
|
1. **Default output strategy**: A (direct-OKLCH + hex) vs B (P3 `@media`).
|
|
|
|
|
|
*I recommend A* (simple, byte-light, automatic wide-gamut).
|
|
|
|
|
|
2. **Algorithm**: template-morph (recommended, reuses the 31) vs parametric
|
|
|
|
|
|
curve. *I recommend morph*.
|
|
|
|
|
|
3. **Do the 31 templates keep shipping by default** (status quo + purge) or
|
|
|
|
|
|
become **build-time data** emitting only what's referenced? *I recommend
|
|
|
|
|
|
status quo + purge* (keeps the `data-color="grass"` override); the
|
|
|
|
|
|
templates are the same data.
|
|
|
|
|
|
4. **The base's `primary`/`tertiary`**: which hues? (purple→indigo/violet? ·
|
|
|
|
|
|
tertiary→?).
|
|
|
|
|
|
5. **APCA thresholds** (60/45) — calibrate against the real catalog.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 13. How this RFC absorbs the "more efficient color model"
|
|
|
|
|
|
|
|
|
|
|
|
The "bloat reduction" document asked for three things. This RFC delivers their
|
|
|
|
|
|
good part **without** their regressions (see the runtime-vs-build analysis):
|
|
|
|
|
|
|
|
|
|
|
|
| The doc's wish | How this RFC delivers it | Without paying |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| "1 base variable instead of 12" | **1-seed generator** (§6), isomorphic: author one color, 12 come out — at build **or** JS runtime | …fidelity (tuned curve), introspectable contrast, alpha, universal support |
|
|
|
|
|
|
| "fewer bytes" | Brand apps author N seeds (not N×12 hex) + **purge** + compact direct-OKLCH | …the `data-color` override (the scales stay available) |
|
|
|
|
|
|
| "semantic reduction to ~6 states" | **Already exists**: the 9 `--color-{role}-{slot}` slots (§25). Untouched. | — |
|
|
|
|
|
|
| OKLCH / vanguard | **Authoring and source space** + wide-gamut output | …breaking the static computation |
|
|
|
|
|
|
|
|
|
|
|
|
Agreement on the goal (OKLCH, less authoring); the right mechanism is
|
|
|
|
|
|
**build-time**, strictly superior because it is a superset: the runtime's
|
|
|
|
|
|
ergonomics *plus* the fidelity, correctness and robustness the runtime
|
|
|
|
|
|
sacrifices.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 14. Comparison with the references
|
|
|
|
|
|
|
|
|
|
|
|
| Capability | activeUIX (post-RFC) | Radix Colors v3 | Tailwind v4 | Chakra Panda | Material 3 |
|
|
|
|
|
|
|---|---|---|---|---|---|
|
|
|
|
|
|
| Authoring space | **OKLCH** | sRGB+P3 (OKLCH tool) | OKLCH | token | HCT |
|
|
|
|
|
|
| 1-color→scale generator | **✅ isomorphic (build+runtime), morph** | ✅ (CLI/tool, build) | ❌ | ❌ | ✅ (HCT) |
|
|
|
|
|
|
| Wide-gamut P3 | **✅** | ✅ | ✅ (oklch+fallback) | ⚠️ | ⚠️ |
|
|
|
|
|
|
| Contrast | **APCA + WCAG2 floor** | APCA | — | — | tone-based |
|
|
|
|
|
|
| Scope-as-contract (TSC) | **✅ unique** | ❌ | ❌ | ⚠️ build | ❌ |
|
|
|
|
|
|
| Perceptual layer (sema) | **✅ unique** | ❌ | ❌ | ❌ | ⚠️ |
|
|
|
|
|
|
| Introspectable static output | **✅** | ✅ | ✅ | ✅ | varies |
|
|
|
|
|
|
|
|
|
|
|
|
The RFC puts eidos **on par with Radix/M3 in color fidelity**
|
|
|
|
|
|
(OKLCH+P3+APCA+generator) while keeping what it already had **exclusively**
|
|
|
|
|
|
(TSC + the sema layer). That is the "next level".
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
**Last revision**: 2026-06-04. If anything here contradicts the code after
|
|
|
|
|
|
implementation, the code wins — open an issue to sync.
|