You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/rfcs/rfc-color-engine.md

765 lines
39 KiB

---
title: RFC — Next-generation color engine (OKLCH · P3 · APCA · 1-seed generator)
type: rfc
audience: human + agent
fix(color): un solo vocabulario de rol, y la documentación del color reconciliada Había DOS tipos exportados con el mismo nombre. config-types.ts:39 declara `ColorRole = HierarchyColorRole | Intent` derivado del const (9 roles, con tertiary) y existía desde el 2026-05-13; eidos/lib/types.ts:125 lo re-escribió a mano siete días después con 8 miembros —sin tertiary— y nada lo justifica: ni el commit (fb5dd0919, «Advance Eidos demos and form validation docs»), ni un comentario, ni un documento. No fue una decisión de excluirlo: fue una omisión, y el 2026-06-27 se fosilizó al añadir HierarchyColorRole AL LADO en vez de arreglarla. Qué fichero importara de dónde decidía si tertiary era legal. La deriva era SÓLO de tipos: el runtime nunca divergió. resolveComponentColor une COLOR_ROLES + PALETTE_SCALES (42), la capa compartida emite la fila [data-color='tertiary'] iterando COLOR_ROLES, y 94 ficheros pasan por ese resolvedor. O sea que el tipo rechazaba en compilación un valor que el motor resuelve. Por eso ensanchar 8 -> 9 es aditivo: check queda en la línea base exacta (73 errores / 53 warnings) y los 362 tests de eidos pasan. - eidos/lib/types.ts re-exporta ambos tipos de config-types, donde derivan del const. El docblock deja escrito el porqué para que no vuelva por ignorancia. - Guard nuevo en recipe-css-contract.test.ts: falla si un fichero de eidos/lib vuelve a deletrear >=2 nombres de rol como literales. Verificado con control negativo — caza el texto del bug original y el HierarchyColorRole a mano; permite el Extract<> legítimo, el re-export y uniones ajenas al color. Documentación del color, reconciliada contra el código: - reference.md §4 «Per-component subset» seguía enseñando los subconjuntos por componente que §25 derogó el 2026-07-18, con tabla y justificación, sin marca. Marcada REVOKED; conserva el arbitraje (intent evaluativo gana), que es lo que sigue vivo. - `tertiary` figuraba como RESERVADO y no consumido en dos sitios (reference §4 y el comentario de themes/base.ts) cuando entra en ComponentColor, lo acepta todo prop color abierto y tiene su fila en la capa compartida. - rfc-color-engine §13 decía «the 9 --color-{role}-{slot} slots»; son 12 (COLOR_ROLE_SLOTS). Ahora enlaza el const en vez de copiar el número. - El frontmatter del mismo RFC daba la fase 5 por «gated» cuando su `border` 6->7 aterrizó el 2026-06-05 (reference §28); y §9 leía como lista de defectos vivos cuando sus cuatro filas están hechas. - reference §40 se contradecía en una línea: llamaba a CONTRAST_PAIRS fuente compartida «con el Stage-2 solver» y 40 líneas después declaraba que Stage 2 cerró SIN solver. - 8 citas a «THEMING §25.5» apuntadas a §25: esa numeración sobrevivió en la crónica (changelog §25.5) pero no en la referencia tras la escisión. - JSDoc de avatar y card documentaba su prop como «Canonical ColorRole» (8) cuando el tipo real es ComponentColorProp (42 + CSS crudo) — justo lo que lee un consumidor. - «8 roles» hardcodeado en callout/README.md (error de docs:check), callout.svelte y mark/README.md: enlazan el const, como manda authoring.md. docs:check baja de 2 errores a 1. Y una anotación, no un arreglo: banner/types.ts declara `BannerIntent = ColorRole`, fundiendo el eje evaluativo con el de pintura (primary/secondary no son evaluativos), y Banner no tiene prop `color` en absoluto. Queda marcado en el docblock como decisión pendiente con sus citas — resolverlo es el otro frente (un solo eje de color arbitrado por intent), y no está decidido. Fuera de este commit: src/uix/blocks/banner/ es un directorio entero sin trackear (trabajo en vuelo), y ahí quedan las dos últimas instancias del conteo hardcodeado, incluido el error que le queda a docs:check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
status: implemented — phases landed through 4-bis; phase 5's `border` 6→7 landed 2026-06-05 (reference §28)
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 **12 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 | 12 slots `track1·bg2·element3·hover4·active5·separator6·border7·solid9·solidHover10·text11·textStrong12·contrast(on-solid)` (border-hover step 8 retired — zero consumers) | `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 33 hand-authored hex scales 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`).
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
**State 2026-07-06 — `pickOnSolid` materialized.** The criterion sketched
above lives as the pure module `eidos/lib/on-solid.ts` (floors +
light-ink-first policy; the color MATH stays in `$color`). Both emission
paths consume it — the role loop and the per-instance `palette-contrast`
cascade, whose flip set is now `computeLightSolidScales(config)` instead of a
hand-curated list (§8).
> **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 */ }
```
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
- **Pick** *(criterion canonized 2026-07-06, auditoría A.7)*: **light-ink-first
with dual floors** — `onSolid` stays on every solid unless it fails BOTH
`|Lc| ≥ 60` (APCA) AND `≥ 3:1` (WCAG 2); only then the slot flips to
`onSolidContrast`. This RFC's earlier draft ("choose the larger `|Lc|`") was
rejected at canonization: near the floor it would flip half the palette to
dark ink (teal 60.5 · grass 60.2 · jade/green/bronze 61.7 · blue 62.6 · the
mid grays 63–64) — a different look, against the field (Radix keeps white on
its mid 9s). White-first keeps the look with the computed guarantee.
- **One criterion, two paths** *(2026-07-06)*: the criterion is the pure module
`eidos/lib/on-solid.ts` (`onSolidClearsFloors` + `computeLightSolidScales`),
consumed by BOTH the role loop and the per-instance `palette-contrast`
cascade. The hand-curated `LIGHT_SOLID_SCALES` list is gone — it had
drifted: `color="orange"` shipped white ink at **2.97:1 (sub-AA)** and cyan
at Lc 59.5, while the risk ROLE (the same orange hex) computed and flipped.
A parity test guards role↔instance agreement per donor scale; the polarity
is computed per configured theme (unanimous → that answer; disagreement →
the `*-light` theme wins, and a genuinely diverging theme is the trigger for
per-theme cascade emission).
- **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)
fix(color): un solo vocabulario de rol, y la documentación del color reconciliada Había DOS tipos exportados con el mismo nombre. config-types.ts:39 declara `ColorRole = HierarchyColorRole | Intent` derivado del const (9 roles, con tertiary) y existía desde el 2026-05-13; eidos/lib/types.ts:125 lo re-escribió a mano siete días después con 8 miembros —sin tertiary— y nada lo justifica: ni el commit (fb5dd0919, «Advance Eidos demos and form validation docs»), ni un comentario, ni un documento. No fue una decisión de excluirlo: fue una omisión, y el 2026-06-27 se fosilizó al añadir HierarchyColorRole AL LADO en vez de arreglarla. Qué fichero importara de dónde decidía si tertiary era legal. La deriva era SÓLO de tipos: el runtime nunca divergió. resolveComponentColor une COLOR_ROLES + PALETTE_SCALES (42), la capa compartida emite la fila [data-color='tertiary'] iterando COLOR_ROLES, y 94 ficheros pasan por ese resolvedor. O sea que el tipo rechazaba en compilación un valor que el motor resuelve. Por eso ensanchar 8 -> 9 es aditivo: check queda en la línea base exacta (73 errores / 53 warnings) y los 362 tests de eidos pasan. - eidos/lib/types.ts re-exporta ambos tipos de config-types, donde derivan del const. El docblock deja escrito el porqué para que no vuelva por ignorancia. - Guard nuevo en recipe-css-contract.test.ts: falla si un fichero de eidos/lib vuelve a deletrear >=2 nombres de rol como literales. Verificado con control negativo — caza el texto del bug original y el HierarchyColorRole a mano; permite el Extract<> legítimo, el re-export y uniones ajenas al color. Documentación del color, reconciliada contra el código: - reference.md §4 «Per-component subset» seguía enseñando los subconjuntos por componente que §25 derogó el 2026-07-18, con tabla y justificación, sin marca. Marcada REVOKED; conserva el arbitraje (intent evaluativo gana), que es lo que sigue vivo. - `tertiary` figuraba como RESERVADO y no consumido en dos sitios (reference §4 y el comentario de themes/base.ts) cuando entra en ComponentColor, lo acepta todo prop color abierto y tiene su fila en la capa compartida. - rfc-color-engine §13 decía «the 9 --color-{role}-{slot} slots»; son 12 (COLOR_ROLE_SLOTS). Ahora enlaza el const en vez de copiar el número. - El frontmatter del mismo RFC daba la fase 5 por «gated» cuando su `border` 6->7 aterrizó el 2026-06-05 (reference §28); y §9 leía como lista de defectos vivos cuando sus cuatro filas están hechas. - reference §40 se contradecía en una línea: llamaba a CONTRAST_PAIRS fuente compartida «con el Stage-2 solver» y 40 líneas después declaraba que Stage 2 cerró SIN solver. - 8 citas a «THEMING §25.5» apuntadas a §25: esa numeración sobrevivió en la crónica (changelog §25.5) pero no en la referencia tras la escisión. - JSDoc de avatar y card documentaba su prop como «Canonical ColorRole» (8) cuando el tipo real es ComponentColorProp (42 + CSS crudo) — justo lo que lee un consumidor. - «8 roles» hardcodeado en callout/README.md (error de docs:check), callout.svelte y mark/README.md: enlazan el const, como manda authoring.md. docs:check baja de 2 errores a 1. Y una anotación, no un arreglo: banner/types.ts declara `BannerIntent = ColorRole`, fundiendo el eje evaluativo con el de pintura (primary/secondary no son evaluativos), y Banner no tiene prop `color` en absoluto. Queda marcado en el docblock como decisión pendiente con sus citas — resolverlo es el otro frente (un solo eje de color arbitrado por intent), y no está decidido. Fuera de este commit: src/uix/blocks/banner/ es un directorio entero sin trackear (trabajo en vuelo), y ahí quedan las dos últimas instancias del conteo hardcodeado, incluido el error que le queda a docs:check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> **All four rows are DONE** (verify in `themes/base.ts`: `primary: 'purple'`,
> `loss: 'plum'`, `tertiary: 'indigo'`; and `border: '7'` in
> `DEFAULT_COLOR_ROLE_SLOT_STEPS`). They landed separately rather than via the
> base→seeds migration this section assumed — see §11 phase 3. Kept as the
> record of what was wrong and why it was fixed; do not read it as a live
> defect list.
| 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
> **Status (2026-07-20):** the generator + math shipped as `$color` (an
> isomorphic art). The config-authoring types (`ColorScaleSeed` /
> `ColorScaleSource`) stay **deferred** — decision D5 (§15): runtime seed paths
> were the validation banks; the types land when a config authors seeds.
- 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
> **Status (2026-07-20): not done, not needed** — decision D2 (§15) keeps the
> base **verbatim ground-truth**; the contrast audit guards it. The §9 cleanup
> rows it bundled (primary≡loss etc.) were already fixed in code separately.
- 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.
fix(color): un solo vocabulario de rol, y la documentación del color reconciliada Había DOS tipos exportados con el mismo nombre. config-types.ts:39 declara `ColorRole = HierarchyColorRole | Intent` derivado del const (9 roles, con tertiary) y existía desde el 2026-05-13; eidos/lib/types.ts:125 lo re-escribió a mano siete días después con 8 miembros —sin tertiary— y nada lo justifica: ni el commit (fb5dd0919, «Advance Eidos demos and form validation docs»), ni un comentario, ni un documento. No fue una decisión de excluirlo: fue una omisión, y el 2026-06-27 se fosilizó al añadir HierarchyColorRole AL LADO en vez de arreglarla. Qué fichero importara de dónde decidía si tertiary era legal. La deriva era SÓLO de tipos: el runtime nunca divergió. resolveComponentColor une COLOR_ROLES + PALETTE_SCALES (42), la capa compartida emite la fila [data-color='tertiary'] iterando COLOR_ROLES, y 94 ficheros pasan por ese resolvedor. O sea que el tipo rechazaba en compilación un valor que el motor resuelve. Por eso ensanchar 8 -> 9 es aditivo: check queda en la línea base exacta (73 errores / 53 warnings) y los 362 tests de eidos pasan. - eidos/lib/types.ts re-exporta ambos tipos de config-types, donde derivan del const. El docblock deja escrito el porqué para que no vuelva por ignorancia. - Guard nuevo en recipe-css-contract.test.ts: falla si un fichero de eidos/lib vuelve a deletrear >=2 nombres de rol como literales. Verificado con control negativo — caza el texto del bug original y el HierarchyColorRole a mano; permite el Extract<> legítimo, el re-export y uniones ajenas al color. Documentación del color, reconciliada contra el código: - reference.md §4 «Per-component subset» seguía enseñando los subconjuntos por componente que §25 derogó el 2026-07-18, con tabla y justificación, sin marca. Marcada REVOKED; conserva el arbitraje (intent evaluativo gana), que es lo que sigue vivo. - `tertiary` figuraba como RESERVADO y no consumido en dos sitios (reference §4 y el comentario de themes/base.ts) cuando entra en ComponentColor, lo acepta todo prop color abierto y tiene su fila en la capa compartida. - rfc-color-engine §13 decía «the 9 --color-{role}-{slot} slots»; son 12 (COLOR_ROLE_SLOTS). Ahora enlaza el const en vez de copiar el número. - El frontmatter del mismo RFC daba la fase 5 por «gated» cuando su `border` 6->7 aterrizó el 2026-06-05 (reference §28); y §9 leía como lista de defectos vivos cuando sus cuatro filas están hechas. - reference §40 se contradecía en una línea: llamaba a CONTRAST_PAIRS fuente compartida «con el Stage-2 solver» y 40 líneas después declaraba que Stage 2 cerró SIN solver. - 8 citas a «THEMING §25.5» apuntadas a §25: esa numeración sobrevivió en la crónica (changelog §25.5) pero no en la referencia tras la escisión. - JSDoc de avatar y card documentaba su prop como «Canonical ColorRole» (8) cuando el tipo real es ComponentColorProp (42 + CSS crudo) — justo lo que lee un consumidor. - «8 roles» hardcodeado en callout/README.md (error de docs:check), callout.svelte y mark/README.md: enlazan el const, como manda authoring.md. docs:check baja de 2 errores a 1. Y una anotación, no un arreglo: banner/types.ts declara `BannerIntent = ColorRole`, fundiendo el eje evaluativo con el de pintura (primary/secondary no son evaluativos), y Banner no tiene prop `color` en absoluto. Queda marcado en el docblock como decisión pendiente con sus citas — resolverlo es el otro frente (un solo eje de color arbitrado por intent), y no está decidido. Fuera de este commit: src/uix/blocks/banner/ es un directorio entero sin trackear (trabajo en vuelo), y ahí quedan las dos últimas instancias del conteo hardcodeado, incluido el error que le queda a docs:check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### Phase 5 — slot→step tuning (P3-3) — ✅ LANDED (2026-06-05)
> The conservative move shipped: `border` = **step 7**
> (`DEFAULT_COLOR_ROLE_SLOT_STEPS`), standing doctrine in
> [`theming/reference.md §28`](../theming/reference.md). The slot set also grew
> past the original nine — `bg2`/`separator`/`textStrong` re-expose steps
> 2/6/12 (rationale in `config-types.ts`, `COLOR_ROLE_SLOTS`).
- 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 33 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 33) vs parametric
curve. *I recommend morph*.
3. **Do the 33 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) |
fix(color): un solo vocabulario de rol, y la documentación del color reconciliada Había DOS tipos exportados con el mismo nombre. config-types.ts:39 declara `ColorRole = HierarchyColorRole | Intent` derivado del const (9 roles, con tertiary) y existía desde el 2026-05-13; eidos/lib/types.ts:125 lo re-escribió a mano siete días después con 8 miembros —sin tertiary— y nada lo justifica: ni el commit (fb5dd0919, «Advance Eidos demos and form validation docs»), ni un comentario, ni un documento. No fue una decisión de excluirlo: fue una omisión, y el 2026-06-27 se fosilizó al añadir HierarchyColorRole AL LADO en vez de arreglarla. Qué fichero importara de dónde decidía si tertiary era legal. La deriva era SÓLO de tipos: el runtime nunca divergió. resolveComponentColor une COLOR_ROLES + PALETTE_SCALES (42), la capa compartida emite la fila [data-color='tertiary'] iterando COLOR_ROLES, y 94 ficheros pasan por ese resolvedor. O sea que el tipo rechazaba en compilación un valor que el motor resuelve. Por eso ensanchar 8 -> 9 es aditivo: check queda en la línea base exacta (73 errores / 53 warnings) y los 362 tests de eidos pasan. - eidos/lib/types.ts re-exporta ambos tipos de config-types, donde derivan del const. El docblock deja escrito el porqué para que no vuelva por ignorancia. - Guard nuevo en recipe-css-contract.test.ts: falla si un fichero de eidos/lib vuelve a deletrear >=2 nombres de rol como literales. Verificado con control negativo — caza el texto del bug original y el HierarchyColorRole a mano; permite el Extract<> legítimo, el re-export y uniones ajenas al color. Documentación del color, reconciliada contra el código: - reference.md §4 «Per-component subset» seguía enseñando los subconjuntos por componente que §25 derogó el 2026-07-18, con tabla y justificación, sin marca. Marcada REVOKED; conserva el arbitraje (intent evaluativo gana), que es lo que sigue vivo. - `tertiary` figuraba como RESERVADO y no consumido en dos sitios (reference §4 y el comentario de themes/base.ts) cuando entra en ComponentColor, lo acepta todo prop color abierto y tiene su fila en la capa compartida. - rfc-color-engine §13 decía «the 9 --color-{role}-{slot} slots»; son 12 (COLOR_ROLE_SLOTS). Ahora enlaza el const en vez de copiar el número. - El frontmatter del mismo RFC daba la fase 5 por «gated» cuando su `border` 6->7 aterrizó el 2026-06-05 (reference §28); y §9 leía como lista de defectos vivos cuando sus cuatro filas están hechas. - reference §40 se contradecía en una línea: llamaba a CONTRAST_PAIRS fuente compartida «con el Stage-2 solver» y 40 líneas después declaraba que Stage 2 cerró SIN solver. - 8 citas a «THEMING §25.5» apuntadas a §25: esa numeración sobrevivió en la crónica (changelog §25.5) pero no en la referencia tras la escisión. - JSDoc de avatar y card documentaba su prop como «Canonical ColorRole» (8) cuando el tipo real es ComponentColorProp (42 + CSS crudo) — justo lo que lee un consumidor. - «8 roles» hardcodeado en callout/README.md (error de docs:check), callout.svelte y mark/README.md: enlazan el const, como manda authoring.md. docs:check baja de 2 errores a 1. Y una anotación, no un arreglo: banner/types.ts declara `BannerIntent = ColorRole`, fundiendo el eje evaluativo con el de pintura (primary/secondary no son evaluativos), y Banner no tiene prop `color` en absoluto. Queda marcado en el docblock como decisión pendiente con sus citas — resolverlo es el otro frente (un solo eje de color arbitrado por intent), y no está decidido. Fuera de este commit: src/uix/blocks/banner/ es un directorio entero sin trackear (trabajo en vuelo), y ahí quedan las dos últimas instancias del conteo hardcodeado, incluido el error que le queda a docs:check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| "semantic reduction to ~6 states" | **Already exists**: the `--color-{role}-{slot}` slots — `COLOR_ROLE_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". One addition since: the
tonal-ramp text contrast is now **by construction + CI-guarded** (§15) — a
guarantee Radix (curation) and Tailwind/Chakra (no contrast logic) don't make,
though the mechanism is inheritance, not a solver.
---
## 15. Stage 2 — tonal-ramp text contrast: inherited, not solved (2026-07-20)
The "contrast parity" initiative ([`next-features.md §1`](../next-features.md))
had two stages. Stage 1 (2026-07-19) measured the slot-pair promises and ratified
the doctrine ([`theming/reference.md §40`](../theming/reference.md) ·
[`changelog.md §44`](../theming/changelog.md)). Stage 2 was planned as a
**by-construction generator that SOLVES each step's luminance** to satisfy the
pair table for any seed ([execution plan](../process/contrast-stage2-plan-2026-07.md),
verified by a 7-agent workflow; 6 user decisions locked 2026-07-20 — D1 `text·11`
soft band ≈APCA 60 · D2 measured-pass · D3 flip-only on-solid · D4 opt-in post-pass
· D5 runtime-first · D6 pair-table-as-data).
**Execution disproved the premise — no solver was needed.** The template morph
(§6) does not *compute* text contrast; it **inherits** it. Steps 11/12 (the text
inks) copy the donor's L-curve **verbatim**, and text contrast is dominated by
lightness — the sRGB gamut-map reduces chroma at **fixed L**, so contrast is
~**chroma-invariant**. The exact-anchor (§6 step 5) only moves step 9. Therefore
**every scale generated from a §40-compliant donor library inherits the ratified
text floors by construction.** The base itself is §40-compliant (D2 keeps it
verbatim ground-truth), so `applyColorScheme` / `generatePalette` — which morph
from the active theme's scales — inherit compliance too.
Measured across three banks (`scripts/contrast-audit.ts` + the CI guard):
| Bank | Hard gate `text-strong·12` ≥ 4.5 WCAG |
|---|---|
| Authored base (33×2) | ✅ 198/198 · min 9.82:1 |
| Morph-generated, leave-one-out (33×2) | ✅ 198/198 · min 9.87:1 |
| Out-of-distribution seeds (L 0.30–0.88, C ≤ 0.24) | ✅ 0 failures · min 9.7:1 |
The luminance solver would have had nothing to resolve for realistic inputs, so
it was **dropped as speculative**. What shipped instead:
- **The ratified pair table as shared data** — `$color` → `CONTRAST_PAIRS`
(`arts/color/contrast-contract.ts`): the hard `text-strong·12` floor, the
`text·11` soft band (cap relational to `text-strong`, no magic number), and the
`border·7` exemption. One source consumed by the audit and any future checker.
- **The audit consumes it + a morph-generated regression bank**
(`scripts/contrast-audit.ts`; its pre-verdict `PAIRS` — which gated `text·11` at
a hard 4.5, contradicting the §40 verdict — is gone).
- **A CI guard that locks the inheritance** —
`eidos/lib/contrast-invariant.test.ts` (the `palette-invariant.test.ts` pattern):
fails if a future authored family or a generator change breaks the property. The
by-construction guarantee is now a test, not a claim.
**Consequences for this RFC's phase plan (§11):** Phase 3 (migrate base→seeds) is
**not done and not needed** — D2 keeps the base verbatim (the audit guards it).
Phase 0's config-authoring types (`ColorScaleSeed` / `ColorScaleSource`) stay
**deferred** (D5) — the runtime seed paths that already exist were the validation
banks; the types land when a config authors seeds. The only scenario where a
luminance solver would earn its keep is a **custom, NON-§40-compliant donor
library** — which the framework does not ship, and which the CI guard would flag.
---
**Last revision**: 2026-07-20 (§15 Stage 2 resolution — contrast inherited, not
solved). If anything here contradicts the code after implementation, the code
wins — open an issue to sync.

Powered by TurnKey Linux.