|
|
|
|
|
---
|
|
|
|
|
|
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.
|
fix(docs): counts sweep round 2 — 12 slots (not 13), 9 roles (not 8) + guards
Extending the I1 count guard to color roles and role slots surfaced
four more stale counts the palette fix predicted: the RFCs still said
'13 slots' and enumerated borderHover8 (the slot was retired 2026-07-02,
zero consumers — COLOR_ROLE_SLOTS is 12), and two component READMEs
(css-field, float-panel) still said '8 roles' from before tertiary made
it 9. The guard itself had the same disease it polices: extractConstArray
counted the quoted 'borderHover' inside the comment documenting its own
retirement, so expected=13 and the stale mentions passed — comments are
now stripped before extraction. css-field's README is fixed on disk but
left unstaged (it carries unrelated local modifications).
docs-check now verifies families=8, intents=6, archetypes=26,
palette=33, roles=9, slots=12 against their consts on every run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
- 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.
|