You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/architecture/eidos.md

1008 lines
41 KiB

---
title: Eidos — the visual layer
type: reference
audience: human + agent
authority: E1 architecture — the visual layer as a module: ActiveEidos runtime, wrappers, API conventions, picker patterns
status: current
source: migrated from src/uix/eidos/README.md (2026-07-02, docs-book F7.2)
---
# Eidos
`Eidos` is UIX's **visual layer**. It covers what the dead `air/` branch
called the "visual runtime" plus the token system — inheriting no code. It
reads from the DOM what the previous layers wrote (the morfo runtime + sema's
visual channel) and applies styles, animations and ergonomic wrappers.
```
Morfo declares the genetics
↓
Soma transcribes the behavior → DOM (data-state, data-color, aria-*)
↓
Sema emits perceptual signals → DOM (data-event-*) during the hold
↓
Eidos applies the visual: tokens, themes, recipes, archetypes, wrappers
```
Eidos never imports soma or sema internals. Its source of truth is **what is
written in the DOM** (parts, data-attrs, ARIA, archetypes, event signals)
plus the public Soma types it needs to compose wrappers.
## Not just CSS
The first mental model was "eidos = reactive CSS". Insufficient: there are
purely visual concerns (variant, size, layout flags, icon slots) that are not
part of the headless behavior yet are orthogonal to the CSS. Eidos hosts them
as **Svelte wrappers over Soma**.
```
src/uix/eidos/
active-eidos.svelte.ts → visual runtime/context and CSS generation
index.css → entrypoint importing the aggregated CSS
archetypes.css → rules common to [data-archetype=*]
events.css → global hints for [data-event-*] (sema visual)
generated/base.css → static output generated from the base EidosConfig (incl. @font-face)
lib/ → config support, recipes, CSS contract and shared types
components/{x}/ → per-component recipe + wrapper + types
{x}.css recipe CSS (selectors [data-{x}], etc.)
{x}.svelte Svelte wrapper over the Soma component
types.ts wrapper props (extends Soma's public contract)
index.ts public namespace (default + attached parts)
```
`lib/` contains the pure visual-configuration support: primitives, visual
semantics, themes, the CSS contract and rendering. `generated/base.css` is
the static artifact generated from that configuration
(`npm run generate:eidos-css`). The old `contracts/` and `themes/base/` CSS
were retired from the active tree: the public contract is obtained with
`ActiveEidos.getCssContract()` / `renderContractCss()` and the visual base
comes out of `generated/base.css`. The old `tokens/components/*` were retired
too: recipe custom-property names (`--toast-*`, `--dialog-*`, etc.) remain
the stable contract, but their base values live in `EidosConfig.recipes` and
are generated into `generated/base.css`.
## The active runtime
Eidos has one active class:
- `ActiveEidos` is the active runtime and the visual context of the Svelte
wrappers. It manages pure visual configuration, primitives, semantic roles,
themes, validation, CSS rendering and persistence. It connects that
configuration with `ActiveUix` services when there is a context: `prefs`,
`dom`, `langs`, `format` and visual helpers like `resolve(...)`,
`breakpoint(...)` or `isBelow(...)`. When `applyDom` is active, it
injects/removes `<style data-uix-eidos>` through `uix.dom`.
- `ActiveEidos.resolveToken(token)` / `resolveTokens(tokens)` resolve a theme
color token (`--scale-{name}-{step}` / `--primitive-{role}-{step}`) to a
concrete sRGB hex in **pure JS** — config + the `uix.color` engine, WITHOUT
touching the DOM. It replaces the `getComputedStyle(probe)` round-trip
consumers used to read a token's value (that read forces a reflow; this one
never touches the DOM). It honors an applied `applyColorScheme`
(override-first) and resolves theme-scoped scales. Returns `null` for
semantic slots / unresolvable tokens.
Dependency rule: a component in `src/uix/eidos/components/*` does not import
`getActiveUix()` directly. It consumes `ActiveEidos.require()` and only knows
Eidos's visual surface.
Ownership rule: `ActiveEidos` creates no shared services. In context mode,
`ActiveEidos.create(...)` obtains those services from `ActiveUix`; if a
wrapper requires one that doesn't exist, it throws. In explicit mode,
`createActiveEidos(...)` receives already-built services. With
`applyDom:false` it stays a DOM-writeless runtime: it can render, serialize
and validate CSS without inserting styles.
Important nuance: `prefs`, `langs` and `format` are optional at construction
because `ActiveEidos` also serves to generate/serialize CSS. The
`activeEidos.prefs` and `activeEidos.langs` getters DO fail early when a
visual wrapper reads them and they were not injected. `dom` is only mandatory
when `applyDom` is active.
Configuration lives in `EidosConfig`:
```ts
interface EidosConfig {
primitives: PrimitiveSet;
semantics: SemanticSet;
themes?: ThemeMap;
recipes?: RecipeTokenSet;
}
```
For incremental authoring over the base theme there is `EidosConfigPatch`. It
does not replace the whole object: it deep-extends it, preserving what is not
declared and replacing whole arrays (for example a typography family's
`fallbacks`).
```ts
const config = createThemeBaseEidosConfig({
primitives: {
typography: {
families: {
primary: { family: 'Inter' }
}
}
},
semantics: {
color: {
roles: {
primary: 'blue'
}
}
}
});
```
If the app wants to start from scratch, it uses `defineEidosConfig()` with a
full `EidosConfig` and passes it to `ActiveEidos.create({ config })`. To
start from the base theme, use `createThemeBaseEidosConfig(patch)` or
directly `ActiveEidos.create({ themeBase: patch })`.
`primitives` contains the non-semantic bases: 12-step color scales, the
canonical size map, spaces, control heights, radii, border, opacity, z-index,
focus ring, layout, typography, shadows, motion and icons.
`semantics.color.roles` maps those scales to the canonical roles:
```text
primary · secondary · tertiary · neutral · affirm · fulfill · risk · threat · loss
```
There is no `danger`, `success`, `warning` or `info`: those names belong to
other models. In Eidos, components speak in intents and visual hierarchy.
`recipes` contains each active recipe's public aliases. It defines no
selectors or component states: only values for the custom properties the CSS
recipes consume. For example, `recipes.tooltip['content-z']` generates
`--tooltip-content-z`; `recipes.dialog['overlay-bg']` generates
`--dialog-overlay-bg`. Components keep their CSS stable while the theme can
persist/edit those values from the same authoring object.
The recipe/CSS contract is validated in `recipe-css-contract.test.ts`: if a
recipe CSS consumes `--{component}-*`, that alias must exist in
`EidosConfig.recipes`; and if the base theme declares a public alias, it must
be consumed by the component or by another composed token. No phantom
reservations, no palettes the component can't activate from its real API.
For theme editors, `ActiveEidos.listRecipes()` enumerates the components with
recipe tokens and `ActiveEidos.getRecipeTokens(component)` returns a
defensive copy of the alias map. It does not mutate the internal config.
The standing decision is to keep `recipes` a flat alias map
(`RecipeTokenSet`). Promoting a recipe to its own structured type is only
justified when a real builder or consumer needs internal semantics; while
recipes remain plain CSS, the public contract is the generated custom
property.
ActiveEidos also generates each physical palette's alpha scale:
```text
--scale-blue-a1 … --scale-blue-a12
--primitive-primary-a1 … --primitive-primary-a12
```
If the theme declares `color.alphaScales`, those values are honored.
Otherwise they derive from the scale's solid step (`--scale-{name}-9`) with a
canonical opacity progression. That enables translucent inks for overlays,
rings, soft hovers or scrims without each recipe inventing its own formula.
### Canonical size
`Size` is discrete and stable:
```text
xxs · xs · sm · md · lg · xl · xxl · full
```
`full` is layout semantics and generates no physical primitive. The config
maps only `xxs..xxl` to coordinated tokens:
```text
--size-md-control-height
--size-md-font-size
--size-md-font-line-height
--size-md-font-letter-spacing
--size-md-icon-size
--size-md-padding-inline
--size-md-padding-block
--size-md-gap
--size-md-radius
```
The rule: `md` never changes meaning per viewport. Responsiveness decides
which active size is used (`ResponsiveProp<Size>`); it does not redefine
`md`'s tokens. Percentages, `vw`, `dvh`, `clamp()` or `min()` belong to
layouts like `full`, panels and containers — not to the canonical size of
controls, icons or typography.
### Transversal primitives
The static block also generates the shared primitives that usually appear in
Radix Themes, Ark/Panda, Chakra, Tailwind or shadcn as foundation tokens:
- `layout`: container widths, container inline padding, content widths and
canonical ratios. Generates `--container-width-*`,
`--container-padding-inline`, `--content-width-*` and `--aspect-ratio-*`.
- `density`: `compact · comfortable · spacious` scales so recipes can adjust
space, control height or content without redefining the canonical tokens.
Generates `--density-{key}-*` and active aliases like
`--density-space-scale`.
- `border`: widths `none · hairline · thin · medium · thick`, styles
`solid · dashed · dotted` and the aliases `--border-width`,
`--border-style`, `--border`.
- `opacity`: `0 · muted · disabled · scrim · overlay · hover · press · full`.
- `zIndex`: `base · raised · sticky · dropdown · popover · tooltip · modal · toast`.
- `shadow`: a physical `1..6` scale plus per-theme semantic aliases
`none · subtle · raised · overlay`.
The rule is the same as for size: these tokens never change meaning per
breakpoint. A component or wrapper may pick a different token at a given
viewport, but `--shadow-3`, `--opacity-disabled` or `--z-index-modal` remain
the same system coordinate.
The responsive bridge lives in `ActiveDom`/`ActiveEidos`: wrappers use
`ActiveEidos.resolve(...)`, `breakpoint(...)`, `isAtLeast(...)` and
`isBelow(...)` to decide which canonical token applies at each viewport.
`--container-width-md` or `--content-width-lg` are not recomputed
responsively; what changes is the token choice. Density follows the same
principle: `ActiveEidos` projects `data-density` and publishes active
scalars:
```css
:root {
--density-compact-space-scale: 0.84;
--density-comfortable-space-scale: 1;
--density-spacious-space-scale: 1.16;
--density-space-scale: var(--density-comfortable-space-scale);
}
[data-density='compact'] {
--density-space-scale: var(--density-compact-space-scale);
}
```
A recipe can use `calc(var(--space-4) * var(--density-space-scale))`.
`--space-4` never changes meaning; the active visual policy does.
`themes` allows overriding scales, roles, surfaces, content, borders, focus
and shadows per theme. The current base theme exposes `base-light` and
`base-dark`; `defaultActiveEidosThemeResolver` uses the active theme when it
exists as an exact id, preserves already-qualified external ids (`*-light`,
`*-dark`) and otherwise tries `${theme}-${mode}`.
`ActiveEidos` persists no CSS to disk. With `applyDom` active it writes to
the DOM:
- `${styleId}-static` with the stable primitives (`renderStaticCss()`).
- `${styleId}-theme` with the resolved theme when it comes from the config.
`themeSource` decides where theme values come from:
- `'auto'` (default): generates CSS when the config knows the theme;
otherwise it assumes external CSS and leaves only the static tokens.
- `'config'`: strict mode; if the resolved theme is not in the config,
`renderThemeCss()` throws.
- `'css'`: never generates theme CSS; the integrator provides the values via
external CSS honoring the custom-property contract.
To inspect or publish that contract, `ActiveEidos` exposes:
- `getCssContract()` returns the structured, typed contract with each token
(`name`, `cssVar`, `scope`, `category`, `path`). The surface meant for
theme editors, validators, tooling or user persistence.
- `renderContractCss()` renders that same contract as empty CSS to document
or bootstrap external themes.
- `renderCssVariables()` accepts a custom-property map and turns it into
runtime CSS validated against Eidos's contract.
```ts
const tokens = eidos.getCssContract();
const recipes = eidos.listRecipes();
const tooltipTokens = eidos.getRecipeTokens('tooltip');
const contract = eidos.renderContractCss({
staticSelector: ':root',
themeSelector: "[data-theme='acme-light']"
});
```
The result assigns no real values; it declares the custom properties a
CSS-only theme can supply or override:
```css
[data-theme='acme-light'] {
--scale-blue-9: ;
--scale-blue-a9: ;
--primitive-primary-9: ;
--primitive-primary-a9: ;
--color-primary-solid: ;
--size-md-control-height: ;
--border-width-thin: ;
--opacity-disabled: ;
--z-index-modal: ;
--shadow-3: ;
}
```
Persistence uses a versioned envelope:
```ts
const document = eidos.toDocument();
const json = eidos.serialize();
const hydrated = createActiveEidos({
config: JSON.parse(json),
prefs,
dom
});
```
The document has the shape `{ kind: 'uix.eidos-config', version: 1, options }`.
`EidosConfig` carries no `version` inside: it remains pure visual
configuration. If the shape ever changes, `EIDOS_CONFIG_DOCUMENT_VERSION`
gets bumped; Eidos attempts no silent compatibility with unsupported
versions. The app decides where to store that document (prefs, backend, a
file, etc.) and which product metadata wraps it (`name`, owner, timestamps).
When hydrating a document as a runtime (`config: document`,
`readEidosConfigFromDocument(...)` or `parseEidosConfigFromJson(...)`), Eidos
also validates the `options` against the full `EidosConfig` contract.
`parseEidosConfigDocument(...)` remains the envelope parser: it confirms
`kind/version/options` but does not turn that envelope into usable visual
configuration by itself.
Normal integration inside an `ActiveUix` tree:
```ts
const uix = createActiveUix({
langs: { schema, defaultLocale: 'es' }
});
setActiveUix(uix);
ActiveEidos.create({
themeBase: {
semantics: {
color: {
roles: { primary: 'blue' }
}
}
},
themeSource: 'auto',
themeResolver,
styleId: 'uix-eidos'
});
```
`config` accepts a full `EidosConfig` or an `EidosConfigDocument`.
`themeBase` accepts only a patch over the base theme. They are mutually
exclusive so there is no ambiguity between "full config" and "base
override".
`theme` in Eidos names the visual family/theme (`base`, `acme`,
`acme-light`, etc.). `mode` names the effective `light | dark` scheme and
`density` names the active visual ergonomics. When `applyDom` is active,
`ActiveEidos` projects `data-theme`, `data-mode` and `data-density` onto the
document through `ActiveDom`.
`mode` and `density` are injected as visual sources, not as `prefs`
dimensions:
```ts
const eidos = ActiveEidos.create({
theme: 'base',
modeSource: {
get: () => colorMode,
onChange: (handler) => subscribeColorMode(handler)
},
densitySource,
applyDom: true
});
```
Without a `modeSource`, `ActiveEidos` uses `prefers-color-scheme` with a
`light` fallback. Without a `densitySource`, it uses `comfortable`. Never
read or write `uix.prefs.theme` for UIX: the visual mode belongs to Eidos.
A CSS-only theme can live outside TypeScript:
```css
[data-theme='acme-light'] {
--scale-blue-9: #006adc;
--scale-blue-a9: color-mix(in srgb, var(--scale-blue-9) 56%, transparent);
--primitive-primary-9: var(--scale-blue-9);
--primitive-primary-a9: var(--scale-blue-a9);
--color-primary-solid: var(--primitive-primary-9);
--font-family-primary: Inter, system-ui, sans-serif;
--size-md-control-height: 38px;
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12);
}
```
To pass values from runtime without recompiling:
```ts
activeEidos.setCssVariables({
'--color-primary-solid': 'rebeccapurple',
'size-md-control-height': '40px',
'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
});
```
`ActiveEidos` writes it into `${styleId}-variables` as a managed `<style>`.
By default it validates the names against `getCssContract()`; an app that
needs local variables outside the contract can pass `{ strict: false }`. The
update is transactional: it first renders and validates the next block, and
only replaces the runtime state when the map is usable. An unknown token
never leaves the previous style block half-applied.
Not creating `ActiveEidos` turns off Eidos's runtime visual layer. Soma
components keep working headless because behavior belongs to Soma.
## What it consumes
### From morfo (declaration)
| Piece | Eidos uses it for |
| -------------------------------------------------------------- | ----------------------------------------------------------- |
| `parts[].kebab` | `[data-{component}-{kebab}]` selectors |
| `parts[].archetype` | transversal rules `[data-archetype=trigger]` |
| `parts[].states` + `data[].values` | variants `[data-state=open]` |
| `parts[].data` with `data-starting-style` / `data-ending-style` | enter/exit animation hooks |
| `events[].name` | selectors `[data-event=dismiss]`, `[data-event^=commit]` |
| `events[].semantic.family` + `.intent` | semantic tinting of transitions |
| `events[].prewrite[]` (e.g. `data-last-action`) | tinting the exit anim by cause |
| `focus.trap` | a layout hint for overlays |
### From Soma
The `components/*` wrappers import Soma's public parts directly
(`$soma/components/{x}`) and only add Eidos's visual surface: tokens,
recipes, layout shells and presentation data-attrs. There is no per-component
intermediate façade. Example:
```ts
// eidos/components/toggle/types.ts
import type { ProviderProps } from '$soma/components/toggle';
export type ToggleProps = ProviderProps & { variant?: ToggleVariant; size?: ToggleSize; … };
```
The `.svelte` wrapper imports Soma's public namespace and uses its parts with
that same namespace:
```svelte
import * as Select from '$soma/components/select';
<Select.Provider {...rest}>
<Select.Trigger />
<Select.Content />
</Select.Provider>
```
Do not use aliases like `Parts`, `SelectBase` or `SomaSelectProvider`. Do not
use loose `<Provider>` / `<Trigger>` tags inside Eidos. The wrapper adds the
visual token data-attrs (`data-variant`, `data-size`, `data-block`,
`data-icon-only`) and does not reimplement state.
### From sema (DOM)
Only the DOM. The visual channel projects `data-event-*` during the hold via
`SignalProjector` and eidos reacts through the generated signatures:
```css
[data-event-family='commit'][data-event-phase='active'] {
animation: eidos-commit-settle 260ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}
```
The per-family/intent signatures live in `EidosConfig.motion` and are
generated into `generated/base.css`; `events.css` keeps only the global
compositor hint + the reduced-motion cap (see
[`eidos-motion.md`](../theming/motion.md) §15).
## What it does NOT consume
- **The provider's logical computed state** (e.g. the composition of a
component's own `isDisabled` with a Field's inherited `disabled`). Eidos
only sees the result: `[data-disabled]` is there or it isn't.
- **Runtime internals**. It doesn't know whether an attr was written by
`dom.apply`, Svelte render or the provider manually. It only cares that it
is there.
- **Soma layers** (Presence, Dismissal, ScrollLock, FocusScope). It reacts to
their visible effects, not to their existence.
## The `--*` token rule
Eidos owns the `--*` namespace in the visual layer. Reasons:
1. **Authorship clarity at debug time** — inspecting an element and seeing
`--toggle-bg` says it came from UIX's visual layer.
2. **Override discipline** — a consumer overriding `--color-primary-element`
knows they are touching the visual contract, not name-colliding with a
local variable.
The upper layers (sema, soma, morfo) **do NOT consume** these tokens and do
not use the prefix. Each carries its own concerns (perceptual durations,
behavior, contract DNA) orthogonal to visual rendering.
## Theming, tokens, bundle — see `THEMING.md`
The full theming-system doctrine (token scope contract, token layers, naming
conventions, bundle purge, motion tokens, comparison with references,
anti-patterns, FAQ) lives in
[`THEMING.md`](../theming/reference.md). That is the canonical
reference.
The **transversal systems every recipe must consume** (state-layer,
per-archetype focus, tokenized elevation, 1:1 typography, opacity, the motion
channel, logical axes) are their own canon in
[`canon/recipe-contract.md`](../canon/recipe-contract.md), enforced by
the R-4.x rules of `scripts/component-audit.ts`.
## Motion — guide in `MOTION_GUIDE.md`, model in `eidos-motion.md`
- **How to animate (the task-oriented front door)** →
[`theming/motion-guide.md`](../theming/motion-guide.md): the `motion`
prop, the 3 usage domains (event/state/**content**), the preset catalog,
loops (`spin`/`pulse`/…), state-domain (`<Card>`), staggered cascade,
reduced-motion, theming and debug.
- **The engine's model and architecture** (two moments `--event`/`--state`,
`keyframes` + `signatures` + `presets`, CSS generation, JS drivers,
productive/expressive sets, typegen) →
[`eidos-motion.md`](../theming/motion.md). The **engine**
(`EngineMotion`) is a service in `arts/motion` (`uix.motion`); Eidos
delegates via `eidos.motion` and registers its `css` presets there.
- **Decisions and history** (incl. the "coordinated" engine retired in
Plan A) →
[`MOTION_SERVICE_RFC.md`](../../src/uix/eidos/MOTION_SERVICE_RFC.md).
Quick map of what THEMING.md covers, to avoid duplicating here:
| Topic | Section in THEMING.md |
| --- | --- |
| Why theming lives in Eidos and not in Morfo | §1.bis |
| 7 token layers and override points | §3 |
| The 9 canonical color roles | §4 |
| The canonical sizes | §5 |
| Naming conventions | §6 |
| Token Scope Contract (TSC): type, scope algebra, cross-axis | §7 |
| TSC v2.2: multi-part scope (`parts: [...]`) + cross-recipe composition | §7 |
| How to add a new component | §8 |
| How to define a theme | §9 |
| How to override tokens at runtime | §10 |
| Bundle strategy + `eidos:purge` | §11 |
| Sema integration via `event:*` (superseded — see the stub) | §13 |
| Anti-patterns and FAQ | §16, §17 |
| Universal TSC coverage (no exceptions) | §18 |
| Variants are eidos canon, NOT the theme's (with `EIDOS_VARIANTS`) | §19 |
| Engine corrections: live density + on-solid `contrast` | §20 |
| The `scaling` axis (global zoom), separate from density | §23 |
| P2 corrections: on-solid text by luminance + translucent surfaces | §24 |
| Color model: palette (33 scales) + roles (aliases) + intents (auto-derived) | §25 |
| Runtime theme builder: `eidos.applyColorScheme(seed)` (`uix.color` engine) | §26 |
| Wide-gamut OKLCH output, default-on (hex fallback + `oklch()` sibling) | §27 |
| a11y forced-colors (focus outline fallback) + border ramp (slot 6→7) | §28 |
| Scale canon — the theming audit: blur · inset-shadow/ring · gradients · breakpoints+container · opacity · border-width · tracking | §35 |
| Focus ring: two parameterized rings (fields, box-shadow) + `outline` on surfaces | §32 |
| Touch-target — 44px on touch, gated by `pointer: coarse` | §37 |
| State layer `--state-*` — unified neutral feedback (MD3, theme-adaptive) | §38 |
What follows in this chapter are the operational decisions of the **visual
layer as a module** (typography sourcing, picker patterns, API conventions,
the active runtime), independent of the theming system.
## Typographic vertebration — single source of truth in the foundation
Eidos has **two typographic anchors, in different layers, by design**. This
section documents why and how they relate.
### The two layers
```
src/uix/eidos/lib/primitives/typography.ts
│
├── families / sizes / weights (numerical scale)
│ ↓
│ foundation tokens
│ --font-family-{primary,secondary,display,mono}
│ --font-size-{xxs..xxxl}
│ --font-weight-{regular,medium,semibold,bold}
│ --font-line-height-{xxs..xxxl}
│
└── styles (semantic layer)
↓
named-style tokens
--style-{hero,h1..h6,body,prose,label,caption,code}-{font-family,
font-size,line-height,letter-spacing,font-weight,color}
```
| Layer | Who consumes it | For what |
|---|---|---|
| **Numerical foundation** (`--font-size-*`, `--font-family-primary`, …) | recipe tokens in `lib/recipes/base.ts` + foundation aliases (`--font-ui`, `--leading-ui`, …) | Component internals (Field labels, Combobox triggers, Button text, …) — they need **t-shirt scaling** (`xs/sm/md/lg/xl`) that does NOT map cleanly to a fixed semantic. |
| **Named styles** (`--style-label-*`, `--style-body-*`, `--style-caption-*`, `--style-h{1..6}-*`, `--style-{hero,prose,code}-*`) | typography primitives (`<Text>`, `<Heading>`, `<Display>`, `<Code>`, `<Link>`, …) | The user-facing API to compose content — the USER picked "label" or "body" and wants that semantic role honored. |
**The two layers are not redundant**: they serve different contexts. The
numerical one vertebrates the system's _interior_; the semantic one
vertebrates the _surface_ the consumer composes.
### How they vertebrate without duplication — the alias chain
Where a value coincides between the two layers, **the foundation alias reads
from the named style, not the other way around**. Single source of truth: the
named style.
```css
/* generated/base.css (via render-css.ts) */
:root {
/* Named style — source of truth */
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
/* Foundation alias — vertebrates the recipes */
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
}
/* recipes/base.ts → generated/base.css */
:root {
--field-label-line-height: var(--leading-ui);
--field-control-line-height: var(--leading-ui);
/* … dozens more recipe tokens */
}
/* components/field/field.css */
[data-field-label] {
line-height: var(--field-label-line-height);
}
```
Changing `STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3'` (in
`primitives/typography.ts`) propagates to `--style-label-line-height` →
`--leading-ui` → every recipe → every component. **One edit** reaches Field,
Form, Combobox, Select, Toolbar, Toast and the typography primitives
simultaneously.
The `, 1.25` / `, var(--font-family-primary)` fallbacks guarantee the system
keeps producing valid CSS if a consumer turns the named styles off in their
foundation override.
### Why component recipes do NOT read `--style-{name}-*` directly
A recurring temptation: "every component should read
`--style-label-font-size` for coherence". **That is not the way.**
1. **T-shirts don't fit four buckets.** A Field with `size="xs"` has a
smaller label than the "canonical label". If its recipe read
`--style-label-font-size`, you'd lose that scaling or you'd need
`--style-label-{xs,sm,md,lg,xl}-*`, replicating what the recipe tokens
already are.
2. **Per-component nuances are legitimate.** Field's message, Tooltip's
description and Card's subtitle are all "caption-ish" but each wants its
own color/weight/letter-spacing. Forcing them into one
`--style-caption-*` kills expressiveness.
3. **Coupling lock-in.** On day 100 the system wants `label-form`,
`label-table`, `label-chart`. Forcing the coupling on day 1 leads to
replicating the recipe hierarchy in the semantic layer.
4. **How the references do it.** Radix Themes, Mantine, MUI all have a
numerical scale components read; the semantic layer exists only for the
typography primitives (`<Text variant="body2">`). Chakra v3 offers a
coupleable `textStyle` but most of its components hardcode anyway.
**Coupling everything to the semantic layer is not the dominant
practice** — for good reasons (1–3).
### Audit: rule `R-2.7`
`scripts/component-audit.ts` detects typographic literals in component CSS:
`font-size: 12px`, `line-height: 1.4`, `font-weight: 500`,
`letter-spacing: 0.02em` not wrapped in `var()`. Severity `warn`, not
`error` — the component still passes, but it stays visible in the report.
**Escape valves** (they don't count as drift):
- values in `var(...)`
- zeros and identities: `0`, `0px`, `0em`, `0rem`, `1`
- keywords: `inherit`, `initial`, `unset`
- an inline comment: `font-size: 13px; /* literal: tight icon affordance */`
If you need a justified literal, annotate it. Otherwise, tokenize it.
## The "2-of-3" rule (inherited from morfo)
A morfo extension is justified when **at least two of the three layers**
(soma, sema, eidos) consume it. The ones that entered with eidos's vote:
- `archetype` — eidos + sema (+ soma as emitter)
- `events[].semantic.family/intent` — sema + eidos
- `events[].prewrite[]` — soma (executes) + eidos (animates)
- `data-starting-style` / `data-ending-style` — soma (Presence) + eidos (animates)
## API conventions — disciplined option C
The doctrinal API conventions were seeded in
[`GUIA_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md)
§13 (historical; instantaneous operation = one event; the unified token
system; intent ↔ color resolution; per-component subset; iconOnly sr-only;
sound prepare-time priming).
The **public shape** convention of an eidos component lives in
[`components/README.md`](../../src/uix/eidos/components/README.md). Summary —
7 hard rules:
1. **One entry point per component** — the default export is the visual root
component, named like the component (`<Drawer>`, `<Tabs>`, `<Checkbox>`).
NOT `<Drawer.Provider>`, NOT `<Drawer.Root>`.
2. **The root lives in `{name}.svelte`** — NOT in `{name}-provider.svelte`.
One file per root.
3. **Do NOT export `Provider` publicly.** The "Provider compound vs flat"
separation is a retired invention; there is ONE compound shape (root +
children attached as properties).
4. **Children follow the air / headless naming**: `Trigger`, `Content`,
`Overlay`, `Title`, `Description`, `Close`, `Portal`, `Header`, `Footer`,
`Item`, `Indicator`, `HiddenInput`, `Group`, `Label`. Don't invent names.
5. **`Portal` is included where air had it** (Dialog, Drawer, Popover,
Tooltip — portaled overlays). Imported from `$soma/components/internal`.
6. **NO flat API with snippet slots as the main shape** —
`<Drawer trigger={...} title={...}>` is retired. The explicit compound
exposes the composition decisions the flat shape hid.
7. **Children attached with explicit assignment**, not `Object.assign`
(Svelte 5 can mishandle bulk mutation of the component constructor during
hydration):
```ts
const Drawer = DrawerRoot as DrawerNamespace;
Drawer.Trigger = Trigger;
Drawer.Content = Content;
```
Plus implementation rules:
8. **Wrapper, not fork.** The eidos `.svelte` imports Soma's public namespace
(`import * as Drawer from '$soma/components/drawer'`), uses
`<Drawer.Provider>` / `<Drawer.Trigger>` internally and adds the visual
token data-attrs. It does not reimplement state.
9. **`Size` from `lib/types.ts`.** Components accepting sizes reuse the
shared type and narrow to the subset their recipe supports
(`Extract<Size, 'sm' | 'md' | 'lg'>`).
10. **No `Eidos` prefix** on types. The path `$uix/eidos/components/{x}`
already identifies the layer.
11. **Recipe CSS without an `--eidos-` prefix.** Custom properties use
`--{component}-…` for public tokens and `--_{component}-…` for internal
ones.
### Why disciplined option C
The previous pattern (a flat default with snippet slots + a duplicated
Provider compound) had two problems:
- **Double truth**: two APIs (flat with `trigger`/`title`/`actions` snippets,
compound with explicit children) reached the same functionality by
divergent paths. Any visual tweak had to update both.
- **It invented over air**: air was pure compound (`<Drawer.Provider>` with
children). The snippet-flat was an invention with no baseline and no user
sign-off.
Disciplined option C:
- Takes from **air** the children convention (Trigger, Content, Portal, …)
- Takes from **bits-ui / shadcn-svelte** the ergonomics of the root with
attached properties (`<Drawer><Drawer.Trigger>`)
- Removes the snippet-flat invention (it was neither air nor soma)
### Special case — Toast
It has two independent roots (not nested):
- `<Toast>` — the manual compound (the consumer iterates `toaster.toasts`)
- `<Toaster />` — the imperative auto-mount (the default template)
Separate exports; `Toaster` is NOT attached as `Toast.Toaster` because it is
a competing root, not a child. Documented in
[`components/toast/index.ts`](../../src/uix/eidos/components/toast/index.ts).
## Selector-drift defense
The morfo → soma → eidos chain depends on the selectors eidos writes
(`[data-{component}]`, `[data-{component}-{part}]`, `[data-state=...]`,
`[data-event-*=...]`) staying in tune with the attrs the morfo declares and
the runtime emits. There are two distinct defenses by consumer type:
### Compile-time (TS / Svelte) — the typed builder
Any TypeScript consumer building selectors **MUST** use
`semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. This covers
the cascade rules in `sema/components/*.ts` and any TypeScript logic in
`eidos/components/{x}/` targeting morfo-backed attrs.
```ts
import { semaSelector } from '$uix/morfo';
import { toggleMorfo } from '$uix/morfo/components/toggle';
semaSelector(toggleMorfo, 'provider', { eventName: 'commit-toggle' });
// → '[data-toggle][data-event="commit-toggle"]'
```
Renaming a part or an event in the morfo breaks the typecheck. A TypeScript
selector cannot drift silently. See
[`architecture/morfo.md#typed-selector-builder--semaselector`](./morfo.md#typed-selector-builder--semaselector).
### Run-time (CSS recipes) — `eidos-lint` as an opt-in safety net
Recipes are plain CSS (`components/{x}/{x}.css`); there is no typed builder
on the CSS side. For that surface:
```bash
node scripts/eidos-lint.ts toggle # one component
node scripts/eidos-lint-all.ts # all of them
```
It classifies every `[data-*]` selector as:
- **morfo-backed** — declared in the morfo; the soma runtime emits it; the
value (when there is an enum) falls inside `data[].values`.
- **eidos-only** — the marker is there, but at least one `data-*` is not
declared in the morfo. Valid by convention (visual tokens like
`data-variant`, `data-size` come from the wrapper).
- **invalid** — references a declared attr with a value outside the enum. A
bug.
**The lint is a safety net, not the contract.** The contract lives in the
morfo and is defended at the type level where possible. The lint exists only
for the pure-CSS portion that doesn't yet consume the morfo through
TypeScript. When recipes migrate to a builder, the lint can retire.
## Picker patterns (the reusable contract)
The pickers (`date-picker`, `date-range-picker`, `time-picker`,
`time-range-picker`, `color-picker`) share a common contract, documented here
as the canonical reference — any new picker builds on this skeleton. Two
standing decisions frame it: **composition wins** (there are no
`MonthPicker` / `YearPicker` components — those forms are
`<DatePicker kind='month'>` etc.) and the **footer is pure composition**
(no `clearButton`/`closeButton` root props; each Footer part renders whenever
mounted — presence = visibility, norm N-7).
<!-- TODO(reconcile): norms N-6/N-7 were documented in src/uix/PENDIENTES.md,
deleted in d68d2c45 — their prose needs a new home (user decision). -->
### P-1 · Provider helpers: `commit() / cancel() / clear()`
Every `*PickerProvider` exposes three imperative methods consumed by the
Footer parts:
- **`commit()`** — closes the popover preserving `value.current` as-is. The
normal confirmation of the selected value.
- **`cancel()`** — reverts `value.current` to the snapshot captured at the
**OPEN edge** and closes the popover. The snapshot is taken via
`watch(opts.open)` when `open` transitions `false → true`:
```ts
private valueOnOpen: TValue | undefined = undefined;
constructor(...) {
watch(() => this.opts.open.current, (isOpen) => {
if (isOpen) this.valueOnOpen = $state.snapshot(this.opts.value.current);
});
}
cancel() {
this.opts.value.current = this.valueOnOpen;
this.opts.open.current = false;
}
```
- **`clear()`** — sets `value.current = undefined`. It does NOT close the
popover (it is a visible destructive action the user may want to keep
editing after). If the consumer wants close-after-clear, they compose
`<Picker.Close/>` in the same footer.
These three operations are orthogonal: each Footer part does exactly one.
Don't mix them (e.g. `clear` must not close; `cancel` must not clear).
### P-2 · `mode: 'inline' | 'modal'` → `popover.modal`
`mode` is a root opt of the provider propagated to the underlying
`Popover.modal`:
- **`mode='inline'`** (default) — non-modal popover. Outside-click closes.
Escape closes. Body scroll free.
- **`mode='modal'`** — modal popover. Outside-click does NOT close (the user
must use `<Picker.Close/>` or `<Picker.Cancel/>`). Escape still closes.
Focus trap inside the popover. Body scroll lock.
The consumer does NOT set `popover.modal` directly — that is the provider's
decision:
```svelte
<Popover.Provider modal={provider.opts.mode.current === 'modal'} ...>
```
Modal mode obliges the consumer to compose a Footer with `<Cancel/>` or
`<Close/>` to escape; without them the popover only closes via Escape. It
remains the consumer's responsibility (nothing is auto-injected).
### P-3 · Shell composition (Provider > Input > Portal > Content > view + Footer)
A picker's canonical layout:
```svelte
<X.Provider value={...} open={...} mode='modal' kind='date'>
{#snippet trigger()}
<X.Input /> {!-- field input with segments, button, etc. --}
{/snippet}
{#snippet content()}
<X.Content>
{#if kind === 'year'} <X.YearView />
{:else if kind === 'month'} <X.MonthView />
{:else} <X.Calendar />
{/if}
<X.Footer>
<X.Clear />
<X.Cancel />
<X.Close />
</X.Footer>
</X.Content>
{/snippet}
</X.Provider>
```
- **Trigger snippet** — the entry surface (input + segments, button,
swatch…).
- **Content** — the popover content; only `<X.Content>` may go here.
- **View** — `Calendar`, `YearView`, `MonthView`, `Clock`, `Picker` (color),
etc. The consumer branches structurally by `kind` or variant (P-4 below).
- **Footer** — `<X.Footer>` is an archetype='footer' container. The parts
(`Clear`, `Cancel`, `Close`, etc.) are composed inside. Each part renders
with no internal checks against provider props — presence = visibility
(N-7).
To omit the whole footer, don't compose `<X.Footer>`. For only `Close`,
compose only `<X.Close>` inside.
### P-4 · `kind` (or equivalent) as the single source of truth
When a picker has granularity variants (date: day/month/year; time:
hour/minute/second), the `kind` opt (or equivalent) is the **single
configuration point**. It drives:
1. **The input's segments** — filtered at the FieldProvider level (soma),
not in the consumer's snippet. The provider exposes a derivation like
`visiblePartsByKind: Set<PartName>` and `segmentContents` filters
`allSegmentContent.arr`, collapsing literal runs.
2. **The popover's view** — the consumer branches structurally on the opt
(`{#if kind === 'year'} <YearView/>` etc).
**There are no** per-variant components (`MonthPicker`, `HourPicker`, etc.).
Those forms are `<X kind='month'>`, `<X kind='hour'>`.
### P-5 · The range state machine (for `*-range-picker`)
For range selection, the provider keeps an implicit internal state (not
exposed as an opt):
- **empty** (`value === undefined` or `{start: undefined, end: undefined}`)
— the next click sets `start`. State moves to `pending`.
- **pending** (`start` set, `end` undefined) — the next click sets `end`. If
the new point < `start`, **automatic swap** (start ↔ end). State moves to
`complete`.
- **complete** (`start` and `end` set) — the next click restarts: sets
`start = click`, `end = undefined`. State moves to `pending`.
Each granularity normalizes the endpoints:
- **Year range** — start = Jan 1, end = Dec 31.
- **Month range** — start = day 1, end = the month's last day.
- **Day range** — start/end are the clicked date as-is.
- **Hour range** — start = `:00`, end = `:59:59`.
Implemented in `YearView`/`MonthView`/`Calendar` (range variant). The
provider only exposes `setValue(start, end)`; the state machine lives in the
views.
The dated hand-offs and snapshots that used to live here were extracted to
[`docs/process/handoffs-2026-05.md`](../process/handoffs-2026-05.md)
(timelessness rule of [`docs/authoring.md`](../authoring.md)). The live
wrapper inventory is the `components/` tree + `npm run component:audit` —
never a list in a doc.

Powered by TurnKey Linux.