56 KiB
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:
ActiveEidosis 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 withActiveUixservices when there is a context:prefs,dom,langs,formatand visual helpers likeresolve(...),breakpoint(...)orisBelow(...). WhenapplyDomis active, it injects/removes<style data-uix-eidos>throughuix.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 + theuix.colorengine, WITHOUT touching the DOM. It replaces thegetComputedStyle(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 appliedapplyColorScheme(override-first) and resolves theme-scoped scales. Returnsnullfor 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 · spaciousscales 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: widthsnone · thin · medium · thick · heavy(linear 0/1/2/3/4 —hairlinewas pruned,heavyadded; changelog §29/§35), stylessolid · dashed · dottedand the aliases--border-width,--border-style,--border+--ring-inset-width.opacity: a dual scale — numeric plus semanticghost · disabled · scrim · muted · overlay · subtle · press · hover · full(changelog §29).zIndex:base · raised · sticky · dropdown · popover · tooltip · modal · toast.shadow: a physical1..6scale plus per-theme semantic aliasesnone · 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.
--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}-staticwith the stable primitives (renderStaticCss()).${styleId}-themewith 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: 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:
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:
- an explicit
preferences: ActiveEidosPreferenceSource— the app owns the question outright; uix.prefs— inside a UIX tree this is the default.theme,mode,densityandscalingare prefs dimensions (2026-09-14; seesrc/arts/prefs/README.md§"One engine"), so eidos reads the slots and subscribes to them;- standalone eidos — no
uix, noprefs, nopreferences:modefollows the OS live throughprefers-color-scheme(fallbacklight) 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.
// 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).
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:
[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 |
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-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
isDisabledwith a Field's inheriteddisabled). 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:
- Authorship clarity at debug time — inspecting an element and seeing
--toggle-bgsays it came from UIX's visual layer. - Override discipline — a consumer overriding
--color-primary-elementknows 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: themotionprop, 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 inarts/motion(uix.motion); Eidos delegates viaeidos.motionand registers itscsspresets 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.
- 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. - 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. - 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. - 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 coupleabletextStylebut 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 + eidosevents[].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:
- 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>. - The root lives in
{name}.svelte— NOT in{name}-provider.svelte. One file per root. - Do NOT export
Providerpublicly. The "Provider compound vs flat" separation is a retired invention; there is ONE compound shape (root + children attached as properties). - Children follow the air / headless naming:
Trigger,Content,Overlay,Title,Description,Close,Portal,Header,Footer,Item,Indicator,HiddenInput,Group,Label. Don't invent names. Portalis included where air had it (Dialog, Drawer, Popover, Tooltip — portaled overlays). Imported from$soma/components/internal.- 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. - 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:
- Wrapper, not fork. The eidos
.svelteimports 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. Sizefromlib/types.ts. Components accepting sizes reuse the shared type and narrow to the subset their recipe supports (Extract<Size, 'sm' | 'md' | 'lg'>).- No
Eidosprefix on types. The path$uix/eidos/components/{x}already identifies the layer. - 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/actionssnippets, 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 iteratestoaster.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 likedata-variant,data-sizecome 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-stateon 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.
- behavioral / a11y attrs — state mirrors that exist for JS, tests,
assistive tech or app-land selectors (
- 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 renderssegmentsas-is) and the opening view. There are noMonthPicker/YearPickercomponents: 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*Buttonroot 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 preservingvalue.currentas-is. The normal confirmation of the selected value. -
cancel()— revertsvalue.currentto the snapshot captured at the OPEN edge and closes the popover. The snapshot is taken viawatch(opts.open)whenopentransitionsfalse → 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()— setsvalue.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).
P-3 · Shell composition (Provider > Input > Portal > Content > view + Footer)
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 bykindor 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:
- 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>andsegmentContentsfiltersallSegmentContent.arr, collapsing literal runs. - 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 === undefinedor{start: undefined, end: undefined}) — the next click setsstart. State moves topending. - pending (
startset,endundefined) — the next click setsend. If the new point <start, automatic swap (start ↔ end). State moves tocomplete. - complete (
startandendset) — the next click restarts: setsstart = click,end = undefined. State moves topending.
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.