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/theming/reference.md

1901 lines
104 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
title: Eidos Theming — Architecture Reference
type: reference
audience: human + agent
authority: E1 reference — the theming system: mental model, token layers, roles, sizes, naming, tooling
status: current
source: migrated from src/uix/eidos/THEMING.md (2026-07-02, docs-book F7.3)
---
# Eidos Theming — Architecture Reference
> **Audience**: any dev opening the repo who needs to understand how theming
> works in UIX. It covers the mental model, the contracts, the tooling and
> the traps. If after reading it you still don't know where a new token
> goes, this doc failed — open an issue.
**TL;DR**:
- **9 canonical color roles** (`primary`, `secondary`, `tertiary`,
`neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss`).
- **7 canonical sizes** + `full` (`xxs..xxl`).
- **3 public token levels**: foundation (stable), per-component recipe
(overrideable), private (`--_*`, no external contract).
- The **Token Scope Contract (TSC)** decides WHERE each token is emitted
(`:root` / `[data-{c}]` / `[data-{c}][data-color='X']` / etc.) and
validates transitivity at generation time.
- **226 KB raw / 25 KB gzip** of foundation CSS by default. Use
`npm run eidos:purge` for production apps → −46 to −55%.
- **The color model**: a palette of 33 scales (designable) → hierarchy
roles (explicit aliases) → intents (auto-derived by the book's
convention, identity = step 9). See §25.
- **Compatible with** versioned persistence, CSS-only themes, runtime
overrides, dark/light, density (compact/comfortable/spacious), scaling
(zoom 90–110, a separate axis), reduced motion, multi-axis breakpoints.
---
## Table of contents
1. [Mental model](#1-mental-model)
1bis. [Theming lives in Eidos, not in Morfo (by design)](#1bis-theming-lives-in-eidos-not-in-morfo-by-design)
2. [The layers of Eidos's CSS](#2-the-layers-of-eidoss-css)
3. [The 7 token layers](#3-the-7-token-layers)
4. [The 9 canonical color roles](#4-the-9-canonical-color-roles)
5. [The size canon](#5-the-size-canon)
6. [Naming conventions](#6-naming-conventions)
7. [Token Scope Contract (TSC)](#7-token-scope-contract-tsc)
8. [How to add a new component](#8-how-to-add-a-new-component)
9. [How to define a theme](#9-how-to-define-a-theme)
10. [How to override tokens at runtime](#10-how-to-override-tokens-at-runtime)
11. [Bundle strategy + `eidos:purge`](#11-bundle-strategy--eidospurge)
12. [Validation tooling](#12-validation-tooling)
13. [Sema integration (`event:*` scope)](#13-sema-integration-event-scope)
14. [Motion](#14-motion)
15. [Comparison with reference libraries](#15-comparison-with-reference-libraries)
16. [Anti-patterns you must NOT commit](#16-anti-patterns-you-must-not-commit)
17. [FAQ — controversial decisions](#17-faq--controversial-decisions)
18. [Universal TSC coverage](#18-universal-tsc-coverage)
19. [Variants are eidos canon, NOT the theme's](#19-variants-are-eidos-canon-not-the-themes)
20. [Theming-engine corrections (2026-06-01)](#20-theming-engine-corrections-2026-06-01)
21. [Two-level color model (RFC — RESOLVED in §25)](#21-two-level-color-model-rfc--resolved-in-25)
22. [Pending theming improvements](#22-pending-theming-improvements)
23. [The `scaling` axis (global zoom)](#23-the-scaling-axis-global-zoom--2026-06-02)
24. [P2 engine corrections (2026-06-02)](#24-p2-engine-corrections-2026-06-02)
25. [The color model — palette + derived roles/intents](#25-the-color-model--palette--derived-rolesintents-2026-06-02)
26. [Runtime theme builder — `eidos.applyColorScheme`](#26-runtime-theme-builder--eidosapplycolorscheme-2026-06-04)
27. [Wide-gamut OKLCH output (default-on)](#27-wide-gamut-oklch-output-default-on-2026-06-04)
28. [Forced-colors accessibility + the border ramp](#28-forced-colors-accessibility--the-border-ramp-2026-06-05)
29. [Depth — the unified, eventful channel](#29-depth--the-unified-eventful-channel-2026-06-05)
30. [Shape — continuity + families + nesting](#30-shape--continuity--families--nesting--eventful-2026-06-05)
31. [Structure (space · density · scale)](#31-structure-space--density--scale--space-as-rhythm-2026-06-05)
32. [Focus ring — the parameterized two-ring model](#32-focus-ring--the-parameterized-two-ring-model-2026-06-11)
33. [Themeable stepper glyphs (`spin-field`)](#33-themeable-stepper-glyphs-spin-field--2026-06-11)
34. [`spin-field` — the stepper-field's shared visual](#34-spin-field--the-stepper-fields-shared-visual-number-field--css-field--2026-06-11)
35. [The scale canon — the theming audit (2026-06-15)](#35-the-scale-canon--the-theming-audit-2026-06-15)
36. [The canonical trigger→panel gap — a token-driven offset (2026-06-22)](#36-the-canonical-triggerpanel-gap--a-token-driven-offset-2026-06-22)
37. [Touch-target — 44px on touch, pointer-gated (2026-06-28)](#37-touch-target--44px-on-touch-pointer-gated-2026-06-28)
38. [The state layer — unified neutral feedback (2026-06-28)](#38-the-state-layer--unified-neutral-feedback-2026-06-28)
---
## 1. Mental model
Eidos is UIX's **visual layer**. It owns NO behavior and NO state. It reads
from the DOM what the previous layers wrote, and applies styles.
```
Morfo declares the genetics (which attrs / events / parts exist)
↓
Soma transcribes behavior (data-state, data-color, aria-*, focus, …)
↓
Sema emits signals (data-event-* during the perceptual hold)
↓
Eidos applies the visual (tokens, themes, recipes, archetypes, motion)
```
**What Eidos owns**:
- The `--*` custom-property namespace.
- The entrypoint's CSS layers (§2: generated foundation, archetypes,
events, recipes) + the theme blocks `ActiveEidos` injects.
- The `ActiveEidos` runtime that injects foundation + theme CSS.
- Tooling: generation, validation, purge, lint.
**What Eidos does NOT own**:
- Components' logical state (that's soma).
- The definition of which events exist (that's morfo).
- Firing perceptual signals (that's sema).
**The 2-of-3 rule**: a system extension (an attribute, a token, a
convention) is only justified when **at least two of the three layers**
(soma, sema, eidos) consume it. The extensions that entered with eidos's
vote: `archetype`, `events[].semantic.{family,intent}`,
`events[].prewrite[]`, `data-starting-style` / `data-ending-style`.
---
## 1.bis Theming lives in Eidos, not in Morfo (by design)
> **This is the most frequent architectural question — and the most
> important answer for not breaking the system.**
A new dev's reasonable intuition is: _"if morfo is the cross-layer source
of truth, visual tokens should live in morfo too"_. **NO.** UIX's explicit
design says the opposite. This section exists to close the case with
citations, before the confusion drags a PR into violating the architecture.
### The two canonical quotes in the repo
**[`architecture/active-architecture.md`](../architecture/active-architecture.md) §9 (What this architecture is NOT)**:
> **Not a classic design system.** Tokens, themes and recipes belong to
> Eidos, not to the core.
**[`architecture/overview.md`](../architecture/overview.md) §2 (Eidos)**:
> The visual layer: **tokens, themes, per-component CSS recipes**, archetype
> rules, event reactions and Svelte wrappers over soma's headless
> providers…
>
> Eidos reads from the DOM what the other layers write — **it never imports
> soma or sema internals**.
Those two sentences, by themselves, close any debate about where theming
lives. If a future proposal contradicts them, the proposal must be rejected
or the canonical doc must be updated first — not after.
### The 2-of-3 rule derives it mechanically
**`active-architecture.md` §7 #12** and **`overview.md` §5** say the same
thing:
> A morfo extension is only justified when **at least two of the three
> layers** (soma, sema, eidos) consume it.
Applied to theming:
| Who consumes the visual tokens? | |
| ------------------------------- | ---------- |
| Soma (the behavior runtime) | ❌ no |
| Sema (perceptual signals) | ❌ no |
| Eidos (the visual layer) | ✅ yes |
| **Count** | **1-of-3** |
**1-of-3 ≠ 2-of-3 → tokens do NOT go in morfo, by rule**. The visual
integration falls into eidos automatically through the 2-of-3 discipline,
with nobody having to decide it case by case.
### What is the morfo↔theming relationship, then?
Morfo is the source of truth of the **cross-layer contract**:
- Parts (which parts exist)
- Events (which events it may fire)
- Attrs and their enumerated values (which attrs appear in the DOM, with
which values)
- Archetypes (the transversal classification)
- Declarative states
**Theming integrates with morfo in ONE PRECISE SENSE**: eidos recipes
target DOM attrs that morfo declares. Without morfo, the attrs wouldn't
exist in the DOM and the eidos selectors would be dead.
```
MORFO declares data-color.values = ['primary', 'affirm', 'threat', ...]
↓
SOMA emits <button data-color="affirm"> (to the DOM)
↓
EIDOS recipe targets scope: 'color:affirm' → [data-toggle][data-color='affirm']
↓
BROWSER cascade resolves the CSS rule
```
The channel between morfo and eidos is **the DOM**, not TypeScript objects.
This is critical and armored by hard rule #6:
**`active-architecture.md` §7 #6**:
> **Eidos consumes DOM and data-\*, not Soma/Sema internals.** If it needs
> something, it must be declared in morfo or emitted in a sema signal.
**`overview.md` §5** repeats it verbatim.
### What eidos must NEVER do
```ts
// ❌ ARCHITECTURAL VIOLATION — Eidos importing morfo at runtime
import { toggleMorfo } from '$uix/morfo/components/toggle';
const validValues = toggleMorfo.parts
.find((p) => p.kebab === 'provider')!
.data.find((d) => d.attr === 'data-color')!.values;
// using validValues to validate TSC scope:'color:X'
```
Even with good intent (validating that `scope: 'color:affirm'` matches a
morfo-declared value), **this import violates rule #6** and breaks the
morfo↔eidos boundary. If you want that validation, the correct defense is
eidos-lint at the DOM/CSS level, not TS coupling.
### The correct defense: eidos-lint at the DOM/CSS level
Validating that eidos recipes target values morfo declares IS DONE, but at
the DOM/CSS level:
```bash
node scripts/eidos-lint.ts toggle
```
It classifies every `[data-*]` selector as:
- **morfo-backed** — declared in morfo; soma emits it with a valid value
- **eidos-only** — an attr added by the wrapper (data-variant, data-size)
- **invalid** — references a morfo-backed attr with a value outside the
enum → a bug
This closes the loop architecturally without cross-layer imports.
### Recap of the canonical split
| Concept | Source of truth | Justification |
| ------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------- |
| Parts (which parts exist) | **Morfo** | Cross-layer: soma emits, eidos selects, sema references |
| Events + semantic | **Morfo** | Cross-layer: soma triggers, sema dispatches, eidos reacts |
| Archetypes | **Morfo** | Cross-layer: soma emits, sema cascades, eidos selects |
| Attr values (`data-color.values`) | **Morfo** | Cross-layer: soma validates, eidos targets, sema references |
| Declarative states | **Morfo** | Cross-layer: soma emits data-state, eidos selects |
| **The 9 systemic canonical roles** | **Eidos** (`lib/themes/base.ts`) | Only eidos materializes them |
| **The 7 canonical sizes** | **Eidos** (`lib/config-types.ts`) | Only eidos coordinates them |
| **Visual variants** (solid/outline/ghost) | **The eidos wrapper** | Only eidos renders them |
| **Tokens** (`--toggle-solid-on-bg`) | **Eidos** (`lib/recipes/base.ts`) | Only eidos consumes them |
| **Themes** (light/dark/custom) | **Eidos** (`lib/themes/`) | Only eidos composes them |
| **Theme persistence** | **Eidos** (`toDocument()`) | Only eidos serializes it |
| **TSC scope axes** (color, state, variant, size, event) | **Eidos** (they refer to morfo-emitted attrs) | Hardcoded in the TSC because they are axes of the DOM contract |
### The canonical sentence
> **Morfo declares the contract. Eidos declares the theming. The DOM
> connects them.**
This is NOT a compromise. It IS the design. Morfo's purity (declarative TS,
no runtime, no visual-layer imports) DEPENDS on theming living outside.
### Future proposals that MUST be rejected citing this section
1. **"Let's put the Toggle's visual tokens in its morfo so morfo is the
source of truth of everything"** — violates §9 and the 2-of-3 rule.
2. **"Let's make the TSC validate `color:affirm` by importing
`toggleMorfo.data['data-color'].values`"** — violates rule #6 (eidos
does not import morfo internals in TS).
3. **"Let's put `variant: 'solid' | 'outline'` in the Toggle's morfo"** —
violates 2-of-3 (variants are consumed by eidos alone).
4. **"Let's define `size` in the morfo with its 6 canonical values"** —
violates 2-of-3 (the 6 sizes are the visual system; only eidos
materializes them with coordinated tokens).
If the proposal has merit, the right move is to **update the canonical doc
(`active-architecture.md` §9) FIRST** — never afterwards.
### What SHOULD enter morfo regarding theming
- A component exposing `color` as a prop → must declare
`data-color.values: ['primary', 'affirm', ...]` in its morfo. Those
values are cross-layer (eidos targets, soma emits, sema could reference).
- A component exposing `state` (open/closed) → declares
`data-state.values: ['open', 'closed']`. Same.
- A component adding purely visual attrs (`data-variant`, `data-size`) that
NOBODY else needs → they do **NOT** go in morfo; the eidos wrapper adds
them directly.
### Consistency with the canonical docs
This section **introduces no new doctrine**. It gathers and consolidates
what was scattered across:
- [`architecture/active-architecture.md`](../architecture/active-architecture.md)
§3 (Morfo = the single cross-layer articulation point), §7 #6 (Eidos
imports no internals), §7 #12 (the 2-of-3 rule), §9 (tokens belong to
Eidos).
- [`architecture/overview.md`](../architecture/overview.md) §2 (Eidos = the
visual layer with tokens), §4 (not a classic design system), §5 (Eidos
consumes DOM and data-\*).
- This same reference, §1 (Mental model) and §7 (TSC).
If any of those canonical docs contradicts this section, **the canonical
doc wins**. This section consolidates; it doesn't decide.
---
## 2. The layers of Eidos's CSS
`src/uix/eidos/index.css` is the entrypoint (the source of truth for the
order is the file itself). It imports, in order:
```
1. generated/base.css ← foundation + recipe tokens + @font-face (generated)
2. archetypes.css ← transversal rules per data-archetype
3. events.css ← reactions to data-event-* (sema)
4. components/{c}/{c}.css ← aggregated recipes: layout primitives + spin-field
```
Outside the entrypoint but part of the visual layer:
- **Code-split recipes**: most components are NOT in `index.css` — each
`.svelte` imports its own CSS and Vite emits a per-component chunk. The
ABSENCE of an `@import` is deliberate; re-adding it would double-load.
- **Shared partials** (`lib/menu-indicator.css`): imported by the component
that uses them, not by the entrypoint.
- **Themes**: CSS blocks injected at runtime by `ActiveEidos`
(`uix-eidos-theme`), not a static `@import`. `themes/fonts.css` is
superseded — the `@font-face` live in `EidosConfig` and come out in
`generated/base.css`.
### Why this order matters
- `generated/base.css` declares tokens (it doesn't style). If recipes
loaded first, the tokens wouldn't be available.
- `archetypes.css` sets the interactive baseline (cursor, hover, focus
ring). Specific recipes override.
- `events.css` reacts to `data-event-*` with `animation: @keyframes` (not
`transition`) because signals are transient and the animation must
complete independently of the signal's lifetime.
- Specific recipes come last → higher cascade priority in equal-specificity
conflicts.
### What each layer concretely does
| Layer | Kind | Purpose |
| ------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `generated/base.css` | `:root` + some `[data-{c}]` blocks + `@font-face` | Foundation tokens (scale, primitive, color, size, density, typography, recipe tokens) + fonts (config-driven) |
| `archetypes.css` | `[data-archetype='X']` selectors | Transversal baseline styling per archetype (the inventory is `ARCHETYPE_VOCABULARY`, morfo/types.ts) |
| `events.css` | global `[data-event-*]` hints | The compositor hint + the reduced-motion cap (the signatures live in `EidosConfig.motion` — see [motion.md](./motion.md) §15) |
| `components/{c}/{c}.css` | `[data-{c}-*]` selectors | The component's own recipe (aggregated or code-split; partials like `lib/menu-indicator.css` are imported by their consumer) |
### Rules for touching each layer
- **`generated/base.css`**: **NEVER hand-edit**. It is generator output. To
change it, edit `lib/recipes/base.ts` or `lib/themes/base.ts` and run
`npm run generate:eidos-css`.
- **`archetypes.css`**: add entries only when the archetype is declared in
some morfo. Low-specificity rules (one attribute).
- **`events.css`**: add reactions only for signals sema emits. Use
`animation: @keyframes`, NOT `transition`. Read `data-event-intent`
(signal-bound), NEVER `data-intent` (state-bound).
- **`components/{c}/{c}.css`**: owned by whoever maintains the component.
Follows the naming convention (§6).
---
## 3. The 7 token layers
Eidos composes an element's final color by crossing 7 levels of
indirection. Each level serves a distinct purpose:
```
┌─ Layer 1: --scale-{name}-{step} :root (stable)
│ Radix physical scales (12 steps + alpha): --scale-teal-9 = #12a594
│
├─ Layer 2: --primitive-{role}-{step} :root (stable)
│ Role → scale mapping: --primitive-affirm-9 = var(--scale-teal-9)
│
├─ Layer 3: --color-{role}-{slot} :root (stable)
│ Semantic slot: --color-affirm-solid = var(--primitive-affirm-9)
│
├─ Layer 4: --{component}-{role}-{slot} :root (stable)
│ Per-component alias: --button-affirm-solid = var(--color-affirm-solid)
│ (NOTE: the "color-" segment was dropped on 2026-05-27)
│
├─ Layer 5: --{component}-palette-{slot} [data-{c}] (DYNAMIC)
│ Per-instance dynamic palette: changes with data-color
│
├─ Layer 6: --{component}-{variant}-{slot} [data-{c}] (host) (DYNAMIC)
│ The variant × palette combination
│
└─ Layer 7: --_{component}-{slot} [data-{c}] (private)
Private token consumed directly by the recipe CSS
```
**Scope rules**:
- Layers 1-4 are constant → `:root`.
- Layer 5 changes per instance → `[data-{c}]` and
`[data-{c}][data-color='X']`.
- Layer 6 depends on 5 → it MUST live at `[data-{c}]` (the TSC enforces
it).
- Layer 7 is private → always at `[data-{c}]`.
### Why so many layers
**Not accidental.** Each hop serves an extension point:
| Layer | Overriding it allows | Usage example |
| ----- | ----------------------------------- | --------------------------------------------------- |
| 1 | Changing the Radix physical scale | The brand wants its own teal |
| 2 | Changing which scale a role maps to | "Affirm" uses green instead of teal |
| 3 | Changing the per-role slot mapping | Affirm's "solid" uses step 10 instead of 9 |
| 4 | Changing a component-specific token | Toggle wants its affirm distinct from the global |
| 5 | The per-instance runtime | `<Toggle color="affirm" />` swaps the palette |
| 6 | Combining variant × color | The toggle's solid variant with the affirm color |
| 7 | Recipe-internal | The recipe decides which internal token serves what |
In practice, **most apps ONLY touch layers 1-3** (brand customization).
Layers 4-7 belong to the component catalog.
### When to create a new token at each layer
- **Layer 1** (scale): an app rarely; a **brand theme** DOES bring or
extend its own palette (§25.7). The default **33 scales** cover the
general case.
- **Layer 2** (primitive): rarely. Only if you add a new canonical role
(which would change the book canon — don't).
- **Layer 3** (color): if you add a new `{slot}` (rare). The canonical slot
inventory and its default step live in the code — **single source**:
`COLOR_ROLE_SLOTS` (`lib/config-types.ts`, with each slot's rationale in
its JSDoc) + `DEFAULT_COLOR_ROLE_SLOT_STEPS` (`lib/render-css.ts`). The
list is not copied here: it already drifted twice (border-hover retired;
bg2/separator/text-strong added).
- **Layer 4** (component-color): when adding color support to a new
component. Generated automatically by `lib/recipes/base.ts`.
- **Layer 5** (palette): when the component accepts a `data-color` prop and
needs a dynamic palette. TSC `scope: 'host'` + `scope: 'color:X'`
overrides.
- **Layer 6** (variant): when a variant (`solid`, `outline`, etc.) combines
the palette + something specific. TSC `scope: 'host'`.
- **Layer 7** (private): the recipe consumes it. Convention: the `_`
prefix.
---
## 4. The 9 canonical color roles
The roles come from the book _Diseñando lo que ocurre_. THEY ARE CANON. Do
NOT invent new ones.
```
HIERARCHY (no evaluative) INTENT (evaluative)
───────────────────────────── ───────────────────────────
primary — brand main affirm — turning ON something positive
secondary — brand support fulfill — completion / success
tertiary — brand tertiary risk — moderate negative consequence
neutral — gray default threat — active negative consequence
loss — irreversible negative outcome
```
**Strict rules**:
- The 9 names are the only valid ones. Do NOT use `success`, `warning`,
`danger`, `info` — those belong to other models (Bootstrap, etc.).
- **`primary`/`secondary`/`tertiary`** are **hierarchical**: use them when
the difference is "more vs less prominent". No evaluative load.
- **`neutral`** is the default. No semantic load.
- **`affirm`/`fulfill`/`risk`/`threat`/`loss`** are **evaluative**: they
communicate what happens with the action.
- If `intent === 'neutral'`, `color` (the hierarchy override) may apply. If
`intent` is evaluative, the `intent` WINS and `color` is ignored.
**Mapping to physical scales** (in the base theme): the standing assignment
lives in the code — **single source**: `THEME_BASE_COLOR_ROLES`
(`lib/themes/base.ts`), with each choice's rationale in its comments (e.g.
`tertiary: 'indigo'` is the saturated third hierarchy accent, in a hue band
no intent occupies; `loss: 'plum'` to avoid colliding with
`primary: 'purple'`). This
table was copied here twice and diverged both times (primary, risk) — hence
it is now a pointer.
> **Convention ≠ authorship.** `CANONICAL_INTENT_SCALES`
> (`lib/config-types.ts`) is the book's **convention** for auto-deriving
> intents from a palette (identity = step 9; e.g. `risk→amber`). The **base
> theme** is authorship and may deviate (e.g. `risk: 'orange'`). The
> hierarchy (`primary`/`secondary`/`tertiary`) is always the theme's
> choice. The complete model is **§25**.
The **palette** is **33 scales** of 12 steps + 12 alpha = 24 tokens each
(**792 `--scale-*` tokens** — the bulk of the foundation's bloat). On top of
it, the 9 roles alias via `--primitive-{role}-{step}` (9 × 24 = **216
primitives**).
### Why 9 roles and not 4 (like shadcn) or 14 (like Mantine)
The 9 are the result of the book's perceptual analysis:
- 3 hierarchy roles cover the "visual prominence" dimension.
- 1 neutral covers the unloaded default.
- 5 intent roles cover the five distinct evaluative valences.
Any system with fewer loses perceptual resolution. Any system with more
falls into redundancy (success vs fulfill, danger vs threat — they are not
the same).
### Per-component subset — REVOKED (2026-07-18)
> **There are no per-component subsets.** `color` accepts the FULL system on
> **every** component — role / intent / 33 donor scales / raw CSS value
> (`ComponentColorProp`) — per the design decision in **§25, "Reversión de los
> subconjuntos"**. This section used to publish a subset table (Toggle
> excluding fulfill/loss, Badge excluding tertiary) and argue that exposing
> them "would be semantically wrong". That argument is precisely what §25
> revoked, and the guard *"keeps every component `*Color` prop open"*
(`recipe-css-contract.test.ts`) now breaks the build on any narrowing.
What survives is the **arbitration**, not the narrowing: identity (`color`)
stays decoupled from evaluation, so an evaluative `intent` still wins over
`color` (the rule above). A component does not decide _which_ colors exist for
it; it decides nothing — the system is open and the intent arbitrates.
---
## 5. The size canon
```
xxs · xs · sm · md · lg · xl · xxl | full
───────────────────────────────────── ───────
7 canonical sizes (physical) 1 layout size
```
`md` is the default. `full` is not physical — it is layout semantics
(`100%` / `100vw` / `100dvh` depending on context). It generates no fixed
tokens.
Each canonical size generates coordinated tokens:
```
--size-md-control-height: 36px
--size-md-font-size: 16px /* = var(--font-size-md), 1:1. Bundle IN ADOPTION (Phase-D decision 2026-07-02; pilot: toggle) */
--size-md-font-line-height: 1.45
--size-md-icon-size: 18px /* = --icon-size-md */
--size-md-padding-inline: 12px
--size-md-padding-block: 8px
--size-md-gap: 8px
--size-md-radius: 6px
```
> **There is only one `md`** (1:1 — the 2026-06-17 override, below): the
> control bundle's `--size-md-font-size` = `var(--font-size-md)` = 16px,
> identical to the typographic scale. The earlier "two `md`s" doctrine
> (compact 14px control vs 16px body) was **revoked** — control text
> follows the typographic scale 1:1.
### The key rule: `md` does NOT change per viewport
Responsiveness decides **which active size is used**; it does NOT redefine
the tokens. If your Toggle uses `sm` on mobile and `md` on desktop, both
tokens are available and the wrapper picks one.
```svelte
<!-- Correct -->
<Toggle size={{ base: 'sm', md: 'md' }} />
<!-- Incorrect -->
@media (max-width: 768px) {
:root { --toggle-height-md: 32px; } /* do NOT redefine a size's token per viewport */
}
```
### Per-component subset
As with color, each component exposes the size subset its recipe supports.
Categories:
| Category | Subset | Examples |
| --------------------------- | -------- | ---------------------------------------------------- |
| Form controls + text inputs | `xs..xl` | input, select, switch, slider, checkbox |
| Nav controls | `xs..lg` | breadcrumb, pagination, tag-group, toolbar |
| Composed panels | `sm..lg` | calendar, date-picker, file-upload, stepper, tooltip |
The declaration itself is each component's recipe + types (demos mirror it
1:1 — the parity rule is
[`demo-authoring.md`](../guides/demo-authoring.md) §6).
### Container→part derivation: capped at `md` (2026-06-19 norm)
When a part derives its `size` from its **container** (e.g. `Dialog.Close`
inherits the dialog's size), the part **follows the container's size only
on the steps below `md`**; at `md` and above (`lg` / `xl` / `full`) it
**caps at the normal `md` density**. A wider container — or `full` — does
NOT fatten its controls: `full` is layout (it fills the viewport), not a
bigger control size.
| container size | part size |
| --------------------------- | ------------------------------ |
| `xs` / `sm` | `xs` / `sm` (follows the step) |
| `md` / `lg` / `xl` / `full` | `md` (normal) |
This matches the references: Radix separates `size` (density) from `width`
(full); Mantine's `fullScreen` **ignores** `size`; Material 3's full-screen
is a **layout type** (top app bar) with standard controls. None enlarges
controls because the dialog is `full`.
First consumer: `Dialog.Close` — `components/dialog/context.ts` publishes
the dialog's size and Close derives it. An explicit `size` prop on the part
always wins.
### The size→font mapping: universal 1:1 (2026-06-17 override)
> **Supersedes Phase 7's `control`/`compact`/`dense` archetypes.** The user
> decided an enterprise-grade framework needs **one source of truth**:
> control text follows the typographic scale **1:1** — `font-size-{size}` =
> `var(--font-size-{size})` — in ALL components. So changing
> `--font-size-md` to 15px re-adapts the whole theme without touching a
> single recipe. The `md` control is **16px** (NOT 14 anymore: the "compact
> control md=14" doctrine is revoked).
**The scale** (xxs/xs/sm/md/lg/xl/xxl/xxxl): **10 · 12 · 14 · 16 · 18→20 ·
24→28 · 32→48 · 40→80** (`lib/primitives/typography.ts`; lg…xxxl are fluid
`clamp()`).
Recipes brought to 1:1 (2026-06-17): button, badge, breadcrumb, calendar,
pagination, radio-group, toolbar, file-upload, tag-group, stepper, toggle,
tooltip — plus the whole field family (field, spin/date/time/color-field,
search/password-field, select, editable, tags-input), already 1:1 since the
fields sprint.
**Legitimate exceptions** (NOT control text → 1:1 doesn't apply):
- **avatar/marker** — the font is the initial inside the circle, scaled to
the **diameter** (24→96px): a proportional glyph, not a control.
- **accordion** — _(re-joined 1:1 on 2026-07-06)_: its former prose-scale
aliases (`--text-N-size`) died in the token-alias purge (changelog §39)
and the trigger/content now consume the size-bundle coordinates
(`--size-{k}-font-size`) like every control.
- **words / palabras / chronos** — excluded WIP tracks.
The `--size-*` bundle (above) is **adopted** — the C7 sweep (2026-07-03,
`notes.md`¹) pointed the 34 sized recipes at the bundle coordinates
(`--size-{k}-control-height` / `-font-size` / `-icon-size`) instead of the
raw primitives, and `recipe-css-contract` **forbids the raw primitive** in
recipes. A recipe keeps its own value only where it **deliberately
deviates** — the deviation stays visible instead of buried in a parallel
re-declaration. The bundle also carries the typographic pair of each size:
`--size-{k}-font-line-height` and `--size-{k}-font-letter-spacing` (the
per-size optical tracking travels with the coordinate). Realigned to 1:1 on
2026-06-29 (`--size-md-font-size` = `var(--font-size-md)` = 16px).
**Hard rule (the coherence guard)** — `recipe-css-contract.test.ts`:
> No recipe `font-size-*` / `icon-size-*` token may be a **px/rem literal**
> — it MUST reference `--font-size-*` / `--icon-size-*` (otherwise the text
> stops following the typographic scale, `--scaling` and
> `applyTypeScale()`).
It closes the hole the earlier (CSS-only) guard left — recipe-token values
are custom properties, not the `font-size:` property.
(`words`/`palabras`/`chronos` excluded — an active track.) The refactor to
_consume_ the archetype bundle (instead of re-declaring the mapping) stays
a follow-up; the guard is what **prevents the drift**.
### Fields: input 1:1 + the label one step below (2026-06-17 override)
The **field** family applies the **universal 1:1** (above) to the
**input**, and adds a rule of its own for the **label**:
- **Input/control → 1:1** (like every control): **12 · 14 · 16 · 18→20 ·
24→28**.
- **Label → one typographic step below the input**: **10 · 12 · 14 · 16 ·
18**. The label is the **only** element in the system that deliberately
steps down — label↔input hierarchy, not the collapsed drift that was
rejected.
| Recipe | input | label |
| -------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------- |
| `field` (generic) | `control-font-size-*` 1:1 | `label-font-size-*` one step below → covers everything `<Field>` wraps |
| `spin-field` (number/css), `date/time/color-field` | `font-size-*` 1:1 | date/time/color: own label via CSS calc; spin: the Field's label |
| `search-field`, `password-field`, `select`, `editable`, `tags-input` | `font-size-*` 1:1 | the Field's label |
**Segmented-field labels** (only date/time/color have
`[data-X-field-label]`):
`font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)))` — the
input (inherited `1em`) minus one scale step (md−sm = 2px, tokenized). The
generic `Field` uses per-size `label-font-size-*` tokens.
**Untouched** (already coherent): `pin-input` (`cell-font-size-*` already
scales), `combobox` (no font of its own). **Excluded**: `words`, `chronos`.
Non-fields (button/calendar/badge…) keep their archetype.
**Pending**: at `lg` the segmented label (calc → 18) and the `Field`'s
(token → 16) diverge by 2px because the input's `lg` is **fluid** (18→20).
At xs/sm/md (fixed) they agree. Resolve by giving the segmented ones
per-size tokens, or by dropping the field input's fluid `lg`.
### The typographic scale ↔ the icon scale
They are two parallel scales — `--font-size-{name}`
(`lib/primitives/typography.ts`) and `--icon-size-{name}`
(`lib/primitives/static.ts` → `STATIC_ICON.size`) — both scaled by density
(`× --scaling`). **They are not independent: the icon accompanies the
text.**
The optical rule was validated **by eye, not by formula** (the test bench
[`/uix/icon-scale-study`](../../web/routes/uix/icon-scale-study/+page.svelte)):
the icon weighs _one point above the text_ — `≈ font + 2` at body sizes,
growing toward `≈ line-height` at the large ones (an icon **tied to
`font-size` below, to the `line-height` above**). It is not a constant:
| size | `--font-size` (px) | line-height (px) | `--icon-size` (px) | icon − font |
| ---- | ------------------ | ---------------- | ------------------ | ----------- |
| xxs | 10 | 15 | 12 | +2 |
| xs | 12 | 18 | 14 | +2 |
| sm | 14 | 20 | 16 | +2 |
| md | 16 | 23 | 18 | +2 |
| lg | 20 | 27 | 20 | 0 |
| xl | 24→28 | 34 | 32 | +4 |
| xxl | 32→48 | 50 | 52 | +4 |
(The `lg`–`xxl` headings are fluid `clamp()`; the `font-size` column shows
the desktop max. The icon jump `lg 20 → xl 32` is faithful to the text jump
`20 → 28` — there is no in-between size because the typography has none
either.)
That is why a component **never invents icon sizes in px**: it declares
`var(--icon-size-{name})` and inherits this correlation. The canonical
`size` already pairs both axes (`--size-md-font-size` +
`--size-md-icon-size`). Archetype nuances:
- **Control icons follow the font 1:1** (the 2026-06-17 override): each
control recipe's `icon-size-{size}` references `var(--icon-size-{size})`,
parallel to the 1:1 font. The icon scale preserves `icon ≈ font+2` at
body (md: font 16 → icon 18). Applied to **button** + **search-field**.
**Exceptions**: `password-field` — its `icon-size-*` is NOT a glyph but
the **visibility-trigger button's box** (the glyph is 65% of it),
control-coupled on purpose; `radio-cards` — the icon follows the card's
**title** font.
- **Density ⊥ typography** (compact/comfortable/spacious): density scales
ONLY layout — `space` (`× --density-space-scale`) + `control-height`
(`× --density-control-scale`). **`--font-size-*` and `--icon-size-*`
carry no density** — only the global zoom `--scaling` (Radix parity)
touches them. Consequence: at 1:1 the icon stays **coupled to the text at
every density** (font 16 / icon 18 constant; only the control's box
tightens: 32.4 / 36 / 40.3). That is why an icon sized from
`--control-height-*` (density-coupled) decouples from the text — an
anti-pattern unless the element IS a control (e.g. the password-field's
trigger).
- **Cards / titles** (radio-cards, empty-states): the icon **accompanies
the title's font**, never a hand-inflated size — e.g. radio-cards =
`16/16/18/18/20` (follows its title). To emphasize, raise the **title's
font** (the icon follows); don't inflate the icon.
Changing the scale = editing `STATIC_ICON` +
`components/icon/create-icon.ts` + regenerating
(`npm run generate:eidos-css`). Nothing else consumes it raw.
---
## 6. Naming conventions
### Public tokens (consumable)
```
--{prefix}-{slot}
```
Where `{prefix}` is one of:
| Prefix | Meaning | Example |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `--scale-{name}-{step}` | Radix physical scale | `--scale-teal-9` |
| `--primitive-{role}-{step}` | Role → step | `--primitive-affirm-9` |
| `--color-{role}-{slot}` | Color role × slot | `--color-affirm-solid` |
| `--font-{kind}-{key}` | Typography | `--font-family-primary` |
| `--size-{key}-{slot}` | Size primitive | `--size-md-control-height` |
| `--space-{n}` | Spacing scale | `--space-3` |
| `--radius-{key}` | Radius scale | `--radius-md` |
| `--border-width-{key}` | Border-width scale (linear `none·thin·medium·thick·heavy` = 0/1/2/3/4) — §35 | `--border-width-thick` |
| `--ring-inset-width` | The inset-ring's default width (the inner `box-shadow` ring) — §35 | `--ring-inset-width` |
| `--shadow-{n}` | Shadow scale (drop) | `--shadow-3` |
| `--shadow-inset-{key}` | Inner-shadow / recessed (`subtle·deep`, mode-aware) — §35 | `--shadow-inset-subtle` |
| `--blur-{key}` | Blur scale (backdrop/frost, `none·sm·md·lg·xl·xxl`) — §35 | `--blur-lg` |
| `--depth-{plane}-translucency` | Per-plane frost opacity = **a function of elevation** (higher = more opaque) — §29 | `--depth-modal-translucency` |
| `--gradient-{name}` | A named, themeable gradient — a token or **role-derived** via `buildGradient`/`applyGradients` (the 6th builder) — §29 | `--gradient-aurora` |
| `--gradient-angle-{dir}` | Gradient direction (8 compass points) — §35 | `--gradient-angle-to-r` |
| `--breakpoint-{key}` | Responsive breakpoint (source = ActiveDom) — §35 | `--breakpoint-md` |
| `--z-index-{key}` | Z-index layer (depth planes) | `--z-index-modal` |
| `--z-index-overlay-{key}` | The flat overlay micro-band (portaled overlays + modals) — §35 | `--z-index-overlay-floating` |
| `--opacity-{key}` | Opacity — a dual numeric (`0..100`) + semantic (`disabled·muted·…`) scale — §35 | `--opacity-disabled` |
| `--tracking-{key}` | Letter-spacing scale (incl. `caps` for UPPERCASE) — §35 | `--tracking-caps` |
| `--leading-{key}` | Line-height scale | `--leading-ui` |
| `--duration-{key}` | Motion duration | `--duration-fast` |
| `--ease-{key}` | Motion ease | `--ease-out` |
| `--motion-distance-{key}` / `--motion-scale-{key}` / `--motion-stagger` / `--motion-loop-{name}` | Motion metrics, event-scale factors, stagger step, loop periods — §14 | `--motion-scale-press` |
| `--motion-stagger-viewport` | Reveal rhythm of a viewport animator inside `[data-stagger]` (config `primitives.motion.staggerViewport` — changelog §57) | `--motion-stagger-viewport` |
| `--control-height-{key}` | Control-height scale (density × scaling composed) — §5 | `--control-height-md` |
| `--icon-size-{key}` / `--icon-stroke-width-{key}` | Icon scale + stroke widths — §5 | `--icon-size-md` |
| `--container-width-{key}` / `--content-width-{key}` / `--aspect-ratio-{key}` / `--container-padding-inline` | Layout family (container widths reference `--breakpoint-{key}` — 2026-07-06) — §35 | `--container-width-xl` |
| `--density-{level}-{space·control}-scale` + `--density-{space·control}-scale` | Density scalars per level + the active bindings — §31 | `--density-compact-space-scale` |
| `--scaling` / `--scaling-{90..120}` | The global zoom axis (per-family participation map — §23) | `--scaling-110` |
| `--state-{hover·press·selected}` | State-layer veils over `currentColor` (config `primitives.state` — §38 + changelog §40) | `--state-hover` |
| `--floating-gap` / `--floating-gap-{menu·panel}` | Trigger→panel gap canon (config `primitives.floating` — §36 + changelog §40) | `--floating-gap-menu` |
| `--radius-factor` / `--radius-default` | The roundness multiplier + the default step (config — changelog §40) | `--radius-factor` |
| `--shape-{key}` | Shape family (squircle smoothing, nest gap) — §30 | `--shape-smoothing` |
| `--depth-{plane}-{cue}` | Depth cues per plane (`surface·border·shadow·halo·z·blur·translucency`) — §29 | `--depth-modal-shadow` |
| `--focus-ring` / `--focus-ring-{color,width,offset,inner-width,error}` | The two-ring focus family — §32 | `--focus-ring-color` |
| `--measure-{key}` | Line-length (measure) scale — §35 | `--measure-narrow` |
| `--font-feature-{key}` | `font-feature-settings` presets — §35 | `--font-feature-tabular` |
| `--style-{name}-*` | Typography named style | `--style-h1-font-size` |
| `--{c}-{slot}` | Component recipe token | `--toggle-height-md` |
| `--{c}-{role}-{slot}` | Component color | `--toggle-affirm-solid` |
| `--{c}-palette-{slot}` | Component runtime palette | `--toggle-palette-solid` |
### Private tokens (component-internal)
```
--_{c}-{slot}
```
The `_` prefix means: do NOT consume this from outside the component's
recipe. It is internal. Example:
```css
[data-toggle] {
--_toggle-bg: var(--toggle-solid-bg); /* private */
--_toggle-on-bg: var(--toggle-palette-solid); /* private */
}
```
### Strict rules
1. **All public Eidos tokens carry the bare `--` prefix**, with no layer
sub-prefix. Reason: debug clarity. See `--toggle-bg` and you know it is
Eidos. See `--bg` and you don't know where it came from.
2. **NEVER use `--eidos-`** as a prefix. The layer is already implicit in
the `$uix/eidos/components/{c}` path.
3. **NEVER use `--soma-`, `--air-` or `--terra-`**. Those layers are dead
or own no tokens.
4. **Component tokens follow the pattern** `--{component-kebab}-...`. The
component kebab is the directory name.
5. **Don't abbreviate component names**. `dropdown-menu` does not become
`ddmenu`. Authorship clarity is worth 6 chars.
6. **Do NOT include the intermediate "color-" segment** in color tokens.
`--toggle-affirm-solid` (correct), `--toggle-color-affirm-solid`
(deprecated 2026-05-27).
7. **Slots follow a fixed vocabulary**: for layers 3-5 the canonical
inventory is `COLOR_ROLE_SLOTS` (`lib/config-types.ts` — see §3, layer
3; not copied here). For layers 6-7 (recipe-level):
`bg, fg, border, on-bg, on-fg, on-border, hover-bg, on-hover-bg`.
> **Guarded since 2026-08-20 (D-TH.6).** This rule and rule 6 spent months
> as documentation without a guard, and the catalogue drifted to 196
> `-color` keys against 55 `fg`, with the modifier landing on both sides
> (`hover-bg` ×13, `bg-hover` ×22). A codemod normalised 269 keys and
> **R-5.3** (`component-audit`) now holds the shape. Note what the slot
> list above already showed: `hover-bg` puts the modifier IN FRONT. The
> full sentence — _in front what is interactive, behind what is dimensional
> or contextual_ — plus the two families with a grammar of their own
> (role/palette, where `COLOR_ROLE_SLOTS` carries the modifier behind by
> construction, and the system tokens a recipe consumes but never mints)
> lives in [`canon/recipe-contract.md`](../canon/recipe-contract.md) §1.
> The platform principle of the px/py normalisation ("a token is named
> after the property it feeds") governs the DIMENSIONAL axes only: if it
> governed colour, `bg` would have to be `background-color`.
### Generated tokens vs authorship
Tokens in `generated/base.css` are **output**. To add a new one, you edit:
- `lib/themes/base.ts` for primitives, scales, theme variants.
- `lib/recipes/base.ts` for component tokens.
And run `npm run generate:eidos-css`.
---
## 7. Token Scope Contract (TSC)
> **Moved to [`canon/tsc.md`](../canon/tsc.md)** (the visual canon, E2).
> The Token Scope Contract — the available scopes, the three ways to
> declare a token, the `scopeCovers` algebra, cross-axis collision
> detection, multi-part scope and cross-recipe composition (v2.2), and the
> 5-layer defense pipeline — lives there as its own chapter. Summary: the
> TSC decides WHERE each token is emitted (root, per component, per color,
> per event) and validates at generation time that every dependency is
> available in the consumer's scope.
---
## 8. How to add a new component
> **Moved to [`guide.md`](./guide.md)** (the E4 guide). The steps to add a
> new component — deciding which tokens it needs, the recipe, the scope and
> validation — live there next to the define-a-theme guide.
>
> **Which transversal systems the recipe MUST consume** (state-layer,
> focus, tokenized elevation, 1:1 typography, opacity, motion, logical
> axes) is its own canon:
> [`canon/recipe-contract.md`](../canon/recipe-contract.md), enforced by
> `component-audit`'s R-4.x rules.
---
## 9. How to define a theme
> **Moved to [`guide.md`](./guide.md)** (the E4 guide).
---
## 10. How to override tokens at runtime
`ActiveEidos.setCssVariables()` allows contract-aware runtime overrides:
```ts
activeEidos.setCssVariables({
'--color-primary-solid': 'rebeccapurple',
'size-md-control-height': '40px', // works without -- too
'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
});
```
Eidos:
1. **Validates** each name against `getCssContract()`. Tokens outside the
contract throw in `strict` mode (the default).
2. **Renders** transactionally: first render + validate, then replace the
runtime `<style>` block.
3. **Applies** the overrides under `:root` (or the selector you pass).
For variables outside the contract (the app's locals):
```ts
activeEidos.setCssVariables({ '--my-app-custom': 'value' }, { strict: false });
```
### Whole-system builders
Above `setCssVariables` sit **six builders** that each derive one axis of
the system from a seed and write it as a managed block (they follow the
active light/dark theme), plus the capstone that composes them
_(count corrected 2026-07-07 — this section used to list two)_:
- **`eidos.applyColorScheme(seed, opts)`** — the 33 scales + 9 roles from a
brand color (`buildScheme`).
- **`eidos.applyTypeScale(seed, opts)`** — the `--font-size-*` scale from a
modular ratio + base (`buildTypeScale`), optionally fluid.
- **`eidos.applyDepth(planes, opts)`** — per-plane depth cue overrides.
- **`eidos.applyShape(seed, opts)`** — squircle smoothing / shape family.
- **`eidos.applySpacing(seed)`** — modular/fluid space scale
(`buildSpaceScale`).
- **`eidos.applyGradients(seeds, opts)`** — named, role-derived gradients
(`buildGradient`, the 6th builder — changelog §29).
- **`eidos.applyTheme(seed, opts)`** — the capstone: `{ color?, type?,
depth?, shape?, space?, gradient? }` (all six axes) in ONE atomic managed
write; `clearTheme()` reverts everything.
Each has its `clear*()`. All are pure in `eidos/lib` (`build-*`) + an
application method on `ActiveEidos`. Live demos under `/temas/*`.
---
## 11. Bundle strategy + `eidos:purge`
`generated/base.css` contains the tokens of EVERY component in the catalog
(≈140 components, 2026-07 — dates arbitrate). In production a typical app
uses 5-20.
### The tool
```bash
npm run eidos:purge -- \
--src 'src/**/*.svelte' \
--src 'src/**/*.ts' \
--src 'src/**/*.css' \
--output dist/eidos.purged.css \
--verbose
```
### How it decides what to keep
1. **Foundation always kept**: scale, primitive, color, size, opacity,
z-index, shadow, border, radius, space, density, motion, icon,
typography, layout. ~1100 tokens (~95 KB raw / ~11 KB gzip).
2. **Source-scanned tokens**: every `var(--XXX)` and `--XXX:` declaration
found in source → `XXX` pinned.
3. **Component-import detection**: every `from '...components/{c}'` →
`{c}`'s full recipe pinned.
4. **Data-attr detection**: every `data-{c}=` (filtered against the
canonical recipe registry) → `{c}`'s full recipe pinned.
5. **Transitive closure**: if X is pinned and X→`var(--Y)`, Y is pinned.
Iterated to a fixed point.
### Measured results
Measured 2026-06 (62-component catalog, 217.7 KB base). The catalog has
since grown (≈140 components / 345 KB base, 2026-07): the PROPORTIONS hold;
the absolute figures are dated.
| Profile | Components | Raw before | Raw after | Reduction | Gzip after |
| ----------------------------- | ---------- | ---------- | --------- | --------- | ---------- |
| Minimal (toggle+button+badge) | 3 | 217.7 KB | 97.4 KB | **−55%** | 11.1 KB |
| Typical SaaS (10 components) | 10 | 217.7 KB | 116.3 KB | **−46%** | 13.6 KB |
| 5 UIX demo pages | 7 | 217.7 KB | 107.3 KB | −51% | 12.4 KB |
| Exhaustive (all) | 62 | 217.7 KB | ~217 KB | −0.4% | ~25 KB |
**The architectural floor**: ~95 KB raw / ~11 KB gzip (the foundation every
app needs).
### When to use it
- **In production**: ALWAYS. Integrate it into your build pipeline.
- **In dev**: optional. The raw 226 KB is fine for local iteration.
- **In SSR**: pre-purge once per build, not per request.
### Known limitations
- **Dynamic component selection**: if your app imports components
dynamically (`await import(...)`), the scanner can miss them. Mitigation:
pass the names via `--keep my-component`.
- **`var()` in dynamic strings**: if you build `var(--${name})` at runtime,
the scanner doesn't see it. Mitigation: declare the names statically in
some scannable file.
---
## 12. Validation tooling
| Tool | Command | What it validates |
| -------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **morfo:check** | `npm run morfo:check` | DOM contracts vs the morfo declarations (a Playwright walk of the demos) |
| **eidos-lint** | `node scripts/eidos-lint.ts {c}` | Recipe CSS selectors vs the morfo enum values |
| **eidos-lint-all** | `node scripts/eidos-lint-all.ts` | Same, all components |
| **TSC validation** | `npm run generate:eidos-css` (implicit) | Scope algebra + cross-axis collision detection |
| **recipe-css-contract** | `npm test -- recipe-css-contract` | Consumed recipe tokens + the TSC v2 scenarios |
| **component-api-contract** | `npm test -- component-api-contract` | Each component's public API surface |
| **component-visual-attrs** | `npm test -- component-visual-attrs` | The visual data-attrs the wrapper emits |
| **generated-css** | `npm test -- generated-css` | The generated CSS's structure |
| **theming-census** | `node --import tsx/esm scripts/theming-census.ts` | How much of each recipe's appearance a theme can reach through the component's OWN public tokens (`--report` writes `docs/audit/theming/`, `--names` the naming grammar, `--debt` the ledger) |
| **theming-reach-floor** | `npm test -- theming-reach-floor` | The catalogue ratchet: no unregistered debt, no stale ledger entry, reach ≥ 69 % |
| **theming:sentinel** | `npm run theming:sentinel -- {c} {url}` | R-5.4: every public token moves a computed value in the live demo, or carries a written adjudication |
### The debt ledger, and what «adjudicated» means (firma 2026-08-25)
A recipe declaration that reaches no theme is a fact, not an opinion, and the
census counts 1088 of them. They are registered ONE BY ONE in
[`scripts/theming-census-debt.ts`](../../scripts/theming-census-debt.ts) —
**deuda registrada, not an exceptions file**: an exception asserts «this is
fine» and every entry there asserts the opposite. It is the sibling of
`theming-sentinel-exceptions.ts` in mechanism (one written line per key, a
STALE detector on top) and its opposite in meaning.
The ratchet is therefore PER KEY, not per total. Until this landed the floor
was two coarse ceilings (`maxLiteral` / `maxGlobal`), which only see a NET
move: five regressions hiding under five unrelated fixes read as green. Now a
literal or a raw global outside the ledger is a regression that arrives NAMED,
and a ledger entry whose knob is no longer debt — tokenized, annotated,
dead — is **STALE** and stays red until the line is deleted. The debt only
shrinks, key by key. Same doctrine as R-5.4 («a public token that moves
nothing and carries no act, lies») and the same mechanism.
The exception valve is untouched and lives where it always did: the
`/* literal: <reason> */` annotation on the declaration (recipe-contract §3)
and the component-level `R-5.1 exception:` of a README. Precedence between the
two acts: a README valve softens the component's audit ROW; the catalogue
floor reads only the ledger, so a valve never blinds the ratchet.
With that, F3's gate reads honestly — **«census 100 % ADJUDICATED»**, not
«census 100 %»: every knob is public, private, system, structural, an
annotated exception, or a ledger entry.
### The recommended pre-commit validation pipeline
```bash
npm run generate:eidos-css # if you touched recipes/themes
npm test -- src/uix/eidos # the eidos suite
npm run check # TS check
npm run morfo:check # DOM contracts (needs the dev server)
node scripts/eidos-lint-all.ts # the CSS-drift safety net
```
---
## 13. Sema integration (`event:*` scope)
> ⚠️ **Superseded.** The current motion model is the **two-moment** one
> documented in [`motion.md`](./motion.md) (F1–F7): the `--event` moment
> (the perceptual signature) is declared in `motion.signatures` and
> generated as CSS against `data-event-*` directly — without the TSC
> `event:*` scope or the `data-motion-ref` this section used to discuss.
> The engine (`EngineMotion`) is a service in `arts/motion` (`uix.motion`).
> The original body (the decision's historical context) lives in
> [`changelog.md §13`](./changelog.md).
---
## 14. Motion
Eidos's motion system — the **two-moment model** (the perceptual `--event`
during a signal's _hold_ + `--state` for persistent transitions), the
`EngineMotion` engine (relocated to `arts/motion`, exposed as `uix.motion`
and consumed by soma and eidos) and how a preset is authored — is its own
system and lives in [`motion.md`](./motion.md). Roadmap F1–F7 implemented
(2026-06-04). This section covers only what touches **theming**.
> **Status note.** Earlier versions of this doc described motion as
> "deferred" and pointed at `data-motion-ref` / the "TSC `event:*` scope"
> (§13) as its future. That is **obsolete**: the perceptual signature
> migrated into the `signatures` registry (not `events.css`) and the engine
> is a service in `arts/motion` today. [`motion.md`](./motion.md) is the
> canonical, current reference.
### Draggable surfaces: the "pickup" lift
When the user **grabs and drags** a surface, it must **rise toward them**
(depth: "I picked it up"). It is a transversal cue — NOT component-specific
— so the scale factor is a **global motion token**, not a per-recipe
literal:
| Token | Value | Use |
| --------------------- | ------ | -------------------------------------------------------------------------------- |
| `--motion-scale-lift` | `1.02` | the pickup scale while dragging (the only `>1` in the `--motion-scale-*` family) |
**The pattern (every draggable applies it the same way)** — gated by the
drag data-attr its morfo declares (`data-dragging`, `data-grabbed`, …),
paired with a shadow elevation, and suppressed under reduced motion:
```css
/* float-panel, a dragging slider thumb, a sortable item, a drawer… */
[data-x][data-dragging] {
scale: var(--motion-scale-lift); /* rises toward the user */
box-shadow: var(--…-shadow-active); /* + elevates the shadow */
}
[data-motion='reduce'] [data-x][data-dragging] {
scale: 1;
transition: none;
}
```
Rules:
- **`scale` (not `transform`)** to compose with the position, which travels
via `translate` (distinct properties) → the lift never fights the 1:1
drag.
- The `scale` change is **transitioned** (lift on grab / settle on
release); during the move it stays **static** (composited, no per-frame
cost).
- `will-change: translate, scale` for the duration of the gesture.
- A theme re-themes the lift in `STATIC_MOTION.scale.lift`
(`primitives/static.ts`) — every draggable inherits it. Don't redefine
the 1.02 per component.
Today `float-panel` consumes it
(`[data-float-panel-content][data-dragging]`); a slider/sortable adding
drag must read the SAME token, not invent its own.
---
## 15. Comparison with reference libraries
> **Moved to [`notes.md`](./notes.md)** (E3). The comparison of eidos with
> Radix, Ark, Mantine and others lives there, next to the decisions FAQ.
---
## 16. Anti-patterns you must NOT commit
### A. Declaring derived tokens at `:root`
```ts
// ❌ WRONG — the pre-TSC Toggle bug
'palette-solid': { value: '...', scope: 'host' },
'solid-on-bg': 'var(--my-component-palette-solid)' // implicit 'root' scope
```
The TSC throws on regeneration. Fix: `scope: 'host'` on the consumer.
### B. Names with the redundant "color-" segment
```ts
// ❌ DEPRECATED (2026-05-27)
'color-affirm-solid': 'var(--color-affirm-solid)'
// ✅ CORRECT
'affirm-solid': 'var(--color-affirm-solid)'
```
### C. Inventing roles outside the canon
```ts
// ❌ NO — success/danger/warning/info belong to other models
'success': 'green',
'danger': 'red'
// ✅ Use the 9 canonical ones
'fulfill': 'green', // success → fulfill
'threat': 'red' // danger → threat
```
### D. Media queries that change canonical tokens
```css
/* ❌ NO — md changes meaning per viewport */
@media (max-width: 768px) {
:root { --size-md-control-height: 32px; }
}
/* ✅ The component picks which size applies per viewport */
<Toggle size={{ base: 'sm', md: 'md' }} />
```
### E. Importing `$libs/dom` directly in eidos
```ts
// ❌ NO
import { foo } from '$libs/dom';
// ✅ Eidos consumes via ActiveEidos.dom
const eidos = ActiveEidos.require();
eidos.dom.apply(...);
```
### F. Hand-editing `generated/base.css`
It is output. Any change is overwritten on regeneration. To change
something, edit `lib/themes/base.ts` or `lib/recipes/base.ts`.
### G. Creating loose physical scales inside an app
The default **33 scales** cover the reasonable palettes. A **brand theme**
DOES bring its own palette as scales (§25.7) — that is legitimate. What you
must NOT do is add a one-off scale inside an app when remapping a role to
an existing scale already solves the case.
### H. Re-exporting between layers
```ts
// ❌ NO — eidos does not re-export soma
export * from '$soma/components/toggle';
// ✅ Each layer exposes its own API
```
---
## 17. FAQ — controversial decisions
> **Moved to [`notes.md`](./notes.md)** (E3).
---
## References
- [`architecture/eidos.md`](../architecture/eidos.md) — the visual layer as
a module (the living architecture chapter).
- `THEMING_AUDIT_2026-06-01.md` — the theming audit (the journal of how we
got here; removed from the tree — its chronicle survives in
[`changelog.md §22`](./changelog.md)).
- [`motion.md`](./motion.md) — the motion system (the two-moment model,
F1–F7; see §14).
- [`src/uix/eidos/lib/config-types.ts`](../../src/uix/eidos/lib/config-types.ts) —
the source of truth of the TSC type.
- [`src/uix/eidos/lib/render-css.ts`](../../src/uix/eidos/lib/render-css.ts) —
the generator (parsing, scope algebra, cross-axis detection).
- [`src/uix/eidos/lib/recipes/base.ts`](../../src/uix/eidos/lib/recipes/base.ts) —
the per-component token catalog.
- [`src/uix/eidos/lib/themes/base.ts`](../../src/uix/eidos/lib/themes/base.ts) —
the base theme (primitives + semantics + themes).
- [`scripts/eidos-purge.ts`](../../scripts/eidos-purge.ts) — the purge
tool.
- [`src/uix/eidos/recipe-css-contract.test.ts`](../../src/uix/eidos/recipe-css-contract.test.ts) —
the test guard.
- [`architecture/active-architecture.md`](../architecture/active-architecture.md) —
the full UIX context.
- [`guia-semantica-historica.md`](../decisions/guia-semantica-historica.md) —
the sema/perceptual doctrine (the historical seed of the 9-role canon;
[`CANON.md`](../CANON.md) rules today).
---
## 18. Universal TSC coverage
> **Moved to [`canon/tsc.md`](../canon/tsc.md)**. The v2.2 extensions
> (multi-part scope and cross-recipe composition) that take the TSC to
> universal coverage live with the rest of the contract there.
---
## 19. Variants are eidos canon, NOT the theme's
**A firmly-held architectural position**: the variant vocabulary (`solid`,
`outline`, `ghost`, `soft`, `surface`, `line`, `pills`, etc.) is **fixed in
the system**. A theme can NOT:
- Add a new variant (there is no `branded`, `bubble`, `corporate` each
theme invents).
- Redefine an existing variant's visual cascade (`outline` means "bordered
restraint" in every theme — only the border's COLOR changes, not its
geometry).
### The 3 layers of the onion
| Layer | What it is | Who changes it |
| --------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| **Sema families / intents** | the book's canonical perceptual vocabulary (8 families × 6 intents) | NOBODY — fixed |
| **Eidos variants** | perceptual visual archetypes (5 shared archetypes + component-specific variants) | NOBODY — fixed |
| **Eidos color roles** | the 9 canonical roles (`primary`/`affirm`/`risk`/…) | NOBODY — fixed |
| **The eidos color palette** | which hex each role is | **THE THEME** |
| **Recipe-internal tokens** | `--toggle-solid-bg` etc. | **The app** (a targeted override in `EidosConfig.recipes`) |
**Theming = retinting the perceptually fixed.** The theme changes WHICH
color `affirm` is, not WHAT `outline` means.
### Why fixed
1. **Component portability.** `<Toggle variant="outline">` must render
coherently in any theme. Theme-defined variants would break that
silently — a component assuming `outline` wouldn't work in a theme that
doesn't declare it.
2. **Type safety = part of the contract.** Consumers need
`SelectionVariant = 'solid' | 'outline' | 'ghost'` for autocomplete and
TS errors. An extensible `Record<string, …>` would lose that guarantee.
Radix Themes 3.x, Chakra v3, Mantine — every serious reference keeps
variants fixed per component.
3. **Variants are perceptual archetypes, parallel to sema families.**
`solid` = "filled emphasis", `outline` = "bordered restraint",
`ghost` = "ambient transparency", `soft` = "tinted background". That is
the framework's perceptual vocabulary — not a theme decision.
4. **There are 5 archetypes, not infinitely many.** The catalog closes; TS
rejects non-canonical ones. If a genuinely perceptual new archetype
emerges, it is added to `EIDOS_VARIANTS` — at the framework level, not
the theme's.
### The single source of truth — `EIDOS_VARIANTS`
`src/uix/eidos/lib/types.ts` declares the constant:
```ts
export const EIDOS_VARIANTS = {
control: ['surface', 'outline', 'ghost'],
selection: ['solid', 'outline', 'ghost'],
chip: ['soft', 'solid', 'outline', 'ghost'],
marker: ['solid', 'soft', 'outline'],
tabs: ['line', 'surface', 'pills', 'segmented']
} as const satisfies Readonly<Record<string, readonly string[]>>;
export type ControlVariant = (typeof EIDOS_VARIANTS.control)[number];
export type SelectionVariant = (typeof EIDOS_VARIANTS.selection)[number];
// …
```
The 5 unions are DERIVED from the const — the value and the type cannot
desynchronize. Each component narrows to the appropriate archetype:
```ts
// components/toggle/types.ts
export type ToggleVariant = SelectionVariant;
// components/accordion/types.ts
export type AccordionVariant = ControlVariant;
```
For finer narrowing within an archetype:
```ts
export type AlertDialogCancelVariant = Extract<ButtonVariant, ControlVariant>;
```
### Component-specific variants
Some components have genuinely unique vocabularies:
- `Banner`: `inline | overlay | persistent` (positioning, not perceptual
treatment).
- `Spinner`: `bars | dots | ring` (the indicator's geometry).
- `Button`: adds `'plain'` for inline/link-like — it doesn't deserve its
own archetype because it only appears in Button + Code.
These live in each `components/{c}/types.ts`. The lint
`recipe-css-contract.test.ts > variant CSS selectors per component match
the declared type union` validates bidirectionally:
- The CSS uses `[data-{c}][data-variant='X']` → X must be in the union.
- The type union declares `'X'` → the CSS should have entries (advisory).
### What a theme CAN do
- Change palettes (`ThemeColorSet.scales`, `ThemeColorSet.roles`).
- Change shadows (`ShadowScale`).
- Change typography styles (`TypographyPrimitiveSet.styles`).
### What a theme can NOT do
- Add variants. (`ThemeDefinition` exposes no `recipes`.)
- Redefine visual cascades. (Recipes are `EidosConfig.recipes`, part of the
app's bootstrap, not the theme's.)
- Change color roles. (The 9 roles are canon.)
- Change the size canon. (`SIZE_PRIMITIVE_KEYS` is fixed.)
### What the app CAN do (at boot, not per theme)
- Override tokens in `EidosConfig.recipes` — it changes the visual
cascade's RESULT; it adds no new variant.
- Create its own wrapper components composing eidos primitives with custom
className/style.
- Change tokens at runtime via `ActiveEidos.setCssVariables()` /
`clearCssVariables()` (per-app CSS variables).
### When to add a new archetype
Only when a repeated perceptual pattern EMERGES in ≥3 components and fits
none of the 5 existing archetypes. Procedure:
1. Document the archetype with 1 paragraph describing the perceptual
affordance (parallel to "solid = filled emphasis").
2. Add it to `EIDOS_VARIANTS` in `lib/types.ts`.
3. Export the derived type.
4. Migrate the consuming components to reference it.
5. Update this section.
### Comparison with the references
| Lib | Theme-extensible variants | App-extensible variants |
| ------------------------ | --------------------------------- | --------------------------------------------------------------------------- |
| **Radix Themes 3.x** | ❌ | ❌ (fixed per component) |
| **Mantine 7+** | ❌ | ❌ (defaultProps + styles override) |
| **Chakra UI v3 (Panda)** | ❌ | ⚠️ via recipes config (compound variants) |
| **Ark UI** | n/a (100% headless, no opinion) | n/a |
| **shadcn/ui** | n/a (copy-paste, not a framework) | ✓ (copy + edit) |
| **activeUIX** | ❌ | ⚠️ via an `EidosConfig.recipes` override (changes tokens; adds no variants) |
activeUIX aligns with Radix Themes and Mantine: a framework with a fixed
contract, a theme with flexibility bounded to color/spacing. Extreme
extensibility (Tailwind, plain CSS-in-JS) is deliberately NOT the goal —
because the framework's promise is perceptual portability across apps and
themes.
---
> **§20–§38 — the chronicle moved to [`changelog.md`](./changelog.md).**
> These sections were dated sprint records (corrections, incidents,
> commits) mixed with doctrine. The full chronicle now lives in the
> changelog **with the same §N numbering**; below, each section keeps the
> standing decision in a sentence + the pointer to the living source (RFC /
> config / generator). Historical `§N` citations across the corpus and the
> code keep resolving here.
## 20. Theming-engine corrections (2026-06-01)
Chronicle in [`changelog.md §20`](./changelog.md). Standing: density emits
real per-level scalars (`data-density` drives `--density-space-scale` /
`--density-control-scale`) and the `contrast` slot resolves legibly on
solids (APCA on-solid with a flip; see §25 and `lib/render-css.ts`).
## 21. Two-level color model (RFC — RESOLVED in §25)
The RFC is resolved — chronicle in [`changelog.md §21`](./changelog.md).
The standing model is §25 +
[`rfc-color-model.md`](../rfcs/rfc-color-model.md).
## 22. Pending theming improvements
A historical, resolved backlog — chronicle in
[`changelog.md §22`](./changelog.md). The prioritized audit that absorbed
it was `THEMING_AUDIT_2026-06-01.md` (the full scorecard; removed from the
tree).
## 23. The `scaling` axis (global zoom) — 2026-06-02
Chronicle in [`changelog.md §23`](./changelog.md). Standing: `data-scaling`
(`90`–`110`) is the **global zoom**, and WHICH families it multiplies is
**declared per element** by the participation map
(`STATIC_SCALING_PARTICIPATION`, `primitives/static.ts` — the axis
definition, 2026-07-06): metric families scale (`space`, `control-height`,
`font-size`, `icon-size`, `blur`); chrome families stay crisp (`radius` —
which keeps its own `--radius-factor` magnitude knob —, `border-width`,
`shadow`). Orthogonal to density (which does NOT touch typography) and
multiplies with it. Full design:
[`rfc-scaling.md`](../rfcs/rfc-scaling.md).
## 24. P2 engine corrections (2026-06-02)
Chronicle in [`changelog.md §24`](./changelog.md). Standing: the per-role
`surface`/`soft` tints are **translucent by construction**
(`--color-{role}-surface` = `a2`, `-surface-hover` = `a3`) — they compose
over non-uniform backgrounds.
## 25. The color model — palette + derived roles/intents (2026-06-02)
Chronicle in [`changelog.md §25`](./changelog.md). Standing (the canonical
color model, three layers):
- **The palette** — functional 12-step + 12-alpha scales, theme-designable
(`lib/themes/color-scales.ts` + `base.ts`); a scale's identity = step 9
(the solid). Directly usable: `var(--scale-{name}-{step})`.
- **Hierarchy roles** (`primary`/`secondary`/`tertiary`) — explicit aliases
to a scale: `THEME_BASE_COLOR_ROLES` (`lib/themes/base.ts`), see §4.
- **Intents** — auto-derived from the palette by the book's convention
(`CANONICAL_INTENT_SCALES`, `lib/config-types.ts`); the theme may deviate
(convention ≠ authorship, §4).
The per-role slots are `COLOR_ROLE_SLOTS` (§3, layer 3 — the single
source). Detail and rationale:
[`rfc-color-model.md`](../rfcs/rfc-color-model.md) +
[`rfc-color-engine.md`](../rfcs/rfc-color-engine.md).
### The per-instance palette layer — `--palette-*` (THM-2, 2026-07-12)
`<X color="teal">` — the promise that a component's `color` prop accepts ANY
of the 33 donor scales (not only roles) — is served by ONE shared,
component-agnostic layer, not a per-component copy of the cascade.
- **The shared layer** (`renderSharedPaletteLayer`, `lib/render-css.ts`):
`[data-color='{role|scale}'] { --palette-{slot}: … }` for all roles + all
`PALETTE_SCALES`, emitted ONCE (~19 KB). Role rows mirror the role tokens
1:1; scale rows use the `PALETTE_SLOT_STEP` map + the on-solid contrast
criterion (`lib/on-solid.ts`), so the value is identical to the retired
per-component cascade.
- **The per-recipe forward** (`renderRecipePaletteForward`): a recipe that
declares `palette-{slot}` (public) or `_palette-{slot}` (private) tokens
gets `[data-{c}]:where([data-color], [data-color-custom]) { --{c}-palette-{slot}: var(--palette-{slot}, <host default>) }`.
It consumes its own `--{c}-palette-*` unchanged — a **private-palette recipe
gains all 33 scales with ZERO CSS change**.
- **The ladder** (firma B′, 2026-08-24 — changelog §53): the three writers of
`--{c}-palette-*` sit on three rungs and **no rung ties another**, so the
order of emission never decides:
| rung | selector | specificity |
| ------- | ----------------------------------------------------- | ----------- |
| floor | `:where([data-{c}])` | `(0,0,0)` |
| forward | `[data-{c}]:where([data-color], [data-color-custom])` | `(0,1,0)` |
| tone | `[data-{c}][data-color='X']` | `(0,2,0)` |
Only the palette SLOTS ride the floor (`isPaletteSlotToken`); every other
host-scoped token of the recipe stays at `(0,1,0)`. Guarded by
`active-eidos-config.test.ts` ("palette cascade is a ladder").
- **Nesting safety**: the `:where([data-color], [data-color-custom])`
**presence** guard means a nested component with no color of its own matches
only the floor (its host default) and never reads a `--palette-*` a colored
ancestor set — the isolation the per-component prefix used to give, kept
without the ~21 KB × N it cost (the per-component rollout to all 17 surfaces
would have been ≈ +315 KB; the shared layer is ~19 KB once).
Wiring a component to the scales = declare `palette-*` / `_palette-*` tokens
(host default) and consume `--{c}-palette-*` in its recipe CSS. Guarded by
`recipe-css-contract.test.ts` ("emits the shared palette layer + a forward for
every palette-\* recipe"). Rollout status: the plan
[`process/continue-cleanroom-fixes-2026-07.md`](../process/continue-cleanroom-fixes-2026-07.md)
§THM-2.
**Reversión de los subconjuntos (decisión de diseño, 2026-07-18).** THM-2
originalmente dejaba los controles semánticos en subconjuntos por componente
(`AffirmativeColorRole`, `ProgressiveColorRole`, …). La decisión de 2026-07-18
lo revierte: **`color` acepta el sistema completo — rol / intent / 33 escalas
donantes / valor CSS crudo (`ComponentColorProp`) — en TODOS los componentes,
sin excepciones** (incluida la tinta de contenido, que conserva su eje como
unión aditiva: `ComponentColorProp | 'subtle' | 'muted' | 'disabled' | 'on-solid'`).
**`primary` / `secondary` YA NO son tinta** (§54, 2026-08-24): significan
jerarquía de marca en todo el catálogo, el paso 82 % se llama `subtle` y el
nivel 1 de tinta es el DEFAULT, sin prop. La
identidad (`color`) sigue desacoplada de la evaluación (`invalid` + el
`intent` del evento). Enforcement estructural: el guard "keeps every component
`*Color` prop open" en `recipe-css-contract.test.ts` — un tipo `*Color` más
estrecho que `ComponentColorProp` rompe el build. Proceso completo:
[`process/open-color-cage-2026-07.md`](../process/open-color-cage-2026-07.md).
## 26. Runtime theme builder — `eidos.applyColorScheme` (2026-06-04)
Chronicle in [`changelog.md §26`](./changelog.md). Standing:
`buildScheme(seed, opts)` (pure, `lib/build-scheme.ts`) +
`ActiveEidos.applyColorScheme(seed, opts)` / `clearColorScheme()` — derives
a full scheme (roles + `a1..a12` alphas + APCA on-solid) from the active
theme and re-derives on mode change. Layers: `uix.color` = the math ·
`build-scheme` = pure composition · `ActiveEidos` = DOM application. API:
[`rfc-color-engine.md`](../rfcs/rfc-color-engine.md) §6.2/§7.
The block it writes repaints the primitives, so it paints an **appearance**,
and it declares it: `color-scheme` first, taken from the `appearance` of the
resolved **donor** theme — not from the raw `mode`. That matters when the
caller forces a donor (`applyColorScheme(seed, { mode: 'dark' })` over a
light theme): the page ends up dark while the theme block still says
`light`, and the UA must follow the block that actually painted it
(«How to define a theme» in [`guide.md`](./guide.md) — `appearance` is what a theme IS).
## 27. Wide-gamut OKLCH output (default-on) (2026-06-04)
Chronicle in [`changelog.md §27`](./changelog.md). Standing: every palette
step emits hex (the fallback) + an `oklch()` sibling that wins where
supported — default-on, no flag (`appendColorScaleDeclarations`,
`lib/render-css.ts`). The REAL wide gamut lives in the generator
(`buildScheme` keeps the unclamped OKLCH → `result.wideGamut`).
## 28. Forced-colors accessibility + the border ramp (2026-06-05)
Chronicle in [`changelog.md §28`](./changelog.md). Standing: the foundation
always emits `@media (forced-colors: active)` (focus via `outline` —
box-shadow dies in HCM) and `@media (prefers-contrast: more)` (borders and
de-emphasized text reinforced via `:root:root`); the `border` slot =
**step 7** of the scale (`DEFAULT_COLOR_ROLE_SLOT_STEPS`).
## 29. Depth — the unified, eventful channel (2026-06-05)
Chronicle in [`changelog.md §29`](./changelog.md). Standing: depth is ONE
channel — `data-depth='{plane}'` (`flush · raised · overlay · modal ·
recessed`) coheres surface + shadow + halo + z at rest, and the event
signature (`present-rise` / `press-squeeze`) moves it in the event-moment.
Config-driven planes (`EidosConfig.depth.planes`); overlays compose
`var(--depth-{plane}-shadow), var(--depth-{plane}-halo)`. Canonical guide:
[`rfc-depth.md`](../rfcs/rfc-depth.md). Demo:
`/temas/profundidad`.
## 30. Shape — continuity + families + nesting + eventful (2026-06-05)
Chronicle in [`changelog.md §30`](./changelog.md). Standing: shape is a
channel — `--shape-smoothing` + `data-shape='{family}'`
(`rounded · continuous · cut · scoop`) over `corner-shape`, degrading to
the `border-radius` arc; nested harmony via `[data-shape-nest]`
(concentric); the squircle is the **surface tier's default**
(`renderShapeBlocks` `:where` + `--shape-surface-default`; `shape` =
opt-OUT). Runtime builder `applyShape(seed)`. Canonical guide:
[`rfc-shape.md`](../rfcs/rfc-shape.md). Demo:
`/temas/forma`.
## 31. Structure (space · density · scale) — space as rhythm (2026-06-05)
Chronicle in [`changelog.md §31`](./changelog.md). Standing: the three
structural axes are state-only — density (`data-density`), scaling
(`data-scaling`, §23) and space: `--space-{key}` =
`calc(value · var(--density-space-scale) · var(--scaling))`, regenerable
from a modular/fluid seed (`buildSpaceScale` + `applySpacing`). Canonical
guide:
[`rfc-structure.md`](../rfcs/rfc-structure.md).
Demo: `/temas/estructura`.
## 32. Focus ring — the outline model (2026-06-11 · canonized 2026-07-07)
Chronicle in [`changelog.md §32`](./changelog.md). Standing: ONE focus
model — **`outline`** driven by the `--focus-ring-*` tokens
(`STATIC_FOCUS_RING`, `primitives/static.ts`); the foundation's ring excludes
field-internal elements. The box-shadow → outline migration for
surfaces/controls completed 2026-06-29; the component-audit checkpoint
(verdict S5, 2026-07-07,
[`audit/components/_veredictos.md`](../audit/components/_veredictos.md))
**canonized outline catalog-wide and retired the "fields stay on box-shadow"
clause** — the audit census found outline was already the de-facto model of
the entire catalog (the `field.css` remnant migrated in the fix phase; the
LAST box-shadow — the foundation's universal `[data-archetype]` fallback,
whose justifying app-layer `outline: none` reset no longer existed — migrated
2026-07-11, EID-1, now `:where()`-wrapped so recipes always win the shared
property). The reasons, now canonical: (1) `outline` survives
forced-colors/High Contrast Mode where box-shadow is stripped — the ring must
not vanish for the users who need it most; (2) segment-fields flicker with a
transitioned border/box-shadow on every increment (blur/refocus repaint) —
date-field's documented rationale; (3) alignment with the reference systems
(Radix Themes, Material 3 `md-focus-ring`, Chakra v3) — box-shadow rings were
the pre-2021 workaround for `outline` not following `border-radius`, fixed in
all modern browsers.
## 33. Themeable stepper glyphs (`spin-field`) — 2026-06-11
Chronicle in [`changelog.md §33`](./changelog.md). Standing: the stepper's
glyphs (`spin-field`) are themeable recipe tokens, not hardcoded SVG.
## 34. `spin-field` — the stepper-field's shared visual (`number-field` / `css-field`) — 2026-06-11
Chronicle in [`changelog.md §34`](./changelog.md). Standing: `number-field`
and `css-field` share ONE visual layer via the structural identity
`data-spin-field*` (`components/spin-field/spin-field.css`, aggregated in
`index.css`) — no clone.
## 35. The scale canon — the theming audit (2026-06-15)
Chronicle in [`changelog.md §35`](./changelog.md). Standing: every scale
axis (blur · inner-shadow · inset-ring · gradients · breakpoints ·
container · opacity — dual numeric+semantic · border-width · tracking) is
theme-retunable and **recipes consume the token, never a literal** (the
R-2.x/R-4.x guards). The inventory lives in `EidosConfig`
(`lib/primitives/*` + `config-types.ts`) and the naming table in §6;
breakpoints source from `ActiveDom`. The size→font mapping is the universal
1:1 of §5 (the audit's `control · compact · dense` size archetypes were
superseded by the 2026-06-17 override).
## 36. The canonical trigger→panel gap — a token-driven offset (2026-06-22)
Chronicle in [`changelog.md §36`](./changelog.md). Standing: the
trigger→panel gap is an OFFSET of the positioning engine via
`@property --floating-gap` — menus `--space-1`, panels `--space-1-5`
_(menus moved 0 → `--space-1` on 2026-06-28; this stub had kept the old
value — dates arbitrate)_. Since 2026-07-07 the two gaps are config data:
`primitives.floating` (changelog §40).
## 37. Touch-target — 44px on touch, pointer-gated (2026-06-28 · rev. 2026-07-08 · confirmed 2026-07-10)
Chronicle in [`changelog.md §37`](./changelog.md). Standing: the 44px touch
target applies ONLY under `@media (pointer: coarse)` — desktop keeps its
compact density. The value is a single hard constant, `--touch-target: 44px`
(WCAG 2.5.5 AAA · Apple HIG), defined once in `archetypes.css` `:root` and
shared by every consumer (button-archetype floor, radio-group row, the
checkbox/switch slop).
**Área ≠ visual (rev. 2026-07-08).** The touch target is a property of the
interactive AREA, decoupled from the painted VISUAL — the reference-grade model
(React Aria's component-height box + full-fill input; Material Web's
`mdc-touch-target` pseudo-element). Two mechanisms by control shape:
- **Button-like controls** (`trigger`/`close`/`action`/`[data-button]`): the
control IS the target, so a `min-block-size`/`min-inline-size: var(--touch-target)`
floor on the box is correct (grows only xs/sm/md; lg/xl already ≥ 44).
- **Small markers** (`checkbox`/`switch`): these are `<button>`s that paint a
small visual (box/track), so a `min-*` floor would grow the VISUAL. Instead a
transparent `::before` grows only the AREA to `max(100%, var(--touch-target))`
— the painted box/track keeps its size-variant visual. Being absolute it adds
no layout (no neighbour-collision margin needed). `radio` keeps its labeled
ROW (`[data-radio-group-row]` `min-block-size`) — the label text supplies the
horizontal extent.
A single floor (44 AAA), not a per-size AA/AAA split: no reference framework
does the split, and the nearest analog (MUI's small variant falling below the
target) is treated as a defect.
**Confirmed 2026-07-10** (user decision — chronicle in changelog §37): the
`::before` slop is the standing mechanism for bare markers (the
Material/Android/iOS lineage). Two riders: (1) dense coarse stacks must keep
pitch ≥ `--touch-target` — WCAG excludes overlapped area from measurement, so
spacing is part of the contract (bare boxes still hold AA via the 2.5.8
Spacing exception in the worst case); (2) the labeled-row task on Field stays
open as the ADDITIVE improvement — once a marker has a labeled row, the ROW is
the real AAA target (2.5.5 Equivalent, the React Aria pattern) and the slop
remains as a harmless net.
## 38. The state layer — unified neutral feedback (2026-06-28)
Chronicle in [`changelog.md §38`](./changelog.md). Standing: neutral
interactive feedback (hover/press/selected) is the MD3 state layer — the
`--state-hover` / `--state-press` / `--state-selected` veils composed as
`background-image: linear-gradient(...)`; bespoke per-component hovers are
deprecated (R-4.3). Since 2026-07-07 the magnitudes are **config data**
(`primitives.state`, generator-emitted — changelog §40); `archetypes.css`
keeps only the RULES.
## 39. Gradient finish — the derived ramp treatment (2026-07-15)
Full chapter (design, alternatives rejected with evidence, the measured
rectification, decision register D1–D9):
[`theming/gradient-finish.md`](./gradient-finish.md). Standing summary:
**Doctrine: a gradient is a FINISH (material) of the fill, never a color
IDENTITY.** `color` keeps saying WHO the instance is (role / scale / custom);
the `gradient` prop says how its saturated fill is painted. The taxonomy:
color = identity · variant = wearing · depth/frost = material · state layer =
feedback → the gradient joins as MATERIAL. A gradient value must never enter
the `data-color` axis: an `<image>` cannot honor that axis' contract (10
mechanically-derived slots + every variant can express the value). The full
design history (the rejected color-value design, the reference-framework
research, the step-lattice analysis) lives in
`docs/process/gradient-finish-plan-2026-07.md`.
Mechanics (v1, Button pilots — `GRADIENT_FINISH_COMPONENTS` gate in
`render-css.ts`):
- **The layered fill.** `background-color` stays the solid base (it is what
`forced-colors` keeps: the UA strips every non-`url()` `background-image`,
so the finish degrades to today's solid button for free). The finish is a
`background-image` layer.
- **The ramp derives from the instance's own palette slots** — no per-color
catalog: `color-mix(in oklch, …)` over `palette-solid` /
`palette-solid-hover`. It re-tints with roles, the 33 donor scales and
light/dark automatically, and the INK stays the inherited
`palette-contrast` (steps 7/11 were rejected as endpoints: they are
border/text slots, mode-relative, and invert in dark).
- **The ramp is ANCHORED to the ink's shadow side** — the doctrine in one
sentence: _la rampa huye de la tinta_. White-ink colors deepen toward
`#000` with the strong stop DOWN (the classic shaded CTA); dark-ink colors
(light solids: amber/yellow/lime…) lift toward `#fff` with the strong stop
UP (glossy). Anchor + direction resolve per color × mode by the SAME flip
the `contrast` slot rides (`--color-{role}-finish-anchor/-angle` at theme
time; light-solid membership for the 33 scales), and both stops move toward
the anchor — so the label's contrast can only IMPROVE over the base solid
pairing: **constructive safety, unbounded dial**. Why not a global
lift-toward-white: measured 2026-07-15 over 84 combos (9 roles + 33 scales
× light/dark, floors APCA ≥ 60 ∧ WCAG ≥ 3) — it breaks the inherited ink on
52/84 at 26% and the global safe ceiling is 0% (teal 1%, gray 15%,
dark-mode blues/greens 0%). The anchored form: 0 regressions up to 40%.
- **ONE theme dial**: `--gradient-finish-lift` (default `26%`), emitted from
`primitives.gradientFinish.lift` — config data like the state-layer
magnitudes (§38). `0%` ≈ finish off; per-instance override is free via the
custom-property cascade, and a theme overrides it by declaring
`gradientFinish.lift` in its `ThemeDefinition` (re-emitted in the theme
block — light/dark can run different intensities).
- **The `spread` kind (v1.5)**: `gradient="spread"` rotates hue
±`--gradient-finish-spread` (default `30`, UNITLESS — the relative-color
`h` channel is a `<number>`; `deg` computes `none`) at constant L/C, with
the ramp's weak anchor mix on both stops (pure constant-L broke grass/gold
— measured; anchored: 0 regressions ≤ ±45°, pinned in the guard). ≈Flat on
achromatic identities (grays). Full record: D10 in
[`gradient-finish.md`](./gradient-finish.md).
- **Named finishes (v2.2, D11)**: `gradientFinish.named` opts open-cage
gradients in as finishes (`gradient="aurora"`), each with its REQUIRED
authored ink (`--gradient-{name}-ink`, overriding the recipe's `--_{c}-fg`
convention); the base stays the identity's solid — alpha-blob materials
need no trailing base and stay `background-image`-valid by architecture.
- **The `Surface` primitive + `on` (v2.1/v2.2, D12)**: the themeable canvas
(Box + treatment) with the MINIMAL subtree ink context — `on="light|dark"`
re-binds the global content/border roles for plain content (nested
components and portals excluded by construction; forced-colors →
`CanvasText`). The invariant is pinned executable in
`src/uix/eidos/gradient-finish-guard.test.ts` (no-regression ≤ 40% + the
pre-existing flat-fail set — cyan/orange mid-tones, an on-solid reality —
must not grow).
- **Generator emits the VAR, the recipe paints.** `renderRecipeGradientFinish`
emits `--_{c}-fill-finish` under `[data-{c}][data-gradient]`; the recipe
paints it on its saturated fill (`[data-variant='solid']`) and re-asserts on
`:hover` (the hover rule uses the `background:` shorthand, which resets the
longhand). Inert on soft/outline/ghost — no saturated fill to finish, and
no broken contract because `gradient` never claimed to be the identity.
- **`data-gradient` is an eidos-only WRAPPER attr** (the
`data-variant`/`data-size` family), stamped by the eidos wrapper and NOT
morfo-declared. Found live: a morfo-declared attr resolves from SOMA's prop
space — the pure-visual `gradient` prop doesn't exist there, so the runtime
emits `undefined` and `mergeProps(restProps, state.props)` clobbers the
wrapper's stamp. The `data-color-custom` precedent (Card) differs because
its prop DOES flow through soma. Rule of thumb: morfo declares an attr only
when its driving prop crosses the soma boundary.
The NAMED gradients (`--gradient-{name}`, the open cage) remain a separate
axis: scenography material (v2 queue: named finishes with authored ink +
the `Surface` primitive for aurora/mesh). Live lab: `web/routes/temas/gradientes`.
## 40. Tonal-ramp contrast contract — the slot-pair floors (Stage 1, 2026-07-19)
Chronicle + the measured drift in [`changelog.md §44`](./changelog.md);
initiative registry [`next-features.md §1`](../next-features.md). Standing
doctrine — which slot pairs the framework promises to clear, and which are
intentionally subtle. The ratified pair table now lives as DATA (`$color` →
`CONTRAST_PAIRS`, `arts/color/contrast-contract.ts`), a single source shared by
the audit and the CI guard (Stage 2 shipped no solver — see the closing note). Measured by `scripts/contrast-audit.ts` (WCAG
2 gate + APCA Lc, over the 33 scales × 2 modes, plus a morph-generated
regression bank), reusing the on-solid math (§8 of `rfc-color-engine.md`,
`lib/on-solid.ts`).
**Text.** Two tiers, by the inherited Radix contract:
- `text·11` is the **secondary / low-contrast** ink (≈APCA 60) — marginal
sub-4.5:1 on the muddy light-mode scales (bronze/orange/teal/gold…), by design.
- `text·12` (`text-strong`) is the **AA-guaranteed body ink** (≥4.5:1 on every
scale × mode). **For AA-critical text, consume `text-strong`, not `text`.**
**Borders — two tiers (WCAG 1.4.11).** The whole border vocabulary lives in the
subtle 4–8 range (Radix's border steps); only `solid·9` clears 3:1 by
construction.
- **Decorative (EXEMPT — not a sole indicator):** the per-scale accent
`border·7` (§28), the semantic `subtle·4` / `default·6`, and any resting
border on a control that also carries a fill. Subtle on purpose; the fill +
content carry the boundary. Do NOT hold these to 3:1.
- **Load-bearing (3:1 target):** checked/selected (`solid·9` — clears it), the
error border and hover/ghost border (measure sub-3:1 but ride a redundant cue
— error text, background — so they're exempt where that cue exists), and the
focus ring (below).
**Focus — a config axis, single `outline` (§32).** The focus ring is the one
always-sole indicator. Its appearance is parameterised, NOT hardcoded:
`primitives.focusRing` (`{ offset, width, innerWidth }`, `primitives/static.ts`)
- `color.focus.{ring,ringError}` (the theme), emitted ONCE as `--focus-ring-*`
tokens (`render-css.ts`). `innerWidth: 0` = single ring (default); a theme raises
it for a second inset line. The shipped default (`primary·8 @ ~50%` translucent)
measures sub-3:1 as a raw ratio; hardening it (opaque color, `innerWidth > 0`,
offset) is a **default-VALUE decision via config, never a per-component CSS
change** — and it stays a single `outline` (§32 canonized outline over
box-shadow: it survives forced-colors/HCM, avoids segment-field flicker).
**Stage 2 (CLOSED 2026-07-20 — no solver).** The plan proposed a generator that
_solves_ each step's luminance to satisfy this table for any seed. Execution
disproved the premise: the template morph **inherits** text contrast (steps 11/12
copy the donor's L-curve verbatim; L-driven contrast is ~chroma-invariant under
gamut-mapping), so any scale generated from a §40-compliant donor library clears
these floors **by construction** — verified across the authored base, a
leave-one-out regeneration, and 45 out-of-distribution seeds (0 hard-gate
failures, min WCAG 9.7:1). The luminance solver was dropped as speculative. What
shipped: this table as shared data (`CONTRAST_PAIRS`), the audit consuming it + a
morph-generated regression bank, and a CI guard (`eidos/lib/contrast-invariant.test.ts`)
that locks the inheritance. With D2 = _measured-pass_ the BASE stays **verbatim
ground-truth** (audit-guarded, no base→seeds migration). Plan + outcome:
[`process/contrast-stage2-plan-2026-07.md`](../process/contrast-stage2-plan-2026-07.md).
---
**Last revision**: 2026-07-19 (contrast contract §40). If anything
in this doc disagrees with the code, the code wins — but open an issue so we
update the doc.

Powered by TurnKey Linux.