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

171 lines
8.3 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`](../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**.
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
## 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.:
```ts
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.