@ -2,13 +2,14 @@
Isomorphic **color math engine** . Pure, deterministic, DOM-free — the same
Isomorphic **color math engine** . Pure, deterministic, DOM-free — the same
functions run at **build** (eidos `render-css` emits static CSS) and at **runtime**
functions run at **build** (eidos `render-css` emits static CSS) and at **runtime**
(`ActiveEidos.setCssVariables ` for live / white-label theming). Because every
(`ActiveEidos.applyColorScheme ` for live / white-label theming). Because every
decision is computed in JS **before** a value is written, **introspection (APCA
decision is computed in JS **before** a value is written, **introspection (APCA
on-solid pick) and alpha fidelity are preserved in every mode**.
on-solid pick) and alpha fidelity are preserved in every mode**.
> **Status — Phase 0** of [`COLOR_ENGINE_RFC.md` ](../../uix/eidos/COLOR_ENGINE_RFC.md ).
> **Status — consumed** ([`COLOR_ENGINE_RFC.md`](../../uix/eidos/COLOR_ENGINE_RFC.md)
> This module + its tests exist; **nothing consumes it yet** (zero behavior change,
> Phases 0/1/2/4). eidos's `render-css` consumes it at build (APCA on-solid pick +
> safe to merge alone). The eidos wiring (build + runtime) lands in later phases.
> OKLCH-native palette output); `ActiveEidos.applyColorScheme` consumes it at runtime
> (the live theme builder). Wide-gamut output (`oklch()` + hex fallback) is default-on.
## Why an art (and not an eidos lib)
## Why an art (and not an eidos lib)
@ -44,6 +45,9 @@ shifts the hue). The `oklch(...)` string keeps the full gamut for the browser.
| `generateScale(seed, template, { solidStep? })` | seed → 12 OKLCH steps (morph; solid anchored to seed) |
| `generateScale(seed, template, { solidStep? })` | seed → 12 OKLCH steps (morph; solid anchored to seed) |
| `pickOnSolid(solid, { onSolid, onSolidContrast }, floor?)` | APCA pick of on-solid text + `passes` |
| `pickOnSolid(solid, { onSolid, onSolidContrast }, floor?)` | APCA pick of on-solid text + `passes` |
| `alphaOverBackground(solid, background)` | translucent fill `(rgb, alpha)` that over `background` = solid |
| `alphaOverBackground(solid, background)` | translucent fill `(rgb, alpha)` that over `background` = solid |
| `deriveScheme(seed, variant?)` | brand seed → hierarchy role seeds (Material 3 `CorePalette` ) |
| `temper(color, reference, amount?)` | match a reference's L+C **keeping hue** (cohere intents) |
| `harmonize(color, toward, amount?)` | rotate hue toward a reference (M3 blend; brand accents) |
## The generator (template morph)
## The generator (template morph)
@ -63,19 +67,28 @@ brand seed → the hierarchy role seeds (`primary` / `secondary` / `tertiary` /
chroma · **neutral** = same hue, near-zero chroma. (Structure is exact; chroma is
chroma · **neutral** = same hue, near-zero chroma. (Structure is exact; chroma is
recalibrated HCT→OKLCH. Tone→contrast is NOT ported — APCA decides contrast.)
recalibrated HCT→OKLCH. Tone→contrast is NOT ported — APCA decides contrast.)
- **Variants** (`SchemeVariant`): `tonal` (default), `vibrant` , `monochrome` .
- **Variants** (`SchemeVariant`): `tonal` (default), `vibrant` , `monochrome` .
- The **6 canonical intents are NOT derived** (an error is always red).
- The **6 canonical intents are NOT derived** (an error is always red). To cohere them
`harmonize(color, toward, amount?)` (M3 `blend.harmonize` ) nudges a hue toward the
with a brand WITHOUT losing meaning, `temper(color, reference, amount?)` matches the
brand for cohesion — opt-in, keeps L + C.
brand's **perceptual temperature** (L + C) while **keeping the hue** (red stays red) —
the right tool for intents. `harmonize` (rotates hue) also exists, but for brand
Feed each derived seed to `generateScale` . **Live builder** : seed → `deriveScheme` →
ACCENTS, not semantic intents (rotating an intent's hue erodes its meaning).
`generateScale` → `ActiveEidos.setCssVariables` — same code at build or runtime. It
produces only VALUES behind the frozen `--color-{role}-{slot}` contract, so it
**Composition + apply**: `buildScheme(seed, opts)` (in `eidos/lib` , pure) composes
touches **no component** . Full spec: `../../uix/eidos/COLOR_ENGINE_RFC.md` §6.2.
`deriveScheme` + `generateScale` + APCA + alpha into the `--primitive-{role}-*` token
map; ** `ActiveEidos.applyColorScheme(seed, opts)` ** writes it as a managed style block
(hex fallback + `oklch()` wide-gamut), follows light/dark, and returns the scheme for
introspection. Same math at build or runtime; only VALUES behind the frozen
`--color-{role}-{slot}` contract, so it touches **no component** . `opts` : `variant`
(tonal/vibrant/monochrome) + `temper` (intent cohesion) + per-role `overrides` . Full
spec: `../../uix/eidos/COLOR_ENGINE_RFC.md` §6.2 + `THEMING.md` §26.
## Notes
## Notes
- **APCA is a WCAG 3 draft** , not legal conformance. `pickOnSolid` decides by APCA
- **APCA is a WCAG 3 draft** , not legal conformance. `pickOnSolid` decides by APCA
but `onSolidWcagRatio` is available as a documented cross-check / safety floor.
but `onSolidWcagRatio` is available as a documented cross-check / safety floor.
- Display-P3 explicit output (`color(display-p3 …)`) is **deferred** — the default
- **Wide-gamut output is live + default-on** (RFC §7 strategy A): the palette emits a
output strategy is `oklch()` + hex fallback, which gets wide-gamut automatically
hex fallback + an `oklch()` sibling that wins where supported → wide-gamut on P3 with
and needs no P3 conversion. P3-explicit lands only if strategy B is chosen (RFC §7).
no `@media` . Explicit `color(display-p3 …)` (strategy B) stays deferred — only if an
app needs hand-tuned P3 values. NOTE: an sRGB-authored palette renders identically in
`oklch()` (no P3 data to recover); real P3 needs a wide-gamut SOURCE (a vivid seed /
OKLCH-authored theme), which the generator preserves (raw OKLCH, never gamut-clamped).