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-model.md

9.0 KiB

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 §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<ColorRole, string | ColorRoleDefinition> 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.:
    export interface ColorRoleAnchor {
    	readonly anchor: string; // hex/CSS color
    }
    export type ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition | ColorRoleAnchor>;
    
    (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.

Powered by TurnKey Linux.