--- title: RFC — Color model: rich accents + single-anchor intents type: rfc audience: human + agent status: resolved (2026-06-02) — kept as the historical record of the original proposal source: migrated from src/uix/eidos/COLOR_MODEL_RFC.md (2026-07-02, docs-book F7.4) --- # RFC — Color model: rich accents + single-anchor intents > **Status: RESOLVED (2026-06-02).** The color model was decided — the > canonical reference is [`theming/reference.md`](../theming/reference.md) §25. > > **What was adopted**: a rich palette (library grown to 33 scales) + a > semantic alias layer, and the **intents auto-derive from the palette by the > book's convention** (`CANONICAL_INTENT_SCALES`, identity = step 9). Hierarchy > roles = explicit alias. Per-component override via the `color` prop. > > **What was REJECTED**: the "intent = single-color anchor" with inline derived > slots (this document, §4-5). Radix doesn't do it (it generates a *scale* from > a hex and aliases it), and with the rich palette the problem that motivated > it (authoring 12 steps for `loss`) disappears — `loss` aliases the palette's > `plum` scale. > > Below is kept as the historical record of the original proposal. ## 0. Origin Born from a real bug (`loss` mapped to a blue `indigo` scale in the `untitled-ui` theme) and from the observation that the cause was not the color but the **authoring model**: the engine forces writing a 12-step ramp per role, even for semantic signals that are, conceptually, **a single color**. The triggering comparison: Radix Themes ships a much richer accent library and lets you define an accent from **a single hex**. ## 1. Current state (historical snapshot) > Accurate when written; details have since moved: the on-solid pick is the > computed single criterion of rfc-color-engine §8, the slot set is 12 > (`border-hover` retired), and the CSS contract derives from the emission. > Dated notes in the changelog arbitrate. *(Annotation 2026-07-07, > theming audit C.)* - **9 color roles**: 3 hierarchical (`primary` / `secondary` / `tertiary`) + 6 intents (`neutral` / `affirm` / `fulfill` / `risk` / `threat` / `loss`). - `ColorRoleMap = Record` where the `string` is a **scale name** and `ColorRoleDefinition = { scale: string; slots?: ... }`. No "anchor color" form exists. - Each role → **1 scale of 12 steps**. The 12 slots **auto-derive** from those steps (`render-css.ts > DEFAULT_COLOR_ROLE_SLOT_STEPS`): | slot | step | use | | --- | --- | --- | | `track` | 1 | soft background | | `element` | 3 | subtle fill | | `hover` | 4 | hover over subtle | | `active` | 5 | pressed over subtle | | `border` | 6 | border | | `solid` | 9 | solid fill (button/badge) | | `solidHover` | 10 | solid's hover | | `text` | 11 | text over surface | | `contrast` | on-solid (12 fallback) | text over the solid | Additionally 12 raw steps (`--primitive-{role}-{step}`) and 12 alpha (`--primitive-{role}-a{step}`) are emitted. - **Base library: 12 scales** (`gray`, `slate`, `blue`, `cyan`, `teal`, `green`, `yellow`, `amber`, `orange`, `red`, `pink`, `purple`) in `lib/themes/base.ts`, ×2 modes. - For a hue outside the library (carbon, violet, indigo, plum…) → **the 12-step ramp must be written by hand** (×2 modes). This is what produced the `loss` bug. ## 2. Comparison with Radix | | Eidos today | Radix Themes | | --- | --- | --- | | Palette library | 12 scales | ~30 scales × (12 + 12 alpha + dark + P3) | | Role model | role → 1 scale of 12; auto-derived slots | accent + gray picked from the library | | Own brand color | write the 12-step ramp by hand | **1 hex → generates** the 12-step ramp (OKLCH/APCA algorithm) | | Semantics | each intent a hand-written 12-step ramp | just more scales from the library | ## 3. Problems with the current model 1. **Expensive, error-prone authoring**: 12 steps × 9 roles × 2 modes. Picking the wrong step 9 of a badly chosen scale = the `loss` bug. 2. **Poor library** (12 vs ~30): missing indigo, violet, plum, iris, jade, grass, lime, sky, mint, gold, bronze, brown, crimson, ruby, tomato, sand, sage, olive, mauve. 3. **Intents don't need a full interactive ramp**: an intent is a signal (soft badge, border, text, solid fill). `hover`/`active`/`element` only matter if you render an intent-tinted interactive surface (e.g. a destructive button). Those few cases are covered by deriving, not authoring. ## 4. Proposal — two tiers - **Tier 1 · accents + neutral** (`primary` / `secondary` / `tertiary` / `neutral`): **rich** scales from an expanded library. They are interactive surfaces (buttons, toggles, tabs) and want the full ramp. `neutral` is grouped as an "intent" in the canon but works as the surfaces/borders/text gray → **stays a scale**. - **Tier 2 · evaluative intents** (`affirm` / `fulfill` / `risk` / `threat` / `loss`): declared as **a single anchor color**; the engine derives the slots. Zero hand-written ramps; impossible to get step 9 wrong. Key nuance: "an intent is one" means **one anchor color → derived slots**, not a single CSS value — otherwise you lose the soft badge, the border and the readable text. ## 5. Concrete engine changes 1. **Types** (`lib/config-types.ts`): extend `ColorRoleMap`'s value to accept an anchor form, e.g.: ```ts export interface ColorRoleAnchor { readonly anchor: string // hex/CSS color } export type ColorRoleMap = Record ``` (or detect that the `string` is a CSS color rather than a scale name). 2. **Resolution** (`lib/render-css.ts`): for anchor roles, emit the slots by derivation with `color-mix()` (in `oklab`, mixing against `--color-surface-default` and the ink `--color-content-*`, which already invert per mode): - `solid` = `anchor` - `solidHover` = `color-mix(in oklab, var(anchor), var(--color-content-on-solid) 12%)` - `track` (soft) = `color-mix(in oklab, var(anchor) 12%, var(--color-surface-default))` - `element` / `hover` / `active` = graded mixes (≈18% / 24% / 32%) - `border` = `color-mix(in oklab, var(anchor) 45%, var(--color-surface-default))` - `text` = `color-mix(in oklab, var(anchor), var(--color-content-primary) 30%)` (readable over surface) - `contrast` = on-solid (already the default) - alpha steps = `color-mix(in oklab, var(anchor) {p}%, transparent)` Percentages to calibrate against the current steps (1/3/4/5/6/9/10/11) for visual parity. 3. **Validation** (`lib/config.ts`): `validateColorRole*` must accept the anchor form (validate `anchor` is a CSS color, not require a scale to exist). 4. **(Phase 2) Expand the library** `THEME_BASE_*_COLOR_SCALES` toward ~24–30 scales (add the missing hues) so accents map without writing hex. 5. **(Phase 3) 1-hex → 12-step generator** Radix-style (OKLCH + APCA contrast). Bigger effort; yields full high-fidelity scales from a single color for arbitrary brand accents. Probably build-time. ## 6. Impact / compatibility - **Downstream (recipes, TSC, eidos CSS): zero changes.** They keep reading `--color-{role}-{slot}`; only *how* those vars are produced changes. - **Sema canon (6 intents): intact.** Only the color authoring changes, not the perceptual engine or morfo. - **Additive**: an intent can keep pointing at a scale if it wants the full ramp. The anchor form is the ergonomic default, not the only one. - **Tests**: update `active-eidos-config.test.ts` / `render-css` for the anchor form and the derivations. ## 7. Open decisions - `color-mix` in `oklab` (perceptual mixing, recommended) vs `srgb`? - Build-time generator vs runtime derivation? `color-mix` covers ~90% without a generator; the generator is for full-scale fidelity. - Calibration of the derivation percentages to match the current steps. - Is per-intent derived alpha worth it, or are the opaque slots enough? ## 8. Concrete effect on the `untitled-ui` theme (when implemented) - The 5 intents go from a 12-step ramp to **a single anchor**: `affirm = #0E9384` · `fulfill = #079455` · `risk = #DC6803` · `threat = #D92D20` · `loss = #7A3AAD`. - `primary` / `secondary` / `tertiary` / `neutral` stay as scales (ideally from the expanded library: carbon, violet, blue, gray). - Result: the `_lib/untitled-ui.ts` file shrinks drastically and the "blue step 9 for loss" error becomes impossible.