docs(book): F7.2 (6/7) — eidos chapter translated to docs/architecture/eidos.md
src/uix/eidos/README.md (1020 L, Spanish) translated to English: the
ActiveEidos runtime (config/patch, themes, themeSource, CSS contract,
setCssVariables, persistence envelope, resolveToken), canonical size +
transversal primitives, what eidos consumes from morfo/soma/sema, the
--* token rule, the THEMING sN map, typographic vertebration (two
anchors + alias chain + R-2.7), the 2-of-3 rule, disciplined-option-C
API conventions (7+4 rules, Toast special case), the selector-drift
defense, and the picker patterns P-1..P-5. Two already-decided
reconciliations folded in: themes/fonts.css superseded (font-faces live
in generated/base.css) and the dated 2026-05-21 picker block merged into
the picker-patterns intro — its dead PENDIENTES.md pointer replaced by a
TODO(reconcile) note (norms N-6/N-7 orphaned by d68d2c45), which also
clears one docs:check warn (13 -> 12). Thin stub at the old path keeps
THEMING/TSC/motion pointers next to the code; corpus links swept.
docs:check 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
---
|
|
|
|
|
|
title: Eidos — the visual layer
|
|
|
|
|
|
type: reference
|
|
|
|
|
|
audience: human + agent
|
|
|
|
|
|
authority: E1 architecture — the visual layer as a module: ActiveEidos runtime, wrappers, API conventions, picker patterns
|
|
|
|
|
|
status: current
|
|
|
|
|
|
source: migrated from src/uix/eidos/README.md (2026-07-02, docs-book F7.2)
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# Eidos
|
|
|
|
|
|
|
|
|
|
|
|
`Eidos` is UIX's **visual layer**. It covers what the dead `air/` branch
|
|
|
|
|
|
called the "visual runtime" plus the token system — inheriting no code. It
|
|
|
|
|
|
reads from the DOM what the previous layers wrote (the morfo runtime + sema's
|
|
|
|
|
|
visual channel) and applies styles, animations and ergonomic wrappers.
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Morfo declares the genetics
|
|
|
|
|
|
↓
|
|
|
|
|
|
Soma transcribes the behavior → DOM (data-state, data-color, aria-*)
|
|
|
|
|
|
↓
|
|
|
|
|
|
Sema emits perceptual signals → DOM (data-event-*) during the hold
|
|
|
|
|
|
↓
|
|
|
|
|
|
Eidos applies the visual: tokens, themes, recipes, archetypes, wrappers
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Eidos never imports soma or sema internals. Its source of truth is **what is
|
|
|
|
|
|
written in the DOM** (parts, data-attrs, ARIA, archetypes, event signals)
|
|
|
|
|
|
plus the public Soma types it needs to compose wrappers.
|
|
|
|
|
|
|
|
|
|
|
|
## Not just CSS
|
|
|
|
|
|
|
|
|
|
|
|
The first mental model was "eidos = reactive CSS". Insufficient: there are
|
|
|
|
|
|
purely visual concerns (variant, size, layout flags, icon slots) that are not
|
|
|
|
|
|
part of the headless behavior yet are orthogonal to the CSS. Eidos hosts them
|
|
|
|
|
|
as **Svelte wrappers over Soma**.
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
src/uix/eidos/
|
|
|
|
|
|
active-eidos.svelte.ts → visual runtime/context and CSS generation
|
|
|
|
|
|
index.css → entrypoint importing the aggregated CSS
|
|
|
|
|
|
archetypes.css → rules common to [data-archetype=*]
|
|
|
|
|
|
events.css → global hints for [data-event-*] (sema visual)
|
|
|
|
|
|
generated/base.css → static output generated from the base EidosConfig (incl. @font-face)
|
|
|
|
|
|
lib/ → config support, recipes, CSS contract and shared types
|
|
|
|
|
|
components/{x}/ → per-component recipe + wrapper + types
|
|
|
|
|
|
{x}.css recipe CSS (selectors [data-{x}], etc.)
|
|
|
|
|
|
{x}.svelte Svelte wrapper over the Soma component
|
|
|
|
|
|
types.ts wrapper props (extends Soma's public contract)
|
|
|
|
|
|
index.ts public namespace (default + attached parts)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`lib/` contains the pure visual-configuration support: primitives, visual
|
|
|
|
|
|
semantics, themes, the CSS contract and rendering. `generated/base.css` is
|
|
|
|
|
|
the static artifact generated from that configuration
|
|
|
|
|
|
(`npm run generate:eidos-css`). The old `contracts/` and `themes/base/` CSS
|
|
|
|
|
|
were retired from the active tree: the public contract is obtained with
|
|
|
|
|
|
`ActiveEidos.getCssContract()` / `renderContractCss()` and the visual base
|
|
|
|
|
|
comes out of `generated/base.css`. The old `tokens/components/*` were retired
|
|
|
|
|
|
too: recipe custom-property names (`--toast-*`, `--dialog-*`, etc.) remain
|
|
|
|
|
|
the stable contract, but their base values live in `EidosConfig.recipes` and
|
|
|
|
|
|
are generated into `generated/base.css`.
|
|
|
|
|
|
|
|
|
|
|
|
## The active runtime
|
|
|
|
|
|
|
|
|
|
|
|
Eidos has one active class:
|
|
|
|
|
|
|
|
|
|
|
|
- `ActiveEidos` is the active runtime and the visual context of the Svelte
|
|
|
|
|
|
wrappers. It manages pure visual configuration, primitives, semantic roles,
|
|
|
|
|
|
themes, validation, CSS rendering and persistence. It connects that
|
|
|
|
|
|
configuration with `ActiveUix` services when there is a context: `prefs`,
|
|
|
|
|
|
`dom`, `langs`, `format` and visual helpers like `resolve(...)`,
|
|
|
|
|
|
`breakpoint(...)` or `isBelow(...)`. When `applyDom` is active, it
|
|
|
|
|
|
injects/removes `<style data-uix-eidos>` through `uix.dom`.
|
|
|
|
|
|
- `ActiveEidos.resolveToken(token)` / `resolveTokens(tokens)` resolve a theme
|
|
|
|
|
|
color token (`--scale-{name}-{step}` / `--primitive-{role}-{step}`) to a
|
|
|
|
|
|
concrete sRGB hex in **pure JS** — config + the `uix.color` engine, WITHOUT
|
|
|
|
|
|
touching the DOM. It replaces the `getComputedStyle(probe)` round-trip
|
|
|
|
|
|
consumers used to read a token's value (that read forces a reflow; this one
|
|
|
|
|
|
never touches the DOM). It honors an applied `applyColorScheme`
|
|
|
|
|
|
(override-first) and resolves theme-scoped scales. Returns `null` for
|
|
|
|
|
|
semantic slots / unresolvable tokens.
|
|
|
|
|
|
|
|
|
|
|
|
Dependency rule: a component in `src/uix/eidos/components/*` does not import
|
|
|
|
|
|
`getActiveUix()` directly. It consumes `ActiveEidos.require()` and only knows
|
|
|
|
|
|
Eidos's visual surface.
|
|
|
|
|
|
|
|
|
|
|
|
Ownership rule: `ActiveEidos` creates no shared services. In context mode,
|
|
|
|
|
|
`ActiveEidos.create(...)` obtains those services from `ActiveUix`; if a
|
|
|
|
|
|
wrapper requires one that doesn't exist, it throws. In explicit mode,
|
|
|
|
|
|
`createActiveEidos(...)` receives already-built services. With
|
|
|
|
|
|
`applyDom:false` it stays a DOM-writeless runtime: it can render, serialize
|
|
|
|
|
|
and validate CSS without inserting styles.
|
|
|
|
|
|
|
|
|
|
|
|
Important nuance: `prefs`, `langs` and `format` are optional at construction
|
|
|
|
|
|
because `ActiveEidos` also serves to generate/serialize CSS. The
|
|
|
|
|
|
`activeEidos.prefs` and `activeEidos.langs` getters DO fail early when a
|
|
|
|
|
|
visual wrapper reads them and they were not injected. `dom` is only mandatory
|
|
|
|
|
|
when `applyDom` is active.
|
|
|
|
|
|
|
|
|
|
|
|
Configuration lives in `EidosConfig`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
interface EidosConfig {
|
|
|
|
|
|
primitives: PrimitiveSet;
|
|
|
|
|
|
semantics: SemanticSet;
|
|
|
|
|
|
themes?: ThemeMap;
|
|
|
|
|
|
recipes?: RecipeTokenSet;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
For incremental authoring over the base theme there is `EidosConfigPatch`. It
|
|
|
|
|
|
does not replace the whole object: it deep-extends it, preserving what is not
|
|
|
|
|
|
declared and replacing whole arrays (for example a typography family's
|
|
|
|
|
|
`fallbacks`).
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const config = createThemeBaseEidosConfig({
|
|
|
|
|
|
primitives: {
|
|
|
|
|
|
typography: {
|
|
|
|
|
|
families: {
|
|
|
|
|
|
primary: { family: 'Inter' }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
semantics: {
|
|
|
|
|
|
color: {
|
|
|
|
|
|
roles: {
|
|
|
|
|
|
primary: 'blue'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
If the app wants to start from scratch, it uses `defineEidosConfig()` with a
|
|
|
|
|
|
full `EidosConfig` and passes it to `ActiveEidos.create({ config })`. To
|
|
|
|
|
|
start from the base theme, use `createThemeBaseEidosConfig(patch)` or
|
|
|
|
|
|
directly `ActiveEidos.create({ themeBase: patch })`.
|
|
|
|
|
|
|
|
|
|
|
|
`primitives` contains the non-semantic bases: 12-step color scales, the
|
|
|
|
|
|
canonical size map, spaces, control heights, radii, border, opacity, z-index,
|
|
|
|
|
|
focus ring, layout, typography, shadows, motion and icons.
|
|
|
|
|
|
`semantics.color.roles` maps those scales to the canonical roles:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
primary · secondary · tertiary · neutral · affirm · fulfill · risk · threat · loss
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
There is no `danger`, `success`, `warning` or `info`: those names belong to
|
|
|
|
|
|
other models. In Eidos, components speak in intents and visual hierarchy.
|
|
|
|
|
|
|
|
|
|
|
|
`recipes` contains each active recipe's public aliases. It defines no
|
|
|
|
|
|
selectors or component states: only values for the custom properties the CSS
|
|
|
|
|
|
recipes consume. For example, `recipes.tooltip['content-z']` generates
|
|
|
|
|
|
`--tooltip-content-z`; `recipes.dialog['overlay-bg']` generates
|
|
|
|
|
|
`--dialog-overlay-bg`. Components keep their CSS stable while the theme can
|
|
|
|
|
|
persist/edit those values from the same authoring object.
|
|
|
|
|
|
|
|
|
|
|
|
The recipe/CSS contract is validated in `recipe-css-contract.test.ts`: if a
|
|
|
|
|
|
recipe CSS consumes `--{component}-*`, that alias must exist in
|
|
|
|
|
|
`EidosConfig.recipes`; and if the base theme declares a public alias, it must
|
|
|
|
|
|
be consumed by the component or by another composed token. No phantom
|
|
|
|
|
|
reservations, no palettes the component can't activate from its real API.
|
|
|
|
|
|
|
|
|
|
|
|
For theme editors, `ActiveEidos.listRecipes()` enumerates the components with
|
|
|
|
|
|
recipe tokens and `ActiveEidos.getRecipeTokens(component)` returns a
|
|
|
|
|
|
defensive copy of the alias map. It does not mutate the internal config.
|
|
|
|
|
|
|
|
|
|
|
|
The standing decision is to keep `recipes` a flat alias map
|
|
|
|
|
|
(`RecipeTokenSet`). Promoting a recipe to its own structured type is only
|
|
|
|
|
|
justified when a real builder or consumer needs internal semantics; while
|
|
|
|
|
|
recipes remain plain CSS, the public contract is the generated custom
|
|
|
|
|
|
property.
|
|
|
|
|
|
|
|
|
|
|
|
ActiveEidos also generates each physical palette's alpha scale:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
--scale-blue-a1 … --scale-blue-a12
|
|
|
|
|
|
--primitive-primary-a1 … --primitive-primary-a12
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
If the theme declares `color.alphaScales`, those values are honored.
|
|
|
|
|
|
Otherwise they derive from the scale's solid step (`--scale-{name}-9`) with a
|
|
|
|
|
|
canonical opacity progression. That enables translucent inks for overlays,
|
|
|
|
|
|
rings, soft hovers or scrims without each recipe inventing its own formula.
|
|
|
|
|
|
|
|
|
|
|
|
### Canonical size
|
|
|
|
|
|
|
|
|
|
|
|
`Size` is discrete and stable:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
xxs · xs · sm · md · lg · xl · xxl · full
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`full` is layout semantics and generates no physical primitive. The config
|
|
|
|
|
|
maps only `xxs..xxl` to coordinated tokens:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
--size-md-control-height
|
|
|
|
|
|
--size-md-font-size
|
|
|
|
|
|
--size-md-font-line-height
|
|
|
|
|
|
--size-md-font-letter-spacing
|
|
|
|
|
|
--size-md-icon-size
|
|
|
|
|
|
--size-md-padding-inline
|
|
|
|
|
|
--size-md-padding-block
|
|
|
|
|
|
--size-md-gap
|
|
|
|
|
|
--size-md-radius
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The rule: `md` never changes meaning per viewport. Responsiveness decides
|
|
|
|
|
|
which active size is used (`ResponsiveProp<Size>`); it does not redefine
|
|
|
|
|
|
`md`'s tokens. Percentages, `vw`, `dvh`, `clamp()` or `min()` belong to
|
|
|
|
|
|
layouts like `full`, panels and containers — not to the canonical size of
|
|
|
|
|
|
controls, icons or typography.
|
|
|
|
|
|
|
|
|
|
|
|
### Transversal primitives
|
|
|
|
|
|
|
|
|
|
|
|
The static block also generates the shared primitives that usually appear in
|
|
|
|
|
|
Radix Themes, Ark/Panda, Chakra, Tailwind or shadcn as foundation tokens:
|
|
|
|
|
|
|
|
|
|
|
|
- `layout`: container widths, container inline padding, content widths and
|
|
|
|
|
|
canonical ratios. Generates `--container-width-*`,
|
|
|
|
|
|
`--container-padding-inline`, `--content-width-*` and `--aspect-ratio-*`.
|
|
|
|
|
|
- `density`: `compact · comfortable · spacious` scales so recipes can adjust
|
|
|
|
|
|
space, control height or content without redefining the canonical tokens.
|
|
|
|
|
|
Generates `--density-{key}-*` and active aliases like
|
|
|
|
|
|
`--density-space-scale`.
|
|
|
|
|
|
- `border`: widths `none · hairline · thin · medium · thick`, styles
|
|
|
|
|
|
`solid · dashed · dotted` and the aliases `--border-width`,
|
|
|
|
|
|
`--border-style`, `--border`.
|
|
|
|
|
|
- `opacity`: `0 · muted · disabled · scrim · overlay · hover · press · full`.
|
|
|
|
|
|
- `zIndex`: `base · raised · sticky · dropdown · popover · tooltip · modal · toast`.
|
|
|
|
|
|
- `shadow`: a physical `1..6` scale plus per-theme semantic aliases
|
|
|
|
|
|
`none · subtle · raised · overlay`.
|
|
|
|
|
|
|
|
|
|
|
|
The rule is the same as for size: these tokens never change meaning per
|
|
|
|
|
|
breakpoint. A component or wrapper may pick a different token at a given
|
|
|
|
|
|
viewport, but `--shadow-3`, `--opacity-disabled` or `--z-index-modal` remain
|
|
|
|
|
|
the same system coordinate.
|
|
|
|
|
|
|
|
|
|
|
|
The responsive bridge lives in `ActiveDom`/`ActiveEidos`: wrappers use
|
|
|
|
|
|
`ActiveEidos.resolve(...)`, `breakpoint(...)`, `isAtLeast(...)` and
|
|
|
|
|
|
`isBelow(...)` to decide which canonical token applies at each viewport.
|
|
|
|
|
|
`--container-width-md` or `--content-width-lg` are not recomputed
|
|
|
|
|
|
responsively; what changes is the token choice. Density follows the same
|
|
|
|
|
|
principle: `ActiveEidos` projects `data-density` and publishes active
|
|
|
|
|
|
scalars:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--density-compact-space-scale: 0.84;
|
|
|
|
|
|
--density-comfortable-space-scale: 1;
|
|
|
|
|
|
--density-spacious-space-scale: 1.16;
|
|
|
|
|
|
--density-space-scale: var(--density-comfortable-space-scale);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[data-density='compact'] {
|
|
|
|
|
|
--density-space-scale: var(--density-compact-space-scale);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
A recipe can use `calc(var(--space-4) * var(--density-space-scale))`.
|
|
|
|
|
|
`--space-4` never changes meaning; the active visual policy does.
|
|
|
|
|
|
|
|
|
|
|
|
`themes` allows overriding scales, roles, surfaces, content, borders, focus
|
|
|
|
|
|
and shadows per theme. The current base theme exposes `base-light` and
|
|
|
|
|
|
`base-dark`; `defaultActiveEidosThemeResolver` uses the active theme when it
|
|
|
|
|
|
exists as an exact id, preserves already-qualified external ids (`*-light`,
|
|
|
|
|
|
`*-dark`) and otherwise tries `${theme}-${mode}`.
|
|
|
|
|
|
|
|
|
|
|
|
`ActiveEidos` persists no CSS to disk. With `applyDom` active it writes to
|
|
|
|
|
|
the DOM:
|
|
|
|
|
|
|
|
|
|
|
|
- `${styleId}-static` with the stable primitives (`renderStaticCss()`).
|
|
|
|
|
|
- `${styleId}-theme` with the resolved theme when it comes from the config.
|
|
|
|
|
|
|
|
|
|
|
|
`themeSource` decides where theme values come from:
|
|
|
|
|
|
|
|
|
|
|
|
- `'auto'` (default): generates CSS when the config knows the theme;
|
|
|
|
|
|
otherwise it assumes external CSS and leaves only the static tokens.
|
|
|
|
|
|
- `'config'`: strict mode; if the resolved theme is not in the config,
|
|
|
|
|
|
`renderThemeCss()` throws.
|
|
|
|
|
|
- `'css'`: never generates theme CSS; the integrator provides the values via
|
|
|
|
|
|
external CSS honoring the custom-property contract.
|
|
|
|
|
|
|
|
|
|
|
|
To inspect or publish that contract, `ActiveEidos` exposes:
|
|
|
|
|
|
|
|
|
|
|
|
- `getCssContract()` returns the structured, typed contract with each token
|
|
|
|
|
|
(`name`, `cssVar`, `scope`, `category`, `path`). The surface meant for
|
|
|
|
|
|
theme editors, validators, tooling or user persistence.
|
|
|
|
|
|
- `renderContractCss()` renders that same contract as empty CSS to document
|
|
|
|
|
|
or bootstrap external themes.
|
|
|
|
|
|
- `renderCssVariables()` accepts a custom-property map and turns it into
|
|
|
|
|
|
runtime CSS validated against Eidos's contract.
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const tokens = eidos.getCssContract();
|
|
|
|
|
|
const recipes = eidos.listRecipes();
|
|
|
|
|
|
const tooltipTokens = eidos.getRecipeTokens('tooltip');
|
|
|
|
|
|
const contract = eidos.renderContractCss({
|
|
|
|
|
|
staticSelector: ':root',
|
|
|
|
|
|
themeSelector: "[data-theme='acme-light']"
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The result assigns no real values; it declares the custom properties a
|
|
|
|
|
|
CSS-only theme can supply or override:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-theme='acme-light'] {
|
|
|
|
|
|
--scale-blue-9: ;
|
|
|
|
|
|
--scale-blue-a9: ;
|
|
|
|
|
|
--primitive-primary-9: ;
|
|
|
|
|
|
--primitive-primary-a9: ;
|
|
|
|
|
|
--color-primary-solid: ;
|
|
|
|
|
|
--size-md-control-height: ;
|
|
|
|
|
|
--border-width-thin: ;
|
|
|
|
|
|
--opacity-disabled: ;
|
|
|
|
|
|
--z-index-modal: ;
|
|
|
|
|
|
--shadow-3: ;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Persistence uses a versioned envelope:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const document = eidos.toDocument();
|
|
|
|
|
|
const json = eidos.serialize();
|
|
|
|
|
|
|
|
|
|
|
|
const hydrated = createActiveEidos({
|
|
|
|
|
|
config: JSON.parse(json),
|
|
|
|
|
|
prefs,
|
|
|
|
|
|
dom
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The document has the shape `{ kind: 'uix.eidos-config', version: 1, options }`.
|
|
|
|
|
|
`EidosConfig` carries no `version` inside: it remains pure visual
|
|
|
|
|
|
configuration. If the shape ever changes, `EIDOS_CONFIG_DOCUMENT_VERSION`
|
|
|
|
|
|
gets bumped; Eidos attempts no silent compatibility with unsupported
|
|
|
|
|
|
versions. The app decides where to store that document (prefs, backend, a
|
|
|
|
|
|
file, etc.) and which product metadata wraps it (`name`, owner, timestamps).
|
|
|
|
|
|
|
|
|
|
|
|
When hydrating a document as a runtime (`config: document`,
|
|
|
|
|
|
`readEidosConfigFromDocument(...)` or `parseEidosConfigFromJson(...)`), Eidos
|
|
|
|
|
|
also validates the `options` against the full `EidosConfig` contract.
|
|
|
|
|
|
`parseEidosConfigDocument(...)` remains the envelope parser: it confirms
|
|
|
|
|
|
`kind/version/options` but does not turn that envelope into usable visual
|
|
|
|
|
|
configuration by itself.
|
|
|
|
|
|
|
|
|
|
|
|
Normal integration inside an `ActiveUix` tree:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const uix = createActiveUix({
|
|
|
|
|
|
langs: { schema, defaultLocale: 'es' }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setActiveUix(uix);
|
|
|
|
|
|
|
|
|
|
|
|
ActiveEidos.create({
|
|
|
|
|
|
themeBase: {
|
|
|
|
|
|
semantics: {
|
|
|
|
|
|
color: {
|
|
|
|
|
|
roles: { primary: 'blue' }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
themeSource: 'auto',
|
|
|
|
|
|
themeResolver,
|
|
|
|
|
|
styleId: 'uix-eidos'
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`config` accepts a full `EidosConfig` or an `EidosConfigDocument`.
|
|
|
|
|
|
`themeBase` accepts only a patch over the base theme. They are mutually
|
|
|
|
|
|
exclusive so there is no ambiguity between "full config" and "base
|
|
|
|
|
|
override".
|
|
|
|
|
|
|
|
|
|
|
|
`theme` in Eidos names the visual family/theme (`base`, `acme`,
|
|
|
|
|
|
`acme-light`, etc.). `mode` names the effective `light | dark` scheme and
|
|
|
|
|
|
`density` names the active visual ergonomics. When `applyDom` is active,
|
|
|
|
|
|
`ActiveEidos` projects `data-theme`, `data-mode` and `data-density` onto the
|
|
|
|
|
|
document through `ActiveDom`.
|
|
|
|
|
|
|
|
|
|
|
|
`mode` and `density` are injected as visual sources, not as `prefs`
|
|
|
|
|
|
dimensions:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const eidos = ActiveEidos.create({
|
|
|
|
|
|
theme: 'base',
|
|
|
|
|
|
modeSource: {
|
|
|
|
|
|
get: () => colorMode,
|
|
|
|
|
|
onChange: (handler) => subscribeColorMode(handler)
|
|
|
|
|
|
},
|
|
|
|
|
|
densitySource,
|
|
|
|
|
|
applyDom: true
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Without a `modeSource`, `ActiveEidos` uses `prefers-color-scheme` with a
|
|
|
|
|
|
`light` fallback. Without a `densitySource`, it uses `comfortable`. Never
|
|
|
|
|
|
read or write `uix.prefs.theme` for UIX: the visual mode belongs to Eidos.
|
|
|
|
|
|
|
|
|
|
|
|
A CSS-only theme can live outside TypeScript:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-theme='acme-light'] {
|
|
|
|
|
|
--scale-blue-9: #006adc;
|
|
|
|
|
|
--scale-blue-a9: color-mix(in srgb, var(--scale-blue-9) 56%, transparent);
|
|
|
|
|
|
--primitive-primary-9: var(--scale-blue-9);
|
|
|
|
|
|
--primitive-primary-a9: var(--scale-blue-a9);
|
|
|
|
|
|
--color-primary-solid: var(--primitive-primary-9);
|
|
|
|
|
|
--font-family-primary: Inter, system-ui, sans-serif;
|
|
|
|
|
|
--size-md-control-height: 38px;
|
|
|
|
|
|
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
To pass values from runtime without recompiling:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
activeEidos.setCssVariables({
|
|
|
|
|
|
'--color-primary-solid': 'rebeccapurple',
|
|
|
|
|
|
'size-md-control-height': '40px',
|
|
|
|
|
|
'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`ActiveEidos` writes it into `${styleId}-variables` as a managed `<style>`.
|
|
|
|
|
|
By default it validates the names against `getCssContract()`; an app that
|
|
|
|
|
|
needs local variables outside the contract can pass `{ strict: false }`. The
|
|
|
|
|
|
update is transactional: it first renders and validates the next block, and
|
|
|
|
|
|
only replaces the runtime state when the map is usable. An unknown token
|
|
|
|
|
|
never leaves the previous style block half-applied.
|
|
|
|
|
|
|
|
|
|
|
|
Not creating `ActiveEidos` turns off Eidos's runtime visual layer. Soma
|
|
|
|
|
|
components keep working headless because behavior belongs to Soma.
|
|
|
|
|
|
|
|
|
|
|
|
## What it consumes
|
|
|
|
|
|
|
|
|
|
|
|
### From morfo (declaration)
|
|
|
|
|
|
|
|
|
|
|
|
| Piece | Eidos uses it for |
|
|
|
|
|
|
| -------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
|
|
|
|
| `parts[].kebab` | `[data-{component}-{kebab}]` selectors |
|
|
|
|
|
|
| `parts[].archetype` | transversal rules `[data-archetype=trigger]` |
|
|
|
|
|
|
| `parts[].states` + `data[].values` | variants `[data-state=open]` |
|
|
|
|
|
|
| `parts[].data` with `data-starting-style` / `data-ending-style` | enter/exit animation hooks |
|
|
|
|
|
|
| `events[].name` | selectors `[data-event=dismiss]`, `[data-event^=commit]` |
|
|
|
|
|
|
| `events[].semantic.family` + `.intent` | semantic tinting of transitions |
|
|
|
|
|
|
| `events[].prewrite[]` (e.g. `data-last-action`) | tinting the exit anim by cause |
|
|
|
|
|
|
| `focus.trap` | a layout hint for overlays |
|
|
|
|
|
|
|
|
|
|
|
|
### From Soma
|
|
|
|
|
|
|
|
|
|
|
|
The `components/*` wrappers import Soma's public parts directly
|
|
|
|
|
|
(`$soma/components/{x}`) and only add Eidos's visual surface: tokens,
|
|
|
|
|
|
recipes, layout shells and presentation data-attrs. There is no per-component
|
|
|
|
|
|
intermediate façade. Example:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
// eidos/components/toggle/types.ts
|
|
|
|
|
|
import type { ProviderProps } from '$soma/components/toggle';
|
|
|
|
|
|
export type ToggleProps = ProviderProps & { variant?: ToggleVariant; size?: ToggleSize; … };
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The `.svelte` wrapper imports Soma's public namespace and uses its parts with
|
|
|
|
|
|
that same namespace:
|
|
|
|
|
|
|
|
|
|
|
|
```svelte
|
|
|
|
|
|
import * as Select from '$soma/components/select';
|
|
|
|
|
|
|
|
|
|
|
|
<Select.Provider {...rest}>
|
|
|
|
|
|
<Select.Trigger />
|
|
|
|
|
|
<Select.Content />
|
|
|
|
|
|
</Select.Provider>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Do not use aliases like `Parts`, `SelectBase` or `SomaSelectProvider`. Do not
|
|
|
|
|
|
use loose `<Provider>` / `<Trigger>` tags inside Eidos. The wrapper adds the
|
|
|
|
|
|
visual token data-attrs (`data-variant`, `data-size`, `data-block`,
|
|
|
|
|
|
`data-icon-only`) and does not reimplement state.
|
|
|
|
|
|
|
|
|
|
|
|
### From sema (DOM)
|
|
|
|
|
|
|
|
|
|
|
|
Only the DOM. The visual channel projects `data-event-*` during the hold via
|
|
|
|
|
|
`SignalProjector` and eidos reacts through the generated signatures:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-event-family='commit'][data-event-phase='active'] {
|
|
|
|
|
|
animation: eidos-commit-settle 260ms var(--ease-out);
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
|
|
|
|
|
|
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The per-family/intent signatures live in `EidosConfig.motion` and are
|
|
|
|
|
|
generated into `generated/base.css`; `events.css` keeps only the global
|
|
|
|
|
|
compositor hint + the reduced-motion cap (see
|
|
|
|
|
|
[`eidos-motion.md`](../../src/uix/eidos/eidos-motion.md) §15).
|
|
|
|
|
|
|
|
|
|
|
|
## 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`](../../src/uix/eidos/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
|
|
|
|
|
|
[`RECIPE_CONTRACT.md`](../../src/uix/eidos/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)** →
|
|
|
|
|
|
[`MOTION_GUIDE.md`](../../src/uix/eidos/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`](../../src/uix/eidos/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`](../../src/uix/eidos/MOTION_SERVICE_RFC.md).
|
|
|
|
|
|
|
|
|
|
|
|
Quick map of what THEMING.md covers, to avoid duplicating here:
|
|
|
|
|
|
|
|
|
|
|
|
| Topic | Section in THEMING.md |
|
|
|
|
|
|
| --- | --- |
|
|
|
|
|
|
| Why theming lives in Eidos and not in Morfo | §1.bis |
|
|
|
|
|
|
| 7 token layers and override points | §3 |
|
|
|
|
|
|
| The 9 canonical color roles | §4 |
|
|
|
|
|
|
| The canonical sizes | §5 |
|
|
|
|
|
|
| Naming conventions | §6 |
|
|
|
|
|
|
| Token Scope Contract (TSC): type, scope algebra, cross-axis | §7 |
|
|
|
|
|
|
| TSC v2.2: multi-part scope (`parts: [...]`) + cross-recipe composition | §7 |
|
|
|
|
|
|
| How to add a new component | §8 |
|
|
|
|
|
|
| How to define a theme | §9 |
|
|
|
|
|
|
| How to override tokens at runtime | §10 |
|
|
|
|
|
|
| Bundle strategy + `eidos:purge` | §11 |
|
|
|
|
|
|
| Sema integration via `event:*` (superseded — see the stub) | §13 |
|
|
|
|
|
|
| Anti-patterns and FAQ | §16, §17 |
|
|
|
|
|
|
| Universal TSC coverage (no exceptions) | §18 |
|
|
|
|
|
|
| Variants are eidos canon, NOT the theme's (with `EIDOS_VARIANTS`) | §19 |
|
|
|
|
|
|
| Engine corrections: live density + on-solid `contrast` | §20 |
|
|
|
|
|
|
| The `scaling` axis (global zoom), separate from density | §23 |
|
|
|
|
|
|
| P2 corrections: on-solid text by luminance + translucent surfaces | §24 |
|
|
|
|
|
|
| Color model: palette (33 scales) + roles (aliases) + intents (auto-derived) | §25 |
|
|
|
|
|
|
| Runtime theme builder: `eidos.applyColorScheme(seed)` (`uix.color` engine) | §26 |
|
|
|
|
|
|
| Wide-gamut OKLCH output, default-on (hex fallback + `oklch()` sibling) | §27 |
|
|
|
|
|
|
| a11y forced-colors (focus outline fallback) + border ramp (slot 6→7) | §28 |
|
|
|
|
|
|
| Scale canon — the theming audit: blur · inset-shadow/ring · gradients · breakpoints+container · opacity · border-width · tracking | §35 |
|
|
|
|
|
|
| Focus ring: two parameterized rings (fields, box-shadow) + `outline` on surfaces | §32 |
|
|
|
|
|
|
| Touch-target — 44px on touch, gated by `pointer: coarse` | §37 |
|
|
|
|
|
|
| State layer `--state-*` — unified neutral feedback (MD3, theme-adaptive) | §38 |
|
|
|
|
|
|
|
|
|
|
|
|
What follows in this chapter are the operational decisions of the **visual
|
|
|
|
|
|
layer as a module** (typography sourcing, picker patterns, API conventions,
|
|
|
|
|
|
the active runtime), independent of the theming system.
|
|
|
|
|
|
|
|
|
|
|
|
## Typographic vertebration — single source of truth in the foundation
|
|
|
|
|
|
|
|
|
|
|
|
Eidos has **two typographic anchors, in different layers, by design**. This
|
|
|
|
|
|
section documents why and how they relate.
|
|
|
|
|
|
|
|
|
|
|
|
### The two layers
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
src/uix/eidos/lib/primitives/typography.ts
|
|
|
|
|
|
│
|
|
|
|
|
|
├── families / sizes / weights (numerical scale)
|
|
|
|
|
|
│ ↓
|
|
|
|
|
|
│ foundation tokens
|
|
|
|
|
|
│ --font-family-{primary,secondary,display,mono}
|
|
|
|
|
|
│ --font-size-{xxs..xxxl}
|
|
|
|
|
|
│ --font-weight-{regular,medium,semibold,bold}
|
|
|
|
|
|
│ --font-line-height-{xxs..xxxl}
|
|
|
|
|
|
│
|
|
|
|
|
|
└── styles (semantic layer)
|
|
|
|
|
|
↓
|
|
|
|
|
|
named-style tokens
|
|
|
|
|
|
--style-{hero,h1..h6,body,prose,label,caption,code}-{font-family,
|
|
|
|
|
|
font-size,line-height,letter-spacing,font-weight,color}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| Layer | Who consumes it | For what |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| **Numerical foundation** (`--font-size-*`, `--font-family-primary`, …) | recipe tokens in `lib/recipes/base.ts` + foundation aliases (`--font-ui`, `--leading-ui`, …) | Component internals (Field labels, Combobox triggers, Button text, …) — they need **t-shirt scaling** (`xs/sm/md/lg/xl`) that does NOT map cleanly to a fixed semantic. |
|
|
|
|
|
|
| **Named styles** (`--style-label-*`, `--style-body-*`, `--style-caption-*`, `--style-h{1..6}-*`, `--style-{hero,prose,code}-*`) | typography primitives (`<Text>`, `<Heading>`, `<Display>`, `<Code>`, `<Link>`, …) | The user-facing API to compose content — the USER picked "label" or "body" and wants that semantic role honored. |
|
|
|
|
|
|
|
|
|
|
|
|
**The two layers are not redundant**: they serve different contexts. The
|
|
|
|
|
|
numerical one vertebrates the system's _interior_; the semantic one
|
|
|
|
|
|
vertebrates the _surface_ the consumer composes.
|
|
|
|
|
|
|
|
|
|
|
|
### How they vertebrate without duplication — the alias chain
|
|
|
|
|
|
|
|
|
|
|
|
Where a value coincides between the two layers, **the foundation alias reads
|
|
|
|
|
|
from the named style, not the other way around**. Single source of truth: the
|
|
|
|
|
|
named style.
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
/* generated/base.css (via render-css.ts) */
|
|
|
|
|
|
:root {
|
|
|
|
|
|
/* Named style — source of truth */
|
|
|
|
|
|
--style-label-font-family: var(--font-family-primary);
|
|
|
|
|
|
--style-label-line-height: 1.25;
|
|
|
|
|
|
|
|
|
|
|
|
/* Foundation alias — vertebrates the recipes */
|
|
|
|
|
|
--font-ui: var(--style-label-font-family, var(--font-family-primary));
|
|
|
|
|
|
--leading-ui: var(--style-label-line-height, 1.25);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* recipes/base.ts → generated/base.css */
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--field-label-line-height: var(--leading-ui);
|
|
|
|
|
|
--field-control-line-height: var(--leading-ui);
|
|
|
|
|
|
/* … dozens more recipe tokens */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* components/field/field.css */
|
|
|
|
|
|
[data-field-label] {
|
|
|
|
|
|
line-height: var(--field-label-line-height);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Changing `STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3'` (in
|
|
|
|
|
|
`primitives/typography.ts`) propagates to `--style-label-line-height` →
|
|
|
|
|
|
`--leading-ui` → every recipe → every component. **One edit** reaches Field,
|
|
|
|
|
|
Form, Combobox, Select, Toolbar, Toast and the typography primitives
|
|
|
|
|
|
simultaneously.
|
|
|
|
|
|
|
|
|
|
|
|
The `, 1.25` / `, var(--font-family-primary)` fallbacks guarantee the system
|
|
|
|
|
|
keeps producing valid CSS if a consumer turns the named styles off in their
|
|
|
|
|
|
foundation override.
|
|
|
|
|
|
|
|
|
|
|
|
### Why component recipes do NOT read `--style-{name}-*` directly
|
|
|
|
|
|
|
|
|
|
|
|
A recurring temptation: "every component should read
|
|
|
|
|
|
`--style-label-font-size` for coherence". **That is not the way.**
|
|
|
|
|
|
|
|
|
|
|
|
1. **T-shirts don't fit four buckets.** A Field with `size="xs"` has a
|
|
|
|
|
|
smaller label than the "canonical label". If its recipe read
|
|
|
|
|
|
`--style-label-font-size`, you'd lose that scaling or you'd need
|
|
|
|
|
|
`--style-label-{xs,sm,md,lg,xl}-*`, replicating what the recipe tokens
|
|
|
|
|
|
already are.
|
|
|
|
|
|
2. **Per-component nuances are legitimate.** Field's message, Tooltip's
|
|
|
|
|
|
description and Card's subtitle are all "caption-ish" but each wants its
|
|
|
|
|
|
own color/weight/letter-spacing. Forcing them into one
|
|
|
|
|
|
`--style-caption-*` kills expressiveness.
|
|
|
|
|
|
3. **Coupling lock-in.** On day 100 the system wants `label-form`,
|
|
|
|
|
|
`label-table`, `label-chart`. Forcing the coupling on day 1 leads to
|
|
|
|
|
|
replicating the recipe hierarchy in the semantic layer.
|
|
|
|
|
|
4. **How the references do it.** Radix Themes, Mantine, MUI all have a
|
|
|
|
|
|
numerical scale components read; the semantic layer exists only for the
|
|
|
|
|
|
typography primitives (`<Text variant="body2">`). Chakra v3 offers a
|
|
|
|
|
|
coupleable `textStyle` but most of its components hardcode anyway.
|
|
|
|
|
|
**Coupling everything to the semantic layer is not the dominant
|
|
|
|
|
|
practice** — for good reasons (1–3).
|
|
|
|
|
|
|
|
|
|
|
|
### Audit: rule `R-2.7`
|
|
|
|
|
|
|
|
|
|
|
|
`scripts/component-audit.ts` detects typographic literals in component CSS:
|
|
|
|
|
|
`font-size: 12px`, `line-height: 1.4`, `font-weight: 500`,
|
|
|
|
|
|
`letter-spacing: 0.02em` not wrapped in `var()`. Severity `warn`, not
|
|
|
|
|
|
`error` — the component still passes, but it stays visible in the report.
|
|
|
|
|
|
|
|
|
|
|
|
**Escape valves** (they don't count as drift):
|
|
|
|
|
|
|
|
|
|
|
|
- values in `var(...)`
|
|
|
|
|
|
- zeros and identities: `0`, `0px`, `0em`, `0rem`, `1`
|
|
|
|
|
|
- keywords: `inherit`, `initial`, `unset`
|
|
|
|
|
|
- an inline comment: `font-size: 13px; /* literal: tight icon affordance */`
|
|
|
|
|
|
|
|
|
|
|
|
If you need a justified literal, annotate it. Otherwise, tokenize it.
|
|
|
|
|
|
|
|
|
|
|
|
## The "2-of-3" rule (inherited from morfo)
|
|
|
|
|
|
|
|
|
|
|
|
A morfo extension is justified when **at least two of the three layers**
|
|
|
|
|
|
(soma, sema, eidos) consume it. The ones that entered with eidos's vote:
|
|
|
|
|
|
|
|
|
|
|
|
- `archetype` — eidos + sema (+ soma as emitter)
|
|
|
|
|
|
- `events[].semantic.family/intent` — sema + eidos
|
|
|
|
|
|
- `events[].prewrite[]` — soma (executes) + eidos (animates)
|
|
|
|
|
|
- `data-starting-style` / `data-ending-style` — soma (Presence) + eidos (animates)
|
|
|
|
|
|
|
|
|
|
|
|
## API conventions — disciplined option C
|
|
|
|
|
|
|
|
|
|
|
|
The doctrinal API conventions were seeded in
|
|
|
|
|
|
[`GUIA_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md)
|
|
|
|
|
|
§13 (historical; instantaneous operation = one event; the unified token
|
|
|
|
|
|
system; intent ↔ color resolution; per-component subset; iconOnly sr-only;
|
|
|
|
|
|
sound prepare-time priming).
|
|
|
|
|
|
|
|
|
|
|
|
The **public shape** convention of an eidos component lives in
|
|
|
|
|
|
[`components/README.md`](../../src/uix/eidos/components/README.md). Summary —
|
|
|
|
|
|
7 hard rules:
|
|
|
|
|
|
|
|
|
|
|
|
1. **One entry point per component** — the default export is the visual root
|
|
|
|
|
|
component, named like the component (`<Drawer>`, `<Tabs>`, `<Checkbox>`).
|
|
|
|
|
|
NOT `<Drawer.Provider>`, NOT `<Drawer.Root>`.
|
|
|
|
|
|
2. **The root lives in `{name}.svelte`** — NOT in `{name}-provider.svelte`.
|
|
|
|
|
|
One file per root.
|
|
|
|
|
|
3. **Do NOT export `Provider` publicly.** The "Provider compound vs flat"
|
|
|
|
|
|
separation is a retired invention; there is ONE compound shape (root +
|
|
|
|
|
|
children attached as properties).
|
|
|
|
|
|
4. **Children follow the air / headless naming**: `Trigger`, `Content`,
|
|
|
|
|
|
`Overlay`, `Title`, `Description`, `Close`, `Portal`, `Header`, `Footer`,
|
|
|
|
|
|
`Item`, `Indicator`, `HiddenInput`, `Group`, `Label`. Don't invent names.
|
|
|
|
|
|
5. **`Portal` is included where air had it** (Dialog, Drawer, Popover,
|
|
|
|
|
|
Tooltip — portaled overlays). Imported from `$soma/components/internal`.
|
|
|
|
|
|
6. **NO flat API with snippet slots as the main shape** —
|
|
|
|
|
|
`<Drawer trigger={...} title={...}>` is retired. The explicit compound
|
|
|
|
|
|
exposes the composition decisions the flat shape hid.
|
|
|
|
|
|
7. **Children attached with explicit assignment**, not `Object.assign`
|
|
|
|
|
|
(Svelte 5 can mishandle bulk mutation of the component constructor during
|
|
|
|
|
|
hydration):
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const Drawer = DrawerRoot as DrawerNamespace;
|
|
|
|
|
|
Drawer.Trigger = Trigger;
|
|
|
|
|
|
Drawer.Content = Content;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Plus implementation rules:
|
|
|
|
|
|
|
|
|
|
|
|
8. **Wrapper, not fork.** The eidos `.svelte` imports Soma's public namespace
|
|
|
|
|
|
(`import * as Drawer from '$soma/components/drawer'`), uses
|
|
|
|
|
|
`<Drawer.Provider>` / `<Drawer.Trigger>` internally and adds the visual
|
|
|
|
|
|
token data-attrs. It does not reimplement state.
|
|
|
|
|
|
9. **`Size` from `lib/types.ts`.** Components accepting sizes reuse the
|
|
|
|
|
|
shared type and narrow to the subset their recipe supports
|
|
|
|
|
|
(`Extract<Size, 'sm' | 'md' | 'lg'>`).
|
|
|
|
|
|
10. **No `Eidos` prefix** on types. The path `$uix/eidos/components/{x}`
|
|
|
|
|
|
already identifies the layer.
|
|
|
|
|
|
11. **Recipe CSS without an `--eidos-` prefix.** Custom properties use
|
|
|
|
|
|
`--{component}-…` for public tokens and `--_{component}-…` for internal
|
|
|
|
|
|
ones.
|
|
|
|
|
|
|
|
|
|
|
|
### Why disciplined option C
|
|
|
|
|
|
|
|
|
|
|
|
The previous pattern (a flat default with snippet slots + a duplicated
|
|
|
|
|
|
Provider compound) had two problems:
|
|
|
|
|
|
|
|
|
|
|
|
- **Double truth**: two APIs (flat with `trigger`/`title`/`actions` snippets,
|
|
|
|
|
|
compound with explicit children) reached the same functionality by
|
|
|
|
|
|
divergent paths. Any visual tweak had to update both.
|
|
|
|
|
|
- **It invented over air**: air was pure compound (`<Drawer.Provider>` with
|
|
|
|
|
|
children). The snippet-flat was an invention with no baseline and no user
|
|
|
|
|
|
sign-off.
|
|
|
|
|
|
|
|
|
|
|
|
Disciplined option C:
|
|
|
|
|
|
|
|
|
|
|
|
- Takes from **air** the children convention (Trigger, Content, Portal, …)
|
|
|
|
|
|
- Takes from **bits-ui / shadcn-svelte** the ergonomics of the root with
|
|
|
|
|
|
attached properties (`<Drawer><Drawer.Trigger>`)
|
|
|
|
|
|
- Removes the snippet-flat invention (it was neither air nor soma)
|
|
|
|
|
|
|
|
|
|
|
|
### Special case — Toast
|
|
|
|
|
|
|
|
|
|
|
|
It has two independent roots (not nested):
|
|
|
|
|
|
|
|
|
|
|
|
- `<Toast>` — the manual compound (the consumer iterates `toaster.toasts`)
|
|
|
|
|
|
- `<Toaster />` — the imperative auto-mount (the default template)
|
|
|
|
|
|
|
|
|
|
|
|
Separate exports; `Toaster` is NOT attached as `Toast.Toaster` because it is
|
|
|
|
|
|
a competing root, not a child. Documented in
|
|
|
|
|
|
[`components/toast/index.ts`](../../src/uix/eidos/components/toast/index.ts).
|
|
|
|
|
|
|
|
|
|
|
|
## Selector-drift defense
|
|
|
|
|
|
|
|
|
|
|
|
The morfo → soma → eidos chain depends on the selectors eidos writes
|
|
|
|
|
|
(`[data-{component}]`, `[data-{component}-{part}]`, `[data-state=...]`,
|
|
|
|
|
|
`[data-event-*=...]`) staying in tune with the attrs the morfo declares and
|
|
|
|
|
|
the runtime emits. There are two distinct defenses by consumer type:
|
|
|
|
|
|
|
|
|
|
|
|
### Compile-time (TS / Svelte) — the typed builder
|
|
|
|
|
|
|
|
|
|
|
|
Any TypeScript consumer building selectors **MUST** use
|
|
|
|
|
|
`semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. This covers
|
|
|
|
|
|
the cascade rules in `sema/components/*.ts` and any TypeScript logic in
|
|
|
|
|
|
`eidos/components/{x}/` targeting morfo-backed attrs.
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
import { semaSelector } from '$uix/morfo';
|
|
|
|
|
|
import { toggleMorfo } from '$uix/morfo/components/toggle';
|
|
|
|
|
|
|
|
|
|
|
|
semaSelector(toggleMorfo, 'provider', { eventName: 'commit-toggle' });
|
|
|
|
|
|
// → '[data-toggle][data-event="commit-toggle"]'
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Renaming a part or an event in the morfo breaks the typecheck. A TypeScript
|
|
|
|
|
|
selector cannot drift silently. See
|
|
|
|
|
|
[`architecture/morfo.md#typed-selector-builder--semaselector`](./morfo.md#typed-selector-builder--semaselector).
|
|
|
|
|
|
|
|
|
|
|
|
### Run-time (CSS recipes) — `eidos-lint` as an opt-in safety net
|
|
|
|
|
|
|
|
|
|
|
|
Recipes are plain CSS (`components/{x}/{x}.css`); there is no typed builder
|
|
|
|
|
|
on the CSS side. For that surface:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
node scripts/eidos-lint.ts toggle # one component
|
|
|
|
|
|
node scripts/eidos-lint-all.ts # all of them
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
It classifies every `[data-*]` selector as:
|
|
|
|
|
|
|
|
|
|
|
|
- **morfo-backed** — declared in the morfo; the soma runtime emits it; the
|
|
|
|
|
|
value (when there is an enum) falls inside `data[].values`.
|
|
|
|
|
|
- **eidos-only** — the marker is there, but at least one `data-*` is not
|
|
|
|
|
|
declared in the morfo. Valid by convention (visual tokens like
|
|
|
|
|
|
`data-variant`, `data-size` come from the wrapper).
|
|
|
|
|
|
- **invalid** — references a declared attr with a value outside the enum. A
|
|
|
|
|
|
bug.
|
|
|
|
|
|
|
|
|
|
|
|
**The lint is a safety net, not the contract.** The contract lives in the
|
|
|
|
|
|
morfo and is defended at the type level where possible. The lint exists only
|
|
|
|
|
|
for the pure-CSS portion that doesn't yet consume the morfo through
|
|
|
|
|
|
TypeScript. When recipes migrate to a builder, the lint can retire.
|
|
|
|
|
|
|
|
|
|
|
|
## Picker patterns (the reusable contract)
|
|
|
|
|
|
|
|
|
|
|
|
The pickers (`date-picker`, `date-range-picker`, `time-picker`,
|
|
|
|
|
|
`time-range-picker`, `color-picker`) share a common contract, documented here
|
|
|
|
|
|
as the canonical reference — any new picker builds on this skeleton. Two
|
|
|
|
|
|
standing decisions frame it: **composition wins** (there are no
|
|
|
|
|
|
`MonthPicker` / `YearPicker` components — those forms are
|
|
|
|
|
|
`<DatePicker kind='month'>` etc.) and the **footer is pure composition**
|
|
|
|
|
|
(no `clearButton`/`closeButton` root props; each Footer part renders whenever
|
|
|
|
|
|
mounted — presence = visibility, norm N-7).
|
|
|
|
|
|
|
|
|
|
|
|
<!-- TODO(reconcile): norms N-6/N-7 were documented in src/uix/PENDIENTES.md,
|
|
|
|
|
|
deleted in d68d2c45 — their prose needs a new home (user decision). -->
|
|
|
|
|
|
|
|
|
|
|
|
### P-1 · Provider helpers: `commit() / cancel() / clear()`
|
|
|
|
|
|
|
|
|
|
|
|
Every `*PickerProvider` exposes three imperative methods consumed by the
|
|
|
|
|
|
Footer parts:
|
|
|
|
|
|
|
|
|
|
|
|
- **`commit()`** — closes the popover preserving `value.current` as-is. The
|
|
|
|
|
|
normal confirmation of the selected value.
|
|
|
|
|
|
- **`cancel()`** — reverts `value.current` to the snapshot captured at the
|
|
|
|
|
|
**OPEN edge** and closes the popover. The snapshot is taken via
|
|
|
|
|
|
`watch(opts.open)` when `open` transitions `false → true`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
private valueOnOpen: TValue | undefined = undefined;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(...) {
|
|
|
|
|
|
watch(() => this.opts.open.current, (isOpen) => {
|
|
|
|
|
|
if (isOpen) this.valueOnOpen = $state.snapshot(this.opts.value.current);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cancel() {
|
|
|
|
|
|
this.opts.value.current = this.valueOnOpen;
|
|
|
|
|
|
this.opts.open.current = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- **`clear()`** — sets `value.current = undefined`. It does NOT close the
|
|
|
|
|
|
popover (it is a visible destructive action the user may want to keep
|
|
|
|
|
|
editing after). If the consumer wants close-after-clear, they compose
|
|
|
|
|
|
`<Picker.Close/>` in the same footer.
|
|
|
|
|
|
|
|
|
|
|
|
These three operations are orthogonal: each Footer part does exactly one.
|
|
|
|
|
|
Don't mix them (e.g. `clear` must not close; `cancel` must not clear).
|
|
|
|
|
|
|
|
|
|
|
|
### P-2 · `mode: 'inline' | 'modal'` → `popover.modal`
|
|
|
|
|
|
|
|
|
|
|
|
`mode` is a root opt of the provider propagated to the underlying
|
|
|
|
|
|
`Popover.modal`:
|
|
|
|
|
|
|
|
|
|
|
|
- **`mode='inline'`** (default) — non-modal popover. Outside-click closes.
|
|
|
|
|
|
Escape closes. Body scroll free.
|
|
|
|
|
|
- **`mode='modal'`** — modal popover. Outside-click does NOT close (the user
|
|
|
|
|
|
must use `<Picker.Close/>` or `<Picker.Cancel/>`). Escape still closes.
|
|
|
|
|
|
Focus trap inside the popover. Body scroll lock.
|
|
|
|
|
|
|
|
|
|
|
|
The consumer does NOT set `popover.modal` directly — that is the provider's
|
|
|
|
|
|
decision:
|
|
|
|
|
|
|
|
|
|
|
|
```svelte
|
|
|
|
|
|
<Popover.Provider modal={provider.opts.mode.current === 'modal'} ...>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Modal mode obliges the consumer to compose a Footer with `<Cancel/>` or
|
|
|
|
|
|
`<Close/>` to escape; without them the popover only closes via Escape. It
|
|
|
|
|
|
remains the consumer's responsibility (nothing is auto-injected).
|
|
|
|
|
|
|
|
|
|
|
|
### P-3 · Shell composition (Provider > Input > Portal > Content > view + Footer)
|
|
|
|
|
|
|
|
|
|
|
|
A picker's canonical layout:
|
|
|
|
|
|
|
|
|
|
|
|
```svelte
|
|
|
|
|
|
<X.Provider value={...} open={...} mode='modal' kind='date'>
|
|
|
|
|
|
{#snippet trigger()}
|
|
|
|
|
|
<X.Input /> {!-- field input with segments, button, etc. --}
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
|
|
{#snippet content()}
|
|
|
|
|
|
<X.Content>
|
|
|
|
|
|
{#if kind === 'year'} <X.YearView />
|
|
|
|
|
|
{:else if kind === 'month'} <X.MonthView />
|
|
|
|
|
|
{:else} <X.Calendar />
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<X.Footer>
|
|
|
|
|
|
<X.Clear />
|
|
|
|
|
|
<X.Cancel />
|
|
|
|
|
|
<X.Close />
|
|
|
|
|
|
</X.Footer>
|
|
|
|
|
|
</X.Content>
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
</X.Provider>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- **Trigger snippet** — the entry surface (input + segments, button,
|
|
|
|
|
|
swatch…).
|
|
|
|
|
|
- **Content** — the popover content; only `<X.Content>` may go here.
|
|
|
|
|
|
- **View** — `Calendar`, `YearView`, `MonthView`, `Clock`, `Picker` (color),
|
|
|
|
|
|
etc. The consumer branches structurally by `kind` or variant (P-4 below).
|
|
|
|
|
|
- **Footer** — `<X.Footer>` is an archetype='footer' container. The parts
|
|
|
|
|
|
(`Clear`, `Cancel`, `Close`, etc.) are composed inside. Each part renders
|
|
|
|
|
|
with no internal checks against provider props — presence = visibility
|
|
|
|
|
|
(N-7).
|
|
|
|
|
|
|
|
|
|
|
|
To omit the whole footer, don't compose `<X.Footer>`. For only `Close`,
|
|
|
|
|
|
compose only `<X.Close>` inside.
|
|
|
|
|
|
|
|
|
|
|
|
### P-4 · `kind` (or equivalent) as the single source of truth
|
|
|
|
|
|
|
|
|
|
|
|
When a picker has granularity variants (date: day/month/year; time:
|
|
|
|
|
|
hour/minute/second), the `kind` opt (or equivalent) is the **single
|
|
|
|
|
|
configuration point**. It drives:
|
|
|
|
|
|
|
|
|
|
|
|
1. **The input's segments** — filtered at the FieldProvider level (soma),
|
|
|
|
|
|
not in the consumer's snippet. The provider exposes a derivation like
|
|
|
|
|
|
`visiblePartsByKind: Set<PartName>` and `segmentContents` filters
|
|
|
|
|
|
`allSegmentContent.arr`, collapsing literal runs.
|
|
|
|
|
|
2. **The popover's view** — the consumer branches structurally on the opt
|
|
|
|
|
|
(`{#if kind === 'year'} <YearView/>` etc).
|
|
|
|
|
|
|
|
|
|
|
|
**There are no** per-variant components (`MonthPicker`, `HourPicker`, etc.).
|
|
|
|
|
|
Those forms are `<X kind='month'>`, `<X kind='hour'>`.
|
|
|
|
|
|
|
|
|
|
|
|
### P-5 · The range state machine (for `*-range-picker`)
|
|
|
|
|
|
|
|
|
|
|
|
For range selection, the provider keeps an implicit internal state (not
|
|
|
|
|
|
exposed as an opt):
|
|
|
|
|
|
|
|
|
|
|
|
- **empty** (`value === undefined` or `{start: undefined, end: undefined}`)
|
|
|
|
|
|
— the next click sets `start`. State moves to `pending`.
|
|
|
|
|
|
- **pending** (`start` set, `end` undefined) — the next click sets `end`. If
|
|
|
|
|
|
the new point < `start`, **automatic swap** (start ↔ end). State moves to
|
|
|
|
|
|
`complete`.
|
|
|
|
|
|
- **complete** (`start` and `end` set) — the next click restarts: sets
|
|
|
|
|
|
`start = click`, `end = undefined`. State moves to `pending`.
|
|
|
|
|
|
|
|
|
|
|
|
Each granularity normalizes the endpoints:
|
|
|
|
|
|
|
|
|
|
|
|
- **Year range** — start = Jan 1, end = Dec 31.
|
|
|
|
|
|
- **Month range** — start = day 1, end = the month's last day.
|
|
|
|
|
|
- **Day range** — start/end are the clicked date as-is.
|
|
|
|
|
|
- **Hour range** — start = `:00`, end = `:59:59`.
|
|
|
|
|
|
|
|
|
|
|
|
Implemented in `YearView`/`MonthView`/`Calendar` (range variant). The
|
|
|
|
|
|
provider only exposes `setValue(start, end)`; the state machine lives in the
|
|
|
|
|
|
views.
|
|
|
|
|
|
|
|
|
|
|
|
The dated hand-offs and snapshots that used to live here were extracted to
|
|
|
|
|
|
[`docs/process/handoffs-2026-05.md`](../process/handoffs-2026-05.md)
|
|
|
|
|
|
(timelessness rule of [`docs/authoring.md`](../authoring.md)). The live
|
|
|
|
|
|
wrapper inventory is the `components/` tree + `npm run component:audit` —
|
|
|
|
|
|
never a list in a doc.
|