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

1193 lines
52 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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 · thin · medium · thick · heavy` (linear 0/1/2/3/4
— `hairline` was pruned, `heavy` added; changelog §29/§35), styles
`solid · dashed · dotted` and the aliases `--border-width`,
`--border-style`, `--border` + `--ring-inset-width`.
- `opacity`: a dual scale — numeric plus semantic
`ghost · disabled · scrim · muted · overlay · subtle · press · hover ·
full` (changelog §29).
- `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.
**Breakpoint VISIBILITY is the one responsive axis that is not a token
choice.** `Box`'s `visibleFrom` / `hiddenFrom` do not pick a value — they
decide whether the element renders at all, and the whole family that composes
`Box` inherits them. It is deliberately not `display: none`: the subtree is
absent from the DOM, its effects never run and its media is never requested.
Every reference (Mantine, Chakra, Panda, and the block builders) solves this
with CSS instead, because with SSR a JS gate paints the wrong variant first;
this project ships as an SPA, so the gate is correct from the first paint. The
price it does pay — crossing a breakpoint destroys the subtree and its state —
is stated in the prop's own docs. Detail:
[`eidos/components/box/README`](../../src/uix/eidos/components/box/README.md).
`--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 consumes `var(--space-4)` directly — density × scaling arrive
**already composed** from the foundation (`--space-4` is emitted as
`calc(16px * var(--density-space-scale) * var(--scaling))`); multiplying by
`--density-*`/`--scaling` inside a recipe double-applies them and is
forbidden (recipe-contract R-2.3 — this paragraph used to show that
anti-pattern as an example).
`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: 2, 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. v2 (2026-09-13) made `ThemeDefinition.appearance` mandatory, so a
v1 document is rejected rather than migrated: nobody can guess whether a
theme written before the field was light or dark. 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`.
### Where the four preferences come from
ONE door answers, and the PINS are applied over what it answered.
The door, in this order:
1. an explicit `preferences: ActiveEidosPreferenceSource` — the app owns the
question outright;
2. **`uix.prefs`** — inside a UIX tree this is the default. `theme`, `mode`,
`density` and `scaling` are prefs dimensions (2026-09-14; see
[`src/arts/prefs/README.md`](../../src/arts/prefs/README.md) §"One engine"),
so eidos reads the slots and subscribes to them;
3. standalone eidos — no `uix`, no `prefs`, no `preferences`: `mode` follows
the OS **live** through `prefers-color-scheme` (fallback `light`) and the
other three stand on eidos' defaults (`base`, `comfortable`, `100`).
Without a first engine there is no second one to hand the question to;
bare CSS serialization, SSR renders and unit tests boot exactly here.
Then the pins. An explicit `theme` / `mode` / `density` / `scaling` **nails**
that axis on this instance and wins over the source, prefs included — a
preview panel, a hero that stays dark whatever the reader prefers. It is one
wrapper over whichever door answered, so the rule reads the same at all three:
`pin ?? source ?? fallback`. A nailed axis stays SUBSCRIBED: a preference
change still runs `apply()`, and `apply()` finds that axis unmoved.
```ts
// Inside a UIX tree, nothing pinned — the user's preference drives the page.
const eidos = ActiveEidos.create({ applyDom: true });
uix.prefs.setIntent('mode', 'dark'); // eidos re-applies data-mode + data-theme
// A NAILED instance: dark whatever prefs says. `density` still follows the user.
const preview = createActiveEidos({ uix, applyDom: true, mode: 'dark' });
// Standalone eidos — no uix at all.
const headless = createActiveEidos({ applyDom: false, theme: 'base', density: 'compact' });
```
Substituting the resolution WHOLE is `preferences` — the one door that
replaces the engine instead of nailing an axis of it. There are no per-axis
source options: `modeSource` / `densitySource` / `scalingSource` were the
second engine's API and left with it
([changelog §60](../theming/changelog.md)).
A pinned ROOT instance has to teach the same pins to the pre-hydration boot
(`renderUixBootScript({ pins })`), or the page paints the preference before
hydration and the pin after it.
Reading the system does not stop at boot. Inside a UIX tree the OS hints
(`prefers-color-scheme`, `prefers-reduced-motion`) seed the prefs environment at
construction and a `matchMedia` watcher keeps patching it, so a user flipping
their system theme mid-session still moves `data-mode`.
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=emerge-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 |
### 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.
Direction crosses this boundary as two different attributes: the native `dir`,
which is what `:dir()` matches, and the opt-in `data-dir`, which a component
stamps only where a recipe needs a hook that always matches. Which one a recipe
may read, and when a provider must stamp at all, is fixed in
[`canon/direction-contract.md`](../canon/direction-contract.md).
### 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);
}
[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] {
animation: shift-cross-forward var(--duration-slow) var(--ease-emphasized);
}
```
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).
The third rule is where the layer split earns its keep. `shift` **sounds** like
a slide (`SEMA_MAP.families.shift.sounds.default = 'slide'`) and until
2026-08-11 it did not slide, because sema owns no motion and eidos had written
FAMILY-keyed signatures for `contact`, `commit` and `delegate` only (`emerge`
and `signal` are covered, but keyed by EVENT name) — the *shift invisible*
antipattern (book ch. 34 §14) living inside the framework that names it. The
missing half was always eidos's to write: sema stamps the SENSE
(`data-event-direction`, decided per emit, `forward` | `backward`) and eidos
decides that a sense means the inline axis. The keyframes multiply their
distance by `--motion-shift-sign`, emitted `+1` under `:dir(ltr)` and `−1` under
`:dir(rtl)` — a physical `translateX` would have shipped a slide that runs
backwards in Arabic.
The stamped node is the event's **subject**, not a paint instruction. Morfo
puts the gesture where the hand is and the terminal where the value lives
([`architecture/morfo.md`](./morfo.md) §Where the stamp lands), so a recipe
routinely needs to paint something the stamp never touches. That is a
**descendant selector** — never a reason to move the stamp:
```css
/* Splitter commits on the provider; the handle is what pulses. */
[data-splitter][data-event='commit-set'][data-event-phase='active']
[data-splitter-resize-trigger] {
background: var(--splitter-active-handle-bg, var(--color-primary-solid));
}
```
The descent costs nothing in specificity terms: four attribute selectors
(0,4,0) against the (0,2,0) ceiling of every other rule on that handle, so the
signature wins without an `!important` or a manufactured hook. The diagnostic
question when a rule doesn't fire is always **is the stamped node the subject
of the event?** — if it is, the recipe descends; if it isn't, the morfo is
wrong. A stamp relocated to make a selector shorter breaks the sound and haptic
projections, which read the same target and have no CSS to compensate with.
## 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` (+ the semantic leading `--leading-ui`, config data) | 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 — recipes read the named style
Where a value coincides between the two layers, **the consumer reads the
named style directly, not the other way around**. Single source of truth: the
named style. (Until 2026-07-06 a `--font-ui` alias sat between the two — it
died with the token-alias purge, one name per concept; recipes now consume
`var(--style-label-font-family)` themselves, and validation requires
`styles.label.family` so the anchor always exists. `--leading-ui` is a
different animal: it is config DATA — `typography.semanticLeading.ui` — whose
authored value references the 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;
/* Semantic leading — config data anchored to the style */
--leading-ui: var(--style-label-line-height, 1.25);
}
/* recipes/base.ts → generated/base.css */
:root {
--accordion-trigger-font-family: var(--style-label-font-family);
--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-semantica-historica.md`](../decisions/guia-semantica-historica.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, plus a
third guard on the direction axis:
### 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 `data-event*` VALUE check (2026-08-11)
The classifier above allowlists `data-event`, `-family`, `-id`, `-intent`,
`-direction` and
`-phase` as eidos-only — they come from the sema stamp, not from a morfo part —
and therefore never looked at their **value**. That is how
`[data-event='commit-resize']` stayed in `splitter.css` after the event was
renamed to `commit-set` (bd2e40366, 2026-05-22): a hook to a name nobody emits,
dead for almost three months, with every test green.
`scripts/eidos-event-vocabulary.ts` closes it. Both linters now check that
every `data-event` value in a recipe is the `name` of an event declared by
**some** morfo in the catalogue (`^=` matches by prefix), that every
`data-event-family` is one of the 8 canon families, that every
`data-event-intent` is one of the 6 intents, and that every
`data-event-direction` is `forward` or `backward`.
The direction row nearly shipped as unchecked prose. `scripts/` is outside the
`tsconfig` graph — `svelte-check` never reads it — so a literal
`['forward', 'backward']` written in the linter would have been free to outlive
the vocabulary it guards, which is this section's own defect wearing a new hat.
`SemaDirection` is therefore derived from a const array (`SEMA_DIRECTIONS` in
`sema/types.ts`, the `INTENTS` pattern) and the linter imports it: the guard
iterates the same thing the compiler enforces.
The event-name check is catalogue-wide on purpose: composition means a node
receives another component's stamps (the card-group item also carries
`data-toggle-group-item` and receives `commit-block`, which toggle-group
declares). A name that exists elsewhere but not in the component's own morfo is
a **WARN**; only a name that exists nowhere is an **ERROR** (exit 1).
**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.
### Direction — `:dir(rtl)`, and what RTL-1 does not cover
A recipe branches on direction with `:dir(rtl)`. The descendant form
`[dir='rtl'] …` is forbidden; `[data-dir='rtl']` reads a different attribute
and is legitimate where a component stamps it. The reasons, and the rule for
when a provider must stamp at all, are in
[`canon/direction-contract.md`](../canon/direction-contract.md).
`RTL-1` (`src/uix/eidos/rtl-lint.ts`, run by `npm run rtl:check`) guards CSS
text, not the contract: it flags a logical inline anchor paired with a physical
inline translate inside one block. It does not check the selector form, which
attribute the provider stamps, or the resolution chain — those are held by
review.
### The `unused` column — doctrine (THM-4, 2026-07-11)
`eidos-lint-all` also reports **contract selectors with no CSS rule** (~1150
across the catalog). "Declared and never styled" is NOT an error category —
the morfo declares the component's whole BEHAVIORAL surface, not just its
paintable one — but it isn't noise either. The criterion:
- **Legitimate without a consumer** (the majority):
- *behavioral / a11y attrs* — state mirrors that exist for JS, tests,
assistive tech or app-land selectors (`data-state` on parts the recipe
styles via a parent, `aria-*` reflections);
- *composition artifacts* — a component whose visual lives in a SHARED
layer or in its composed children shows its own contract as "unused"
(css-field's 21 live in `spin-field.css`; collapsible is headless by
design and its consumers style it; picker roots restyle the composed
field/calendar contracts instead);
- *cross-component selectors* — entries like
`[data-popover-content] [data-year-grid]` are consumed from the
SIBLING's recipe, which the per-component report can't see.
- **Debt** (the minority worth burning): an attr that was declared FOR a
visual axis and that nothing anywhere consumes — no recipe, no shared
layer, no sibling, no behavioral reason. Disposition: consume it or prune
it from the morfo (never leave "declared for styling, styled nowhere").
Adjudication is dossier-work (each entry needs the morfo's intent), so it
runs as scoped batches over the hotspots — media-player (47), stepper (30),
color-picker (29), avatar (28), time-range-picker (26) — registered in the
clean-room continuation plan. The lint column stays severity-less until a
batch shows the debt rate justifies a rule.
## 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 — norms N-6/N-7, canonical HERE since 2026-07-11
(recovered from the deleted `src/uix/PENDIENTES.md`, `d68d2c45`):
- **N-6 · Picker `kind` = single source.** `kind: 'date' | 'month' | 'year'`
(Chakra-style) is the ONLY configuration point for granularity variants —
it drives both the input's segments (filtered in the soma provider; the
consumer renders `segments` as-is) and the opening view. There are no
`MonthPicker` / `YearPicker` components: those forms are
`<DatePicker kind='month'>` / `<DateRangePicker kind='year'>` etc.
- **N-7 · Composition over visibility props.** Optional parts (`Footer`,
`Clear`, `Cancel`, `Close`, …) expose NO boolean `*Button` root props.
Visibility is composition: compose `<X.Clear/>` inside `<X.Footer/>` and
it exists; omit it and it doesn't. Parts render whenever mounted — no
internal checks; presence = visibility.
### 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.