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

1550 lines
66 KiB

---
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`).
- **6 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 6 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 a RESERVED hierarchy slot no component consumes
yet; `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
Each component exposes its own subset of the 9. Examples:
| Component | Subset | Excludes |
|---|---|---|
| Toggle | primary, secondary, neutral, affirm, risk, threat | fulfill, loss (doesn't apply) |
| Button | all 9 | — |
| Badge | primary, secondary, neutral, affirm, fulfill, risk, threat, loss | tertiary (not canonical for it) |
Why subsets: a toggle is neither completion nor irreversible loss. Exposing
fulfill/loss in its API would be semantically wrong.
---
## 5. The size canon
```
xxs · xs · sm · md · lg · xl · xxl | full
───────────────────────────────────── ───────
6 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_GUIDE.md`](../../web/routes/uix/lib/DEMO_AUTHORING_GUIDE.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** (xs/sm/md/lg/xl/xxl): **12 · 14 · 16 · 18→20 · 24→28 ·
32→48** (`lib/primitives/typography.ts`; lg/xl/xxl 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** — the trigger is a section heading: it uses the **prose**
scale `--text-N-size` (xs→text-2 … full→text-6), its own coherent
progression.
- **words / palabras / chronos** — excluded WIP tracks.
The `--size-*` bundle (above) is **in adoption** (a Phase-D decision,
2026-07-02 — it had been orphaned for a year). The pattern is set by the
pilot (`toggle`, `lib/recipes/base.ts`): the recipe consumes `--size-{k}-*`
where its value IS the canonical coordinate (height, font-size — identical
alias chains, zero visual change) and keeps its own value where it
**deliberately deviates** (px/gap tighter than the bundle's padding) — the
deviation stays visible instead of buried in a parallel re-declaration. The
sweep across the rest of the catalog is the open workstream; the bundle was
already **realigned to 1:1** (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` |
| `--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`.
### 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 two builders that derive an entire system from
a seed and write it as a managed block (they follow the active light/dark
theme):
- **`eidos.applyColorScheme(seed, opts)`** — derives the 33 scales + 9
roles from a brand color (`buildScheme`). `clearColorScheme()` reverts.
- **`eidos.applyTypeScale(seed, opts)`** — derives the 8 `--font-size-*`
from a modular ratio + base (`buildTypeScale`), optionally fluid
(`ratioMax`). `clearTypeScale()` reverts.
Both are pure in `eidos/lib` (`build-scheme` / `build-type-scale`) + an
application method on `ActiveEidos`. Live demos: `/temas/color` and
`/temas/tipografia`.
---
## 11. Bundle strategy + `eidos:purge`
`generated/base.css` contains the tokens of EVERY component in the catalog
(~95 components). 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
| 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 |
### 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 */
}
@media (prefers-reduced-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`](../../src/uix/eidos/THEMING_AUDIT_2026-06-01.md) —
the theming audit (the journal of how we got here).
- [`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_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.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']
} 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 +
[`COLOR_MODEL_RFC.md`](../../src/uix/eidos/COLOR_MODEL_RFC.md).
## 22. Pending theming improvements
A historical, resolved backlog — chronicle in
[`changelog.md §22`](./changelog.md). The prioritized audit that absorbed
it is
[`THEMING_AUDIT_2026-06-01.md`](../../src/uix/eidos/THEMING_AUDIT_2026-06-01.md)
(the full scorecard).
## 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** — it multiplies `space`,
`control-height`, `font-size`, `icon-size` (typography YES); it does NOT
scale radius / border / shadow. Orthogonal to density (which does NOT touch
typography) and multiplies with it. Full design:
[`SCALING_RFC.md`](../../src/uix/eidos/SCALING_RFC.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:
[`COLOR_MODEL_RFC.md`](../../src/uix/eidos/COLOR_MODEL_RFC.md) +
[`COLOR_ENGINE_RFC.md`](../../src/uix/eidos/COLOR_ENGINE_RFC.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:
[`COLOR_ENGINE_RFC.md`](../../src/uix/eidos/COLOR_ENGINE_RFC.md) §6.2/§7.
## 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:
[`DEPTH_ENGINE_RFC.md`](../../src/uix/eidos/DEPTH_ENGINE_RFC.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:
[`SHAPE_ENGINE_RFC.md`](../../src/uix/eidos/SHAPE_ENGINE_RFC.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:
[`STRUCTURE_ENGINE_RFC.md`](../../src/uix/eidos/STRUCTURE_ENGINE_RFC.md).
Demo: `/temas/estructura`.
## 32. Focus ring — the parameterized two-ring model (2026-06-11)
Chronicle in [`changelog.md §32`](./changelog.md). Standing: ONE focus
model — the canonical ring (`--focus-ring` + the recipes' `*-focus-shadow`)
is two parameterized rings (`--focus-ring-inner-width`, default `0` = outer
frame only; `STATIC_FOCUS_RING`, `primitives/static.ts`); the foundation's
ring excludes field-internal elements. A11y note §28: under forced-colors
the visible focus is `outline` (the per-component migration of box-shadow →
outline is open work).
## 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 `0`, panels `--space-1-5`.
## 37. Touch-target — 44px on touch, pointer-gated (2026-06-28)
Chronicle in [`changelog.md §37`](./changelog.md). Standing: the 44px touch
targets apply ONLY under `@media (pointer: coarse)`; markers via the
label-row, no DOM restructuring.
## 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` tokens composed as
`background-image: linear-gradient(...)` in `archetypes.css`; bespoke
per-component hovers are deprecated (R-4.3).
---
**Last revision**: 2026-07-02. 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.