104 KiB
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:purgefor 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
- Mental model 1bis. Theming lives in Eidos, not in Morfo (by design)
- The layers of Eidos's CSS
- The 7 token layers
- The 9 canonical color roles
- The size canon
- Naming conventions
- Token Scope Contract (TSC)
- How to add a new component
- How to define a theme
- How to override tokens at runtime
- Bundle strategy +
eidos:purge - Validation tooling
- Sema integration (
event:*scope) - Motion
- Comparison with reference libraries
- Anti-patterns you must NOT commit
- FAQ — controversial decisions
- Universal TSC coverage
- Variants are eidos canon, NOT the theme's
- Theming-engine corrections (2026-06-01)
- Two-level color model (RFC — RESOLVED in §25)
- Pending theming improvements
- The
scalingaxis (global zoom) - P2 engine corrections (2026-06-02)
- The color model — palette + derived roles/intents
- Runtime theme builder —
eidos.applyColorScheme - Wide-gamut OKLCH output (default-on)
- Forced-colors accessibility + the border ramp
- Depth — the unified, eventful channel
- Shape — continuity + families + nesting
- Structure (space · density · scale)
- Focus ring — the parameterized two-ring model
- Themeable stepper glyphs (
spin-field) spin-field— the stepper-field's shared visual- The scale canon — the theming audit (2026-06-15)
- The canonical trigger→panel gap — a token-driven offset (2026-06-22)
- Touch-target — 44px on touch, pointer-gated (2026-06-28)
- 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
ActiveEidosinjects. - The
ActiveEidosruntime 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 §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 §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
// ❌ 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:
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
- "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.
- "Let's make the TSC validate
color:affirmby importingtoggleMorfo.data['data-color'].values" — violates rule #6 (eidos does not import morfo internals in TS). - "Let's put
variant: 'solid' | 'outline'in the Toggle's morfo" — violates 2-of-3 (variants are consumed by eidos alone). - "Let's define
sizein 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
coloras a prop → must declaredata-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) → declaresdata-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§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§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.svelteimports its own CSS and Vite emits a per-component chunk. The ABSENCE of an@importis 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.cssis superseded — the@font-facelive inEidosConfigand come out ingenerated/base.css.
Why this order matters
generated/base.cssdeclares tokens (it doesn't style). If recipes loaded first, the tokens wouldn't be available.archetypes.csssets the interactive baseline (cursor, hover, focus ring). Specific recipes override.events.cssreacts todata-event-*withanimation: @keyframes(nottransition) 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 §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, editlib/recipes/base.tsorlib/themes/base.tsand runnpm 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. Useanimation: @keyframes, NOTtransition. Readdata-event-intent(signal-bound), NEVERdata-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-colorprop and needs a dynamic palette. TSCscope: 'host'+scope: 'color:X'overrides. - Layer 6 (variant): when a variant (
solid,outline, etc.) combines the palette + something specific. TSCscope: '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/tertiaryare hierarchical: use them when the difference is "more vs less prominent". No evaluative load.neutralis the default. No semantic load.affirm/fulfill/risk/threat/lossare evaluative: they communicate what happens with the action.- If
intent === 'neutral',color(the hierarchy override) may apply. Ifintentis evaluative, theintentWINS andcoloris 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.
coloraccepts 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*Colorprop 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 "twomds" 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.
<!-- 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 §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/densearchetypes. 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-mdto 15px re-adapts the whole theme without touching a single recipe. Themdcontrol 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,--scalingandapplyTypeScale()).
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):
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}referencesvar(--icon-size-{size}), parallel to the 1:1 font. The icon scale preservesicon ≈ font+2at body (md: font 16 → icon 18). Applied to button + search-field. Exceptions:password-field— itsicon-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:
[data-toggle] {
--_toggle-bg: var(--toggle-solid-bg); /* private */
--_toggle-on-bg: var(--toggle-palette-solid); /* private */
}
Strict rules
-
All public Eidos tokens carry the bare
--prefix, with no layer sub-prefix. Reason: debug clarity. See--toggle-bgand you know it is Eidos. See--bgand you don't know where it came from. -
NEVER use
--eidos-as a prefix. The layer is already implicit in the$uix/eidos/components/{c}path. -
NEVER use
--soma-,--air-or--terra-. Those layers are dead or own no tokens. -
Component tokens follow the pattern
--{component-kebab}-.... The component kebab is the directory name. -
Don't abbreviate component names.
dropdown-menudoes not becomeddmenu. Authorship clarity is worth 6 chars. -
Do NOT include the intermediate "color-" segment in color tokens.
--toggle-affirm-solid(correct),--toggle-color-affirm-solid(deprecated 2026-05-27). -
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
-colorkeys against 55fg, 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-bgputs 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, whereCOLOR_ROLE_SLOTScarries the modifier behind by construction, and the system tokens a recipe consumes but never mints) lives incanon/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,bgwould have to bebackground-color.
Generated tokens vs authorship
Tokens in generated/base.css are output. To add a new one, you edit:
lib/themes/base.tsfor primitives, scales, theme variants.lib/recipes/base.tsfor component tokens.
And run npm run generate:eidos-css.
7. Token Scope Contract (TSC)
Moved to
canon/tsc.md(the visual canon, E2). The Token Scope Contract — the available scopes, the three ways to declare a token, thescopeCoversalgebra, 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(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, enforced bycomponent-audit's R-4.x rules.
9. How to define a theme
Moved to
guide.md(the E4 guide).
10. How to override tokens at runtime
ActiveEidos.setCssVariables() allows contract-aware runtime overrides:
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:
- Validates each name against
getCssContract(). Tokens outside the contract throw instrictmode (the default). - Renders transactionally: first render + validate, then replace the
runtime
<style>block. - Applies the overrides under
:root(or the selector you pass).
For variables outside the contract (the app's locals):
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
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
- 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).
- Source-scanned tokens: every
var(--XXX)and--XXX:declaration found in source →XXXpinned. - Component-import detection: every
from '...components/{c}'→{c}'s full recipe pinned. - Data-attr detection: every
data-{c}=(filtered against the canonical recipe registry) →{c}'s full recipe pinned. - 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 buildvar(--${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 —
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
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(F1–F7): the--eventmoment (the perceptual signature) is declared inmotion.signaturesand generated as CSS againstdata-event-*directly — without the TSCevent:*scope or thedata-motion-refthis section used to discuss. The engine (EngineMotion) is a service inarts/motion(uix.motion). The original body (the decision's historical context) lives inchangelog.md §13.
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. 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 "TSCevent:*scope" (§13) as its future. That is obsolete: the perceptual signature migrated into thesignaturesregistry (notevents.css) and the engine is a service inarts/motiontoday.motion.mdis 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:
/* 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(nottransform) to compose with the position, which travels viatranslate(distinct properties) → the lift never fights the 1:1 drag.- The
scalechange is transitioned (lift on grab / settle on release); during the move it stays static (composited, no per-frame cost). will-change: translate, scalefor 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(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
// ❌ 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
// ❌ DEPRECATED (2026-05-27)
'color-affirm-solid': 'var(--color-affirm-solid)'
// ✅ CORRECT
'affirm-solid': 'var(--color-affirm-solid)'
C. Inventing roles outside the canon
// ❌ 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
/* ❌ 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
// ❌ 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
// ❌ 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(E3).
References
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 inchangelog.md §22).motion.md— the motion system (the two-moment model, F1–F7; see §14).src/uix/eidos/lib/config-types.ts— the source of truth of the TSC type.src/uix/eidos/lib/render-css.ts— the generator (parsing, scope algebra, cross-axis detection).src/uix/eidos/lib/recipes/base.ts— the per-component token catalog.src/uix/eidos/lib/themes/base.ts— the base theme (primitives + semantics + themes).scripts/eidos-purge.ts— the purge tool.src/uix/eidos/recipe-css-contract.test.ts— the test guard.architecture/active-architecture.md— the full UIX context.guia-semantica-historica.md— the sema/perceptual doctrine (the historical seed of the 9-role canon;CANON.mdrules today).
18. Universal TSC coverage
Moved to
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,corporateeach theme invents). - Redefine an existing variant's visual cascade (
outlinemeans "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
-
Component portability.
<Toggle variant="outline">must render coherently in any theme. Theme-defined variants would break that silently — a component assumingoutlinewouldn't work in a theme that doesn't declare it. -
Type safety = part of the contract. Consumers need
SelectionVariant = 'solid' | 'outline' | 'ghost'for autocomplete and TS errors. An extensibleRecord<string, …>would lose that guarantee. Radix Themes 3.x, Chakra v3, Mantine — every serious reference keeps variants fixed per component. -
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. -
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:
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:
// components/toggle/types.ts
export type ToggleVariant = SelectionVariant;
// components/accordion/types.ts
export type AccordionVariant = ControlVariant;
For finer narrowing within an archetype:
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. (
ThemeDefinitionexposes norecipes.) - 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_KEYSis 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:
- Document the archetype with 1 paragraph describing the perceptual affordance (parallel to "solid = filled emphasis").
- Add it to
EIDOS_VARIANTSinlib/types.ts. - Export the derived type.
- Migrate the consuming components to reference it.
- 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. 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§Ncitations across the corpus and the code keep resolving here.
20. Theming-engine corrections (2026-06-01)
Chronicle in changelog.md §20. 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.
The standing model is §25 +
rfc-color-model.md.
22. Pending theming improvements
A historical, resolved backlog — chronicle in
changelog.md §22. 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. 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.
24. P2 engine corrections (2026-06-02)
Chronicle in changelog.md §24. 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. 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 +
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 + allPALETTE_SCALES, emitted ONCE (~19 KB). Role rows mirror the role tokens 1:1; scale rows use thePALETTE_SLOT_STEPmap + 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 declarespalette-{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 byactive-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
§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.
26. Runtime theme builder — eidos.applyColorScheme (2026-06-04)
Chronicle in changelog.md §26. 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 §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 — appearance is what a theme IS).
27. Wide-gamut OKLCH output (default-on) (2026-06-04)
Chronicle in changelog.md §27. 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. 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. 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. Demo:
/temas/profundidad.
30. Shape — continuity + families + nesting + eventful (2026-06-05)
Chronicle in changelog.md §30. 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. Demo:
/temas/forma.
31. Structure (space · density · scale) — space as rhythm (2026-06-05)
Chronicle in changelog.md §31. 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.
Demo: /temas/estructura.
32. Focus ring — the outline model (2026-06-11 · canonized 2026-07-07)
Chronicle in changelog.md §32. 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)
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. 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. 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. 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. 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. 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 amin-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 amin-*floor would grow the VISUAL. Instead a transparent::beforegrows only the AREA tomax(100%, var(--touch-target))— the painted box/track keeps its size-variant visual. Being absolute it adds no layout (no neighbour-collision margin needed).radiokeeps 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. 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. 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-colorstays the solid base (it is whatforced-colorskeeps: the UA strips every non-url()background-image, so the finish degrades to today's solid button for free). The finish is abackground-imagelayer. - The ramp derives from the instance's own palette slots — no per-color
catalog:
color-mix(in oklch, …)overpalette-solid/palette-solid-hover. It re-tints with roles, the 33 donor scales and light/dark automatically, and the INK stays the inheritedpalette-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
#000with the strong stop DOWN (the classic shaded CTA); dark-ink colors (light solids: amber/yellow/lime…) lift toward#fffwith the strong stop UP (glossy). Anchor + direction resolve per color × mode by the SAME flip thecontrastslot rides (--color-{role}-finish-anchor/-angleat 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(default26%), emitted fromprimitives.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 declaringgradientFinish.liftin itsThemeDefinition(re-emitted in the theme block — light/dark can run different intensities). - The
spreadkind (v1.5):gradient="spread"rotates hue ±--gradient-finish-spread(default30, UNITLESS — the relative-colorhchannel is a<number>;degcomputesnone) 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 ingradient-finish.md. - Named finishes (v2.2, D11):
gradientFinish.namedopts open-cage gradients in as finishes (gradient="aurora"), each with its REQUIRED authored ink (--gradient-{name}-ink, overriding the recipe's--_{c}-fgconvention); the base stays the identity's solid — alpha-blob materials need no trailing base and staybackground-image-valid by architecture. - The
Surfaceprimitive +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 insrc/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.
renderRecipeGradientFinishemits--_{c}-fill-finishunder[data-{c}][data-gradient]; the recipe paints it on its saturated fill ([data-variant='solid']) and re-asserts on:hover(the hover rule uses thebackground:shorthand, which resets the longhand). Inert on soft/outline/ghost — no saturated fill to finish, and no broken contract becausegradientnever claimed to be the identity. data-gradientis an eidos-only WRAPPER attr (thedata-variant/data-sizefamily), stamped by the eidos wrapper and NOT morfo-declared. Found live: a morfo-declared attr resolves from SOMA's prop space — the pure-visualgradientprop doesn't exist there, so the runtime emitsundefinedandmergeProps(restProps, state.props)clobbers the wrapper's stamp. Thedata-color-customprecedent (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;
initiative registry next-features.md §1. 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·11is 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, consumetext-strong, nottext.
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 semanticsubtle·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 singleoutline(§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.
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.