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