39 KiB
| title | type | audience | status | source |
|---|---|---|---|---|
| RFC — Next-generation color engine (OKLCH · P3 · APCA · 1-seed generator) | rfc | human + agent | implemented — phases landed through 4-bis; phase 5's `border` 6→7 landed 2026-06-05 (reference §28) | 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 §25and 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(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):
- 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. - 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. - 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. - 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).
- Cleanup:
primary≡loss=purplein 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.tspath). - 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 viaActiveEidos.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.tsColorScale= 12 hex). That is what produced theloss: indigobug in untitled-ui. - WCAG2 contrast (
render-css.tswcagContrastRatio), imprecise in mid-tones. Radix decides with APCA. - Visible residue: in the base theme
primaryandlossboth map topurple(themes/base.ts) → indistinguishable, even thoughplum/indigoalready 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
- 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.
- Additive and backwards-compatible. 12-hex
ColorScales remain valid verbatim. The seed is the new, ergonomic form, not the only one. - Zero-downstream. Var names don't change; recipes/CSS/components stay
untouched (the same contract
rfc-color-model.md §6promised). - OKLCH as the authoring and source space. sRGB+P3 are output, not authoring.
- 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).
- 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):
/** 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
// 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:
- Template selection. If no
templateis 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. - Re-hueing. For each step
i, takeH_i := Hs(+ optionally the template's relative hue drift:H_i := Hs + (H_i_tpl − H9_tpl)). - 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 byL9 ≈ Ls, step 9 already falls on the seed. - 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. - 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. - 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:
// 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).
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 youronSolid/onSolidContrasttokens) 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; tests in
build-scheme.test.ts + active-eidos.test.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)
[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
@mediablocks. 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)
[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):
/** APCA lightness contrast, −108..+106. Polarity-aware (text vs bg). */
function apcaLc(text: Srgb, bg: Srgb): number { /* APCA-W3 0.1.9 */ }
- Pick (criterion canonized 2026-07-06, auditoría A.7): light-ink-first
with dual floors —
onSolidstays on every solid unless it fails BOTH|Lc| ≥ 60(APCA) AND≥ 3:1(WCAG 2); only then the slot flips toonSolidContrast. 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-instancepalette-contrastcascade. The hand-curatedLIGHT_SOLID_SCALESlist 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*-lighttheme 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)
All four rows are DONE (verify in
themes/base.ts:primary: 'purple',loss: 'plum',tertiary: 'indigo'; andborder: '7'inDEFAULT_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, widenThemeColorSet.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→apcaLcin 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.tsand_lib/grafito.tswithseed. Fixprimary≡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/defineEidosConfigand 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) — ✅ LANDED (2026-06-05)
The conservative move shipped:
border= step 7 (DEFAULT_COLOR_ROLE_SLOT_STEPS), standing doctrine intheming/reference.md §28. The slot set also grew past the original nine —bg2/separator/textStrongre-expose steps 2/6/12 (rationale inconfig-types.ts,COLOR_ROLE_SLOTS).
- Only if the visual probe supports it. Conservative (
border6→7).
Phase 6 — Docs
- Theming reference: §25 gains a "physical layer" subsection pointing here;
§22 marks P3-1 resolved. Mark
rfc-color-model.md §5phase 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 purebuildSchemehelper) and writes the scale + resolved role slots (--color-{role}-contrast) as a managed style block (hex fallback + wide-gamutoklch()). 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.- 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):
- Default output strategy: A (direct-OKLCH + hex) vs B (P3
@media). I recommend A (simple, byte-light, automatic wide-gamut). - Algorithm: template-morph (recommended, reuses the 33) vs parametric curve. I recommend morph.
- 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. - The base's
primary/tertiary: which hues? (purple→indigo/violet? · tertiary→?). - 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 --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)
had two stages. Stage 1 (2026-07-19) measured the slot-pair promises and ratified
the doctrine (theming/reference.md §40 ·
changelog.md §44). 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,
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 hardtext-strong·12floor, thetext·11soft band (cap relational totext-strong, no magic number), and theborder·7exemption. 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-verdictPAIRS— which gatedtext·11at a hard 4.5, contradicting the §40 verdict — is gone). - A CI guard that locks the inheritance —
eidos/lib/contrast-invariant.test.ts(thepalette-invariant.test.tspattern): 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.