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>
menubar-v4-safe
dev 3 months ago
parent 36e79c8f8d
commit 732e128bf1

@ -21,14 +21,13 @@ the semantic vocabulary lives in [`CANON.md`](./CANON.md); this file collects th
Each entry gives the document, its status, and the one decision it records. Open
the document for the full argument — this index never copies it.
> **Naming note.** The filenames below are inconsistent (`*_ENGINE_RFC.md`,
> `*_RFC.md`, `DESIGN_*.md`, `DESIGN.md`). They are kept as-is on purpose: each
> name is cited as a provenance anchor in the source it governs (e.g.
> `// (DEPTH_ENGINE_RFC §5)` appears across `src/uix/eidos/lib/*.ts`, and
> `DESIGN_TIMR §12.8` across `src/arts/timer/*`). Renaming the files would drift
> ~30 of those citations. This index is the consistent surface; the filenames
> stay load-bearing. A physical rename is deferred until those citations are
> swept in the same pass.
> **Naming note.** The eidos RFCs were renamed to `rfc-*` when they moved into
> `docs/rfcs/` (docs-book F7.4, 2026-07-02). The provenance anchors cited from
> source (e.g. `// (DEPTH_ENGINE_RFC §5)` across `src/uix/eidos/lib/*.ts`)
> keep resolving: every old path holds a stub pointing at the new chapter with
> the same section numbering. The arts documents (`DESIGN_*.md`, `DESIGN.md`)
> keep their legacy names in-place — same rationale, their citations (e.g.
> `DESIGN_TIMR §12.8` across `src/arts/timer/*`) have not been swept.
---
@ -43,7 +42,7 @@ references rather than copying it — with the cage open".
| RFC | Status | The decision it records |
| --- | --- | --- |
| [`rfc-color-model.md`](./rfcs/rfc-color-model.md) | RESUELTO (2026-06-02) — canon in [`theming/reference.md`](./theming/reference.md) §25 | The conceptual color model: rich palette (31 Radix scales) + hierarchy roles by explicit alias + intents auto-derived from the palette (identity = step 9). Rejected the "intent = single-anchor" variant. |
| [`COLOR_ENGINE_RFC.md`](../src/uix/eidos/COLOR_ENGINE_RFC.md) | PROPUESTA (2026-06-04) | The *physical* layer of color: OKLCH · P3 wide-gamut · APCA contrast · 1-seed generator. Changes how the color variables are produced, not which exist or what they mean. |
| [`rfc-color-engine.md`](./rfcs/rfc-color-engine.md) | ✅ Implementado (hasta fase 4-bis) | The *physical* layer of color: OKLCH · P3 wide-gamut · APCA contrast · 1-seed generator. Changes how the color variables are produced, not which exist or what they mean. |
| [`rfc-typography.md`](./rfcs/rfc-typography.md) | ✅ Implementado (fases 1–5) | Typography to reference-grade, additively behind the frozen token contract (audit → compare → extend, mirroring color). |
| [`rfc-depth.md`](./rfcs/rfc-depth.md) | ✅ Implementado (fases 1–5) | The depth/presence channel: "depth is not something an element *has*, it is something that *happens*". Two-moment model (state + event). |
| [`rfc-shape.md`](./rfcs/rfc-shape.md) | ✅ Implementado (fases 1–5) | The shape channel (the book's 8th and last expression channel) as orthogonal axes on top of the untouched `--radius-*` magnitude. |

@ -60,10 +60,15 @@ capítulo. **F7.3 HECHA (2026-07-02)**: `docs/canon/` (tsc, recipe-contract) +
`docs/theming/` completo (reference §1–§38 · guide · notes · channels ·
motion §1–§19 · motion-guide · changelog verbatim) — los stubs de THEMING.md
y eidos-motion.md llevan mapa § completo (los docs más citados por §N desde
código/CLAUDE.md). Siguiente: **F7.4** (`docs/rfcs/` — 7 RFCs con rename
`rfc-*` seguro vía stub: COLOR_MODEL, COLOR_ENGINE, DEPTH, SHAPE, STRUCTURE,
SCALING, TYPOGRAPHY; MOTION_SERVICE_RFC NO — foráneo. Los stubs de
THEMING/canon ya apuntan a las rutas viejas de los RFCs: barrer al mover).
código/CLAUDE.md). **F7.4 en curso (6/7 movidos, commits
1/4–3/4)**: rfc-scaling · rfc-structure · rfc-depth · rfc-shape ·
rfc-typography · rfc-color-model HECHOS (es→en + stub + barrido de
reference/decisions/channels; decisions.md Status alineado con lo que cada
RFC declara). Falta SOLO `rfcs/rfc-color-engine.md` ← COLOR_ENGINE_RFC.md
(619 L es→en; barrer sus citas en reference §26/§27, decisions.md:46 y
canon/tsc; MOTION_SERVICE_RFC NO se mueve — foráneo; cerrar actualizando el
preámbulo del índice de decisions.md que aún describe el rename como
diferido).
Luego F7.5 guides (tocar ruta checklist en docs-check I5), F7.6 decisions +
TOC-libro, F7.7 CLAUDE.md (diff antes de commitear).

@ -0,0 +1,663 @@
---
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.

