9.0 KiB
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§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 thecolorprop.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 —lossaliases the palette'splumscale.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-hoverretired), 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<ColorRole, string | ColorRoleDefinition>where thestringis a scale name andColorRoleDefinition = { 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 track1 soft background element3 subtle fill hover4 hover over subtle active5 pressed over subtle border6 border solid9 solid fill (button/badge) solidHover10 solid's hover text11 text over surface contraston-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) inlib/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
lossbug.
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
- Expensive, error-prone authoring: 12 steps × 9 roles × 2 modes. Picking
the wrong step 9 of a badly chosen scale = the
lossbug. - 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.
- Intents don't need a full interactive ramp: an intent is a signal (soft
badge, border, text, solid fill).
hover/active/elementonly 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.neutralis 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
- Types (
lib/config-types.ts): extendColorRoleMap's value to accept an anchor form, e.g.:
(or detect that theexport interface ColorRoleAnchor { readonly anchor: string; // hex/CSS color } export type ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition | ColorRoleAnchor>;stringis a CSS color rather than a scale name). - Resolution (
lib/render-css.ts): for anchor roles, emit the slots by derivation withcolor-mix()(inoklab, mixing against--color-surface-defaultand the ink--color-content-*, which already invert per mode):solid=anchorsolidHover=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.
- Validation (
lib/config.ts):validateColorRole*must accept the anchor form (validateanchoris a CSS color, not require a scale to exist). - (Phase 2) Expand the library
THEME_BASE_*_COLOR_SCALEStoward ~24–30 scales (add the missing hues) so accents map without writing hex. - (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-cssfor the anchor form and the derivations.
7. Open decisions
color-mixinoklab(perceptual mixing, recommended) vssrgb?- Build-time generator vs runtime derivation?
color-mixcovers ~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/neutralstay as scales (ideally from the expanded library: carbon, violet, blue, gray).- Result: the
_lib/untitled-ui.tsfile shrinks drastically and the "blue step 9 for loss" error becomes impossible.