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

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 §25 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 (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 ColorScales 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):

/** 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:

  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:

// 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 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; 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):

[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)

[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 — 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)

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.

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. 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.
  • 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)
"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 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.