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

49 KiB

title: Eidos — the visual layer type: reference audience: human + agent authority: E1 architecture — the visual layer as a module: ActiveEidos runtime, wrappers, API conventions, picker patterns status: current source: migrated from src/uix/eidos/README.md (2026-07-02, docs-book F7.2)

Eidos

Eidos is UIX's visual layer. It covers what the dead air/ branch called the "visual runtime" plus the token system — inheriting no code. It reads from the DOM what the previous layers wrote (the morfo runtime + sema's visual channel) and applies styles, animations and ergonomic wrappers.

Morfo declares the genetics
  ↓
Soma transcribes the behavior → DOM (data-state, data-color, aria-*)
  ↓
Sema emits perceptual signals → DOM (data-event-*) during the hold
  ↓
Eidos applies the visual: tokens, themes, recipes, archetypes, wrappers

Eidos never imports soma or sema internals. Its source of truth is what is written in the DOM (parts, data-attrs, ARIA, archetypes, event signals) plus the public Soma types it needs to compose wrappers.

Not just CSS

The first mental model was "eidos = reactive CSS". Insufficient: there are purely visual concerns (variant, size, layout flags, icon slots) that are not part of the headless behavior yet are orthogonal to the CSS. Eidos hosts them as Svelte wrappers over Soma.

src/uix/eidos/
  active-eidos.svelte.ts      → visual runtime/context and CSS generation
  index.css                  → entrypoint importing the aggregated CSS
  archetypes.css             → rules common to [data-archetype=*]
  events.css                 → global hints for [data-event-*] (sema visual)
  generated/base.css         → static output generated from the base EidosConfig (incl. @font-face)
  lib/                       → config support, recipes, CSS contract and shared types
  components/{x}/            → per-component recipe + wrapper + types
    {x}.css                    recipe CSS (selectors [data-{x}], etc.)
    {x}.svelte                 Svelte wrapper over the Soma component
    types.ts                   wrapper props (extends Soma's public contract)
    index.ts                   public namespace (default + attached parts)

lib/ contains the pure visual-configuration support: primitives, visual semantics, themes, the CSS contract and rendering. generated/base.css is the static artifact generated from that configuration (npm run generate:eidos-css). The old contracts/ and themes/base/ CSS were retired from the active tree: the public contract is obtained with ActiveEidos.getCssContract() / renderContractCss() and the visual base comes out of generated/base.css. The old tokens/components/* were retired too: recipe custom-property names (--toast-*, --dialog-*, etc.) remain the stable contract, but their base values live in EidosConfig.recipes and are generated into generated/base.css.

The active runtime

Eidos has one active class:

  • ActiveEidos is the active runtime and the visual context of the Svelte wrappers. It manages pure visual configuration, primitives, semantic roles, themes, validation, CSS rendering and persistence. It connects that configuration with ActiveUix services when there is a context: prefs, dom, langs, format and visual helpers like resolve(...), breakpoint(...) or isBelow(...). When applyDom is active, it injects/removes <style data-uix-eidos> through uix.dom.
  • ActiveEidos.resolveToken(token) / resolveTokens(tokens) resolve a theme color token (--scale-{name}-{step} / --primitive-{role}-{step}) to a concrete sRGB hex in pure JS — config + the uix.color engine, WITHOUT touching the DOM. It replaces the getComputedStyle(probe) round-trip consumers used to read a token's value (that read forces a reflow; this one never touches the DOM). It honors an applied applyColorScheme (override-first) and resolves theme-scoped scales. Returns null for semantic slots / unresolvable tokens.

Dependency rule: a component in src/uix/eidos/components/* does not import getActiveUix() directly. It consumes ActiveEidos.require() and only knows Eidos's visual surface.

Ownership rule: ActiveEidos creates no shared services. In context mode, ActiveEidos.create(...) obtains those services from ActiveUix; if a wrapper requires one that doesn't exist, it throws. In explicit mode, createActiveEidos(...) receives already-built services. With applyDom:false it stays a DOM-writeless runtime: it can render, serialize and validate CSS without inserting styles.

Important nuance: prefs, langs and format are optional at construction because ActiveEidos also serves to generate/serialize CSS. The activeEidos.prefs and activeEidos.langs getters DO fail early when a visual wrapper reads them and they were not injected. dom is only mandatory when applyDom is active.

Configuration lives in EidosConfig:

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

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:

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:

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

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:

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

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

[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:

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:

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:

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:

[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:

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

// 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:

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.

From sema (DOM)

Only the DOM. The visual channel projects data-event-* during the hold via SignalProjector and eidos reacts through the generated signatures:

[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 §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 §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:

/* 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-handle-bg-active, 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. 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, 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: 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. 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.

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

/* 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 §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. 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):
    const Drawer = DrawerRoot as DrawerNamespace;
    Drawer.Trigger = Trigger;
    Drawer.Content = Content;
    

Plus implementation rules:

  1. 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.
  2. 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'>).
  3. No Eidos prefix on types. The path $uix/eidos/components/{x} already identifies the layer.
  4. 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.

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.

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.

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:

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.

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:

    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:

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

A picker's canonical layout:

<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 (timelessness rule of docs/authoring.md). The live wrapper inventory is the components/ tree + npm run component:audit — never a list in a doc.

Powered by TurnKey Linux.