@ -1437,7 +1437,7 @@ Chronicle in [`changelog.md §26`](./changelog.md). Standing:
a full scheme (roles + `a1..a12` alphas + APCA on-solid) from the active
theme and re-derives on mode change. Layers: `uix.color` = the math ·
`build-scheme` = pure composition · `ActiveEidos` = DOM application. API:
[`COLOR_ENGINE_RFC.md`](../../src/uix/eidos/COLOR_ENGINE_RFC.md) §6.2/§7.
[`rfc-color-engine.md`](../rfcs/rfc-color-engine.md) §6.2/§7.
## 27. Wide-gamut OKLCH output (default-on) (2026-06-04)

@ -1,619 +1,17 @@
# RFC — Motor de color de nueva generación (OKLCH · P3 · APCA · generador 1-seed)
> **Estado: PROPUESTA (2026-06-04).**
>
> Este RFC es la **capa física** del color. El **modelo conceptual**
> (paleta → roles de jerarquía → intents auto-derivados) está **cerrado** en
> [`THEMING.md §25`](./THEMING.md) y NO se toca. Aquí cambiamos **cómo se
> producen** las variables de color, no qué variables existen ni qué significan.
>
> Supersedes las fases diferidas de [`COLOR_MODEL_RFC.md §5`](./COLOR_MODEL_RFC.md)
> (fase 2 «ampliar la librería» ya hecha — 33 escalas; fase 3 «generador 1-hex»
> y el wide-gamut/APCA es lo que este RFC concreta y eleva).
---
## 0. TL;DR — qué cambia y qué NO
**Cambia (la capa física / autoría):**
1. **Autoría en OKLCH.** Una escala puede declararse como **una semilla** (`seed`)
en vez de 12 hex a mano. El motor genera el ramp funcional de 12 pasos.
2. **Generador 1-seed → 12 pasos × light/dark** por *morph de plantilla* (método
Radix): reusa las 33 escalas afinadas como **donantes de curva**, re-tinta a la
semilla. Mata el «autorar 12 pasos × 2 modos a mano» y el bug clase-`loss`.
3. **Salida wide-gamut.** Cada token de color se emite como **OKLCH con fallback
hex sRGB** (estilo Tailwind v4) — wide-gamut automático donde el navegador lo
soporta, universal donde no. Opción `@media (color-gamut: p3)` para fidelidad-máx.
4. **Contraste APCA.** La decisión texto-on-solid pasa de WCAG2 (gamma-linealizado)
a **APCA (Lc)**, con un suelo WCAG2 como red de seguridad (APCA es borrador WCAG3).
5. **Saneo**: `primary≡loss=purple` en el base se arregla; afinado conservador del
mapa slot→step (P3-3 del audit).
**NO cambia (invariantes — ver §10):**
- Los **nombres** de todas las vars: `--scale-{name}-{step}`, `--scale-{name}-a{step}`,
`--primitive-{role}-{step}`, `--color-{role}-{slot}`, `--color-{role}-surface`.
- El **modelo de 3 capas** (§25), los **9 roles**, los **13 slots**, las **8 sema
families**, los **variants canon** (§19).
- El **TSC** (scope-as-data, `scopeCovers`, cross-axis), el **purge**, la
**introspección de contraste**, el **alpha compositing-inverse**.
- **Aguas abajo (recipes, eidos CSS, componentes, demos): cero cambios.**
- Todo se calcula **en build-time**; la salida sigue siendo CSS estático.
**El generador es ISOMÓRFICO, no build-only** (descartado el «Santo Grial» de
relative-colors *en CSS* como mecanismo). El eje que sacrifica la introspección
(pick APCA) y el alpha compositing-inverse **no es build-vs-runtime** — es
**CSS-puro vs JS**. Esas dos propiedades solo necesitan el valor numérico del color
en el momento de decidir; la matemática (OKLCH↔sRGB↔P3 + gamut-map + APCA +
inverse-alpha) es **pura, sin DOM**, así que corre idéntica en build **y** en runtime:
- **build** → temas estáticos (camino `render-css.ts`).
- **runtime JS** → white-label / live theming: el usuario elige un hex, el motor
genera la escala + dark (hex fallback + `oklch()` wide-gamut) y la escribe vía
`ActiveEidos.applyColorScheme(seed)`.
En **ambos** modos la salida son **valores estáticos ya resueltos** (contraste
elegido por APCA, alpha invertido) → **cero sacrificio en cualquier modo**. Lo único
que pierde ambas cosas es resolver el color *dentro* del CSS (`oklch(from …)`), que se
degrada a **azúcar opcional** para derivaciones triviales (§11.6), nunca el motor.
Ver §6.1 (isomorfismo) y §12 (coste). El generador se promueve a **`uix.color`**, un
*art* isomórfico paralelo a `uix.motion`.
---
## 1. Motivación
La auditoría ([`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md))
confirmó que el agujero de eidos **no es el bloat** (el `eidos:purge` ya da piso
~11 KB gzip; el bloat sobre el cable es competitivo) sino la **fidelidad y
ergonomía del color**:
- **P3-1** — Cero wide-gamut. Todo es sRGB hex. Radix ships P3 para toda su paleta.
**Ni siquiera está en backlog.**
- **Sin OKLCH ni generador.** Una marca con un hue fuera de las 33 escalas tiene que
**autorar 12 pasos × 2 modos a mano** (`config-types.ts` `ColorScale` = 12 hex).
Fue lo que produjo el bug `loss: indigo` en untitled-ui.
- **Contraste WCAG2** (`render-css.ts:1629` `wcagContrastRatio`), impreciso en
mid-tones. Radix decide con APCA.
- **Residuo visible**: en el tema base `primary` y `loss` mapean **ambos a `purple`**
(`themes/base.ts:18,31`) → indistinguibles, pese a que `plum`/`indigo` ya existen.
Un framework que aspira a ser referencia perceptual (la promesa de la capa sema +
el motion de dos momentos) **no puede quedarse en sRGB de 2020**. Este RFC sube el
techo de fidelidad y, **como subproducto**, absorbe el deseo legítimo del «modelo de
color más eficiente» (menos superficie de autoría — ver §13).
---
## 2. Principios de diseño
1. **Generador isomórfico, salida estática.** El generador es matemática pura (sin
DOM) y corre en build (temas estáticos) **o** en runtime JS (white-label). En
ambos escupe valores ya resueltos → conserva TSC, purge, introspección (pick
APCA), alpha compositing-inverse y soporte universal **en todos los modos**. El
sacrificio solo lo impone resolver el color en CSS puro, que no usamos como motor.
2. **Aditivo y backwards-compatible.** Las 12-hex `ColorScale` siguen siendo válidas
verbatim. La semilla es la forma *nueva y ergonómica*, no la única.
3. **Zero-downstream.** Los nombres de vars no cambian; los recipes/CSS/componentes
no se tocan (mismo contrato que prometió `COLOR_MODEL_RFC §6`).
4. **OKLCH como espacio de autoría y de fuente.** sRGB+P3 son **salida**, no autoría.
5. **Fidelidad sobre fórmula.** El generador reusa las 33 escalas afinadas como
plantillas de curva (no inventa una rampa lineal naíf — el error que comete la
propuesta de relative-colors en runtime).
6. **Verificable por fase.** Cada fase aporta valor y se valida sola (§11).
---
## 3. Estado actual (anclas en código)
| Pieza | Hoy | Archivo |
|---|---|---|
| Forma de escala | `ColorScale = Record<ColorScaleStep, string>` (12 hex) | `config-types.ts:27` |
| Paleta | 33 escalas hex (muchas sembradas desde Radix) | `themes/base.ts` + `color-scales.ts` |
| Roles | jerarquía explícita + intents auto-derivados | `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` |
| Emisión color | `renderThemeCss` → `--scale-*`, `--primitive-*`, `--color-{role}-{slot}` | `render-css.ts:288-380` |
| Contraste | WCAG2 gamma-lin, swap a `onSolidContrast` si `<3:1` | `render-css.ts:343-363,1629` |
| Alpha | compositing-inverse (genera `aN` que sobre el fondo reproduce el sólido N) | `render-css.ts:1647-1700` |
| Salida | sRGB hex en bloques `[data-theme='…']` (light+dark ambos shipped, uno activo) | `render-css.ts:379` |
---
## 4. Arquitectura propuesta (capas del color, revisadas)
```
┌─ AUTORÍA (nuevo) build-time
│ seed OKLCH | oklch() string | 12-hex verbatim (legacy)
│ │
│ ▼ generador (morph de plantilla) §6
├─ FUENTE: escala de 12 pasos en OKLCH build-time
│ │
│ ▼ proyección de gamut §7
├─ SALIDA: --scale-{name}-{step} = hex sRGB + oklch() override
│ --scale-{name}-a{step} = alpha compositing-inverse (P3-aware)
│ │
│ ▼ (SIN CAMBIOS — capas 2..7 del §25)
├─ --primitive-{role}-{step} alias rol→escala
├─ --color-{role}-{slot} slots (pick de contraste = APCA) §8
└─ recipes / TSC / eidos CSS INTACTO
```
Solo se inserta una etapa **delante** (autoría OKLCH → generación → proyección de
gamut). De `--scale-*` hacia abajo, todo es idéntico a hoy.
---
## 5. Autoría OKLCH + tipos (aditivo)
`config-types.ts` (las formas existentes se conservan; se añaden las nuevas):
```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` se ensancha de `ColorScales` a `ColorScaleSourceMap`
(retro-compatible: un `Record<string, ColorScale>` lo satisface). El motor, al
renderizar, expande las semillas antes del bucle de emisión.
### Ejemplo de autoría — antes vs después
```ts
// HOY (untitled-ui / grafito): 12 hex × 2 modos a mano, por escala.
violet: s('#fcfaff','#f9f5ff','#f4ebff','#e9d7fe','#d6bbfb','#c3a5f7',
'#b692f6','#9e77ed','#7f56d9','#6941c6','#5b34b5','#42307d'),
// PROPUESTA: una semilla. light y dark se generan del MISMO seed contra el
// surface de cada modo.
violet: { seed: '#7f56d9' } // hex
violet: { seed: [0.556, 0.196, 296.5] } // OKLCH directo
violet: { seed: '#7f56d9', template: 'iris' } // forzar donante de curva
```
El override por componente (`<Button color="grass">`) y los roles (`primary:
'violet'`) **no cambian** — siguen referenciando escalas por nombre.
---
## 6. El generador 1-seed → 12 pasos (algoritmo)
**Método: morph de plantilla** (lo que hace `@radix-ui/colors`'
`generateRadixColors`). No es una rampa paramétrica naíf — reusa la forma
perceptual de una escala afinada. Vive en build-time, p.ej.
`lib/themes/generate-scale.ts`.
Dado `seed → OKLCH (Ls, Cs, Hs)`, modo `m`, y su fondo `bg = mode.surface.default`:
1. **Selección de plantilla.** Si no se da `template`, elegir la escala de la
librería que minimice `|L9_template − Ls|` ponderado por proximidad de hue.
*Clave*: al elegir la plantilla cuyo step-9 tiene la luminosidad más cercana al
seed, preservar la curva-L de la plantilla **aterriza el step 9 sobre el seed**.
2. **Re-tintado.** Para cada step `i`, tomar `H_i := Hs` (+ opcional el *drift* de
hue relativo de la plantilla: `H_i := Hs + (H_i_tpl − H9_tpl)`).
3. **Luminosidad.** Conservar la curva-L de la plantilla **verbatim** (`L_i :=
L_i_tpl`). Son los hitos perceptuales (1-2 fondo casi-surface, 9 sólido, 11-12
texto). Como la plantilla se eligió por `L9 ≈ Ls`, el step 9 ya cae en el seed.
4. **Croma.** Re-escalar el perfil de croma para que el step 9 alcance `Cs`:
`C_i := C_i_tpl × (Cs / C9_tpl)`, con tope de gamut (§7). Los steps 1-2, de
croma bajísimo en la plantilla, quedan casi-grises tras el re-tintado → los
fondos siguen pegados al surface **sin** casos especiales.
5. **Anclaje exacto.** En `solidStep` (9 por defecto) forzar el resultado = seed
exacto (`L9:=Ls, C9:=Cs, H9:=Hs`) para fidelidad de marca pura en el botón.
6. **Alpha.** Reusar el compositing-inverse existente sobre `bg` (ya implementado,
`render-css.ts:1647`) — ahora con entrada OKLCH→sRGB, salida P3-aware (§7).
Resultado: 12 OKLCH por modo, fieles a la marca en el step 9, armónicos en el resto,
gamut-safe. **Las 33 escalas dejan de ser «768 vars muertas» (la queja del doc de
bloat) y pasan a ser la librería de plantillas del generador** — su valor se
multiplica.
> **Calibración (obligatoria antes de mergear, §11.4).** El generador debe
> reproducir las escalas Radix originales dentro de tolerancia (ΔE2000 por paso +
> delta APCA del par de contraste). Donde no llegue, la escala se queda **verbatim**
> (las 31 hex actuales son ground-truth). El generador es para *marcas nuevas*, no
> para regenerar lo ya afinado.
### 6.1 — El generador es isomórfico (build + runtime, mismo código)
La introspección (pick de contraste) y el alpha compositing-inverse **solo necesitan
el valor numérico del color al decidir** — no exigen build-time, exigen **JS, no CSS**.
La matemática de color (OKLCH↔sRGB↔Display-P3, gamut-map, APCA, inverse-alpha) es
**pura y determinista, sin DOM**, así que el mismo módulo corre en los dos sitios:
```ts
// lib/color/engine.ts — PURO, isomórfico. Sin imports de DOM.
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
```
| Modo | Quién llama | Qué hace con el resultado |
|---|---|---|
| **Build** | `render-css.ts` (temas estáticos del framework + app) | concatena strings CSS (camino actual) |
| **Runtime JS** | `buildScheme(seed)` (composición pura; white-label / editor de temas) | `ActiveEidos.applyColorScheme(seed)` escribe el bloque scheme (hex + `oklch()`) |
En **ambos** la salida son **valores estáticos resueltos** (el `contrast` ya elegido
por APCA, el `aN` ya invertido). Por eso **no se sacrifica introspección ni alpha en
ningún modo** — se calcularon en JS *antes* de escribir.
**Coste de mantener ambas en runtime**: al cambiar la marca en vivo se reescriben
**también los slots del rol resueltos** (`--color-{role}-contrast`, `-surface`,
`-surface-hover`), no solo la escala cruda — para que el pick APCA y el alpha sigan
correctos con la nueva luminancia. Son ~24-48 vars/color, math sub-ms. El único peso
añadido es **shippear el módulo de color-math al cliente, y solo si la app usa
generación runtime** (tree-shakeable; las apps con temas estáticos no lo cargan).
**Promoción a `uix.color`**: el generador deja de ser un script de build y pasa a ser
un *art* isomórfico (paralelo a `uix.motion`): servicio puro que consumen el build
(`render-css`) y el runtime (`ActiveEidos`). Esto absorbe el sueño white-label / live
del documento de bloat **sin** la regresión de CSS-relative-colors.
**Estado 2026-06-29 — realizado.** `uix.color` existe como accessor *stateless* en
`ActiveUix` (tipo `EngineColor` — sin estado, por eso `Engine*` y no `Active*`),
descubrible junto a `uix.motion` / `uix.timers`; `eidos` sigue importando `$color`
directo para build/SSR. El consumidor recíproco —resolver un token de tema a un
color concreto en JS, sin probe `getComputedStyle`— es `eidos.resolveToken(token)`
(config + `$color`).
> **Frontera CSS-nativa (watch, no solución).** `contrast-color()` (CSS Color 5)
> haría el pick de contraste en CSS puro algún día — pero no está listo (prototipo
> Safari 18, nada en Chrome/Firefox en 2026), solo elige blanco/negro puro (no tus
> tokens `onSolid`/`onSolidContrast`) y no controlas el criterio. Para el
> alpha-inverse **no hay primitiva CSS** — JS es el único camino. Por eso el motor es
> JS isomórfico, no CSS.
### 6.2 — Derivación de esquema (theme builder · fórmula Material 3)
Encima de `generateScale` (un seed → 12 pasos) vive **`deriveScheme`** (un seed → los
SEEDS de los roles de jerarquía). Es el núcleo de un theme builder:
```
seed de marca → deriveScheme(seed, variant) → { primary, secondary, tertiary, neutral, neutralVariant }
→ generateScale(cada seed) → escalas de 12 pasos (todo dentro de buildScheme)
→ applyColorScheme(seed) → tema en vivo (bloque hex + oklch)
```
**La fórmula** es la de Material 3 (`CorePalette` HCT) portada a OKLCH:
| rol | hue | chroma (OKLCH calibrado) | regla M3 (HCT) |
| --- | --- | --- | --- |
| primary | H (seed) | el del seed (verbatim) | `max(C, 48)` |
| secondary | H | `0.04` (bajo) | `16` |
| **tertiary** | **H + 60°** | `0.09` | `+60 / 24` |
| neutral | H | `0.008` (casi gris) | `4` |
| neutral-variant | H | `0.016` | `8` |
La **estructura** (mismo-hue-desaturado para secondary · +60° para tertiary) es
model-agnóstica, así que porta exacta; solo los números de croma se recalibran (HCT
`0..120` ≠ OKLCH `0..0.37`). El truco **tone→contraste** de HCT NO se porta — el
contraste lo decide **APCA** (§8).
**Variantes** (`SchemeVariant`) — el "estilo" del builder:
- `tonal` (default) — la tabla (look M3 clásico).
- `vibrant` — más croma + pequeña rotación en secondary; tertiary saturado.
- `monochrome` — croma 0 en todo: la jerarquía **colapsa a una tinta neutra** (look
Vercel / Linear); se diferencia por tono + énfasis, no por hue (colisión *por
diseño*, a diferencia del bug del base).
Estructurado para añadir `expressive` / `neutral` / `content` como ~15 líneas de
reglas, sin tocar nada más.
**Override por rol** — `deriveScheme` da DEFAULTS, no una jaula. El diseñador puede
**fijar** cualquier rol a su color exacto (reemplaza el seed de ese rol; el resto se
sigue derivando del seed base, y cambiar el seed re-deriva solo los no fijados). Es
el patrón de Radix/M3 (colores custom por rol) y del camino hand-authored (grafito
mapea cada rol explícito: `secondary: 'violet'`). El builder de `/temas/color` lo
expone con un input de color por fila + «auto» para volver a derivado.
**Los 6 intents NO se derivan** — son hues canónicos del libro (un error es rojo
siempre). Para que no **desentonen** con la marca se **afinan** con
`temper(color, reference, amount)`: mantiene el **hue** (rojo sigue rojo) y solo
acerca **croma + luminosidad** al perfil de la marca — la *temperatura perceptual*.
Eso es lo que cohesiona una paleta; **rotar el hue erosiona el significado** (un rojo
deja de leerse como error). Un valor **sutil (~10-15%)** basta; el builder de
`/temas/color` lo usa como default (slider en *Roles canónicos*, 0 = canónico puro →
fuerte). `harmonize(color, toward, amount)` (M3 `blend.harmonize`, **rota hue**) sigue
en el motor para **acentos de marca** custom, NO para intents semánticos.
**Caveat del +60°**: la rotación de Material puede caer cerca de un intent según el
primary (p. ej. `purple + 60° = H6 ≈ red/threat`). Por eso el tema base afinó su
`tertiary` a `indigo` (−60°, frío, libre de intents) **a mano**. Un builder debería
ofrecer override del hue del tertiary o esquivar la banda de los intents (red 25° ·
orange 55° · amber 75° · green 158° · teal 182° · plum 330°).
**Blast radius: cero sobre componentes.** `deriveScheme` produce VALORES que entran
por el contrato congelado `--color-{role}-{slot}` (§10). Ningún componente, recipe,
CSS ni el TSC cambian — una variante es "otro tema", como `base` ↔ `grafito`. El
**número de variantes es decisión de catálogo del builder, no coste arquitectónico**
(no se shippean N CSS; se computa un tema a la vez, build o runtime).
Vive en `uix.color` (`scheme.ts`): matemática pura, isomórfica. La COMPOSICIÓN de
`deriveScheme` + `generateScale` + APCA + alpha en el mapa de tokens
`--primitive-{role}-*` es `buildScheme(seed, opts)` (`eidos/lib/build-scheme.ts`,
pura). El método runtime **`eidos.applyColorScheme(seed, opts)`** (ActiveEidos)
resuelve las escalas-donantes + background del tema activo, escribe el bloque de
estilo y **sigue light/dark** (re-deriva al cambiar de modo); devuelve un
`BuildSchemeResult` (steps hex + `stepsOklch` + solid / on-solid por rol) para
introspección. `eidos.clearColorScheme()` revierte. El bloque apila **hex + `oklch()`**
por paso (wide-gamut, §7) y `generateScale` retiene el OKLCH raw sin clamp, así que un
seed vívido (croma > sRGB) sale P3 (demo: slider *vivacidad*). **Estado: implementado**
(Fase 4) — ver THEMING.md §26; tests en `build-scheme.test.ts` + `active-eidos.test.ts`.
```ts
const result = eidos.applyColorScheme('#8e4ec6', {
variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome'
temper: 0.12, // cohesión de intents (mantiene hue)
overrides: { tertiary: '#3e63dd' } // fija un rol; el resto deriva
})
```
---
## 7. Salida wide-gamut (P3 + sRGB)
**Estado: estrategia A implementada, default-on** (2026-06-04). `render-css` emite por
cada paso de paleta el **hex (fallback universal)** + un hermano **`oklch()`** que gana
donde se soporta (`appendColorScaleDeclarations`). SIN flag de config: es el
comportamiento por defecto. La estrategia B (P3 explícito vía `@media`) NO está
implementada (se añadiría como opción si una app necesita afinar valores P3 a mano).
**Honestidad sobre el efecto visible**: la paleta por defecto (Radix) está autorada en
**hex sRGB**, así que su `oklch()` es **sRGB-equivalente** — no hay datos P3 que
recuperar de un sRGB, se ve idéntico hoy (verificado: `--scale-purple-9` →
`oklch(0.5556 0.1829 305.86)` pinta `#8e4ec6`). El valor es que el token-layer es ahora
**OKLCH-nativo y wide-gamut-ready**: un tema autorado en OKLCH, o un esquema generado
con un seed vívido, renderiza más saturado en P3 **sin trabajo extra**. Hacer la paleta
SHIPPED visiblemente wide-gamut es Fase 3 (autorar/generar la paleta en OKLCH).
Dos estrategias (histórico de diseño):
### A. OKLCH-directo + fallback hex (recomendado · estilo Tailwind v4)
```css
[data-theme='brand-light'] {
--scale-violet-9: #7f56d9; /* fallback universal (gamut-mapped sRGB) */
--scale-violet-9: oklch(0.556 0.196 296.5); /* gana donde OKLCH existe → wide-gamut AUTOMÁTICO */
}
```
- **Wide-gamut gratis**: `oklch()` usa el gamut del display; en pantallas P3 el color
sale más saturado que el hex sin un `@media`.
- **Universal**: navegadores pre-OKLCH (raros en 2026) usan el hex.
- **Byte-light**: dos líneas por token, sin duplicar bloques `@media`. Gzip las come.
- Soporte OKLCH: Chrome 111+ / Safari 15.4+ / Firefox 113+ (amplio desde 2023).
### B. P3 explícito vía `@media (color-gamut: p3)` (fidelidad-máx · estilo Radix)
```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); }
}
}
```
Más control (valores P3 distintos por paso) a costa de más bytes. Para apps que
afinan el gamut a mano.
### Gamut-mapping del fallback sRGB
El hex de fallback se obtiene por **gamut-mapping CSS Color 4** (reducción de croma
preservando L y H hasta entrar en sRGB), **no** por clip de canales (que desplaza el
hue). Implementación: reducir `C` iterativamente hasta que el OKLCH→sRGB esté
in-gamut. (Reusable también para el step 9 anclado si el seed es P3-only.)
---
## 8. Contraste APCA
Reemplazar `wcagContrastRatio` (`render-css.ts:1629`) por **APCA (Lc)** para el pick
texto-on-solid (`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**: para cada rol, calcular `Lc(onSolid, solid9)` y `Lc(onSolidContrast,
solid9)`; elegir el de mayor `|Lc|`.
- **Suelo**: exigir `|Lc| ≥ 60` (texto normal) / `≥ 45` (UI / texto grande). Si
ninguno llega, fallback al step-12 (como hoy) + warning de validación.
- **Por qué APCA**: WCAG2 sobre/infra-estima el contraste en mid-tones (el caso
exacto de `risk`=naranja que se coló, audit P2-2). APCA modela la percepción real.
- **Honestidad**: APCA es **borrador WCAG3**, no conformidad legal. Mantener un
**cross-check WCAG2 ≥ 3:1** como red de seguridad y dejarlo documentado; si APCA y
WCAG2 discrepan fuerte, ganar el más conservador.
Efecto: el pick mejora en sólidos claros (amber/yellow/lime/mint) sin regresión en
los oscuros (purple/red/blue mantienen blanco).
---
## 9. Saneo del modelo actual (incluido en el giro)
| Defecto | Fix | Ancla |
|---|---|---|
| `primary` ≡ `loss` = purple en el base | Migrar base a semillas; `loss` → su seed propio (plum). Auto-deriva si se omite. | `themes/base.ts:18,31` |
| `tertiary` ≡ `neutral` = gray | `tertiary` → un hue distinto (p.ej. indigo) o quitarlo del default. | `themes/base.ts:20,21` |
| Doc-drift `primary: indigo` (doc) vs `purple` (código) | Alinear doc + base tras decidir el primary del base. | `THEMING.md §4/§9` |
| **P3-3** slot→step apretado abajo, 7-8 infrautilizados, `border=6` lavado | *Conservador*: `border` 6→7; evaluar `border-strong`=8. **Gated tras probe visual** — no romper armonía. | `render-css.ts:71-81` |
El saneo del modelo no es el corazón del RFC pero entra «de gratis» al migrar el
base a semillas.
---
## 10. Compatibilidad / invariantes (qué NO cambia)
**Contrato de nombres — congelado.** El generador y la proyección de gamut producen
exactamente las mismas vars que hoy:
```
--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
```
Por tanto **no cambian**: recipes (`lib/recipes/base.ts`), TSC, eidos CSS de
componentes, demos, el purge, el contrato público (`contract.ts`), ni la API de
componentes (`data-color`, prop `color`). Es el mismo blindaje que prometió
`COLOR_MODEL_RFC §6`: «aguas abajo, cero cambios; solo cambia *cómo* se producen esas
vars».
**Sema canon — intacta.** Las 6 intents, las 8 families, la doctrina «el color
expresa el intent, no lo define». El color sigue aportando solo la identidad de hue.
**Variants canon — intacta** (§19). El theme retinta; no añade variants ni redefine
cascadas.
**Persistencia** — el envelope versionado (`toDocument()`) gana un `version` bump si
la forma de `scales` autoradas cambia; las escalas 12-hex viejas se leen igual.
---
## 11. Plan de fases (cada una verificable y mergeable sola)
### Fase 0 — Tipos + generador (build-time lib), sin cambio de comportamiento
- Añadir `Oklch`, `ColorScaleSeed`, `ColorScaleSource`, ensanchar `ThemeColorSet.scales`.
- `lib/themes/generate-scale.ts` (morph de plantilla) + conversión OKLCH↔sRGB↔P3 +
gamut-mapping. Sin consumir todavía — las escallas existentes siguen verbatim.
- **Verifica**: tests unitarios del generador (round-trip, gamut, anclaje step 9).
### Fase 1 — APCA contrast
- Swap `wcagContrastRatio` → `apcaLc` en el pick on-solid; suelo + cross-check WCAG2.
- **Verifica**: re-asertar AA/Lc de los 9 roles × 2 modos (cubre el punto ciego
P1-6); probe en navegador de button/badge solid de cada rol.
### Fase 2 — Salida wide-gamut (estrategia A)
- Emitir cada token de color como `hex; oklch()`-override. Sin `@media` (default A).
- **Verifica**: hex idéntico al actual (cero regresión sRGB); en display P3 el color
satura. Test de que el fallback existe siempre.
### Fase 3 — Migrar base + grafito a semillas
- Reautorar `themes/base.ts` y `_lib/grafito.ts` con `seed`. Arreglar `primary≡loss`.
- **Verifica**: ΔE2000 por paso vs las hex actuales dentro de tolerancia; probe
visual de las páginas `/uix/components/*` + `/temas/grafito` (light+dark).
### Fase 4 — Generador 1-seed público + demo
- Exponer en `ActiveEidos` / `defineEidosConfig` y documentar.
- Demo en `/temas` (o `/uix/lib`): un input de color → escala completa + dark + P3
live (build-time vía un endpoint o precomputado).
- **Verifica**: una marca arbitraria genera escala AA sin autorar hex.
### Fase 5 — Afinado slot→step (P3-3), opcional, gated tras probe
- Solo si el probe visual lo respalda. Conservador (`border` 6→7).
### Fase 6 — Docs
- `THEMING.md`: §25 gana una subsección «capa física» que apunta aquí; §22 marca P3-1
resuelto. Marcar `COLOR_MODEL_RFC §5` fase 3 como resuelta por este RFC.
### Fase 4-bis — Generación en runtime (white-label / live theming) — ✅ IMPLEMENTADO
- `ActiveEidos.applyColorScheme(seed, opts)` corre el **mismo generador isomórfico**
(§6.1) en JS (vía el helper puro `buildScheme`) y escribe la escala + **slots de rol
resueltos** (`--color-{role}-contrast`) como un **bloque de estilo gestionado** (hex
fallback + `oklch()` wide-gamut). Conserva pick APCA + alpha (se calculan antes de
escribir) y **sigue light/dark** (re-deriva al cambiar de modo). Cubre el white-label
**sin** CSS-relative. Ver §6.2 + THEMING.md §26.
- **Verifica**: un hex de marca elegido en vivo produce escala AA + dark + P3
coherentes; cambiar de marca reescribe solo las vars del rol afectado.
### Azúcar opcional (sin fase fija) — relative colors en CSS
- **Solo** para derivaciones triviales donde el valor numérico no hace falta
(`oklch(from var(--color-x-solid) calc(l - .05) c h)` para un hover). Siempre con
`@supports` + fallback al token estático. **Nunca** genera escalas ni decide
contraste/alpha (eso lo hace el motor JS, no el CSS).
---
## 12. Riesgos y preguntas abiertas
| Riesgo | Mitigación |
|---|---|
| **Fidelidad del generador < hand-tuned** | Calibrar contra Radix (ΔE + APCA); donde no llegue, dejar verbatim. Las 31 hex son ground-truth, no se borran. |
| **APCA es borrador** | Mantener suelo WCAG2 ≥3:1 como cross-check; ganar el conservador. |
| **Suelo OKLCH (pre-2023)** | El fallback hex es universal — cero pérdida. |
| **P3 duplica bytes (estrategia B)** | Default = estrategia A (OKLCH-directo, sin `@media`); B solo opt-in. |
| **Gamut-map mal hecho desplaza hue** | Usar reducción de croma CSS Color 4, no clip de canales. |
| **Coste de build del generador** | Memoizar por seed; es build-time, no runtime. |
| **Drift doc/código** | Fase 6 cierra; §10 congela el contrato de nombres. |
**Preguntas abiertas** (a decidir antes de Fase 0):
1. **Estrategia de salida default**: A (OKLCH-directo + hex) vs B (P3 `@media`).
*Recomiendo A* (simple, byte-light, wide-gamut automático).
2. **Algoritmo**: morph-de-plantilla (recomendado, reusa las 31) vs curva paramétrica.
*Recomiendo morph*.
3. **¿Las 31 plantillas siguen shippeando por defecto** (status quo + purge) o pasan a
ser **data build-time** que solo emite lo referenciado? *Recomiendo status quo +
purge* (conserva el override `data-color="grass"`); las plantillas son la misma data.
4. **`primary`/`tertiary` del base**: ¿qué hues? (purple→¿indigo/violet? · tertiary→¿?).
5. **Umbrales APCA** (60/45) — calibrar contra el catálogo real.
---
## 13. Cómo este RFC absorbe el «modelo de color más eficiente»
El documento de «reducción de bloat» pedía tres cosas. Este RFC entrega su parte
buena **sin** sus regresiones (ver el análisis de runtime-vs-build):
| Deseo del doc | Cómo lo entrega este RFC | Sin pagar |
|---|---|---|
| «1 variable base en vez de 12» | **Generador 1-seed** (§6) isomórfico: autoras un color, salen los 12 — en build **o** runtime JS | …la fidelidad (curva afinada), el contraste introspectable, el alpha, el soporte universal |
| «menos bytes» | Apps de marca authoran N seeds (no N×12 hex) + **purge** + OKLCH-directo compacto | …el override `data-color` (las escalas siguen disponibles) |
| «reducción semántica a ~6 estados» | **Ya existe**: los 9 slots `--color-{role}-{slot}` (§25). No se toca. | — |
| OKLCH / vanguardia | **Espacio de autoría y fuente** + salida wide-gamut | …romper el cálculo estático |
Acuerdo en el objetivo (OKLCH, menos autoría); el mecanismo correcto es **build-time**,
estrictamente superior porque es un superconjunto: la ergonomía del runtime *más* la
fidelidad, correctitud y robustez que el runtime sacrifica.
---
## 14. Comparación con referentes
| Capacidad | activeUIX (post-RFC) | Radix Colors v3 | Tailwind v4 | Chakra Panda | Material 3 |
|---|---|---|---|---|---|
| Espacio de autoría | **OKLCH** | sRGB+P3 (tool OKLCH) | OKLCH | token | HCT |
| Generador 1-color→escala | **✅ isomórfico (build+runtime), morph** | ✅ (CLI/tool, build) | ❌ | ❌ | ✅ (HCT) |
| Wide-gamut P3 | **✅** | ✅ | ✅ (oklch+fallback) | ⚠️ | ⚠️ |
| Contraste | **APCA + suelo WCAG2** | APCA | — | — | tone-based |
| Scope-as-contract (TSC) | **✅ único** | ❌ | ❌ | ⚠️ build | ❌ |
| Capa perceptual (sema) | **✅ único** | ❌ | ❌ | ❌ | ⚠️ |
| Salida estática introspectable | **✅** | ✅ | ✅ | ✅ | varía |
El RFC pone a eidos a la **par de Radix/M3 en fidelidad de color** (OKLCH+P3+APCA+
generador) manteniendo lo que ya tenía **en exclusiva** (TSC + capa sema). Ese es el
«siguiente nivel».
---
**Última revisión**: 2026-06-04. Si algo aquí contradice el código tras
implementar, gana el código — abrir issue para sincronizar.
# RFC — Next-generation color engine (moved)
✅ Implemented through phase 4-bis. The **physical layer** of color: OKLCH
authoring (`ColorScaleSeed`), the isomorphic 1-seed → 12-step generator
(template morph, promoted to `uix.color`), wide-gamut output (hex +
`oklch()` sibling, default-on), APCA contrast with a WCAG2 floor,
`deriveScheme` (M3 formula) + `buildScheme` + runtime
`eidos.applyColorScheme(seed, opts)` / `resolveToken`. The conceptual model
stays closed in the theming reference §25 — this RFC changes how the vars
are produced, never their names.
**The RFC moved to the docs corpus:**
[`docs/rfcs/rfc-color-engine.md`](../../../docs/rfcs/rfc-color-engine.md)
— TL;DR, principles, generator algorithm, isomorphism (§6.1), scheme
derivation (§6.2), wide-gamut strategies, APCA, invariants, phases, risks.
Canonical reference: [`docs/theming/reference.md`](../../../docs/theming/reference.md) §25–§27.

Loading…
Cancel
Save

Powered by TurnKey Linux.