41 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 · hairline · thin · medium · thick, stylessolid · dashed · dottedand the aliases--border-width,--border-style,--border.opacity:0 · muted · disabled · scrim · overlay · hover · press · full.zIndex:base · raised · sticky · dropdown · popover · tooltip · modal · toast.shadow: a 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.
--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 can use calc(var(--space-4) * var(--density-space-scale)).
--space-4 never changes meaning; the active visual policy does.
themes allows overriding scales, roles, surfaces, content, borders, focus
and shadows per theme. The current base theme exposes base-light and
base-dark; defaultActiveEidosThemeResolver uses the active theme when it
exists as an exact id, preserves already-qualified external ids (*-light,
*-dark) and otherwise tries ${theme}-${mode}.
ActiveEidos persists no CSS to disk. With applyDom active it writes to
the DOM:
${styleId}-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: 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=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.
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);
}
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).
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 + foundation aliases (--font-ui, --leading-ui, …) |
Component internals (Field labels, Combobox triggers, Button text, …) — they need t-shirt scaling (xs/sm/md/lg/xl) that does NOT map cleanly to a fixed semantic. |
Named styles (--style-label-*, --style-body-*, --style-caption-*, --style-h{1..6}-*, --style-{hero,prose,code}-*) |
typography primitives (<Text>, <Heading>, <Display>, <Code>, <Link>, …) |
The user-facing API to compose content — the USER picked "label" or "body" and wants that semantic role honored. |
The two layers are not redundant: they serve different contexts. The numerical one vertebrates the system's interior; the semantic one vertebrates the surface the consumer composes.
How they vertebrate without duplication — the alias chain
Where a value coincides between the two layers, the foundation alias reads from the named style, not the other way around. Single source of truth: the named style.
/* generated/base.css (via render-css.ts) */
:root {
/* Named style — source of truth */
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
/* Foundation alias — vertebrates the recipes */
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
}
/* recipes/base.ts → generated/base.css */
:root {
--field-label-line-height: var(--leading-ui);
--field-control-line-height: var(--leading-ui);
/* … dozens more recipe tokens */
}
/* components/field/field.css */
[data-field-label] {
line-height: var(--field-label-line-height);
}
Changing STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3' (in
primitives/typography.ts) propagates to --style-label-line-height →
--leading-ui → every recipe → every component. One edit reaches Field,
Form, Combobox, Select, Toolbar, Toast and the typography primitives
simultaneously.
The , 1.25 / , var(--font-family-primary) fallbacks guarantee the system
keeps producing valid CSS if a consumer turns the named styles off in their
foundation override.
Why component recipes do NOT read --style-{name}-* directly
A recurring temptation: "every component should read
--style-label-font-size for coherence". That is not the way.
- 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_IMPLEMENTACION_SEMAUIX.md
§13 (historical; instantaneous operation = one event; the unified token
system; intent ↔ color resolution; per-component subset; iconOnly sr-only;
sound prepare-time priming).
The public shape convention of an eidos component lives in
components/README.md. 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:
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 lint is a safety net, not the contract. The contract lives in the morfo and is defended at the type level where possible. The lint exists only for the pure-CSS portion that doesn't yet consume the morfo through TypeScript. When recipes migrate to a builder, the lint can retire.
Picker patterns (the reusable contract)
The pickers (date-picker, date-range-picker, time-picker,
time-range-picker, color-picker) share a common contract, documented here
as the canonical reference — any new picker builds on this skeleton. Two
standing decisions frame it: composition wins (there are no
MonthPicker / YearPicker components — those forms are
<DatePicker kind='month'> etc.) and the footer is pure composition
(no clearButton/closeButton root props; each Footer part renders whenever
mounted — presence = visibility, norm N-7).
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.