docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
---
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
docs(book): F7.3 (8/9) — eidos-motion translated to docs/theming/motion.md
src/uix/eidos/eidos-motion.md (698 L, Spanish) translated to English, same
s1-s19 numbering: the two-moment thesis, the closed cascade model
(D.11-D.13), motion across the 4 layers, the two-surface registry, types,
the EngineMotion API + cleanup policy, the 5 drivers, the DOM contract
(data-animation-style), Presence integration, reduced motion, primitives/
keyframes, built-in content, per-component defaults, code map, s15 (the
events.css -> signatures migration — cited from events.css), Chakra
comparison, naming, phases F1-F7, deferred. Stub with the full s-map at
the old path; corpus links swept (comparison, eidos chapter, changelog,
motion-guide). docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
[`eidos-motion.md` ](../theming/motion.md ) §15).
docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
## 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
docs(book): F7.3 (9/9) — THEMING reference translated; theming/ + canon/ batch COMPLETE
src/uix/eidos/THEMING.md (1499 L, Spanish) translated to English as
docs/theming/reference.md, same s1-s38 numbering: the mental model,
s1.bis (theming lives in eidos — the 2-of-3 derivation and the canonical
split), the CSS layers, the 7 token layers, the 9 roles, the size canon
(universal 1:1 + label step-down + container cap + the icon scale), the
naming conventions, the s7/s8/s9/s13/s15/s17/s18 stubs repointed into the
book (canon/tsc, theming/guide, theming/notes, theming/changelog,
theming/motion), runtime overrides + builders, bundle/purge, validation
tooling, s14 motion (the pickup lift), s16 anti-patterns, s19 variants
canon, and the s20-s38 standing-decision stubs. One internal
reconciliation applied: the s35 stub cited the control/compact/dense size
archetypes that s5 declares superseded by the universal 1:1 — aligned.
One stale v1 DEMO_AUTHORING s12.8 citation in s5 replaced by the v2 s6
parity rule. Stub at the old path carries the full s1-s38 map (the most
sN-cited doc in the repo: code comments, CLAUDE.md, RFCs). Corpus links
swept (CANON, docs map, comparison, glossary, eidos chapter, canon/tsc,
theming/guide + notes). docs:check 0 errors (260 docs).
F7.3 complete: docs/canon/ (tsc, recipe-contract) + docs/theming/
(reference, guide, notes, channels, motion, motion-guide, changelog).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
[`THEMING.md` ](../theming/reference.md ). That is the canonical
docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
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
docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
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)** →
docs(book): F7.3 (2/3) — theming/ satellites: guide + notes + channels + motion-guide + changelog
Five theming docs into the book tree:
- docs/theming/guide.md — THEMING_GUIDE (241 L, Spanish -> English): add a
component's recipe step-by-step + the three theme-definition modes.
- docs/theming/notes.md — THEMING_NOTES (168 L, Spanish -> English): the
bundle/feature comparison + the controversial-decisions FAQ (in-page
anchor to s1.bis fixed to a real THEMING link).
- docs/theming/channels.md — CHANNELS_SYNTHESIS (115 L, Spanish ->
English): the per-channel-RFC capstone (two moments, 8 expression vs 3
runtime channels, the builder sextet + applyTheme).
- docs/theming/motion-guide.md — MOTION_GUIDE (213 L, already English):
moved with frontmatter + links repointed.
- docs/theming/changelog.md — THEMING_CHANGELOG (1310 L): chronicle,
moved VERBATIM (recorded history is not translated), links repointed.
Thin stubs at all five old paths; corpus links swept (docs map E3/E4
rows, building-a-component phase 5, decisions umbrella, getting-started,
eidos chapter). docs:check 0 errors (258 docs).
Remaining in F7.3: THEMING.md itself (1499 L) + eidos-motion.md (698 L).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
[`theming/motion-guide.md` ](../theming/motion-guide.md ): the `motion`
docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
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) →
docs(book): F7.3 (8/9) — eidos-motion translated to docs/theming/motion.md
src/uix/eidos/eidos-motion.md (698 L, Spanish) translated to English, same
s1-s19 numbering: the two-moment thesis, the closed cascade model
(D.11-D.13), motion across the 4 layers, the two-surface registry, types,
the EngineMotion API + cleanup policy, the 5 drivers, the DOM contract
(data-animation-style), Presence integration, reduced motion, primitives/
keyframes, built-in content, per-component defaults, code map, s15 (the
events.css -> signatures migration — cited from events.css), Chakra
comparison, naming, phases F1-F7, deferred. Stub with the full s-map at
the old path; corpus links swept (comparison, eidos chapter, changelog,
motion-guide). docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
[`eidos-motion.md` ](../theming/motion.md ). The **engine**
docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
(`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.