You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/architecture/eidos.md

1146 lines
49 KiB

---
title: Eidos — the visual layer
type: reference
audience: human + agent
authority: E1 architecture — the visual layer as a module: ActiveEidos runtime, wrappers, API conventions, picker patterns
status: current
source: migrated from src/uix/eidos/README.md (2026-07-02, docs-book F7.2)
---
# Eidos
`Eidos` is UIX's **visual layer**. It covers what the dead `air/` branch
called the "visual runtime" plus the token system — inheriting no code. It
reads from the DOM what the previous layers wrote (the morfo runtime + sema's
visual channel) and applies styles, animations and ergonomic wrappers.
```
Morfo declares the genetics
↓
Soma transcribes the behavior → DOM (data-state, data-color, aria-*)
↓
Sema emits perceptual signals → DOM (data-event-*) during the hold
↓
Eidos applies the visual: tokens, themes, recipes, archetypes, wrappers
```
Eidos never imports soma or sema internals. Its source of truth is **what is
written in the DOM** (parts, data-attrs, ARIA, archetypes, event signals)
plus the public Soma types it needs to compose wrappers.
## Not just CSS
The first mental model was "eidos = reactive CSS". Insufficient: there are
purely visual concerns (variant, size, layout flags, icon slots) that are not
part of the headless behavior yet are orthogonal to the CSS. Eidos hosts them
as **Svelte wrappers over Soma**.
```
src/uix/eidos/
active-eidos.svelte.ts → visual runtime/context and CSS generation
index.css → entrypoint importing the aggregated CSS
archetypes.css → rules common to [data-archetype=*]
events.css → global hints for [data-event-*] (sema visual)
generated/base.css → static output generated from the base EidosConfig (incl. @font-face)
lib/ → config support, recipes, CSS contract and shared types
components/{x}/ → per-component recipe + wrapper + types
{x}.css recipe CSS (selectors [data-{x}], etc.)
{x}.svelte Svelte wrapper over the Soma component
types.ts wrapper props (extends Soma's public contract)
index.ts public namespace (default + attached parts)
```
`lib/` contains the pure visual-configuration support: primitives, visual
semantics, themes, the CSS contract and rendering. `generated/base.css` is
the static artifact generated from that configuration
(`npm run generate:eidos-css`). The old `contracts/` and `themes/base/` CSS
were retired from the active tree: the public contract is obtained with
`ActiveEidos.getCssContract()` / `renderContractCss()` and the visual base
comes out of `generated/base.css`. The old `tokens/components/*` were retired
too: recipe custom-property names (`--toast-*`, `--dialog-*`, etc.) remain
the stable contract, but their base values live in `EidosConfig.recipes` and
are generated into `generated/base.css`.
## The active runtime
Eidos has one active class:
- `ActiveEidos` is the active runtime and the visual context of the Svelte
wrappers. It manages pure visual configuration, primitives, semantic roles,
themes, validation, CSS rendering and persistence. It connects that
configuration with `ActiveUix` services when there is a context: `prefs`,
`dom`, `langs`, `format` and visual helpers like `resolve(...)`,
`breakpoint(...)` or `isBelow(...)`. When `applyDom` is active, it
injects/removes `<style data-uix-eidos>` through `uix.dom`.
- `ActiveEidos.resolveToken(token)` / `resolveTokens(tokens)` resolve a theme
color token (`--scale-{name}-{step}` / `--primitive-{role}-{step}`) to a
concrete sRGB hex in **pure JS** — config + the `uix.color` engine, WITHOUT
touching the DOM. It replaces the `getComputedStyle(probe)` round-trip
consumers used to read a token's value (that read forces a reflow; this one
never touches the DOM). It honors an applied `applyColorScheme`
(override-first) and resolves theme-scoped scales. Returns `null` for
semantic slots / unresolvable tokens.
Dependency rule: a component in `src/uix/eidos/components/*` does not import
`getActiveUix()` directly. It consumes `ActiveEidos.require()` and only knows
Eidos's visual surface.
Ownership rule: `ActiveEidos` creates no shared services. In context mode,
`ActiveEidos.create(...)` obtains those services from `ActiveUix`; if a
wrapper requires one that doesn't exist, it throws. In explicit mode,
`createActiveEidos(...)` receives already-built services. With
`applyDom:false` it stays a DOM-writeless runtime: it can render, serialize
and validate CSS without inserting styles.
Important nuance: `prefs`, `langs` and `format` are optional at construction
because `ActiveEidos` also serves to generate/serialize CSS. The
`activeEidos.prefs` and `activeEidos.langs` getters DO fail early when a
visual wrapper reads them and they were not injected. `dom` is only mandatory
when `applyDom` is active.
Configuration lives in `EidosConfig`:
```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`.
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
- `border`: widths `none · thin · medium · thick · heavy` (linear 0/1/2/3/4
— `hairline` was pruned, `heavy` added; changelog §29/§35), styles
`solid · dashed · dotted` and the aliases `--border-width`,
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
`--border-style`, `--border` + `--ring-inset-width`.
- `opacity`: a dual scale — numeric plus semantic
`ghost · disabled · scrim · muted · overlay · subtle · press · hover ·
full` (changelog §29).
- `zIndex`: `base · raised · sticky · dropdown · popover · tooltip · modal · toast`.
- `shadow`: a physical `1..6` scale plus per-theme semantic aliases
`none · subtle · raised · overlay`.
The rule is the same as for size: these tokens never change meaning per
breakpoint. A component or wrapper may pick a different token at a given
viewport, but `--shadow-3`, `--opacity-disabled` or `--z-index-modal` remain
the same system coordinate.
The responsive bridge lives in `ActiveDom`/`ActiveEidos`: wrappers use
`ActiveEidos.resolve(...)`, `breakpoint(...)`, `isAtLeast(...)` and
`isBelow(...)` to decide which canonical token applies at each viewport.
`--container-width-md` or `--content-width-lg` are not recomputed
responsively; what changes is the token choice. Density follows the same
principle: `ActiveEidos` projects `data-density` and publishes active
scalars:
```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);
}
```
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
A recipe consumes `var(--space-4)` directly — density × scaling arrive
**already composed** from the foundation (`--space-4` is emitted as
`calc(16px * var(--density-space-scale) * var(--scaling))`); multiplying by
`--density-*`/`--scaling` inside a recipe double-applies them and is
forbidden (recipe-contract R-2.3 — this paragraph used to show that
anti-pattern as an example).
`themes` allows overriding scales, roles, surfaces, content, borders, focus
and shadows per theme. The current base theme exposes `base-light` and
`base-dark`; `defaultActiveEidosThemeResolver` uses the active theme when it
exists as an exact id, preserves already-qualified external ids (`*-light`,
`*-dark`) and otherwise tries `${theme}-${mode}`.
`ActiveEidos` persists no CSS to disk. With `applyDom` active it writes to
the DOM:
- `${styleId}-static` with the stable primitives (`renderStaticCss()`).
- `${styleId}-theme` with the resolved theme when it comes from the config.
`themeSource` decides where theme values come from:
- `'auto'` (default): generates CSS when the config knows the theme;
otherwise it assumes external CSS and leaves only the static tokens.
- `'config'`: strict mode; if the resolved theme is not in the config,
`renderThemeCss()` throws.
- `'css'`: never generates theme CSS; the integrator provides the values via
external CSS honoring the custom-property contract.
To inspect or publish that contract, `ActiveEidos` exposes:
- `getCssContract()` returns the structured, typed contract with each token
(`name`, `cssVar`, `scope`, `category`, `path`). The surface meant for
theme editors, validators, tooling or user persistence.
- `renderContractCss()` renders that same contract as empty CSS to document
or bootstrap external themes.
- `renderCssVariables()` accepts a custom-property map and turns it into
runtime CSS validated against Eidos's contract.
```ts
const tokens = eidos.getCssContract();
const recipes = eidos.listRecipes();
const tooltipTokens = eidos.getRecipeTokens('tooltip');
const contract = eidos.renderContractCss({
staticSelector: ':root',
themeSelector: "[data-theme='acme-light']"
});
```
The result assigns no real values; it declares the custom properties a
CSS-only theme can supply or override:
```css
[data-theme='acme-light'] {
--scale-blue-9: ;
--scale-blue-a9: ;
--primitive-primary-9: ;
--primitive-primary-a9: ;
--color-primary-solid: ;
--size-md-control-height: ;
--border-width-thin: ;
--opacity-disabled: ;
--z-index-modal: ;
--shadow-3: ;
}
```
Persistence uses a versioned envelope:
```ts
const document = eidos.toDocument();
const json = eidos.serialize();
const hydrated = createActiveEidos({
config: JSON.parse(json),
prefs,
dom
});
```
The document has the shape `{ kind: 'uix.eidos-config', version: 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 |
feat(morfo,sema,eidos): todo evento dice de que familia es, y el cruce por fin se ve El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho componentes y `emerge-open` en tres. No era estetica — un preset de movimiento engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna firma y simplemente no animaba, sin romper una sola prueba. Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos, 256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran 40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y `handle-drop` ya existian en 10 y 4 componentes. `validateMorfo` cierra la puerta: un `events[].name` que no empiece por su familia ahora lanza. Visto fallar antes con un nombre pelado inyectado. Lo que el renombrado destapo, y va aqui tambien: - La receta del splitter enganchaba `commit-resize`, muerto desde `bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el catalogo de morfos — el guard que lo habria cazado en su dia. - La familia `shift` era muda en el canal visual, contra su propia doctrina (c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora tiene firma direccional: sexto atributo del sello (`data-event-direction`, `forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por `:dir()`. Medido: LTR -30px/+30px, RTL los invierte. - El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues —media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze` moria sin pintar un fotograma. Una superficie, una ranura (A-36). - 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de decision, el componente vivo). Las docs desfasadas, corregidas; los nueve DEFECTOS de codigo obsoleto quedan abiertos y sin tocar. - `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y habia tres cosas distintas deletreadas «direction». check en su linea base con 0 errores nuevos por diferencia de conjuntos · docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador medidas con raton real y rAF vivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| `events[].name` | selectors `[data-event=emerge-dismiss]`, `[data-event^=commit]` |
| `events[].semantic.family` + `.intent` | semantic tinting of transitions |
| `events[].prewrite[]` (e.g. `data-last-action`) | tinting the exit anim by cause |
| `focus.trap` | a layout hint for overlays |
### From Soma
The `components/*` wrappers import Soma's public parts directly
(`$soma/components/{x}`) and only add Eidos's visual surface: tokens,
recipes, layout shells and presentation data-attrs. There is no per-component
intermediate façade. Example:
```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.
docs(direction): el contrato pasa a ser canon, y el corpus deja de contradecirlo El eje estaba cerrado en CODIGO y no en DOCUMENTACION. El contrato vivia solo en `CONTINUE-direction.md`, un handoff que `docs/README.md` declara «never a source of truth». Un capitulo E2 lo fija ahora: `docs/canon/direction-contract.md`. Va a canon y no a arquitectura por la misma razon que `recipe-contract.md`: es normativo y tiene guard ejecutable. Cubre la cadena y donde corre cada eslabon; por que `undefined` no es `'ltr'`; las DOS atributos —`dir` crudo (nativo, lo que `:dir()` mira) y `data-dir` resuelto (opt-in, para recetas que necesitan un hook incondicional)—; cuando el estampado es OBLIGATORIO; la doctrina de selector; las trampas que el guard no ve; que espeja y que no; y la mitad global de prefs. EL CORPUS SE CONTRADECIA en cuatro sitios, y dos de ellos ENSENABAN mal: - `component-guide.md:372` daba la cadena como `prefs → 'ltr'`, DOS eslabones, mandando al wrapper a leer prefs directamente. Eso excluye la prop. - `soma-architecture.md` §3.4 presentaba el resolutor a pelo (`soma.prefs.getDir()` dentro del provider) como LA forma de obtener la direccion — justo lo que el eje retiro del catalogo. - `html.ts` ensenaba en su JSDoc `dir?: 'ltr' | 'rtl'`, el union a mano que la regla E-2.5 ahora prohibe. El mecanismo que ensena (extender para estrechar una clave HTML) es correcto y sobrevive; solo cambia el ejemplo. - `active-architecture.md:416` no listaba `lang` en la proyeccion, contra `contracts.ts` y contra su propio test de frontera. Igual `prefs/README.md`. Y no lo mencionaba en absoluto: el glosario (`activeDir`, `resolvedDir`, `data-dir`, RTL-1, el escape `rtl-physical:`), `eidos.md` (RTL-1 es el TERCER guard de deriva y faltaba junto al builder tipado y eidos-lint), `soma.md`, `building-a-component.md` (§Known traps es exactamente donde va «la prop mueve la matematica y deja la pintura atras»), y la tabla de atributos de la arquitectura, que afirma «todo lo que viaja entre capas viaja por atributos» y omitia `dir`. El checklist gana cuatro reglas y el guard que faltaba (`rtl:check` no estaba en la matriz de aceptacion pese a que la tabla canon lo nombra), mas un recorte en E-3.5: «todas las props visuales mapean a `data-{prop}`» leia como mandato de estampar `data-dir`, que `:dir()` no puede ver. DIVERGENCIA DECLARADA (§7): la familia `chart` no tiene prop ni provider y resuelve leyendo `getComputedStyle(node).direction`. Es el unico sitio del catalogo que hace lo que §1 prohibe. Se registra en vez de esconderse. Tres verificadores adversariales sobre el barrido; sus hallazgos, corregidos: RTL-1 estaba anunciado como guard de TODO el capitulo (solo cubre §4) · «nunca lee al padre» era absoluto y borraba la composicion sancionada en el punto de llamada · el estampado se afirmaba incondicional en un sitio y condicional en otro · las cuatro filas nuevas usaban una aplicabilidad que ni la leyenda ni `component-audit.ts` conocen. `docs:check` 0 errores sobre 564 docs. `check` 77 = la linea base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
Direction crosses this boundary as two different attributes: the native `dir`,
which is what `:dir()` matches, and the opt-in `data-dir`, which a component
stamps only where a recipe needs a hook that always matches. Which one a recipe
may read, and when a provider must stamp at all, is fixed in
[`canon/direction-contract.md`](../canon/direction-contract.md).
### From sema (DOM)
Only the DOM. The visual channel projects `data-event-*` during the hold via
`SignalProjector` and eidos reacts through the generated signatures:
```css
[data-event-family='commit'][data-event-phase='active'] {
animation: eidos-commit-settle 260ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}
feat(morfo,sema,eidos): todo evento dice de que familia es, y el cruce por fin se ve El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho componentes y `emerge-open` en tres. No era estetica — un preset de movimiento engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna firma y simplemente no animaba, sin romper una sola prueba. Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos, 256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran 40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y `handle-drop` ya existian en 10 y 4 componentes. `validateMorfo` cierra la puerta: un `events[].name` que no empiece por su familia ahora lanza. Visto fallar antes con un nombre pelado inyectado. Lo que el renombrado destapo, y va aqui tambien: - La receta del splitter enganchaba `commit-resize`, muerto desde `bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el catalogo de morfos — el guard que lo habria cazado en su dia. - La familia `shift` era muda en el canal visual, contra su propia doctrina (c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora tiene firma direccional: sexto atributo del sello (`data-event-direction`, `forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por `:dir()`. Medido: LTR -30px/+30px, RTL los invierte. - El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues —media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze` moria sin pintar un fotograma. Una superficie, una ranura (A-36). - 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de decision, el componente vivo). Las docs desfasadas, corregidas; los nueve DEFECTOS de codigo obsoleto quedan abiertos y sin tocar. - `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y habia tres cosas distintas deletreadas «direction». check en su linea base con 0 errores nuevos por diferencia de conjuntos · docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador medidas con raton real y rAF vivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] {
animation: shift-cross-forward var(--duration-slow) var(--ease-emphasized);
}
```
The per-family/intent signatures live in `EidosConfig.motion` and are
generated into `generated/base.css`; `events.css` keeps only the global
compositor hint + the reduced-motion cap (see
[`eidos-motion.md`](../theming/motion.md) §15).
feat(morfo,sema,eidos): todo evento dice de que familia es, y el cruce por fin se ve El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho componentes y `emerge-open` en tres. No era estetica — un preset de movimiento engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna firma y simplemente no animaba, sin romper una sola prueba. Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos, 256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran 40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y `handle-drop` ya existian en 10 y 4 componentes. `validateMorfo` cierra la puerta: un `events[].name` que no empiece por su familia ahora lanza. Visto fallar antes con un nombre pelado inyectado. Lo que el renombrado destapo, y va aqui tambien: - La receta del splitter enganchaba `commit-resize`, muerto desde `bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el catalogo de morfos — el guard que lo habria cazado en su dia. - La familia `shift` era muda en el canal visual, contra su propia doctrina (c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora tiene firma direccional: sexto atributo del sello (`data-event-direction`, `forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por `:dir()`. Medido: LTR -30px/+30px, RTL los invierte. - El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues —media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze` moria sin pintar un fotograma. Una superficie, una ranura (A-36). - 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de decision, el componente vivo). Las docs desfasadas, corregidas; los nueve DEFECTOS de codigo obsoleto quedan abiertos y sin tocar. - `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y habia tres cosas distintas deletreadas «direction». check en su linea base con 0 errores nuevos por diferencia de conjuntos · docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador medidas con raton real y rAF vivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
The third rule is where the layer split earns its keep. `shift` **sounds** like
a slide (`SEMA_MAP.families.shift.sounds.default = 'slide'`) and until
2026-08-11 it did not slide, because sema owns no motion and eidos had written
FAMILY-keyed signatures for `contact`, `commit` and `delegate` only (`emerge`
and `signal` are covered, but keyed by EVENT name) — the *shift invisible*
antipattern (book ch. 34 §14) living inside the framework that names it. The
missing half was always eidos's to write: sema stamps the SENSE
(`data-event-direction`, decided per emit, `forward` | `backward`) and eidos
decides that a sense means the inline axis. The keyframes multiply their
distance by `--motion-shift-sign`, emitted `+1` under `:dir(ltr)` and `−1` under
`:dir(rtl)` — a physical `translateX` would have shipped a slide that runs
backwards in Arabic.
The stamped node is the event's **subject**, not a paint instruction. Morfo
puts the gesture where the hand is and the terminal where the value lives
([`architecture/morfo.md`](./morfo.md) §Where the stamp lands), so a recipe
routinely needs to paint something the stamp never touches. That is a
**descendant selector** — never a reason to move the stamp:
```css
/* Splitter commits on the provider; the handle is what pulses. */
[data-splitter][data-event='commit-set'][data-event-phase='active']
[data-splitter-resize-trigger] {
background: var(--splitter-handle-bg-active, var(--color-primary-solid));
}
```
The descent costs nothing in specificity terms: four attribute selectors
(0,4,0) against the (0,2,0) ceiling of every other rule on that handle, so the
signature wins without an `!important` or a manufactured hook. The diagnostic
question when a rule doesn't fire is always **is the stamped node the subject
of the event?** — if it is, the recipe descends; if it isn't, the morfo is
wrong. A stamp relocated to make a selector shorter breaks the sound and haptic
projections, which read the same target and have no CSS to compensate with.
## What it does NOT consume
- **The provider's logical computed state** (e.g. the composition of a
component's own `isDisabled` with a Field's inherited `disabled`). Eidos
only sees the result: `[data-disabled]` is there or it isn't.
- **Runtime internals**. It doesn't know whether an attr was written by
`dom.apply`, Svelte render or the provider manually. It only cares that it
is there.
- **Soma layers** (Presence, Dismissal, ScrollLock, FocusScope). It reacts to
their visible effects, not to their existence.
## The `--*` token rule
Eidos owns the `--*` namespace in the visual layer. Reasons:
1. **Authorship clarity at debug time** — inspecting an element and seeing
`--toggle-bg` says it came from UIX's visual layer.
2. **Override discipline** — a consumer overriding `--color-primary-element`
knows they are touching the visual contract, not name-colliding with a
local variable.
The upper layers (sema, soma, morfo) **do NOT consume** these tokens and do
not use the prefix. Each carries its own concerns (perceptual durations,
behavior, contract DNA) orthogonal to visual rendering.
## Theming, tokens, bundle — see `THEMING.md`
The full theming-system doctrine (token scope contract, token layers, naming
conventions, bundle purge, motion tokens, comparison with references,
anti-patterns, FAQ) lives in
[`THEMING.md`](../theming/reference.md). That is the canonical
reference.
The **transversal systems every recipe must consume** (state-layer,
per-archetype focus, tokenized elevation, 1:1 typography, opacity, the motion
channel, logical axes) are their own canon in
[`canon/recipe-contract.md`](../canon/recipe-contract.md), enforced by
the R-4.x rules of `scripts/component-audit.ts`.
## Motion — guide in `MOTION_GUIDE.md`, model in `eidos-motion.md`
- **How to animate (the task-oriented front door)** →
[`theming/motion-guide.md`](../theming/motion-guide.md): the `motion`
prop, the 3 usage domains (event/state/**content**), the preset catalog,
loops (`spin`/`pulse`/…), state-domain (`<Card>`), staggered cascade,
reduced-motion, theming and debug.
- **The engine's model and architecture** (two moments `--event`/`--state`,
`keyframes` + `signatures` + `presets`, CSS generation, JS drivers,
productive/expressive sets, typegen) →
[`eidos-motion.md`](../theming/motion.md). The **engine**
(`EngineMotion`) is a service in `arts/motion` (`uix.motion`); Eidos
delegates via `eidos.motion` and registers its `css` presets there.
- **Decisions and history** (incl. the "coordinated" engine retired in
Plan A) →
[`MOTION_SERVICE_RFC.md`](../../src/uix/eidos/MOTION_SERVICE_RFC.md).
Quick map of what THEMING.md covers, to avoid duplicating here:
| Topic | Section in THEMING.md |
| --- | --- |
| Why theming lives in Eidos and not in Morfo | §1.bis |
| 7 token layers and override points | §3 |
| The 9 canonical color roles | §4 |
| The canonical sizes | §5 |
| Naming conventions | §6 |
| Token Scope Contract (TSC): type, scope algebra, cross-axis | §7 |
| TSC v2.2: multi-part scope (`parts: [...]`) + cross-recipe composition | §7 |
| How to add a new component | §8 |
| How to define a theme | §9 |
| How to override tokens at runtime | §10 |
| Bundle strategy + `eidos:purge` | §11 |
| Sema integration via `event:*` (superseded — see the stub) | §13 |
| Anti-patterns and FAQ | §16, §17 |
| Universal TSC coverage (no exceptions) | §18 |
| Variants are eidos canon, NOT the theme's (with `EIDOS_VARIANTS`) | §19 |
| Engine corrections: live density + on-solid `contrast` | §20 |
| The `scaling` axis (global zoom), separate from density | §23 |
| P2 corrections: on-solid text by luminance + translucent surfaces | §24 |
| Color model: palette (33 scales) + roles (aliases) + intents (auto-derived) | §25 |
| Runtime theme builder: `eidos.applyColorScheme(seed)` (`uix.color` engine) | §26 |
| Wide-gamut OKLCH output, default-on (hex fallback + `oklch()` sibling) | §27 |
| a11y forced-colors (focus outline fallback) + border ramp (slot 6→7) | §28 |
| Scale canon — the theming audit: blur · inset-shadow/ring · gradients · breakpoints+container · opacity · border-width · tracking | §35 |
| Focus ring: two parameterized rings (fields, box-shadow) + `outline` on surfaces | §32 |
| Touch-target — 44px on touch, gated by `pointer: coarse` | §37 |
| State layer `--state-*` — unified neutral feedback (MD3, theme-adaptive) | §38 |
What follows in this chapter are the operational decisions of the **visual
layer as a module** (typography sourcing, picker patterns, API conventions,
the active runtime), independent of the theming system.
## Typographic vertebration — single source of truth in the foundation
Eidos has **two typographic anchors, in different layers, by design**. This
section documents why and how they relate.
### The two layers
```
src/uix/eidos/lib/primitives/typography.ts
│
├── families / sizes / weights (numerical scale)
│ ↓
│ foundation tokens
│ --font-family-{primary,secondary,display,mono}
│ --font-size-{xxs..xxxl}
│ --font-weight-{regular,medium,semibold,bold}
│ --font-line-height-{xxs..xxxl}
│
└── styles (semantic layer)
↓
named-style tokens
--style-{hero,h1..h6,body,prose,label,caption,code}-{font-family,
font-size,line-height,letter-spacing,font-weight,color}
```
| Layer | Who consumes it | For what |
|---|---|---|
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
| **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.
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
### How they vertebrate without duplication — recipes read the named style
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
Where a value coincides between the two layers, **the consumer reads the
named style directly, not the other way around**. Single source of truth: the
named style. (Until 2026-07-06 a `--font-ui` alias sat between the two — it
died with the token-alias purge, one name per concept; recipes now consume
`var(--style-label-font-family)` themselves, and validation requires
`styles.label.family` so the anchor always exists. `--leading-ui` is a
different animal: it is config DATA — `typography.semanticLeading.ui` — whose
authored value references the style.)
```css
/* generated/base.css (via render-css.ts) */
:root {
/* Named style — source of truth */
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
/* Semantic leading — config data anchored to the style */
--leading-ui: var(--style-label-line-height, 1.25);
}
/* recipes/base.ts → generated/base.css */
:root {
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress) Re-audit of the whole component catalog at pilot depth (91 fichas + the checkpoint verdicts in docs/audit/components/) and the executed fix packages. - P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 + API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d, component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts (role=application removed ×4, aria-selected off the Day, drp translationRef, field data-state prune, pin-input commit-set, media-player renames), 13 new sema packs + 12 morfos family-default → pack. - P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar); onValueCommit terminal-callback norm (pin-input/search/password/textarea + date/time/color-field add); typed validation reason + onInvalid (tags-input, css-field); index/onIndexChange (carousel); deselectable; openDelay/ groupSkipDelay; allowCustomValue; defaultValue prune. - P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel); touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual, 44 AAA). S6 (Field composition) + S8 (calendar-surface) pending. Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline); morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched components; per-component vitest suites green. Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md Excluded (broken by the N1 rename, left broken per user decision, not staged): words/**, palabras/**, chronos, web/routes/alpha/**. Reconciliation pending: the touch-rows ::before for checkbox/switch reverses changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed markers to a labeled-row/Field task — flagged for the user in the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
--accordion-trigger-font-family: var(--style-label-font-family);
--field-label-line-height: var(--leading-ui);
--field-control-line-height: var(--leading-ui);
/* … dozens more recipe tokens */
}
/* components/field/field.css */
[data-field-label] {
line-height: var(--field-label-line-height);
}
```
Changing `STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3'` (in
`primitives/typography.ts`) propagates to `--style-label-line-height` →
`--leading-ui` → every recipe → every component. **One edit** reaches Field,
Form, Combobox, Select, Toolbar, Toast and the typography primitives
simultaneously.
The `, 1.25` / `, var(--font-family-primary)` fallbacks guarantee the system
keeps producing valid CSS if a consumer turns the named styles off in their
foundation override.
### Why component recipes do NOT read `--style-{name}-*` directly
A recurring temptation: "every component should read
`--style-label-font-size` for coherence". **That is not the way.**
1. **T-shirts don't fit four buckets.** A Field with `size="xs"` has a
smaller label than the "canonical label". If its recipe read
`--style-label-font-size`, you'd lose that scaling or you'd need
`--style-label-{xs,sm,md,lg,xl}-*`, replicating what the recipe tokens
already are.
2. **Per-component nuances are legitimate.** Field's message, Tooltip's
description and Card's subtitle are all "caption-ish" but each wants its
own color/weight/letter-spacing. Forcing them into one
`--style-caption-*` kills expressiveness.
3. **Coupling lock-in.** On day 100 the system wants `label-form`,
`label-table`, `label-chart`. Forcing the coupling on day 1 leads to
replicating the recipe hierarchy in the semantic layer.
4. **How the references do it.** Radix Themes, Mantine, MUI all have a
numerical scale components read; the semantic layer exists only for the
typography primitives (`<Text variant="body2">`). Chakra v3 offers a
coupleable `textStyle` but most of its components hardcode anyway.
**Coupling everything to the semantic layer is not the dominant
practice** — for good reasons (1–3).
### Audit: rule `R-2.7`
`scripts/component-audit.ts` detects typographic literals in component CSS:
`font-size: 12px`, `line-height: 1.4`, `font-weight: 500`,
`letter-spacing: 0.02em` not wrapped in `var()`. Severity `warn`, not
`error` — the component still passes, but it stays visible in the report.
**Escape valves** (they don't count as drift):
- values in `var(...)`
- zeros and identities: `0`, `0px`, `0em`, `0rem`, `1`
- keywords: `inherit`, `initial`, `unset`
- an inline comment: `font-size: 13px; /* literal: tight icon affordance */`
If you need a justified literal, annotate it. Otherwise, tokenize it.
## The "2-of-3" rule (inherited from morfo)
A morfo extension is justified when **at least two of the three layers**
(soma, sema, eidos) consume it. The ones that entered with eidos's vote:
- `archetype` — eidos + sema (+ soma as emitter)
- `events[].semantic.family/intent` — sema + eidos
- `events[].prewrite[]` — soma (executes) + eidos (animates)
- `data-starting-style` / `data-ending-style` — soma (Presence) + eidos (animates)
## API conventions — disciplined option C
The doctrinal API conventions were seeded in
[`guia-semantica-historica.md`](../decisions/guia-semantica-historica.md)
§13 (historical; instantaneous operation = one event; the unified token
system; intent ↔ color resolution; per-component subset; iconOnly sr-only;
sound prepare-time priming).
The **public shape** convention of an eidos component lives in
[`components/README.md`](../../src/uix/eidos/components/README.md). Summary —
7 hard rules:
1. **One entry point per component** — the default export is the visual root
component, named like the component (`<Drawer>`, `<Tabs>`, `<Checkbox>`).
NOT `<Drawer.Provider>`, NOT `<Drawer.Root>`.
2. **The root lives in `{name}.svelte`** — NOT in `{name}-provider.svelte`.
One file per root.
3. **Do NOT export `Provider` publicly.** The "Provider compound vs flat"
separation is a retired invention; there is ONE compound shape (root +
children attached as properties).
4. **Children follow the air / headless naming**: `Trigger`, `Content`,
`Overlay`, `Title`, `Description`, `Close`, `Portal`, `Header`, `Footer`,
`Item`, `Indicator`, `HiddenInput`, `Group`, `Label`. Don't invent names.
5. **`Portal` is included where air had it** (Dialog, Drawer, Popover,
Tooltip — portaled overlays). Imported from `$soma/components/internal`.
6. **NO flat API with snippet slots as the main shape** —
`<Drawer trigger={...} title={...}>` is retired. The explicit compound
exposes the composition decisions the flat shape hid.
7. **Children attached with explicit assignment**, not `Object.assign`
(Svelte 5 can mishandle bulk mutation of the component constructor during
hydration):
```ts
const Drawer = DrawerRoot as DrawerNamespace;
Drawer.Trigger = Trigger;
Drawer.Content = Content;
```
Plus implementation rules:
8. **Wrapper, not fork.** The eidos `.svelte` imports Soma's public namespace
(`import * as Drawer from '$soma/components/drawer'`), uses
`<Drawer.Provider>` / `<Drawer.Trigger>` internally and adds the visual
token data-attrs. It does not reimplement state.
9. **`Size` from `lib/types.ts`.** Components accepting sizes reuse the
shared type and narrow to the subset their recipe supports
(`Extract<Size, 'sm' | 'md' | 'lg'>`).
10. **No `Eidos` prefix** on types. The path `$uix/eidos/components/{x}`
already identifies the layer.
11. **Recipe CSS without an `--eidos-` prefix.** Custom properties use
`--{component}-…` for public tokens and `--_{component}-…` for internal
ones.
### Why disciplined option C
The previous pattern (a flat default with snippet slots + a duplicated
Provider compound) had two problems:
- **Double truth**: two APIs (flat with `trigger`/`title`/`actions` snippets,
compound with explicit children) reached the same functionality by
divergent paths. Any visual tweak had to update both.
- **It invented over air**: air was pure compound (`<Drawer.Provider>` with
children). The snippet-flat was an invention with no baseline and no user
sign-off.
Disciplined option C:
- Takes from **air** the children convention (Trigger, Content, Portal, …)
- Takes from **bits-ui / shadcn-svelte** the ergonomics of the root with
attached properties (`<Drawer><Drawer.Trigger>`)
- Removes the snippet-flat invention (it was neither air nor soma)
### Special case — Toast
It has two independent roots (not nested):
- `<Toast>` — the manual compound (the consumer iterates `toaster.toasts`)
- `<Toaster />` — the imperative auto-mount (the default template)
Separate exports; `Toaster` is NOT attached as `Toast.Toaster` because it is
a competing root, not a child. Documented in
[`components/toast/index.ts`](../../src/uix/eidos/components/toast/index.ts).
## Selector-drift defense
The morfo → soma → eidos chain depends on the selectors eidos writes
(`[data-{component}]`, `[data-{component}-{part}]`, `[data-state=...]`,
`[data-event-*=...]`) staying in tune with the attrs the morfo declares and
docs(direction): el contrato pasa a ser canon, y el corpus deja de contradecirlo El eje estaba cerrado en CODIGO y no en DOCUMENTACION. El contrato vivia solo en `CONTINUE-direction.md`, un handoff que `docs/README.md` declara «never a source of truth». Un capitulo E2 lo fija ahora: `docs/canon/direction-contract.md`. Va a canon y no a arquitectura por la misma razon que `recipe-contract.md`: es normativo y tiene guard ejecutable. Cubre la cadena y donde corre cada eslabon; por que `undefined` no es `'ltr'`; las DOS atributos —`dir` crudo (nativo, lo que `:dir()` mira) y `data-dir` resuelto (opt-in, para recetas que necesitan un hook incondicional)—; cuando el estampado es OBLIGATORIO; la doctrina de selector; las trampas que el guard no ve; que espeja y que no; y la mitad global de prefs. EL CORPUS SE CONTRADECIA en cuatro sitios, y dos de ellos ENSENABAN mal: - `component-guide.md:372` daba la cadena como `prefs → 'ltr'`, DOS eslabones, mandando al wrapper a leer prefs directamente. Eso excluye la prop. - `soma-architecture.md` §3.4 presentaba el resolutor a pelo (`soma.prefs.getDir()` dentro del provider) como LA forma de obtener la direccion — justo lo que el eje retiro del catalogo. - `html.ts` ensenaba en su JSDoc `dir?: 'ltr' | 'rtl'`, el union a mano que la regla E-2.5 ahora prohibe. El mecanismo que ensena (extender para estrechar una clave HTML) es correcto y sobrevive; solo cambia el ejemplo. - `active-architecture.md:416` no listaba `lang` en la proyeccion, contra `contracts.ts` y contra su propio test de frontera. Igual `prefs/README.md`. Y no lo mencionaba en absoluto: el glosario (`activeDir`, `resolvedDir`, `data-dir`, RTL-1, el escape `rtl-physical:`), `eidos.md` (RTL-1 es el TERCER guard de deriva y faltaba junto al builder tipado y eidos-lint), `soma.md`, `building-a-component.md` (§Known traps es exactamente donde va «la prop mueve la matematica y deja la pintura atras»), y la tabla de atributos de la arquitectura, que afirma «todo lo que viaja entre capas viaja por atributos» y omitia `dir`. El checklist gana cuatro reglas y el guard que faltaba (`rtl:check` no estaba en la matriz de aceptacion pese a que la tabla canon lo nombra), mas un recorte en E-3.5: «todas las props visuales mapean a `data-{prop}`» leia como mandato de estampar `data-dir`, que `:dir()` no puede ver. DIVERGENCIA DECLARADA (§7): la familia `chart` no tiene prop ni provider y resuelve leyendo `getComputedStyle(node).direction`. Es el unico sitio del catalogo que hace lo que §1 prohibe. Se registra en vez de esconderse. Tres verificadores adversariales sobre el barrido; sus hallazgos, corregidos: RTL-1 estaba anunciado como guard de TODO el capitulo (solo cubre §4) · «nunca lee al padre» era absoluto y borraba la composicion sancionada en el punto de llamada · el estampado se afirmaba incondicional en un sitio y condicional en otro · las cuatro filas nuevas usaban una aplicabilidad que ni la leyenda ni `component-audit.ts` conocen. `docs:check` 0 errores sobre 564 docs. `check` 77 = la linea base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
the runtime emits. There are two distinct defenses by consumer type, plus a
third guard on the direction axis:
### Compile-time (TS / Svelte) — the typed builder
Any TypeScript consumer building selectors **MUST** use
`semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. This covers
the cascade rules in `sema/components/*.ts` and any TypeScript logic in
`eidos/components/{x}/` targeting morfo-backed attrs.
```ts
import { semaSelector } from '$uix/morfo';
import { toggleMorfo } from '$uix/morfo/components/toggle';
semaSelector(toggleMorfo, 'provider', { eventName: 'commit-toggle' });
// → '[data-toggle][data-event="commit-toggle"]'
```
Renaming a part or an event in the morfo breaks the typecheck. A TypeScript
selector cannot drift silently. See
[`architecture/morfo.md#typed-selector-builder--semaselector`](./morfo.md#typed-selector-builder--semaselector).
### Run-time (CSS recipes) — `eidos-lint` as an opt-in safety net
Recipes are plain CSS (`components/{x}/{x}.css`); there is no typed builder
on the CSS side. For that surface:
```bash
node scripts/eidos-lint.ts toggle # one component
node scripts/eidos-lint-all.ts # all of them
```
It classifies every `[data-*]` selector as:
- **morfo-backed** — declared in the morfo; the soma runtime emits it; the
value (when there is an enum) falls inside `data[].values`.
- **eidos-only** — the marker is there, but at least one `data-*` is not
declared in the morfo. Valid by convention (visual tokens like
`data-variant`, `data-size` come from the wrapper).
- **invalid** — references a declared attr with a value outside the enum. A
bug.
feat(morfo,sema,eidos): todo evento dice de que familia es, y el cruce por fin se ve El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho componentes y `emerge-open` en tres. No era estetica — un preset de movimiento engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna firma y simplemente no animaba, sin romper una sola prueba. Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos, 256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran 40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y `handle-drop` ya existian en 10 y 4 componentes. `validateMorfo` cierra la puerta: un `events[].name` que no empiece por su familia ahora lanza. Visto fallar antes con un nombre pelado inyectado. Lo que el renombrado destapo, y va aqui tambien: - La receta del splitter enganchaba `commit-resize`, muerto desde `bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el catalogo de morfos — el guard que lo habria cazado en su dia. - La familia `shift` era muda en el canal visual, contra su propia doctrina (c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora tiene firma direccional: sexto atributo del sello (`data-event-direction`, `forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por `:dir()`. Medido: LTR -30px/+30px, RTL los invierte. - El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues —media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze` moria sin pintar un fotograma. Una superficie, una ranura (A-36). - 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de decision, el componente vivo). Las docs desfasadas, corregidas; los nueve DEFECTOS de codigo obsoleto quedan abiertos y sin tocar. - `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y habia tres cosas distintas deletreadas «direction». check en su linea base con 0 errores nuevos por diferencia de conjuntos · docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador medidas con raton real y rAF vivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
#### 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.
docs(direction): el contrato pasa a ser canon, y el corpus deja de contradecirlo El eje estaba cerrado en CODIGO y no en DOCUMENTACION. El contrato vivia solo en `CONTINUE-direction.md`, un handoff que `docs/README.md` declara «never a source of truth». Un capitulo E2 lo fija ahora: `docs/canon/direction-contract.md`. Va a canon y no a arquitectura por la misma razon que `recipe-contract.md`: es normativo y tiene guard ejecutable. Cubre la cadena y donde corre cada eslabon; por que `undefined` no es `'ltr'`; las DOS atributos —`dir` crudo (nativo, lo que `:dir()` mira) y `data-dir` resuelto (opt-in, para recetas que necesitan un hook incondicional)—; cuando el estampado es OBLIGATORIO; la doctrina de selector; las trampas que el guard no ve; que espeja y que no; y la mitad global de prefs. EL CORPUS SE CONTRADECIA en cuatro sitios, y dos de ellos ENSENABAN mal: - `component-guide.md:372` daba la cadena como `prefs → 'ltr'`, DOS eslabones, mandando al wrapper a leer prefs directamente. Eso excluye la prop. - `soma-architecture.md` §3.4 presentaba el resolutor a pelo (`soma.prefs.getDir()` dentro del provider) como LA forma de obtener la direccion — justo lo que el eje retiro del catalogo. - `html.ts` ensenaba en su JSDoc `dir?: 'ltr' | 'rtl'`, el union a mano que la regla E-2.5 ahora prohibe. El mecanismo que ensena (extender para estrechar una clave HTML) es correcto y sobrevive; solo cambia el ejemplo. - `active-architecture.md:416` no listaba `lang` en la proyeccion, contra `contracts.ts` y contra su propio test de frontera. Igual `prefs/README.md`. Y no lo mencionaba en absoluto: el glosario (`activeDir`, `resolvedDir`, `data-dir`, RTL-1, el escape `rtl-physical:`), `eidos.md` (RTL-1 es el TERCER guard de deriva y faltaba junto al builder tipado y eidos-lint), `soma.md`, `building-a-component.md` (§Known traps es exactamente donde va «la prop mueve la matematica y deja la pintura atras»), y la tabla de atributos de la arquitectura, que afirma «todo lo que viaja entre capas viaja por atributos» y omitia `dir`. El checklist gana cuatro reglas y el guard que faltaba (`rtl:check` no estaba en la matriz de aceptacion pese a que la tabla canon lo nombra), mas un recorte en E-3.5: «todas las props visuales mapean a `data-{prop}`» leia como mandato de estampar `data-dir`, que `:dir()` no puede ver. DIVERGENCIA DECLARADA (§7): la familia `chart` no tiene prop ni provider y resuelve leyendo `getComputedStyle(node).direction`. Es el unico sitio del catalogo que hace lo que §1 prohibe. Se registra en vez de esconderse. Tres verificadores adversariales sobre el barrido; sus hallazgos, corregidos: RTL-1 estaba anunciado como guard de TODO el capitulo (solo cubre §4) · «nunca lee al padre» era absoluto y borraba la composicion sancionada en el punto de llamada · el estampado se afirmaba incondicional en un sitio y condicional en otro · las cuatro filas nuevas usaban una aplicabilidad que ni la leyenda ni `component-audit.ts` conocen. `docs:check` 0 errores sobre 564 docs. `check` 77 = la linea base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### Direction — `:dir(rtl)`, and what RTL-1 does not cover
A recipe branches on direction with `:dir(rtl)`. The descendant form
`[dir='rtl'] …` is forbidden; `[data-dir='rtl']` reads a different attribute
and is legitimate where a component stamps it. The reasons, and the rule for
when a provider must stamp at all, are in
[`canon/direction-contract.md`](../canon/direction-contract.md).
`RTL-1` (`src/uix/eidos/rtl-lint.ts`, run by `npm run rtl:check`) guards CSS
text, not the contract: it flags a logical inline anchor paired with a physical
inline translate inside one block. It does not check the selector form, which
attribute the provider stamps, or the resolution chain — those are held by
review.
fix(cleanroom): F2+F3+F4-C+SEM-4s1 — lote mecánico, censos con guard, corpus documental y el close polimórfico de los pickers VIVO F2 — lote mecánico (13 ítems): - DEP-2 ogl eliminado (0 imports) · DEP-1 clsx inlineado como toClassString propio + suite de contrato (props.test.ts; soma.md §12 cerrado). - THM-7: los 5 selectores manuales de sema.md reescritos con semaSelector (los ejemplos [data-toast-root] apuntaban a un part INEXISTENTE — la deriva que el builder previene, demostrada en el propio doc). - MOR-1 escape isomorfo + validación de attr-names en semaSelector + 9 tests (selectors.test.ts, matches() real con comillas/corchetes) · MOR-2 partMarkerAttr = única fuente compilador↔builder + test de paridad · MOR-3 _resetCompileCache borrado (0 usos). - SOM-2 keydown continue en match sin handler + keyboardFixtureMorfo · SOM-1 no-await de handlers (censo async = 0; contrato V1 cumplido) + pin. - SEM-2 trigger pre-attacha catch con logger (void trigger sin unhandled rejection; throw intacto para awaiters) + pin · SEM-3 fallback muerto de applyDominance → skip defensivo + timer tope de awaitExpression cancelado · SEC-1 adjudicado YA implementado (assertCssVariableValue desde 2026-05-11) + pin del path de VALOR. - accordion → outline (§32; su outline:none dejaba CERO anillo en HCM) — verificado en vivo · THM-6 radius-full 9999px · EID-4 recuentos 33. F3 — censos con guard: - SOM-3 cerrado: announcer + image-provider migrados a scheduler-preferred (consumidores cableados: date/time-field vía soma.uix.timers; avatar/image vía eidos.timers — verificado en vivo); guard de timers ENSANCHADO de soma/components a TODO soma y pasado a EVIDENCIA (setTimeout exige .schedule( en el fichero — layers/ y datetime/ escapaban del ámbito viejo). - THM-5: R-4.7 nueva (válvula same-line /* important: <razón> */, escaneo comment-blanked) + las 15 declaraciones anotadas con su razón + canon recipe-contract §3/§4. - SOM-4 adjudicado: el censo/guard YA existían (49 pins); knob/mask-field/ timeline pinneados (overrides documentados en call-site); media-player Batch-4 (35 hits, cero renderProps) = único batch restante, registrado. - THM-4 doctrinado en eidos.md §unused (comportamiento/composición = legítimo; deuda = eje visual sin consumidor; hotspots por lotes). F4-C — corpus documental (decisiones de usuario aplicadas): - DOC-3: los 15 enlaces muertos resueltos (repoint a la edición FINAL trackeada / des-link históricos) · docs:check I6-links WARN→ERROR. - DOC-1: tabla «Build contract» MIGRADA a component-guide con estados modernizados (A3–A5 → LIVE + guards de hoy); banners reapuntados; citas de CANON/sema.md historificadas; lápida-redirect en el §13 del fósil. - DOC-4: hold chain → holds.ts · FAQ event:* SUPERSEDED por signatures · gradient añadido a los DOS capstones (sextet real) · nota de paleta de demo-authoring corregida (universalPaletteDecls + decisión THM-2 = mecanismo universal como sucesor del tracker borrado). - DOC-5/6: recuentos anti-frágiles datados · §4.11 dup → §4.12 · Known gaps historificado · N-6/N-7 recuperadas de git (d68d2c45^) y canonizadas en eidos.md §pickers · authoring E2 → canon/tsc.md · air-old des-linkado · EID-3 (placement) en la fila RTL · AUX-2 disabledDom documentado. SEM-4 sesión 1 — el close polimórfico de los pickers, VIVO (D.11): - Reconciliación: los morfos ya no declaran close (delegated al Popover, de-dialoged 06-27); el agujero real era el cierre programático bypaseando dismissWith → save/cancel/select eran perceptualmente SILENCIOSOS. - Fix: PickerShellHandle.setPopoverDismiss + closeWith(cause) en los 5 providers (14 sitios; select/commit → 'save' = commit.save+fulfill, cancel → 'cancel' = emerge; fallback raw para headless) + UN inyector en el eidos PickerShell root (norma N-8). Picker genérico fuera a propósito (ya suena commit-set/cancel por diseño S9). - Verificado en vivo (date-picker): Done → close·commit·fulfill·active · Cancel → close·emerge · cierre real. Gates: matriz 141/141 (los 6 morfos nuevos de la pista de texto paralela también PASS) · contracts 38/38 · eidos 314 · sema 178 · morfo 94 · docs:check 0/0 con I6 en error · baseline propio 57. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
### The `unused` column — doctrine (THM-4, 2026-07-11)
`eidos-lint-all` also reports **contract selectors with no CSS rule** (~1150
across the catalog). "Declared and never styled" is NOT an error category —
the morfo declares the component's whole BEHAVIORAL surface, not just its
paintable one — but it isn't noise either. The criterion:
- **Legitimate without a consumer** (the majority):
- *behavioral / a11y attrs* — state mirrors that exist for JS, tests,
assistive tech or app-land selectors (`data-state` on parts the recipe
styles via a parent, `aria-*` reflections);
- *composition artifacts* — a component whose visual lives in a SHARED
layer or in its composed children shows its own contract as "unused"
(css-field's 21 live in `spin-field.css`; collapsible is headless by
design and its consumers style it; picker roots restyle the composed
field/calendar contracts instead);
- *cross-component selectors* — entries like
`[data-popover-content] [data-year-grid]` are consumed from the
SIBLING's recipe, which the per-component report can't see.
- **Debt** (the minority worth burning): an attr that was declared FOR a
visual axis and that nothing anywhere consumes — no recipe, no shared
layer, no sibling, no behavioral reason. Disposition: consume it or prune
it from the morfo (never leave "declared for styling, styled nowhere").
Adjudication is dossier-work (each entry needs the morfo's intent), so it
runs as scoped batches over the hotspots — media-player (47), stepper (30),
color-picker (29), avatar (28), time-range-picker (26) — registered in the
clean-room continuation plan. The lint column stays severity-less until a
batch shows the debt rate justifies a rule.
## Picker patterns (the reusable contract)
The pickers (`date-picker`, `date-range-picker`, `time-picker`,
`time-range-picker`, `color-picker`) share a common contract, documented here
as the canonical reference — any new picker builds on this skeleton. Two
fix(cleanroom): F2+F3+F4-C+SEM-4s1 — lote mecánico, censos con guard, corpus documental y el close polimórfico de los pickers VIVO F2 — lote mecánico (13 ítems): - DEP-2 ogl eliminado (0 imports) · DEP-1 clsx inlineado como toClassString propio + suite de contrato (props.test.ts; soma.md §12 cerrado). - THM-7: los 5 selectores manuales de sema.md reescritos con semaSelector (los ejemplos [data-toast-root] apuntaban a un part INEXISTENTE — la deriva que el builder previene, demostrada en el propio doc). - MOR-1 escape isomorfo + validación de attr-names en semaSelector + 9 tests (selectors.test.ts, matches() real con comillas/corchetes) · MOR-2 partMarkerAttr = única fuente compilador↔builder + test de paridad · MOR-3 _resetCompileCache borrado (0 usos). - SOM-2 keydown continue en match sin handler + keyboardFixtureMorfo · SOM-1 no-await de handlers (censo async = 0; contrato V1 cumplido) + pin. - SEM-2 trigger pre-attacha catch con logger (void trigger sin unhandled rejection; throw intacto para awaiters) + pin · SEM-3 fallback muerto de applyDominance → skip defensivo + timer tope de awaitExpression cancelado · SEC-1 adjudicado YA implementado (assertCssVariableValue desde 2026-05-11) + pin del path de VALOR. - accordion → outline (§32; su outline:none dejaba CERO anillo en HCM) — verificado en vivo · THM-6 radius-full 9999px · EID-4 recuentos 33. F3 — censos con guard: - SOM-3 cerrado: announcer + image-provider migrados a scheduler-preferred (consumidores cableados: date/time-field vía soma.uix.timers; avatar/image vía eidos.timers — verificado en vivo); guard de timers ENSANCHADO de soma/components a TODO soma y pasado a EVIDENCIA (setTimeout exige .schedule( en el fichero — layers/ y datetime/ escapaban del ámbito viejo). - THM-5: R-4.7 nueva (válvula same-line /* important: <razón> */, escaneo comment-blanked) + las 15 declaraciones anotadas con su razón + canon recipe-contract §3/§4. - SOM-4 adjudicado: el censo/guard YA existían (49 pins); knob/mask-field/ timeline pinneados (overrides documentados en call-site); media-player Batch-4 (35 hits, cero renderProps) = único batch restante, registrado. - THM-4 doctrinado en eidos.md §unused (comportamiento/composición = legítimo; deuda = eje visual sin consumidor; hotspots por lotes). F4-C — corpus documental (decisiones de usuario aplicadas): - DOC-3: los 15 enlaces muertos resueltos (repoint a la edición FINAL trackeada / des-link históricos) · docs:check I6-links WARN→ERROR. - DOC-1: tabla «Build contract» MIGRADA a component-guide con estados modernizados (A3–A5 → LIVE + guards de hoy); banners reapuntados; citas de CANON/sema.md historificadas; lápida-redirect en el §13 del fósil. - DOC-4: hold chain → holds.ts · FAQ event:* SUPERSEDED por signatures · gradient añadido a los DOS capstones (sextet real) · nota de paleta de demo-authoring corregida (universalPaletteDecls + decisión THM-2 = mecanismo universal como sucesor del tracker borrado). - DOC-5/6: recuentos anti-frágiles datados · §4.11 dup → §4.12 · Known gaps historificado · N-6/N-7 recuperadas de git (d68d2c45^) y canonizadas en eidos.md §pickers · authoring E2 → canon/tsc.md · air-old des-linkado · EID-3 (placement) en la fila RTL · AUX-2 disabledDom documentado. SEM-4 sesión 1 — el close polimórfico de los pickers, VIVO (D.11): - Reconciliación: los morfos ya no declaran close (delegated al Popover, de-dialoged 06-27); el agujero real era el cierre programático bypaseando dismissWith → save/cancel/select eran perceptualmente SILENCIOSOS. - Fix: PickerShellHandle.setPopoverDismiss + closeWith(cause) en los 5 providers (14 sitios; select/commit → 'save' = commit.save+fulfill, cancel → 'cancel' = emerge; fallback raw para headless) + UN inyector en el eidos PickerShell root (norma N-8). Picker genérico fuera a propósito (ya suena commit-set/cancel por diseño S9). - Verificado en vivo (date-picker): Done → close·commit·fulfill·active · Cancel → close·emerge · cierre real. Gates: matriz 141/141 (los 6 morfos nuevos de la pista de texto paralela también PASS) · contracts 38/38 · eidos 314 · sema 178 · morfo 94 · docs:check 0/0 con I6 en error · baseline propio 57. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
standing decisions frame it — norms N-6/N-7, canonical HERE since 2026-07-11
(recovered from the deleted `src/uix/PENDIENTES.md`, `d68d2c45`):
- **N-6 · Picker `kind` = single source.** `kind: 'date' | 'month' | 'year'`
(Chakra-style) is the ONLY configuration point for granularity variants —
it drives both the input's segments (filtered in the soma provider; the
consumer renders `segments` as-is) and the opening view. There are no
`MonthPicker` / `YearPicker` components: those forms are
`<DatePicker kind='month'>` / `<DateRangePicker kind='year'>` etc.
- **N-7 · Composition over visibility props.** Optional parts (`Footer`,
`Clear`, `Cancel`, `Close`, …) expose NO boolean `*Button` root props.
Visibility is composition: compose `<X.Clear/>` inside `<X.Footer/>` and
it exists; omit it and it doesn't. Parts render whenever mounted — no
internal checks; presence = visibility.
### P-1 · Provider helpers: `commit() / cancel() / clear()`
Every `*PickerProvider` exposes three imperative methods consumed by the
Footer parts:
- **`commit()`** — closes the popover preserving `value.current` as-is. The
normal confirmation of the selected value.
- **`cancel()`** — reverts `value.current` to the snapshot captured at the
**OPEN edge** and closes the popover. The snapshot is taken via
`watch(opts.open)` when `open` transitions `false → true`:
```ts
private valueOnOpen: TValue | undefined = undefined;
constructor(...) {
watch(() => this.opts.open.current, (isOpen) => {
if (isOpen) this.valueOnOpen = $state.snapshot(this.opts.value.current);
});
}
cancel() {
this.opts.value.current = this.valueOnOpen;
this.opts.open.current = false;
}
```
- **`clear()`** — sets `value.current = undefined`. It does NOT close the
popover (it is a visible destructive action the user may want to keep
editing after). If the consumer wants close-after-clear, they compose
`<Picker.Close/>` in the same footer.
These three operations are orthogonal: each Footer part does exactly one.
Don't mix them (e.g. `clear` must not close; `cancel` must not clear).
### P-2 · `mode: 'inline' | 'modal'` → `popover.modal`
`mode` is a root opt of the provider propagated to the underlying
`Popover.modal`:
- **`mode='inline'`** (default) — non-modal popover. Outside-click closes.
Escape closes. Body scroll free.
- **`mode='modal'`** — modal popover. Outside-click does NOT close (the user
must use `<Picker.Close/>` or `<Picker.Cancel/>`). Escape still closes.
Focus trap inside the popover. Body scroll lock.
The consumer does NOT set `popover.modal` directly — that is the provider's
decision:
```svelte
<Popover.Provider modal={provider.opts.mode.current === 'modal'} ...>
```
Modal mode obliges the consumer to compose a Footer with `<Cancel/>` or
`<Close/>` to escape; without them the popover only closes via Escape. It
remains the consumer's responsibility (nothing is auto-injected).
### P-3 · Shell composition (Provider > Input > Portal > Content > view + Footer)
A picker's canonical layout:
```svelte
<X.Provider value={...} open={...} mode='modal' kind='date'>
{#snippet trigger()}
<X.Input /> {!-- field input with segments, button, etc. --}
{/snippet}
{#snippet content()}
<X.Content>
{#if kind === 'year'} <X.YearView />
{:else if kind === 'month'} <X.MonthView />
{:else} <X.Calendar />
{/if}
<X.Footer>
<X.Clear />
<X.Cancel />
<X.Close />
</X.Footer>
</X.Content>
{/snippet}
</X.Provider>
```
- **Trigger snippet** — the entry surface (input + segments, button,
swatch…).
- **Content** — the popover content; only `<X.Content>` may go here.
- **View** — `Calendar`, `YearView`, `MonthView`, `Clock`, `Picker` (color),
etc. The consumer branches structurally by `kind` or variant (P-4 below).
- **Footer** — `<X.Footer>` is an archetype='footer' container. The parts
(`Clear`, `Cancel`, `Close`, etc.) are composed inside. Each part renders
with no internal checks against provider props — presence = visibility
(N-7).
To omit the whole footer, don't compose `<X.Footer>`. For only `Close`,
compose only `<X.Close>` inside.
### P-4 · `kind` (or equivalent) as the single source of truth
When a picker has granularity variants (date: day/month/year; time:
hour/minute/second), the `kind` opt (or equivalent) is the **single
configuration point**. It drives:
1. **The input's segments** — filtered at the FieldProvider level (soma),
not in the consumer's snippet. The provider exposes a derivation like
`visiblePartsByKind: Set<PartName>` and `segmentContents` filters
`allSegmentContent.arr`, collapsing literal runs.
2. **The popover's view** — the consumer branches structurally on the opt
(`{#if kind === 'year'} <YearView/>` etc).
**There are no** per-variant components (`MonthPicker`, `HourPicker`, etc.).
Those forms are `<X kind='month'>`, `<X kind='hour'>`.
### P-5 · The range state machine (for `*-range-picker`)
For range selection, the provider keeps an implicit internal state (not
exposed as an opt):
- **empty** (`value === undefined` or `{start: undefined, end: undefined}`)
— the next click sets `start`. State moves to `pending`.
- **pending** (`start` set, `end` undefined) — the next click sets `end`. If
the new point < `start`, **automatic swap** (start ↔ end). State moves to
`complete`.
- **complete** (`start` and `end` set) — the next click restarts: sets
`start = click`, `end = undefined`. State moves to `pending`.
Each granularity normalizes the endpoints:
- **Year range** — start = Jan 1, end = Dec 31.
- **Month range** — start = day 1, end = the month's last day.
- **Day range** — start/end are the clicked date as-is.
- **Hour range** — start = `:00`, end = `:59:59`.
Implemented in `YearView`/`MonthView`/`Calendar` (range variant). The
provider only exposes `setValue(start, end)`; the state machine lives in the
views.
The dated hand-offs and snapshots that used to live here were extracted to
[`docs/process/handoffs-2026-05.md`](../process/handoffs-2026-05.md)
(timelessness rule of [`docs/authoring.md`](../authoring.md)). The live
wrapper inventory is the `components/` tree + `npm run component:audit` —
never a list in a doc.

Powered by TurnKey Linux.