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>
alpha-0.1-dir-prefs
dev 2 months ago
parent 8864ba93ca
commit 6f42eebfe8

@ -83,6 +83,14 @@ active channels and hold: `SEMA_MAP.families` in
That split is a *classification*; it no longer dictates the intent rule — the
policy (§4) does.
The perceptual question is a **demand on the expression**, not a label: a family
that fails to answer its own question has failed at the only thing it exists
for. `shift` is the case that proves it — its question IS the crossing, so a
`shift` nobody perceives crossing is the *shift invisible* of the antipattern
catalogue (ch. 34 §14), and it was exactly that here until 2026-08-11: it
sounded like a slide and did not slide (§8 rule 5; the sense it now travels in,
`data-event-direction`, in §9).
---
## 3. The 6 intents
@ -179,8 +187,8 @@ declarada."*). This is exactly the morfo → soma → sema → eidos contract.
Verbs concrete the action within a family (book ch. 8 §"niveles", ch. 22–29).
`morfo.events[].semantic.verb` MUST be in its family's set; `events[].name`
should follow `{family}-{verb}[-{variant}]` or `{verb}-{variant}` so sema / sound
/ haptic / eidos can subscribe transversally.
**declares the family** — `{family}-{verb}[-{nuance}]`, guaranteed by
`validateMorfo` — so sema / sound / haptic / eidos can subscribe transversally.
**Executable source of truth:** `SEMA_VERBS` in
[`src/uix/sema/verbs.ts`](../src/uix/sema/verbs.ts) — including the documented
@ -279,7 +287,16 @@ ch. 6 §13). The rules are derived from perceptual need, not decree (ch. 30 §3)
consequence).
4. **emerge does not absorb the content's intent** — `emerge.open → signal.warn +
risk`, not `emerge.open + risk` (frame ≠ message).
5. **shift must orient** the context change (focus + title + landmark).
5. **shift must orient** the context change (focus + title + landmark) — and it
must be PERCEIVED crossing it. A frame that swaps with nothing moving is the
*shift invisible* of the antipattern catalogue (ch. 34 §14): the user is
somewhere else and was never told they travelled. Until 2026-08-11 `shift`
was exactly that in this framework — it sounded (`slide`) and did not slide,
because `BUILTIN_SIGNATURES` had a FAMILY-keyed visual signature for
`contact`, `commit` and `delegate` and none for `shift` (`emerge` and
`signal` have theirs keyed by event name, so they were never mute). It has
one now, and it is directional:
see `data-event-direction` in §9.
6. **handle concentrates evaluation on the drop**, not the carry.
7. **every open process needs an exit** (commit / cancel / fail / persisted-warn).
@ -306,7 +323,18 @@ Cross-layer vocabularies that anchor this:
[`src/uix/morfo/types.ts`](../src/uix/morfo/types.ts), emitted as
`data-archetype`.
- **The `data-event-*` tokens** — the contact surface sema stamps and eidos reads
(see [architecture/sema](./architecture/sema.md)).
(see [architecture/sema](./architecture/sema.md)). Six: `data-event` ·
`-family` · `-intent` · `-direction` · `-phase` · `-id`.
- **Sense of traversal** — `data-event-direction`, `'forward' | 'backward'`
(`SemaDirection`). Two values because the event NAME already carries every
distinction a DECLARATION can make (`shift-enter-mode` ≠ `shift-exit-mode`);
what a name cannot carry is which way THIS occurrence went, since one
`shift-navigate` is the previous month and the next one is the following
month. So the sense is decided per emit, like `intent`, and like `intent` it
is optional — a route with no sense of its own (a month picked from a select)
stamps none, because an invented sense is worse than none. It is a SENSE, not
an axis: eidos maps it onto the inline axis, so `:dir(rtl)` flips it and no
layer above CSS knows about left or right.
---

@ -159,7 +159,7 @@ export const dialogMorfo = {
kebab: 'dialog',
scope: ['soma', 'sema'],
events: [{
name: 'close-cancel',
name: 'emerge-close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
@ -222,8 +222,8 @@ Canonical vocabulary (`SEMA_MAP` in `src/uix/sema/sema-map.ts`):
Each intent declares per-channel `deltas` applied over the family base when
the family is valenced.
- **Action verbs** (`SEMA_VERBS` in `src/uix/sema/verbs.ts`) — `present`,
`dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical names
for `morfo.events[].name`.
`dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical verbs
for `morfo.events[].semantic.verb`, and the tail of `morfo.events[].name`.
Sema **does not decide which event happened** — the provider decides. The
`EngineSemantic` only:
@ -251,7 +251,8 @@ The **visual channel** (built-in) is the only one sharing the DOM plane with
the subsequent structural commit, and therefore the only one that blocks the
caller. `EngineSemantic` runs generic channel hooks; `VisualChannel.prepare()`
projects `data-event` + `data-event-id` + `data-event-phase` (and optionally
`data-event-family` and `data-event-intent`) onto the target through a
`data-event-family`, `data-event-intent` and `data-event-direction`) onto the
target through a
`SignalProjector`. In `ActiveUix` that projector receives `uix.dom`, so attr
writes enter through the same DOM owner soma uses. `VisualChannel` holds the
configurable window and the cleanup removes the projection before resolving
@ -513,12 +514,12 @@ A concrete example: the user clicks a Toast's **×** button.
1. Browser fires click → Svelte calls Close.onclick
2. Close.onclick runs:
void this.toastItem.runtime.trigger('dismiss')
void this.toastItem.runtime.trigger('emerge-dismiss')
3. SomaRuntime.trigger('dismiss'):
3.1. Looks up event 'dismiss' in morfo.events ✓
3. SomaRuntime.trigger('emerge-dismiss'):
3.1. Looks up event 'emerge-dismiss' in morfo.events ✓
3.2. Resolves target = the Item DOM element via partRef('item')
3.3. AWAITS events.emit({ target, name: 'dismiss', family: 'emerge' })
3.3. AWAITS events.emit({ target, name: 'emerge-dismiss', family: 'emerge' })
EngineSemantic dispatches the signal to ALL registered channels:
- VisualChannel.prepare(): SignalProjector applies data-event*
via dom.apply(target, data-event-family=emerge)
@ -529,7 +530,7 @@ A concrete example: the user clicks a Toast's **×** button.
(strict sequential semantics)
4. SomaRuntime invokes the provider's handler:
sources.events.dismiss() →
sources.events['emerge-dismiss']() →
this.provider.toaster.dismiss(opts.toast.current.id) →
toast.dismissing = true (state mutation)
@ -538,7 +539,7 @@ A concrete example: the user clicks a Toast's **×** button.
dom.apply(target, { 'data-state': 'closed' }) on the next tick
6. Eidos (CSS) has been reacting throughout the sequence:
- during t=0..240ms: [data-event^="dismiss"] fires an @keyframes fade-out
- during t=0..240ms: [data-event^="emerge-dismiss"] fires an @keyframes fade-out
(CSS animation, not transition: it runs full-duration even if the attr
disappears afterwards)
- at t≈245ms: [data-state="closed"] takes over
@ -568,11 +569,12 @@ Everything that travels between layers travels through DOM attributes:
| `aria-*` | dom.apply (effect) | Screen readers, Eidos |
| `data-state="open"` | dom.apply (effect) | Eidos (variant selector) |
| `data-disabled` | dom.apply (effect) | Eidos (state selector) |
| `data-event="dismiss"` | sema.emit (transient) | Eidos (event selector) |
| `data-event="emerge-dismiss"` | sema.emit (transient) | Eidos (event selector) |
| `data-event-phase="active"` | sema.emit (transient) | Eidos |
| `data-event-id="sig-N"` | sema.emit (transient) | Future sound/haptic |
| `data-event-family="commit"` | sema.emit (transient) | Eidos (family selector) |
| `data-event-intent="risk"` | sema.emit (transient) | Eidos (signal tinting) |
| `data-event-direction="forward"` | sema.emit (transient, per-emit only) | Eidos (directional signature) |
| `data-color="primary"` | dom.apply (effect) | Eidos (per-token recipe) |
| `data-intent="risk"` | dom.apply (effect, optional per morfo) | Eidos (persistent state) |
| `dir` | prefs projection (page) / provider | Browser bidi, Eidos `:dir()` |
@ -621,11 +623,11 @@ Verbs that look like one family but belong to another per the canon:
**select / toggle / acknowledge** are `commit` (they fix state; they are not
mere contact); **edit** is `shift.enter-mode` (it changes the regime).
`morfo.events[].name` should align with this vocabulary in one of two
shapes: `{verb}-{variant}` (`dismiss-outside`, `close-cancel`) or
`{family}-{verb}` (`commit-toggle`, `commit-save`). That lets
Sema/Sound/Haptic/Eidos subscribe or style by verb or family without
enumerating components. `validateEventName` recognizes both shapes.
`morfo.events[].name` **declares its family**: the shape is
`{family}-{verb}[-{nuance}]` (`commit-toggle`, `emerge-close-cancel`), and
`validateMorfo` rejects a name that does not start with its own family. That
lets Sema/Sound/Haptic/Eidos subscribe or style by family or verb without
enumerating components.
**Intents** (`src/uix/sema/sema-map.ts:SEMA_MAP.intents`), 6 values:

@ -454,7 +454,7 @@ components keep working headless because behavior belongs to Soma.
| `parts[].archetype` | transversal rules `[data-archetype=trigger]` |
| `parts[].states` + `data[].values` | variants `[data-state=open]` |
| `parts[].data` with `data-starting-style` / `data-ending-style` | enter/exit animation hooks |
| `events[].name` | selectors `[data-event=dismiss]`, `[data-event^=commit]` |
| `events[].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 |
@ -507,6 +507,9 @@ Only the DOM. The visual channel projects `data-event-*` during the hold via
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}
[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] {
animation: shift-cross-forward var(--duration-slow) var(--ease-emphasized);
}
```
The per-family/intent signatures live in `EidosConfig.motion` and are
@ -514,6 +517,41 @@ 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).
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
@ -864,6 +902,37 @@ It classifies every `[data-*]` selector as:
- **invalid** — references a declared attr with a value outside the enum. A
bug.
#### The `data-event*` VALUE check (2026-08-11)
The classifier above allowlists `data-event`, `-family`, `-id`, `-intent`,
`-direction` and
`-phase` as eidos-only — they come from the sema stamp, not from a morfo part —
and therefore never looked at their **value**. That is how
`[data-event='commit-resize']` stayed in `splitter.css` after the event was
renamed to `commit-set` (bd2e40366, 2026-05-22): a hook to a name nobody emits,
dead for almost three months, with every test green.
`scripts/eidos-event-vocabulary.ts` closes it. Both linters now check that
every `data-event` value in a recipe is the `name` of an event declared by
**some** morfo in the catalogue (`^=` matches by prefix), that every
`data-event-family` is one of the 8 canon families, that every
`data-event-intent` is one of the 6 intents, and that every
`data-event-direction` is `forward` or `backward`.
The direction row nearly shipped as unchecked prose. `scripts/` is outside the
`tsconfig` graph — `svelte-check` never reads it — so a literal
`['forward', 'backward']` written in the linter would have been free to outlive
the vocabulary it guards, which is this section's own defect wearing a new hat.
`SemaDirection` is therefore derived from a const array (`SEMA_DIRECTIONS` in
`sema/types.ts`, the `INTENTS` pattern) and the linter imports it: the guard
iterates the same thing the compiler enforces.
The event-name check is catalogue-wide on purpose: composition means a node
receives another component's stamps (the card-group item also carries
`data-toggle-group-item` and receives `commit-block`, which toggle-group
declares). A name that exists elsewhere but not in the component's own morfo is
a **WARN**; only a name that exists nowhere is an **ERROR** (exit 1).
**The lint is a safety net, not the contract.** The contract lives in the
morfo and is defended at the type level where possible. The lint exists only
for the pure-CSS portion that doesn't yet consume the morfo through

@ -143,10 +143,10 @@ const runtime = createSomaRuntime(morfo, {
props: { disabled: () => this.opts.disabled.current },
parts: { content: () => this.contentId.current },
events: {
open: () => {
'emerge-open': () => {
this.opts.open.current = true;
},
'close-cancel': () => {
'emerge-close-cancel': () => {
this.opts.open.current = false;
}
}
@ -297,17 +297,18 @@ directional `emerge` events:
```ts
{
name: 'expand',
name: 'emerge-expand',
semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' }
},
{
name: 'collapse',
semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'pre' }
name: 'emerge-collapse',
semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'post' }
}
```
`expand` is `post` so Eidos reacts after content exists. `collapse` is `pre` so
the exit signal can play while content is still visible. Any visual color,
`emerge-expand` is `post` so Eidos reacts after content exists. `emerge-collapse` is `post`
too (collapsible-NEW-001), so the conceal runs against a content the flip has
not yet hidden. Any visual color,
motion or density response belongs to Eidos recipes, not to the morfo event.
---
@ -614,10 +615,10 @@ events: [
Field rules:
- **`name`** — the addressable id used by `runtime.trigger(name)`.
Convention: `{verb}-{variant}` (e.g. `dismiss-outside`,
`close-cancel`) or `{family}-{verb}` (e.g. `commit-toggle`,
`commit-save`). The validator accepts both shapes.
- **`name`** — the addressable id used by `runtime.trigger(name)`. The name
**declares the family**: `{family}-{verb}[-{nuance}]` (e.g. `commit-toggle`,
`emerge-dismiss-outside`). `validateMorfo` rejects a name that does not
start with its own `semantic.family`.
- **`semantic.family`** — one of the 8: `contact`, `commit`, `signal`,
`handle`, `emerge`, `shift`, `sustain`, `delegate` (per `SEMA_MAP`).
Whether `intent` is required is set per-family by `SEMA_FAMILY_POLICY`
@ -646,6 +647,39 @@ Field rules:
declared `target` nor one of these. Runtime rather than static because the
override is an expression (`e.currentTarget`) and only the live element can
answer which part it is.
- **`semantic.targetFallback`** — ordered `partRef` chain the RUNTIME resolves
when the canonical `target` has **no live element** at emit time: the first
listed part with a registered instance takes the stamp (and the a11y focus
move, when the event declares one — `resolveEmitTarget` is the ONE
resolution both share, so they can never disagree). This is the second axis
of emission targeting, split from `allowedTargets` on the precedent of
`intentRequirement`/`intentGuidance`:
| Axis | Who decides | When | Example |
| --- | --- | --- | --- |
| `allowedTargets` | the CALLER, per trigger | normal operation, repeated parts | the pressed `day`, the clicked `item` |
| `targetFallback` | the RUNTIME, from mount state | the declared target is unmounted | drawer `emerge-close` → `trigger` once `content` is gone |
It replaces the hand-rolled `content ?? partRef('trigger')` every overlay
provider used to write around `targetOverride` (dialog / drawer / popover /
float-panel `close`; aura's terminals landing on `provider` when the
decorative ring was never composed; chronos' editor commits landing on
`provider` when no chip names them). Declared in the morfo so soma, sema and
eidos read the same truth: `pack-census.test.ts` counts these parts as
stampable, exactly like `allowedTargets` — but note the perceptual
difference: an `allowedTargets` part is stamped in routine use, a
`targetFallback` part only in the degraded mount, so a sound rule that
matches ONLY fallback parts almost never fires.
`validateMorfo` enforces three invariants (all tested): every entry is an
existing part; the canonical `target` may not list itself (it is always
resolved first); no duplicates (order is meaning — a duplicate reads as two
chances where there is one). Anchored emissions (`SomaRuntimePart.trigger` /
`partInstance(...).trigger`) do NOT fall back: an anchored emit stamps ITS
instance or raises a target error, never a silent redirection — and the
anchored name union (`EventNameTargeting`) excludes fallback-only events on
purpose, because anchoring to the degraded surface would force the poor
landing while the primary is mounted.
- **`regime`** — what this event does when it arrives and the target surface
already carries a live occurrence: `replace` (default) or `queue`. The
`data-event-*` projection is ONE SLOT per element. Declare it only for pairs
@ -659,6 +693,16 @@ Field rules:
(commit pulses on completed actions). `'coincident'` is for
in-flight processes (sustain).
**Not declarable here: `direction`.** The morfo cannot state the sense of a
traversal, because the same declared event goes backward on one press and
forward on the next — only the emitter knows which. It travels per-call as
`TriggerOptions.direction` (`forward` | `backward`, the `SemaDirection`
vocabulary) and lands as `data-event-direction`, which is what lets the `shift`
family's motion firma slide in the right sense. Omit it where the route has no
clear sense — a month picked from a select is a jump, not a step, and a sense
inferred from comparing dates is not a sense. Contrast with `intent`, the other
per-emission axis, which the morfo CAN declare a default for.
For a comprehensive worked example see the toggle and dialog morfos.
### Step 6 — Wire the provider
@ -807,6 +851,42 @@ These don't change the morfo shape — they're authoring conventions that enable
---
## Where the stamp lands — gesture vs terminal
`semantic.target` is a claim about **subject**, not about paint: the part it names is the one the occurrence is _about_. Read across the catalogue, that claim resolves into a single rule in two halves:
> **The gesture is stamped where the hand is. The terminal is stamped where the value lives.**
Seventeen components declare the `handle` family. Twelve put the two halves on different parts:
| Component | Grip (`handle-*`) | Terminal |
| ------------------------------- | ------------------------------------ | --------------------------------------- |
| `rotate-align` | `needle` | `dial` (`handle-drop`) |
| `path-trace` | `token` | `track` (`handle-drop`) |
| `drag-drop` | `draggable` | `droppable` (`handle-drop`) |
| `splitter` | `resize-trigger` | `provider` (`commit-set`) |
| `color-picker` | `area` | `provider` |
| `css-field` · `number-field` | `scrubber` | `provider` |
| `gradient-builder` | `track` | `provider` |
| `cropper` | `selection` · `handle` · `viewport` | `provider` (`commit-crop`) |
| `image-picker` | `preview` | `provider` |
| `virtual-list` · `virtual-grid` | `viewport` | `provider` (`commit-set-resize`) |
| `chronos` | `event-chip` · `event-resize-handle` | `event-chip` (falls back to `provider`) |
| `knob` | `control` | `control` |
| `drawer` · `float-panel` | `content` | `content` |
| `slider` | `provider` | `provider` |
The five that do not separate them are not exceptions — they are the components where grip and value are the **same node**: Chronos' chip _is_ the event, Knob's control _is_ the dial, Drawer's and FloatPanel's content _is_ the position. Slider is the borderline case: it declares a `thumb` part and the provider gates `handle-pick` on it (`isHandleTarget` in `slider-provider.svelte.ts`), but the pointer capture and the whole grabbable track belong to the provider, so the provider is the surface under the hand.
Two consequences worth naming:
- **The terminal is not a synonym for `commit`.** Rotate-align, path-trace and drag-drop terminate on a `handle-drop`. The family says what kind of occurrence it is; the target says whose.
- **A signature that must paint a node other than the stamped one is a descendant selector, never a reason to move the stamp.** The corollary and its worked example live in [`architecture/eidos.md`](./eidos.md) §From sema (DOM).
When a rule doesn't fire, the diagnostic question 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.
---
## Typed selector builder — `semaSelector`
When a TypeScript consumer needs to construct a CSS selector that targets the morfo's emitted attrs (e.g. `sema/components/*.ts` cascade rules), it MUST use [`semaSelector`](../../src/uix/morfo/selectors.ts) instead of hand-writing strings:
@ -818,11 +898,11 @@ import { dialogMorfo } from '$uix/morfo/components/dialog';
// [data-dialog-content][data-event-family="commit"]
semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' });
// [data-dialog-content][data-event="close-after-fail"]
semaSelector(dialogMorfo, 'content', { eventName: 'close-after-fail' });
// [data-dialog-content][data-event="signal-alert-close-fail"]
semaSelector(dialogMorfo, 'content', { eventName: 'signal-alert-close-fail' });
// [data-dialog-content][data-event^="close-"][data-event-family="emerge"]
semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-', eventFamily: 'emerge' });
// [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"]
semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-close', eventFamily: 'emerge' });
```
### What it guarantees

@ -470,8 +470,9 @@ virtual prop on the provider — do not extend the contract.
`src/uix/morfo/types.ts:ARCHETYPE_VOCABULARY`. Emitted as
`data-archetype="..."` by `runtime.partProps`.
- **Verbs**: the canonical action verbs for `morfo.events[].name`. Defined in
`src/uix/sema/verbs.ts:SEMA_VERBS`. Convention for composite names:
`{verb}-{variant}` (e.g. `commit-save`, `dismiss-outside`).
`src/uix/sema/verbs.ts:SEMA_VERBS`. Name convention:
`{family}-{verb}[-{nuance}]` (e.g. `commit-save`, `emerge-dismiss-escape`).
The family prefix is mandatory — `validateMorfo` throws without it.
---

@ -166,8 +166,8 @@ On every `emit`, the engine:
1. Runs the channels' `prepare` hooks. The `VisualChannel` projects the
semantic tokens `data-event`, `data-event-family`, `data-event-intent`,
`data-event-phase`, `data-event-id` onto `signal.target`. These are the
tokens the cascade and eidos's CSS read.
`data-event-direction`, `data-event-phase`, `data-event-id` onto
`signal.target`. These are the tokens the cascade and eidos's CSS read.
2. Calls `resolveSignature(signal, opts)`, which applies the cascade
(canonical numbering **1 · 2 · 3 · 4 · 5a · 5b** — the same in
`engine.ts`, `resolver.ts` and CLAUDE.md; each layer overrides the
@ -239,13 +239,22 @@ If `signal.family` is missing or not in the map, it returns an empty
BEFORE the cascade resolves. Rules with selectors over these attrs match
natively via `target.matches()` / `target.closest()`:
| Attr | Value | Origin |
| ------------------- | -------------------- | ------------------------------ |
| `data-event` | `'close-after-fail'` | `signal.name` |
| `data-event-family` | `'signal'` | `signal.family` |
| `data-event-intent` | `'threat'` | `signal.intent` (when present) |
| `data-event-phase` | `'active'` | while the hold lasts |
| `data-event-id` | `'sig-42'` | occurrence id |
| Attr | Value | Origin |
| ---------------------- | -------------------- | --------------------------------- |
| `data-event` | `'signal-alert-close-fail'` | `signal.name` |
| `data-event-family` | `'signal'` | `signal.family` |
| `data-event-intent` | `'threat'` | `signal.intent` (when present) |
| `data-event-direction` | `'forward'` | `signal.direction` (when present) |
| `data-event-phase` | `'active'` | while the hold lasts |
| `data-event-id` | `'sig-42'` | occurrence id |
`data-event-direction` (`forward` / `backward`, `SemaDirection`) is the SENSE of
a traversal, decided per emit: the event name already separates
`shift-enter-mode` from `shift-exit-mode`, but one `shift-navigate` goes to the
previous month and the next one to the following month under the same name. It
is a sense, never an axis — eidos maps it onto the inline axis so `:dir(rtl)`
flips it. A route with no clear sense (a month picked from a select) stamps
nothing.
Those tokens are the **cross-channel contact surface**: sema's cascade
(`sound`, `haptic` and future channels) reads them with CSS selectors, the
@ -254,7 +263,7 @@ hold. One perceptual surface, separate owners.
### The surface is ONE SLOT — ownership and `regime`
Those five attrs are a **single slot per element**. Two occurrences on one node
Those six attrs are a **single slot per element**. Two occurrences on one node
cannot both express, and the framework spent a year not saying so: three
independent audits found the same defect and none closed it (fable **S1**
2026-07-01 · sema **S-17** 2026-08-05 · blocks **A-36 / A-65**, reproduced in
@ -285,10 +294,19 @@ channel resolve that number through one path (`VisualChannel.holdMsFor`).
and no re-targeting can separate — a toggle's `contact-press` + `commit-toggle`
(its provider IS the button), the knob's `handle-drop` + `commit-set`. When the
collision comes from a **redirection** instead, the fix is to stop redirecting:
that is how A-36 closed, with the overlays' `open` giving up a `targetOverride`
left over from when they were `sequence: 'pre'`. Measured after: the Button's
`contact-activate` stamps the trigger and the Drawer's `open` stamps its
content — two surfaces, both expressing.
that is how A-36 closed, with the overlays' `emerge-open` giving up a
`targetOverride` left over from when they were `sequence: 'pre'`. Measured
after: the Button's `contact-activate` stamps the trigger and the Drawer's
`emerge-open` stamps its content — two surfaces, both expressing.
The redirection those overlays DID need — landing `emerge-close` on the trigger
once the content has unmounted — is not a `targetOverride` either: it is
**mount-state resolution, declared in the morfo as `targetFallback`** and
resolved by the runtime through the same single path that moves a11y focus
(`resolveEmitTarget`). A rule may select a fallback part — the census counts it
stampable — but it only fires in the degraded mount; the routine surface is
still the declared target. The field, its invariants and its split from
`allowedTargets`: [`architecture/morfo.md` §Step 5.5](./morfo.md).
`collapse` and `lock` were declared here from the founding commit and never
meant anything; both were retired on 2026-08-10 rather than left as a contract
@ -328,12 +346,12 @@ cascade: [
// [data-dialog-content][data-event-family="commit"]
{ selector: onContent({ eventFamily: 'commit' }), haptic: { kind: 'tap' } },
// [data-dialog-content][data-event="close-after-fail"]
{ selector: onContent({ eventName: 'close-after-fail' }), sound: { sampleUrl: '/fail.wav' } },
// [data-dialog-content][data-event="signal-alert-close-fail"]
{ selector: onContent({ eventName: 'signal-alert-close-fail' }), sound: { sampleUrl: '/fail.wav' } },
// [data-dialog-content][data-event^="close-"][data-event-family="emerge"]
// [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"]
{
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
selector: onContent({ eventNamePrefix: 'emerge-close', eventFamily: 'emerge' }),
sound: { contour: 'descending', pitch: { op: 'add', value: -150 } }
}
];
@ -583,13 +601,14 @@ was the Form — before anyone noticed, since no check ever looked at these keys
`VisualChannel.prepare()` projects **only** attributes under the
`data-event-*` prefix:
| Attr | When |
| ------------------- | ------------------- |
| `data-event` | always |
| `data-event-id` | always |
| `data-event-phase` | always (`'active'`) |
| `data-event-family` | if `signal.family` |
| `data-event-intent` | if `signal.intent` |
| Attr | When |
| ---------------------- | --------------------- |
| `data-event` | always |
| `data-event-id` | always |
| `data-event-phase` | always (`'active'`) |
| `data-event-family` | if `signal.family` |
| `data-event-intent` | if `signal.intent` |
| `data-event-direction` | if `signal.direction` |
**Rule**: the channel never touches state attrs (`data-state`,
`data-intent`, `data-disabled`, ...). State is managed by the runtime/morfo.
@ -632,13 +651,13 @@ falls back to `setTimeout` when a channel is built without a scheduler
(direct unit tests); production always injects the scheduler.
> **Overlay openings — `sequence: 'post'`, not `'pre'`.** An appearance event
> whose provider sets `open` in the HANDLER (Popover `present`, Dialog
> `open`, Drawer `present`) MUST declare `sequence: 'post'`. With `'pre'` the
> whose provider sets `open` in the HANDLER (Popover, Dialog and Drawer's
> `emerge-open`) MUST declare `sequence: 'post'`. With `'pre'` the
> runtime awaits the emit — and therefore the ~240ms hold — BEFORE the
> handler, gating the content mount behind the hold: the overlay opens late
> and its first render lands inside the hold's `setTimeout` turn (the
> "setTimeout handler took N ms" violation). Same doctrine as the
> checkbox-lag fix. Closing (`close`) stays `'pre'`: there the element exists
> checkbox-lag fix. Closing (`emerge-close`) stays `'pre'`: there the element exists
> and the signal MUST precede the unmount.
```ts
@ -731,7 +750,7 @@ const semantic = new EngineSemantic({
// src/uix/morfo/components/dialog.ts
{
name: 'close-after-fail',
name: 'signal-alert-close-fail',
semantic: {
family: 'signal',
verb: 'alert',
@ -775,10 +794,10 @@ hand-written examples this section used to show targeted `[data-toast-root]`
{ selector: semaSelector(toastMorfo, 'item', { eventFamily: 'signal' }), sound: { gain: 0.4 } }
// Vary by exact event name (typed against the morfo's declared events)
{ selector: semaSelector(toastMorfo, 'item', { eventName: 'dismiss' }), sound: { sampleUrl: '/dismiss.wav' } }
{ selector: semaSelector(toastMorfo, 'item', { eventName: 'emerge-dismiss' }), sound: { sampleUrl: '/dismiss.wav' } }
// Vary by event prefix
{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-' }), sound: { contour: 'descending' } }
{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-close' }), sound: { contour: 'descending' } }
```
### SoundChannel — doctrine here, machine in `$sound`
@ -1001,21 +1020,22 @@ That Promise is the control the caller uses to opt into or out of the hold:
- `void runtime.trigger('handle-drag')` — **fire-and-forget**. The signal
starts; the caller does not wait for the hold. Correct for high-frequency
emits, where blocking the gesture loop on a ~240ms hold would be absurd.
- `await runtime.trigger('close', …)` — **blocking**. The caller waits for the
hold to finish before its next step. Correct when a structural change must
observe the resolved signal (e.g. a `close` whose element unmounts after the
pulse — see the `sequence: 'post'` note under _Hold_).
- `await runtime.trigger('emerge-close', …)` — **blocking**. The caller waits for
the hold to finish before its next step. Correct when a structural change must
observe the resolved signal (e.g. an `emerge-close` whose element unmounts after
the pulse — see the `sequence: 'post'` note under _Hold_).
Slider and Drawer go through `trigger` exclusively — `handle-pick`,
`handle-drag`, `commit-set` on the slider; `drag-start`, `drag-progress`,
`drag-end` on the drawer. Every continuous one is `void`.
`handle-drag`, `commit-set` on the slider; `handle-pick`,
`handle-drag-progress`, `handle-drop` on the drawer. Every continuous one is
`void`.
### `coincident` vs `post` for a moving value
A continuous gesture has two temporally distinct moments, and the morfo encodes
each with its `sequence`:
- **The move** (`handle-drag`, `drag-progress`) is `sequence: 'coincident'` —
- **The move** (`handle-drag`, `handle-drag-progress`) is `sequence: 'coincident'` —
the perceptual signal and the value update are indivisible. The user _is_ the
value changing, so the signal fires alongside the mutation, neither
anticipating nor trailing it. (`coincident` is emit-then-handler, like `pre`;
@ -1133,9 +1153,9 @@ Cross-component action verbs grouped by family. The canon lives in
[`verbs.ts`](../../src/uix/sema/verbs.ts) and reflects the book _Diseñando lo
que ocurre_ ch. 22–29 (families) + ch. 10 (intents).
`morfo.events[].semantic.verb` MUST be in this vocabulary;
`morfo.events[].name` should follow the `{family}-{verb}[-{variant}]` shape
so sema/sound/haptic can subscribe by verb and eidos can write transversal
selectors (`[data-event^="dismiss"]`).
`morfo.events[].name` MUST follow the `{family}-{verb}[-{nuance}]` shape
(guaranteed by `validateMorfo`) so sema/sound/haptic can subscribe by verb and
eidos can write transversal selectors (`[data-event^="emerge-dismiss"]`).
> **The literal table used to live here, and it rotted.** It was missing
> `commit.unselect` and `handle.zoom` — both live in `verbs.ts` and both used
@ -1155,25 +1175,25 @@ Defined in [`verbs.ts:SEMA_VERBS`](../../src/uix/sema/verbs.ts).
### Naming shapes
A `morfo.events[].name` can take two canonical shapes:
A `morfo.events[].name` takes ONE canonical shape — the name declares the
family, and `validateMorfo` rejects any that does not:
```ts
// Shape 1: {verb}-{variant} — head is the verb, tail explains the nuance.
'dismiss'; // bare verb
'dismiss-outside'; // verb + variant
'close-cancel'; // verb (close) + variant (cancel)
// Shape 2: {family}-{verb} — head is the family, tail the canonical verb.
// {family}-{verb}[-{nuance}] — head is the family, then the canonical verb,
// then the nuance when the component needs one.
'emerge-dismiss'; // family=emerge, verb=dismiss
'emerge-dismiss-outside'; // + nuance (outside)
'commit-toggle'; // family=commit, verb=toggle
'commit-save'; // family=commit, verb=save
```
`validateEventName(name)` recognizes both shapes and returns `{ family,
verb, variant, matchesCanonical }`. Used by
`validateEventName(name)` parses a name — it still accepts the retired
bare-verb shape — and returns `{ family, verb, variant, matchesCanonical }`.
Used by
`scripts/morfo-vocabulary-check.ts` (`npm run morfo:vocabulary`), which
hard-fails when a morfo's declared `semantic.verb` is not in its family's
canon, and soft-warns when the event NAME doesn't fit
`{family}-{verb}[-{variant}]` even though the verb is canonical. A temporary
`{family}-{verb}[-{nuance}]` even though the verb is canonical. A temporary
allowlist in the script covers deliberate divergences.
```ts

@ -370,7 +370,7 @@ therefore what the runtime emits:
archetype.
- **`data-event*`** — emitted by `events.emit` (through the VisualChannel)
during a configurable hold. Eidos uses it to tint event transitions
(`[data-event^=dismiss]`). Per-family hold values live in
(`[data-event^=emerge-dismiss]`). Per-family hold values live in
`SEMA_MAP.families[*].hold`.
- **`data-{component}` / `data-{component}-{part}`** — the classic structural
markers. Eidos uses them for per-component selectors.

@ -144,7 +144,8 @@ motion channel**:
`delayed-open` alias), content mounts (`motionAttrs`).
2. **Event signature** (`EidosConfig.motion.signatures`) when the animation
reacts to sema's `data-event-*` and is generic per verb/family
(expand/collapse, present/dismiss, announce per intent).
(expand/collapse, present/dismiss, announce per intent, the `shift`
crossing per `data-event-direction`).
3. **Materials pattern** (the rule lives in the recipe but consumes ONLY
registered keyframes + `--motion-*` hooks) when the trigger is
irreducible to the generic: a semantic `data-state` of its own (card),

@ -108,6 +108,24 @@ require one; the intent policy per family is `SEMA_FAMILY_POLICY`.
`neutral` · `affirm` · `fulfill` · `risk` · `threat` · `loss`
## Directions (2)
The sense of a traversal (`SEMA_DIRECTIONS`, `src/uix/sema/types.ts`),
projected as `data-event-direction`. Per emission like the intent, and
unlike it undeclarable on the EVENT: one `shift-navigate` is the previous
month and the next one is the following month, so only the caller knows
(`TriggerOptions.direction`). Optional — most occurrences have no sense to
declare, and an invented one is worse than none.
A SENSE, never an axis: eidos maps `forward` onto the inline end and
`backward` onto the inline start, so RTL flips through `:dir(rtl)` and
nothing upstream knows about it. Distinct from `SoundContour`
(`ascending` / `descending`, `src/uix/sema/sounds.ts`), which shapes a pitch,
and from `Morfo.direction`, which names the parts that carry the `dir` stamp
(the RTL contract — see `docs/canon/direction-contract.md`).
`forward` · `backward`
## Hold / perceptual durations
The named perceptual scale (`SEMA_DURATIONS`, `src/uix/sema/durations.ts`);

@ -59,7 +59,7 @@ New here? Start at [`docs/README.md`](./README.md).
| **Active\<T\> / State\<T\>** | Reactive containers (readonly / mutable, exposing `.current`) that let runes be passed by reference between classes. |
| **context convention** | The `X.create()` / `X.get()` / `X.require()` static methods every context-using class follows. |
| **roving vs virtual focus** | Two keyboard strategies: real DOM focus with one `tabindex=0` (roving) vs focus stays on the input and items are `data-highlighted` via `aria-activedescendant` (virtual). |
| **polymorphic close** | One `close` event with `allowedFamilies`; the provider chooses the family at dismiss time (used by Dialog/Drawer/Popover). |
| **polymorphic close** | One `emerge-close` event with `allowedFamilies`; the provider chooses the family at dismiss time (used by Dialog/Drawer/Popover). |
| **prewrite / commit** | DOM written imperatively *before* the semantic emit (`prewrite`, e.g. `data-last-action`) vs the structural state written *after* (`commit`). |
| **activeDir** | The direction resolver a wrapper runs for a component; returns `Active<Direction \| undefined>`, where `undefined` means nobody asserted a direction. → [`canon/direction-contract`](./canon/direction-contract.md) |
| **resolvedDir** | A provider's concrete direction — `activeDir`'s value with the fallback applied, once, for the component's own maths. |
@ -73,6 +73,7 @@ The values live in [`CANON.md`](./CANON.md); these are the term shapes.
| **family** | One of the **8** perceptual event families (contact · commit · signal · handle · emerge · shift · sustain · delegate). → CANON |
| **intent** | The evaluative load of an occurrence (neutral · affirm · fulfill · risk · threat · loss) — only on valenced families. → CANON |
| **verb** | The specific act within a family (`tap`, `select`, `close`, …). → CANON |
| **direction** | The sense of a traversal (`forward` \| `backward`, `SemaDirection`), stamped as `data-event-direction`. Two values, because the event NAME already separates `shift-enter-mode` from `shift-exit-mode`; what a name cannot carry is which way THIS occurrence went. Decided per emit and optional, like intent. A SENSE, not an axis — eidos maps it onto the inline axis so `:dir(rtl)` flips it. → CANON |
| **channel** | An expression modality. Sema runs two at runtime (sound, haptic) and projects `visual`; eidos owns the rest (motion/presence/depth/shape/color). → CANON |
| **hold** | The minimum perceptible duration a signal stays projected (`data-event-*` stamped during it). |
| **cascade** | The layered resolution of a perceptual signature (1 family base → 2 intent deltas → 3 per-event → 4 globals → 5a packs / 5b app rules). → [`architecture/sema`](./architecture/sema.md) |

@ -82,7 +82,7 @@ The morfo is DNA. If it's incomplete, every downstream layer is incomplete.
| A-3.4 | `semantic.verb` ∈ `SEMA_VERBS[family]` | error | all | audit |
| A-3.4b | Per-event `family.verb` pairing is canonical (no verb borrowed from another family) | warn | all | audit |
| A-3.5 | `semantic.sequence` ∈ `'pre' \| 'coincident' \| 'post'` declared explicitly | warn | interactive | audit |
| A-3.6 | Event name follows `{verb}-{variant}` or `{family}-{verb}` pattern | warn | interactive | audit |
| A-3.6 | Event name follows the `{family}-{verb}[-{nuance}]` pattern (`validateMorfo` throws otherwise) | error | interactive | audit |
| A-3.7 | **Event/keyboard coverage**: every distinct keyboard action that mutates state has a corresponding semantic event. Pure focus moves don't need an event. | error | interactive | audit |
| A-3.8 | If component supports value reset/clear: `commit.reset` or `commit.discard` event declared | warn | interactive | manual |
| A-3.9 | If component supports navigation steps (calendar, stepper, pagination): `shift.navigate` event declared with target part | warn | interactive | manual |

@ -24,7 +24,7 @@ An occurrence flows through **two moments**, connected by the **token**:
- **Sema's moment (emission)** — sema evaluates the occurrence and **emits**
it. Sound + haptics it **executes right there** (runtime channels); for the
visual, it **stamps it as tokens** `data-event-*` (family · intent ·
phase). Sema **knows no DOM/CSS**.
direction · phase). Sema **knows no DOM/CSS**.
- **Eidos's moment (materialization)** — eidos **reads** those tokens
(+ `data-state`) and **materializes** them in CSS (the visual channel). It
is the **sole visual owner**.
@ -92,6 +92,38 @@ a shim:
> (family / semantic intent), eidos contributes the **how** (the visual
> vocabulary and its materialization). Co-layers, not one subordinate.
The split is load-bearing, and the `shift` repair of 2026-08-11 is the case
that shows it. `shift` had a sound (`slide`) and no visual signature at all —
the *shift invisible* antipattern (book ch. 34 §14) inside the framework that
names it. The token was never the problem: sema was already stamping
`data-event-family='shift'`. What was missing was on eidos's side of the line,
and the fix stayed there — two entries in `BUILTIN_SIGNATURES`. Sema's only
addition was a new token, `data-event-direction` (`forward` | `backward`), the
SENSE of the crossing; eidos is what decides that a sense means the inline axis
and flips it under `:dir(rtl)`. Sema still knows nothing about left and right,
which is exactly the property that lets it stay DOM-agnostic.
What `shift` has now are two signatures keyed on that token — `shift-forward`
and `shift-backward` (they name the `shift-cross-*` keyframes): the frame
arrives displaced one `--motion-distance-xl` (30 px) along the **inline** axis
and settles in 320 ms on the `emphasized` curve. An emission that stamps no
direction matches neither and stays visually silent — deliberate, since there
is no neutral sense of travel to draw. Geometry, the `--motion-shift-sign`
pair and the selector shape: [`motion.md`](./motion.md) §8.
And the misreading that delayed the repair, because the structure invites it:
`SEMA_MAP.families.shift.activeChannels` is `['sound']`, which looks like a
family declared mute everywhere else. It is not. **"Mute by doctrine" is a
statement about a CHANNEL, never about a family** — `activeChannels` governs
only the two channels sema *executes*. `delegate` is the proof: its
`activeChannels` is literally `[]`, and it still carries two visual signatures
(`delegate-return-fulfill`, `delegate-return-loss`). No field of `SEMA_MAP`
could say otherwise, because eidos is the sole visual owner (§1) — the visual
channel is not in the map to be switched off, which is also why motion does not
live there. Read an empty `activeChannels` as "silent", and `shift` files
itself beside `sustain`, whose continuity is genuinely carried by persistent
state and not by a firma.
(The full canonical narrative lives in `CLAUDE.md` → "Sema: open channel
registry".)

@ -269,7 +269,7 @@ axes.
| Layer | What it contributes | Moment |
|---|---|---|
| **Morfo** | Declares the attrs: `data-state` + states (`open`/`closed`), the **events** (`emerge`/`commit`/`signal` + `sequence`/`persistence`/`intent`), `data-side`/`data-align`, `data-starting/ending-style`. | both |
| **Sema** | Stamps `data-event-*` (`family`/`intent`/`phase`/`id`) during the `hold`; resolves the signature (sound/haptic are runtime channels; **`motion`/`color`/`presence` are materialized by eidos** reading `data-event-*`). | `--event` |
| **Sema** | Stamps `data-event-*` (`family`/`intent`/`direction`/`phase`/`id`) during the `hold`; resolves the signature (sound/haptic are runtime channels; **`motion`/`color`/`presence` are materialized by eidos** reading `data-event-*`). | `--event` |
| **Soma** | Writes `data-state` via effects; `Presence` keeps the node during the exit and awaits the animation (`getAnimations()` + `Promise.all(finished)`). Fires the event with its `sequence`. | `--state` (+ fires the event) |
| **Eidos** | `keyframes` + `signatures` (event-moment, over `data-event-*`) + `presets` (state-moment, over `data-state`) + **CSS generation**; delegates the JS engine to **`uix.motion`** (the service) and registers its `css` presets there at boot. **Reads both axes and animates.** | both |
@ -287,7 +287,7 @@ EidosConfig.motion
├── keyframes: { 'fade-in': {...}, 'scale-in': {...}, ... } registered @keyframes
│
├── signatures: { ← the --event MOMENT (the signature, generic per event)
│ 'emerge-present': { family:'emerge', event:'present', keyframes:['fade-in'], ... },
│ 'emerge-present': { family:'emerge', event:'emerge-present', keyframes:['fade-in'], ... },
│ 'commit-settle': { family:'commit', keyframes:['settle'], ... },
│ 'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... }
│ } → generates [data-event-*][data-event-phase='active'] rules
@ -342,7 +342,9 @@ interface CssPhase {
interface EventSignature {
family?: string // data-event-family ('emerge' | 'commit' | 'signal' | …)
intent?: string // data-event-intent (valenced families)
event?: string | string[] // data-event name(s)/prefix(es) (['present','open'], …)
event?: string | string[] // data-event name(s)/prefix(es) (['emerge-present','emerge-open'], …)
direction?: string // data-event-direction ('forward' | 'backward') — sense of travel,
// decided per emit. A REFINEMENT, never a matcher on its own.
keyframes: KeyframeName | KeyframeName[]
duration?: string // token key OR raw hold ('600ms', outside the scale)
ease?: EaseKey
@ -467,11 +469,19 @@ to the driver, not to `ActiveDom`.
**The `--event` moment** (sema writes it during the hold; eidos reacts):
```css
[data-event='present'][data-event-phase='active'] { animation: fade-in …; }
[data-event='emerge-present'][data-event-phase='active'] { animation: fade-in …; }
[data-event-family='commit'][data-event-phase='active'] { animation: settle …; }
[data-event-family='signal'][data-event-intent='threat'][data-event-phase='active'] { animation: pulse …; }
[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] { animation: shift-cross-forward …; }
```
`data-event-direction` (`forward` | `backward`) is the sixth attr of the stamp
and the only one decided PER EMIT rather than declared in the morfo: the event
name already says `shift-enter-mode` vs `shift-exit-mode`, so the attr carries
only the sense of travel. The `shift` crossing is the first firma to read it —
its keyframes travel the inline axis via `--motion-shift-sign` (+1 `:dir(ltr)` /
−1 `:dir(rtl)`), never a physical `translateX`.
**The `--state` moment** (soma writes it; eidos reacts). The wrapper sets
`data-animation-style` (the `motion` prop); the rest state comes from the
recipe over the same `data-state`:

@ -1,10 +1,10 @@
/**
* docs:vocabularies — generate the canonical-vocabulary appendix from the code
* consts, so agents building from the docs-book can SEE the closed sets they
* must draw from (archetypes, sema families + holds + verbs, intents, haptic
* kinds, palette scales, sizes, variants, shared strings) without any
* copy-the-list drift objection. Generated = the ONE sanctioned place these
* lists are spelled out; every other doc links here.
* must draw from (archetypes, sema families + holds + verbs, intents,
* directions, haptic kinds, palette scales, sizes, variants, shared strings)
* without any copy-the-list drift objection. Generated = the ONE sanctioned
* place these lists are spelled out; every other doc links here.
*
* Closes STUMBLES #1 (invisible canonical vocabularies — an agent could not
* assign archetypes / holds from the docs alone).
@ -28,6 +28,7 @@ import { SEMA_HOLDS_BY_INTENT } from '../src/uix/sema/holds';
import { SEMA_VERBS } from '../src/uix/sema/verbs';
import { SOUNDS } from '../src/uix/sema/sound-names';
import { SEMA_DURATIONS } from '../src/uix/sema/durations';
import { SEMA_DIRECTIONS } from '../src/uix/sema/types';
import { INTENTS } from '../src/uix/intent';
import { commonLangs } from '../src/uix/langs/common';
@ -161,6 +162,26 @@ export function generateVocabulariesDoc(): string {
L.push(INTENTS.map((i) => `\`${i}\``).join(' · '));
L.push('');
// Directions
L.push(`## Directions (${SEMA_DIRECTIONS.length})`);
L.push('');
L.push('The sense of a traversal (`SEMA_DIRECTIONS`, `src/uix/sema/types.ts`),');
L.push('projected as `data-event-direction`. Per emission like the intent, and');
L.push('unlike it undeclarable on the EVENT: one `shift-navigate` is the previous');
L.push('month and the next one is the following month, so only the caller knows');
L.push('(`TriggerOptions.direction`). Optional — most occurrences have no sense to');
L.push('declare, and an invented one is worse than none.');
L.push('');
L.push('A SENSE, never an axis: eidos maps `forward` onto the inline end and');
L.push('`backward` onto the inline start, so RTL flips through `:dir(rtl)` and');
L.push('nothing upstream knows about it. Distinct from `SoundContour`');
L.push('(`ascending` / `descending`, `src/uix/sema/sounds.ts`), which shapes a pitch,');
L.push('and from `Morfo.direction`, which names the parts that carry the `dir` stamp');
L.push('(the RTL contract — see `docs/canon/direction-contract.md`).');
L.push('');
L.push(SEMA_DIRECTIONS.map((d) => `\`${d}\``).join(' · '));
L.push('');
// Hold durations
L.push('## Hold / perceptual durations');
L.push('');

@ -0,0 +1,206 @@
/**
* The closed vocabulary an eidos recipe may NAME through the `data-event*`
* stamp.
*
* `src/uix/sema/stamp.ts` writes `data-event` = the morfo event's `name`, plus
* `data-event-family` / `data-event-intent` / `data-event-direction` from the
* same signal. The
* structural linter (`src/uix/eidos/lint.ts`) classifies those attrs as
* "eidos-only" and never reads their VALUE — which is how
* `[data-event='commit-resize']` survived in `splitter.css` from the
* 2026-05-22 rename (`commit-resize` → `commit-set`, bd2e40366) without a
* single test complaining. A recipe hooked to a name nobody emits is dead
* CSS that looks alive.
*
* The check is CATALOGUE-wide, not per-component, and deliberately so:
* composition means a node receives stamps from another component's morfo
* (the card-group item carries `data-toggle-group-item` on the same node and
* receives `commit-block`, which toggle-group declares; the calendar
* nav-buttons receive `contact-activate` from the composed `Button`). A
* per-component ERROR would be a dozen false positives. The mismatch is
* still surfaced — as a WARN.
*/
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { INTENTS } from '../src/uix/intent';
import { SEMA_DIRECTIONS, SEMA_FAMILY_POLICY } from '../src/uix/sema/types';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MORFO_DIRS = [
join(__dirname, '..', 'src', 'uix', 'morfo', 'components'),
join(__dirname, '..', 'src', 'uix', 'morfo', 'internal')
];
const FAMILIES: readonly string[] = Object.keys(SEMA_FAMILY_POLICY);
const INTENT_VALUES: readonly string[] = INTENTS;
/**
* Imported, never restated. `scripts/` is outside the `tsconfig` graph, so a
* literal `['forward', 'backward']` written here would be unchecked prose that
* silently outlives the vocabulary it guards — the same shape of defect this
* whole file exists to prevent. `SemaDirection` is derived FROM this array
* (`sema/types.ts`), so the two cannot diverge.
*/
const DIRECTION_VALUES: readonly string[] = SEMA_DIRECTIONS;
export interface EventVocabulary {
/** event `name` → the morfo kebabs that declare it. */
readonly names: ReadonlyMap<string, readonly string[]>;
}
/** Import every morfo in the catalogue and index its declared event names. */
export async function loadEventVocabulary(): Promise<EventVocabulary> {
const names = new Map<string, string[]>();
for (const dir of MORFO_DIRS) {
for (const file of readdirSync(dir)) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
// file:// URL — Node's ESM loader rejects bare Windows paths ("g:\…").
const mod = (await import(pathToFileURL(join(dir, file)).href)) as Record<string, unknown>;
for (const value of Object.values(mod)) {
if (typeof value !== 'object' || value === null) continue;
const morfo = value as { kebab?: unknown; parts?: unknown; events?: unknown };
if (typeof morfo.kebab !== 'string' || !Array.isArray(morfo.parts)) continue;
if (!Array.isArray(morfo.events)) continue;
for (const event of morfo.events as readonly { name?: unknown }[]) {
if (typeof event?.name !== 'string') continue;
const owners = names.get(event.name);
if (owners) owners.push(morfo.kebab);
else names.set(event.name, [morfo.kebab]);
}
}
}
}
return { names };
}
export interface EventValueFinding {
readonly level: 'error' | 'warn';
/** The selector the attribute was authored in. */
readonly rule: string;
readonly message: string;
}
const COMMENT = /\/\*[\s\S]*?\*\//g;
const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g;
const EVENT_ATTR =
/\[\s*(data-event-family|data-event-intent|data-event-direction|data-event)\s*([~^$*|]?)=\s*(?:'([^']*)'|"([^"]*)"|([^\]\s]+))/gi;
/**
* Validate every `data-event` / `-family` / `-intent` / `-direction` VALUE in
* a CSS file.
*
* `ownKebab` (optional) turns "the name exists, but no morfo of this
* component declares it" into a WARN — the composition case.
*/
export function lintEventValues(
cssText: string,
vocabulary: EventVocabulary,
ownKebab?: string
): EventValueFinding[] {
// Comments first: splitter.css documents the hook in prose right above the
// rule, and a commented name is not a selector.
const text = cssText.replace(COMMENT, '');
const out: EventValueFinding[] = [];
for (const ruleMatch of text.matchAll(SELECTOR_LINE)) {
const rule = ruleMatch[1].trim().replace(/\s+/g, ' ');
for (const m of rule.matchAll(EVENT_ATTR)) {
const attr = m[1].toLowerCase();
const op = m[2];
const value = m[3] ?? m[4] ?? m[5];
if (attr === 'data-event-family') {
check(out, rule, attr, op, value, FAMILIES, 'a canon sema family');
continue;
}
if (attr === 'data-event-intent') {
check(out, rule, attr, op, value, INTENT_VALUES, 'a canon intent');
continue;
}
if (attr === 'data-event-direction') {
check(out, rule, attr, op, value, DIRECTION_VALUES, 'a sense of traversal');
continue;
}
const matched = matchable(value, op, vocabulary.names.keys());
if (!matched) {
out.push(unmodelledOperator(rule, attr, op, value));
continue;
}
if (matched.length === 0) {
out.push({
level: 'error',
rule,
message: `[${attr}${op}='${value}'] — no morfo declares an event ${op === '^' ? 'starting with' : 'named'} '${value}'.`
});
continue;
}
if (!ownKebab) continue;
const owners = [...new Set(matched.flatMap((name) => vocabulary.names.get(name) ?? []))];
if (owners.includes(ownKebab)) continue;
out.push({
level: 'warn',
rule,
message: `[${attr}${op}='${value}'] — declared by ${owners.join(', ')}, not by the '${ownKebab}' morfo (composition, or drift).`
});
}
}
return out;
}
function check(
out: EventValueFinding[],
rule: string,
attr: string,
op: string,
value: string,
vocabulary: readonly string[],
label: string
): void {
const matched = matchable(value, op, vocabulary);
if (!matched) {
out.push(unmodelledOperator(rule, attr, op, value));
return;
}
if (matched.length > 0) return;
out.push({
level: 'error',
rule,
message: `[${attr}${op}='${value}'] — '${value}' is not ${label} {${vocabulary.join(', ')}}.`
});
}
/**
* The vocabulary entries this attribute selector can ever match.
* `undefined` — an operator this guard does not model (nothing uses one today).
*/
function matchable(value: string, op: string, vocabulary: Iterable<string>): string[] | undefined {
if (op === '') return [...vocabulary].filter((entry) => entry === value);
if (op === '^') return [...vocabulary].filter((entry) => entry.startsWith(value));
return undefined;
}
function unmodelledOperator(
rule: string,
attr: string,
op: string,
value: string
): EventValueFinding {
return {
level: 'warn',
rule,
message: `[${attr}${op}='${value}'] — operator "${op}=" is not modelled; value left unvalidated.`
};
}
export function formatEventFindings(findings: readonly EventValueFinding[], indent = ' '): string {
const lines: string[] = [];
for (const f of findings) {
lines.push(`${indent}${f.level === 'error' ? '✗' : '·'} ${f.rule}`);
lines.push(`${indent} ${f.message}`);
}
return lines.join('\n');
}

@ -5,6 +5,9 @@
*
* Prints a per-component summary so the drift between eidos and the
* morfo declarations is visible at a glance.
*
* Also validates the `data-event*` VALUES against the morfo catalogue and
* the sema canon — see `scripts/eidos-event-vocabulary.ts`.
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs';
@ -13,6 +16,11 @@ import { resolve } from 'node:path';
import { compileMorfo } from '../src/uix/morfo/compile';
import { lintEidosCss } from '../src/uix/eidos/lint';
import type { Morfo } from '../src/uix/morfo/types';
import {
formatEventFindings,
lintEventValues,
loadEventVocabulary
} from './eidos-event-vocabulary';
const EIDOS_ONLY_ATTRS = new Set([
'data-archetype',
@ -22,7 +30,11 @@ const EIDOS_ONLY_ATTRS = new Set([
'data-color',
'data-columns',
'data-dragging',
// The sema stamp (`src/uix/sema/stamp.ts`), not a morfo part. Allowlisted
// STRUCTURALLY only — the VALUE is validated against the morfo catalogue
// by `lintEventValues` below.
'data-event',
'data-event-direction',
'data-event-family',
'data-event-id',
'data-event-intent',
@ -157,7 +169,26 @@ async function main() {
console.log('\nNo drift hotspots beyond documented Eidos-only attrs/parts.');
}
if (totalInvalid > 0) process.exit(1);
// `data-event*` VALUE check. Its own pass over the same files: it needs no
// compiled morfo, so it also covers the CSS skipped above for lack of one.
const vocabulary = await loadEventVocabulary();
let deadEvents = 0;
const eventBlocks: string[] = [];
for (const file of cssFiles) {
const findings = lintEventValues(readFileSync(file.path, 'utf-8'), vocabulary, file.name);
if (!findings.length) continue;
deadEvents += findings.filter((f) => f.level === 'error').length;
eventBlocks.push(` ${file.name}\n${formatEventFindings(findings, ' ')}`);
}
if (eventBlocks.length) {
console.log('\ndata-event* vocabulary (value must exist in a morfo / the canon):');
for (const block of eventBlocks) console.log(`\n${block}`);
} else {
console.log('\nEvery data-event* value in a recipe exists in the morfo catalogue.');
}
if (totalInvalid > 0 || deadEvents > 0) process.exit(1);
}
function isAllowedEidosOnlyAttr(attr: string): boolean {

@ -9,6 +9,9 @@
* `components/{name}.css`), compiles the morfo, and reports which
* selectors are morfo-backed, eidos-only DOM signals/wrapper attrs, or
* invalid (value not in declared enum).
*
* Also validates the `data-event*` VALUES against the morfo catalogue and
* the sema canon — see `scripts/eidos-event-vocabulary.ts`.
*/
import { readFileSync, existsSync } from 'node:fs'
@ -17,6 +20,11 @@ import { resolve } from 'node:path'
import { compileMorfo } from '../src/uix/morfo/compile'
import { formatLintReport, lintEidosCss } from '../src/uix/eidos/lint'
import type { Morfo } from '../src/uix/morfo/types'
import {
formatEventFindings,
lintEventValues,
loadEventVocabulary
} from './eidos-event-vocabulary'
async function main() {
const name = process.argv[2]
@ -51,7 +59,17 @@ async function main() {
const report = lintEidosCss(css, compiled)
console.log(formatLintReport(report))
if (report.counts.invalid > 0) process.exit(1)
// The structural report allowlists `data-event*` as eidos-only and never
// reads its value — the vocabulary is checked separately.
const eventFindings = lintEventValues(css, await loadEventVocabulary(), name)
if (eventFindings.length) {
console.log('')
console.log(' data-event* vocabulary (value must exist in a morfo / the canon):')
console.log(formatEventFindings(eventFindings, ' '))
}
const deadEvents = eventFindings.filter((f) => f.level === 'error').length
if (report.counts.invalid > 0 || deadEvents > 0) process.exit(1)
}
main().catch((err) => {

@ -73,6 +73,15 @@ export interface EventSignature {
readonly intent?: string
/** Name(s) / prefix(es) of `data-event` (`['present','open']`, …). */
readonly event?: string | readonly string[]
/**
* Sense of travel of the signal (`data-event-direction`), decided PER EMIT:
* `'forward'` / `'backward'`. A REFINEMENT, never a matcher on its own — the
* event NAME already distinguishes `shift-enter-mode` from `shift-exit-mode`;
* this carries only what the name cannot. Not to be confused with the
* sound's `SoundContour` (`ascending` / `descending`) — that one shapes a
* pitch, this one a traversal.
*/
readonly direction?: string
readonly keyframes: KeyframeName | readonly KeyframeName[]
/** Duration token key or raw hold (`'600ms'`). */
readonly duration?: string

@ -78,9 +78,9 @@ Passive **at the alert-dialog morfo level** — the morfo declares only
the Action / Cancel button parts (Provider is virtual). All
behavioural events (open/close presence, escape, focus trap, …) come
from the Dialog runtime that soma's AlertDialog delegates to. There
is no alert-dialog-specific sema event to fire; the
`commit-confirm` and `close-cancel` flows live inside the Dialog
morfo's vocabulary.
is no alert-dialog-specific sema event to fire; the confirm / cancel
flows live inside the Dialog morfo's vocabulary — the polymorphic
`emerge-close` (book §5.3) and its `data-last-action` cause.
The component IS interactive from the user's perspective. "Passive"
here is a contract-layer classification, not a UX one.
@ -128,7 +128,7 @@ behavioural locking.
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Soma's `commit-confirm` / `close-cancel` events not yet wired to a per-component sema cascade | **diferir** | The `commit` family base ships sound; suffices for now. Add cascade if we want different sounds for confirm-destroy vs confirm-affirm. |
| Dialog's polymorphic `emerge-close` (and its `commit.save` concretion) not yet wired to a per-component sema cascade | **diferir** | The `commit` family base ships sound; suffices for now. Add cascade if we want different sounds for confirm-destroy vs confirm-affirm. |
| No async `onAction` returning Promise to gate close | **diferir** | Soma's wrapper closes synchronously; consumers gate via a parent state machine instead. Open spec question. |
| `size='xs'` / `'sm'` shaping for very small confirmations | **diferir** | Inherits Dialog's size scale (`sm/md/lg/xl/full`). Smaller would require Dialog-level changes. |

@ -37,7 +37,7 @@ APG Date Picker Dialog/Grid.
| ----------------- | ---------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------- |
| `commit-select` | `commit.select` | `day` | `CalendarProvider.select(date, target)` after value changes | soft form commit + tap |
| `commit-unselect` | `commit.remove` | `day` | same, when a date is removed/cleared | subtle form commit + tap |
| `shift-navigate` | `shift.navigate` | provider fallback, actual button/select/day target when known | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | subtle ascending navigation sound |
| `shift-navigate` | `shift.navigate` | `grid` | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | family default `slide` (arc, gain 0.06) + the directional `shift` motion firma |
Calendar is not passive: a 0-event contract would hide committed selection and
visible-range navigation from the semantic layer. This migration closes that gap
@ -97,9 +97,25 @@ before adding the Eidos recipe.
fecha es un compromiso evaluativamente positivo (suave). Un commit
destructivo correspondería a `commit-unselect` con `intent: 'neutral'`.
- **`shift-navigate` cubre todo el movimiento de mes/año** (prev/next,
selects, atajos de teclado). No se subdivide por dirección — la
perceptiva de Sema se modula con `target` y `sequence`, no con
variantes de verbo.
selects, atajos de teclado). No se subdivide en variantes de verbo: el
sentido del paso viaja POR EMISIÓN en `data-event-direction`
(`forward`/`backward`), y lo pone quien da el paso — la paginación y el
teclado saben hacia dónde fueron; un salto desde el select no pasa
ninguno.
- **El sello de `shift-navigate` cae en el `grid`, no en el provider ni
en el botón**. El grid es el sujeto del cruce (es lo que cambia de mes;
la cabecera no se mueve y la flecha es sólo el instrumento) y es lo
único que puede LUCIR una firma de `shift`: un mes se desliza, un botón
no. Medido el 2026-08-11 sobre la flecha «anterior»: recibía su
`contact-activate` y arrancaba el `press-squeeze`, y 8,5 ms después
—media trama— `shift-navigate` pisaba la misma ranura y el squeeze
moría sin pintar. Una superficie, una ranura.
- **El anillo de evento perdió el provider en la receta**
(`box-shadow: var(--calendar-event-shadow)`): con el sello en el grid,
nada apunta ya a `[data-calendar]` y la regla era una selección muerta.
Se quedan el día (lo alimenta `commit-select`) y las flechas y los dos
selects, que componen `<Button>` y conservan el anillo por su propio
`contact-activate`. Verificado en navegador.
- **Las flechas de teclado son focus moves puros**: mueven el día
enfocado pero no mutan `value`. Por eso el morfo declara 10
keyboards y sólo 3 eventos: Enter/Space (mutación) → `commit-select`,

@ -246,7 +246,14 @@
color: var(--calendar-day-color);
}
[data-calendar][data-event],
/* Event ring — any surface of this component that carries a stamp gets it for
the hold. The provider is NOT in the list since 2026-08-11: `shift-navigate`
used to be redirected onto whichever control caused the navigation and fell
back to the provider, so `[data-calendar]` could be stamped. It now targets
`grid` (the thing that actually navigated), and nothing else aims at the
provider — measured: no gesture stamps it. The controls below keep the ring
through their OWN `contact-activate` (they compose `<Button>`), which was
always their real feeder; the day keeps it through `commit-select`. */
[data-calendar-day][data-event],
[data-calendar-prev-button][data-event],
[data-calendar-next-button][data-event],

@ -10,7 +10,7 @@ primitive** — it owns no selection or disclosure behaviour of its own:
`commit-toggle` / `commit-block` sema all come from ToggleGroup.
- **Disclosure → [`Collapsible`](../collapsible/README.md)** — the Title is a
`Collapsible.Trigger` (rendered as the system `<Button>`); the Content
collapses via the `hidden` attribute. The `expand` / `collapse` sema is
collapses via the `hidden` attribute. The `emerge-expand` / `emerge-collapse` sema is
Collapsible's.
- **Card visual → [`Card`](../card/README.md)** — each item IS a card via
structural identity (`data-card`), reusing the entire Card recipe
@ -107,7 +107,7 @@ CardGroup declares **no own events** — its perceptual surface is composed:
| Event | From | Family · verb · intent | When |
| --- | --- | --- | --- |
| `expand` / `collapse` | Collapsible | `emerge` · expand/collapse | Title toggles the group. |
| `emerge-expand` / `emerge-collapse` | Collapsible | `emerge` · expand/collapse | Title toggles the group. |
| `commit-toggle` | ToggleGroup | `commit` · toggle · neutral | A card is selected / unselected. |
| `commit-block` | ToggleGroup | `commit` · block · risk | A pick is rejected by the cardinality limit (at-max with `whenFull: 'reject'`, or below a required min). The card shakes (`card-group-block`) over the risk-tinted sound/haptic. |

@ -3,7 +3,7 @@
Collapsible no existe como componente visual en la rama Air anterior
(`glm-5:src/uix/air/components`). Air solo tenia la familia semantica/motion
`expansion` para Accordion/Collapsible; UIX la reemplaza por eventos Morfo/Sema
`expand` y `collapse`.
`emerge-expand` y `emerge-collapse`.
## Superficie
@ -44,9 +44,10 @@ Partes publicas: `Trigger`, `Content`.
se resuelve con CSS o con un wrapper de producto.
- `Content` esta siempre montado. Eso cubre el caso `forceMount` que Radix/Bits
necesitan para transiciones; Soma usa `hidden` cuando esta cerrado.
- `expand` es `sequence: 'post'`: primero abre el contenido y luego Sema/Eidos
emiten la senal de entrada. `collapse` es `sequence: 'pre'`: la senal de
salida se reproduce mientras el contenido aun es visible.
- `emerge-expand` es `sequence: 'post'`: primero abre el contenido y luego
Sema/Eidos emiten la senal de entrada. `emerge-collapse` es `post` tambien
(collapsible-NEW-001): la senal de salida corre contra un contenido que el
flip todavia no ha ocultado.
- No se anade `Indicator` como part Eidos. Radix/Bits/shadcn lo componen dentro
del Trigger; Ark si lo incluye, pero en UIX un indicador sin ARIA ni estado
propio seria SVG/composicion visual dentro de `Trigger`.

@ -50,9 +50,9 @@
padding: var(--collapsible-content-padding);
}
/* The expand/collapse firma is the generic `expand` / `collapse` EVENT
* SIGNATURE (EidosConfig.motion.signatures → generated against
* `[data-event^='expand'/'collapse'][data-event-phase='active']`) — the local
/* The expand/collapse firma is the generic `emerge-expand` / `emerge-collapse`
* EVENT SIGNATURE (EidosConfig.motion.signatures → generated against
* `[data-event^='emerge-expand'/'emerge-collapse'][data-event-phase='active']`) — the local
* per-component keyframes it replaced lived outside the motion channel. Soma
* still owns persistence through the HTML `hidden` attribute; the signature
* only plays during Sema's transient hold (expand after the state opens,

@ -135,10 +135,11 @@ Estado completo + pendientes (presets en 4 grupos, tamaño del eyedropper):
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Picker contract (`mode`/`Footer`/`Clear`/`Cancel`/`Close` con commit/cancel snapshot) | **diferir** | El soma actual implementa `closeOnSelect` + `inline`; el contrato canónico (P-1..P-5) se aplicará en una pasada follow-up alineando con date-picker / time-picker. |
| Tests browser-level (Playwright) | **implementar** | Cobertura visual + interacciones: drag de area-thumb, drag de channel-slider-thumb, swatch click, eyedropper API, format switch. |
| Saturation/brightness sliders en el demo | **implementar** | Soma lo soporta; demo lo dejará seleccionable cuando se walk visual del componente. |
| Documentación per-prop exhaustiva | **diferir** | Cubierto por la tabla del demo (`API` tab). |
| El botón `Clear` es MUDO | **implementar** | El morfo declara `commit-reset`, pero `clear()` sólo hace `value.current = undefined` — nunca dispara el evento. Mismo defecto en los otros cuatro pickers. Medido 2026-08-11. |
| Swatch y eyedropper commitean en silencio | **implementar** | El morfo (`color-picker.ts:40-42`) y el pack de sema declaran que `commit-set` dispara también en «swatch click» y «eyedropper pick»; los dos providers sólo llaman `commitChange()`. El único `commit-set` vivo sale del Área. Medido 2026-08-11. |
## Referencias

@ -12,8 +12,8 @@ Eidos Combobox wraps Soma Combobox and keeps the component compound:
state. Es la referencia ergonomic.
- **soma actual**: cubre input value + open state + single/multiple values +
hidden inputs + groups + floating + dismissal + keyboard nav.
- **morfo**: declara las parts y el contrato data-/aria-; eventos sema sólo
para `commit-select` y `commit-unselect`.
- **morfo**: declara las parts y el contrato data-/aria-; eventos sema para
`commit-select`, `commit-unselect`, `emerge-open` y `emerge-close`.
## Comparativa
@ -49,9 +49,9 @@ References:
| Layer | Owns |
| --- | --- |
| Morfo | Parts, ARIA/data contract, `commit-select` and `commit-unselect` events. |
| Morfo | Parts, ARIA/data contract, `commit-select`, `commit-unselect`, `emerge-open` and `emerge-close` events. |
| Soma | Input text, open state, single/multiple values, hidden form inputs, groups, floating, dismissal and keyboard navigation. |
| Sema | Subtle form commit sounds + tap haptic for select/unselect. Typing, filtering, open/close and highlight are intentionally silent. |
| Sema | Subtle form commit sounds + tap haptic for select/unselect; the popup's open/close ride the `emerge` family defaults (the pack adds no rule of its own). Typing, filtering and highlight are intentionally silent. |
| Eidos | Control layout, size, variant, intent color, trigger icon, selected item indicator, panel recipe and animation. |
## Morfo/Sema
@ -60,22 +60,27 @@ References:
| --- | --- | --- | --- | --- | --- | --- |
| `commit-select` | `commit` | `select` | `affirm` | `item` | `post` | User confirms an option. The concrete item is the perceived target. |
| `commit-unselect` | `commit` | `remove` | `neutral` | `item` | `post` | Multiple deselect or single clear via `deselectable`. |
| `emerge-open` | `emerge` | `open` | — | `content` (stamped on `trigger` / `input`) | `post` | The popup appears (trigger, typing, ArrowUp/Down). The content isn't mounted yet, so the stamp lands on the surface that opened it. |
| `emerge-close` | `emerge` | `close` | — | `content` (stamped on `trigger` / `input`) | `pre` | The popup recedes (trigger, Escape, selection, outside, Tab). `pre` so the retreat fires while the content still exists. |
Opening, closing, typing, filtering and highlighting are navigation mechanics,
not committed choices. They do not emit Sema events.
Typing, filtering and highlighting are navigation mechanics, not committed
choices: they emit no Sema event. Appearing and receding do — a dropdown list
is `emerge`, not `present` (option-list pass, 2026-06-19).
## Decisiones
- **No se emiten eventos sema en open/close/typing/filter/highlight**. Son
mecánicas de navegación, no compromisos del usuario. El sema sólo viaja
con `commit-select` y `commit-unselect` (la elección concreta).
- **No se emiten eventos sema en typing/filter/highlight**. Son mecánicas de
navegación, no compromisos del usuario. El compromiso viaja con
`commit-select` y `commit-unselect` (la elección concreta); la aparición y
la retirada del popup viajan con `emerge-open` / `emerge-close`.
- **Las teclas de navegación interna de la listbox son focus moves**:
ArrowDown/ArrowUp/Home/End mueven el highlight, no mutan el valor. El
audit las cuenta como focus-only — el único keystroke que mutea es
Enter (mapped a `select` → covered por `commit-select`).
- **Escape delega al popover**: la tecla cierra cerrando la layer
popover/dismissal; combobox no duplica el evento `close-dismiss` que ya
emite el popover.
- **Escape cierra por la capa `Dismissal`**: su `onEscapeKeydown` llama a
`handleClose()`, que emite `emerge-close` y devuelve el foco al input. El
combobox no compone `Popover` — monta `Floating` + `Dismissal` directamente,
así que el evento de cierre es suyo, no heredado.
- **`Combobox.Control` es composición Eidos-only**: agrupa Input + Trigger
+ Indicator dentro de un único chrome. No tiene contraparte en
morfo/soma porque es puramente layout visual.

@ -74,7 +74,7 @@ Partes publicas: `Label`, `Input`, `Segment`, `HiddenInput`.
- `size`, `variant` y `color` pertenecen a Eidos porque solo afectan receta
visual; `value`, `placeholder`, `granularity`, `locale`, `dir`, validacion y
navegacion de segmentos pertenecen a Soma.
- Los eventos declarados por Morfo son `commit-set` y `commit-clear`; la
- Los eventos declarados por Morfo son `commit-set` y `commit-reset`; la
edicion continua de segmentos no se modela como evento perceptivo por cada
pulsacion.

@ -106,15 +106,6 @@ sus targets y como se observan en `data-event`.
| Documentación per-prop exhaustiva | **implementar** | Cuando se cierre el ciclo de remediación de cada componente. |
| Tests browser-level del flujo completo (Playwright) | **implementar** | Cobertura visual + interacciones. Se hace en una pasada conjunta de tests. |
## Passive justification
Componente passive por diseño: no gestiona estado mutable propio,
no responde a teclado más allá del foco del navegador, no emite
eventos sema propios. El feedback perceptivo correspondiente al
cambio que rodea al componente (validación, progreso, transición)
pertenece a quien orquesta ese cambio — Form / Field / Toast /
Dialog — no al componente visual.
## Audit exceptions
- `R-1.5 exception:` the picker's focusable parts are the composed field segments + trigger — the foundation archetype ring (`archetypes.css` universal `[data-archetype]:focus-visible` rule) and the composed field/calendar recipes own the treatment; the content panel is the documented `content` exclusion (elevation is its boundary).

@ -15,11 +15,13 @@ Incidencia 2026-05-20 (parcialmente atendida):
`secondary` cuando el `data-color` colisiona con `affirm/fulfill`
(incidencia #4).
- ✓ Raw hex purgados; tokens de eidos regenerados.
- ✓ Morfo declara eventos (`open`, `close-range-commit`, `close-cancel`,
`close-dismiss`, `close-dismiss-outside`, `commit-clear`) y `data-last-action`.
- ⚠️ Modo modal/no-modal con acciones explícitas (incidencia #5) — todavía
no implementado.
- ⚠️ Botón `clear` explícito en la UI — todavía no implementado.
- ✓ Morfo declara `commit-reset` — su único evento propio — y
`data-last-action`; abrir y cerrar pertenecen al Popover compuesto
(`expression: 'delegated'`).
- ✓ Modo modal/no-modal con acciones explícitas (incidencia #5): prop
`mode: 'inline' | 'modal'` + `DateRangePicker.Footer` / `.Close` / `.Cancel`.
- ✓ Botón `clear` explícito: `DateRangePicker.Clear` (parte compartida de
PickerShell).
- ⚠️ Controles de demo `start segments` / `end segments` / `paged nav` —
pendiente decidir si exponer en demo o sólo en API doc.
@ -42,9 +44,9 @@ Incidencia 2026-05-20 (parcialmente atendida):
| `allowSingleDay` | ✓ | ✗ | ✗ | ✗ |
| `isDateHoliday` | ✓ | ✗ | ✗ | ✗ |
| Segmentos `readonly` por endpoint | ✓ | ✗ | parcial | ✗ |
| Eventos sema declarativos | ✓ (5 events + intent) | n/a | n/a | n/a |
| Eventos sema declarativos | ✓ (`commit-reset` propio + `emerge-*` del Popover) | n/a | n/a | n/a |
| `data-last-action` para tinte de salida | ✓ | ✗ | ✗ | ✗ |
| Modos modal/no-modal | ✗ (pendiente) | ✓ | ✗ | ✗ |
| Modos modal/no-modal | ✓ | ✓ | ✗ | ✗ |
| Auto-page al seleccionar en mes final | ✗ (deliberado) | ✗ | ✓ | ✓ |
| Limpieza por endpoint individual | ✓ | parcial | ✗ | ✗ |
| Distinción visual start/end | ✓ (stripe + swap) | ✓ | parcial | parcial |
@ -69,16 +71,17 @@ Incidencia 2026-05-20 (parcialmente atendida):
- **Start usa `affirm` por defecto, end usa el `data-color` accent**. Cuando
el accent colisiona (`data-color="affirm"`/`"fulfill"`), el start cambia a
`secondary` para mantener distinción perceptiva (incidencia #4).
- **El morfo declara eventos del picker** (`open`, `close-*`, `commit-clear`).
Eventos de celda (`commit-start`, `commit-range`, `shift-navigate`) los
emite `range-calendar`; el picker no los duplica.
- **El morfo del picker declara un solo evento propio** (`commit-reset`).
Abrir y cerrar los emite el Popover compuesto (`emerge-open` y el
`emerge-close` polimórfico). Los eventos de celda (`commit-select-start`,
`commit-select-range`, `commit-reset`, `shift-navigate`) los emite
`range-calendar`; el picker no los duplica.
## Gaps
| ID | Disposición | Detalle |
| --- | --- | --- |
| Modal mode con `Save`/`Cancel` (#5) | **implementar** | Prop `mode: 'modal' \| 'inline'`. Modal añade Footer + SaveButton + CancelButton; click en celdas no auto-cierra. Requiere extensión del morfo (Footer/Save/Cancel parts) y nuevo evento `close-save`. |
| Botón `Clear` explícito | **implementar** | Limpia ambos endpoints. Emite `commit-clear` (ya declarado). Va en el header del calendario o en el field input. |
| Demo: `start segments` / `end segments` / `paged nav` | **diferir** | Mover a sección API-only del README; quitar de los controles interactivos del demo si no mejoran el preview. |
| `data-disabled-reason` en celdas para min/maxDays | **diferir** | El cell ya emite `data-disabled`; el "porqué" (out-of-bounds vs range-length) puede añadirse vía `data-disabled-reason` para que el screen reader explique. No bloqueante. |
| Tests browser-level del flujo completo | **implementar** | Falta cobertura visual (Playwright) del flujo abrir → seleccionar start → seleccionar end → commit/dismiss. |
| El botón `Clear` es MUDO | **implementar** | El morfo declara `commit-reset`, pero `clear()` en el provider sólo hace `value.current = undefined` — nunca llama a `runtime.trigger('commit-reset')`. Afecta por igual a date-picker, time-picker, time-range-picker y color-picker: o se cablea el trigger en los cinco, o el evento sale del morfo. Medido 2026-08-11. |

@ -12,14 +12,15 @@ attached, sin `Root` ni `Provider` publico.
focus trap/restore, escape/outside policies, scroll lock, force mount,
nested dialogs. Tamaños `sm/md/lg/full`.
- **soma actual**: cubre todo lo de Air en behavior (focus, dismissal,
scroll lock, modal/non-modal, alertdialog variant) más outcome-specific
close events (`close-save` / `close-cancel` / `close-dismiss` /
`close-dismiss-outside` / `close-after-fail`) que ningún competidor
expone.
- **morfo**: declara 7 events (open + 5 close variants + open intent
fromProp), focus policy con trap/return/restore, parts Provider /
Trigger / Content / Overlay / Title / Description / Close. Eidos
añade Header / Footer visuales y dimensiones / position.
scroll lock, modal/non-modal, alertdialog variant) más cierre con causa
(`dismissWith('save' | 'cancel' | 'dismiss' | 'dismiss-outside' |
'fail')`, que concreta el `emerge-close` polimórfico y sella
`data-last-action`) que ningún competidor expone.
- **morfo**: declara 2 events (`emerge-open`, con intent `fromProp`, y el
`emerge-close` polimórfico del libro §5.3), focus policy con
trap/return/restore, parts Provider / Trigger / Content / Overlay /
Title / Description / Close. Eidos añade Header / Footer visuales y
dimensiones / position.
## Superficie
@ -91,9 +92,10 @@ Partes publicas: `Trigger`, `Portal`, `Overlay`, `Content`, `Title`,
(`trapFocus={true}` / `preventScroll={true}`).
- `variant="alertdialog"` vive en Soma porque cambia comportamiento: role,
escape/outside defaults y expectativa de accion explicita.
- Los eventos de cierre (`close-save`, `close-cancel`, `close-dismiss`,
`close-dismiss-outside`, `close-after-fail`) son ventaja propia de UIX:
permiten que Sema/Eidos distingan causa perceptiva sin inventar props visuales.
- El cierre con causa (`dismissWith('save' | 'cancel' | 'dismiss' |
'dismiss-outside' | 'fail')`, que concreta el `emerge-close` polimórfico y
sella `data-last-action`) es ventaja propia de UIX: permite que Sema/Eidos
distingan causa perceptiva sin inventar props visuales.
## Diferencias con Air

@ -15,9 +15,11 @@ y anade lo que Air no tenia: `Handle`, snap points, drag/resize, variantes
handle, snap points (`activeSnapPoint` controlled), drag-to-dismiss,
variantes `overlay/inline/persistent`, RTL-aware `start/end`
direction, outcome-specific close events.
- **morfo**: declara `present` + 5 close variants (save/cancel/
dismiss/dismiss-outside/after-fail), focus policy con
trap/return/restore, parts Provider/Trigger/Content/Overlay/Handle
- **morfo**: declara `emerge-open` + un `emerge-close` polimorfico
(emerge.close / commit.save+fulfill / signal.alert+threat segun la
causa) + `handle-pick`/`handle-drag-progress`/`handle-drop`/
`handle-resize`, focus policy con trap/return/restore, parts
Provider/Trigger/Content/Overlay/Handle
+ Header/Footer/Title/Description/Close.
## Superficie

@ -81,7 +81,7 @@ Soma owns:
When the menu opens, its items **fade in staggered by structural order** — the
first concrete consumer of the framework's *event-driven* cascade (vs the
explicit `<Cascade>` orchestrator). Nothing in the app wires it; the appearance
**flows from the `open` (emerge) event**:
**flows from the `emerge-open` (emerge) event**:
- `<DropdownMenu.Content>` marks its panel `[data-stagger]`.
- each item carries `data-animation-style="fade"`.

@ -53,8 +53,10 @@ composes the visual `Field` with the switcher injected inside the control.
- `FieldLangs.Control` injects the switcher inside `Field.Control` at the logical
side; the control's padding on that edge is zeroed so the switcher docks flush
against the border, nothing between.
- Only three parts are FieldLangs' own (`provider`, `switcher`, `lang-option`);
the composed `Field` and `Popover` surfaces keep their own morfo contracts.
- Five parts are FieldLangs' own: `provider`, `switcher` and `lang-option`, plus
`textarea` (the multiline control shell wrapping a composed `TextArea`) and
`menu` (the select-mode dropdown list). The composed `Field` and `Popover`
surfaces keep their own morfo contracts.
## Baseline

@ -128,9 +128,12 @@ Decision: si se necesita, se implementa en Soma/Form.
## Eventos Sema
Field declara 0 eventos semanticos de forma deliberada. Es una envoltura de
composicion para label, control, mensajes y estado de formulario; las acciones
del usuario pertenecen al control que vive dentro (`Input`, `SearchField`,
Field declara 1 evento semantico: `commit-submit` (familia `commit`, verbo
`submit`, intent `neutral`, `sequence: 'post'`), disparado por `Field.Input` al
pulsar Enter — el contrato de teclado del morfo. Es la adopcion de la doctrina
de la familia de inputs de texto, que cerro la asimetria con `textarea` /
`password-field` (ficha de auditoria field F-2). El resto de acciones del
usuario pertenecen al control especializado que vive dentro (`SearchField`,
`NumberField`, `Select`, etc.) o al modulo `Form`, no al shell visual del campo.
## Gaps
@ -141,12 +144,3 @@ del usuario pertenecen al control que vive dentro (`Input`, `SearchField`,
| Cobertura adicional de variantes visuales | **diferir** | El recipe cubre sm/md/lg + solid/outline/ghost. Más variantes requieren caso concreto. |
| Documentación per-prop exhaustiva | **implementar** | Cuando se cierre el ciclo de remediación de cada componente. |
| Tests browser-level del flujo completo (Playwright) | **implementar** | Cobertura visual + interacciones. Se hace en una pasada conjunta de tests. |
## Passive justification
Componente passive por diseño: no gestiona estado mutable propio,
no responde a teclado más allá del foco del navegador, no emite
eventos sema propios. El feedback perceptivo correspondiente al
cambio que rodea al componente (validación, progreso, transición)
pertenece a quien orquesta ese cambio — Form / Field / Toast /
Dialog — no al componente visual.

@ -36,16 +36,16 @@ git ls-tree -r --name-only gita/glm-5 -- src/uix/air/components/file-upload
## Morfo / Sema
`FileUpload` is interactive and mixed: some actions are contact-only
(`trigger-picker`), some are committed state changes and rejection is a
signal.
(`contact-trigger-picker`), some are committed state changes and rejection
is a signal.
| Event | Family | Verb | Intent | Target | Sequence | Soma trigger |
| ----- | ------ | ---- | ------ | ------ | -------- | ------------ |
| `trigger-picker` | `contact` | `trigger` | none | `trigger` | `coincident` | Trigger/Dropzone opens picker |
| `commit-add` | `commit` | `set` | `affirm` | `provider` | `post` | Accepted files added |
| `signal-reject` | `signal` | `warn` | `risk` | `provider` | `post` | File validation rejection |
| `contact-trigger-picker` | `contact` | `trigger` | none | `trigger` | `coincident` | Trigger/Dropzone opens picker |
| `commit-set-add` | `commit` | `set` | `affirm` | `provider` | `post` | Accepted files added |
| `signal-warn-reject` | `signal` | `warn` | `risk` | `provider` | `post` | File validation rejection |
| `commit-remove` | `commit` | `remove` | `neutral` | `item` | `post` | ItemRemove |
| `commit-clear` | `commit` | `reset` | `neutral` | `provider` | `post` | ClearTrigger |
| `commit-reset` | `commit` | `reset` | `neutral` | `provider` | `post` | ClearTrigger |
Drag hover is not emitted as Sema. The structural state is `data-dragging`;
continuous pointer/drag movement would be noisy and adds no committed meaning.

@ -16,7 +16,7 @@ attrs: `data-size`, `data-layout`, `data-variant` on the root and
reflexivamente desde el schema SIUM.
- **morfo**: declara `Provider` + actions (`Submit`, `Reset`,
`ErrorSummary`) + 11 parts de AutoFields para selectores de la receta.
Eventos sema: `commit-submit` (fulfill), `signal-invalid` (risk),
Eventos sema: `commit-submit` (fulfill), `signal-warn-invalid` (risk),
`commit-reset` (neutral).
## API Shape
@ -74,7 +74,7 @@ invalid value, unless a demo explicitly documents an initially invalid state.
| Event | Family | Intent | Target | Decision |
| --- | --- | --- | --- | --- |
| `commit-submit` | `commit` | `fulfill` | provider | Valid submit confirms data. |
| `signal-invalid` | `signal` | `risk` | provider | Invalid submit warns without committing. |
| `signal-warn-invalid` | `signal` | `risk` | provider | Invalid submit warns without committing. |
| `commit-reset` | `commit` | `neutral` | provider | Reset is a neutral commit to defaults. |
AutoFields structural parts are declared in `formMorfo` because their selectors
@ -111,7 +111,7 @@ are part of the recipe. They do not create new semantic events.
inmediatamente y mostraría errores antes de que el usuario edite, lo
cual es ruido perceptivo. Sólo demos que explícitamente quieran un
estado inválido inicial parten así.
- **Sema sólo en commit-submit / signal-invalid / commit-reset**: los
- **Sema sólo en commit-submit / signal-warn-invalid / commit-reset**: los
cambios per-field son del Field, no del Form. El Form sólo emite en
los momentos donde el "todo" cambia (envío, fallo de validación
global, reset).

@ -71,7 +71,7 @@ default `+`→`×` morph) · `aria-label` · `onOpenChange`.
## Layers
- **morfo** (`$uix/morfo/components/menu-dial`) — Menu Button (APG); parts
`provider`/`trigger`/`list`/`action`; events `open`/`close` (emerge) +
`provider`/`trigger`/`list`/`action`; events `emerge-open`/`emerge-close` (emerge) +
`commit-select` (commit · affirm); focus policy (non-modal, return to trigger).
- **soma** (`$soma/components/menu-dial`) — `createMenuDialRuntime` (emission +
keyboard dispatch) + the pure linear `dialNav*` maths (testable apart).

@ -163,7 +163,7 @@
if (open) return;
open = true;
onOpenChange?.(true);
runtime.trigger('open', { targetOverride: asTarget(listEl) });
runtime.trigger('emerge-open', { targetOverride: asTarget(listEl) });
pendingFocus = dialNavFirst(navState());
}
function closeMenu(restoreFocus = true) {
@ -172,7 +172,7 @@
onOpenChange?.(false);
focusedIndex = 0;
pendingFocus = null;
runtime.trigger('close', { targetOverride: asTarget(listEl) });
runtime.trigger('emerge-close', { targetOverride: asTarget(listEl) });
if (restoreFocus) triggerEl?.focus();
}
function toggle() {

@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`.
- `shift-navigate-step` — `shift.navigate` on `provider`, sequence `post`.
- `shift-navigate-step` — `shift.navigate` on `grid`, sequence `post`.
## Baseline
@ -72,7 +72,18 @@ fuera del DatePicker (filtros, reportes, año fiscal).
- **`shift-navigate-step` cubre toda la paginación** — un solo evento sema
para cualquier shift del header (PrevButton / NextButton / PageUp / PageDown).
Family `shift.navigate` semánticamente correcto, variant `step` por paso
discreto.
discreto. El sentido del paso viaja por emisión en `data-event-direction`
(`forward`/`backward`).
- **El sello cae en el `grid`, no en el provider ni en la flecha**
(2026-08-11): el grid es el sujeto del cruce — es la superficie de celdas
que cambia de año, la cabecera no se mueve y la flecha es sólo el
instrumento — y es lo único que puede lucir una firma de `shift`.
- **El anillo de evento se quedó sólo en la celda**
(`box-shadow: var(--calendar-event-shadow)`): con el sello en el grid, ni el
provider ni las dos flechas reciben ya `data-event`, y a diferencia de las de
calendar estas flechas son botones NATIVOS de soma —no `<Button>`
compuestos—, así que no tienen un `contact-activate` propio que mantuviera el
anillo vivo. La celda lo conserva por `commit-set`. Verificado en navegador.
## Gaps

@ -206,10 +206,14 @@
color: var(--calendar-day-color);
}
[data-month-grid][data-event],
[data-month-grid-cell][data-event],
[data-month-grid-prev-button][data-event],
[data-month-grid-next-button][data-event] {
/* Event ring — only the cell is left since 2026-08-11. `shift-navigate-step`
used to be redirected onto the pressed arrow (off-contract: the event
declares no `allowedTargets`) and now targets `grid`, so the provider and the
two arrows are never stamped. Unlike calendar's, these arrows are NATIVE soma
buttons, not composed `<Button>`s, so they have no `contact-activate` of
their own to keep the ring alive — measured: no gesture stamps them. The cell
keeps it through `commit-set`. */
[data-month-grid-cell][data-event] {
box-shadow: var(--calendar-event-shadow);
}

@ -56,26 +56,28 @@ Key `<OnionMenu>` props: `placement` (anchor → arc span 90/180/360 + orientati
- **morfo** (`$uix/morfo/components/onion-menu`) — the contract: a Menu Button (APG),
`role="menu"` surface, `menuitem` sectors, the `focus` policy (non-modal: arrows
navigate, Tab / outside-click exit + close, focus returns to the trigger), the
keyboard map, and the `open` / `close` / `emerge-expand` / `emerge-collapse`
(emerge) + `commit-select` (commit · affirm) events.
`expression: 'family-default'` — no per-component sema pack.
keyboard map, and the `emerge-open` / `emerge-close` / `emerge-expand` /
`emerge-collapse` (emerge) + `commit-select` (commit · affirm) events.
`expression: 'pack'` — the radial pack shipped 2026-07-07 (checkpoint S3b).
`emerge-expand` / `emerge-collapse` were added 2026-08-10: drilling into a
submenu — the gesture that defines a radial menu — emitted NOTHING, because
the surface only declared open/close for the whole menu and `commit-select`
for a leaf. Same family+verb as TreeView's node expansion (book cap. 26), on
the SECTOR that drilled; `item` already declared `states: ['expanded',
'collapsed']` in anticipation. Bottoming out of the drill is `close`, not a
collapse.
'collapsed']` in anticipation. Bottoming out of the drill is `emerge-close`,
not a collapse.
- **soma** (`$soma/components/onion-menu`) — the headless behaviour bridge:
`createOnionMenuRuntime` emits the perceptual signals and dispatches the keyboard
contract; `onionNav*` are the pure radial focus maths. The root drives focus / drill
/ dismiss against them.
- **eidos** (here) — the SVG render + the two pure engines (geometry + colour) + the
recipe. The root owns `open` / `drill` state and paints.
- **sema** — picks `open`/`close`/`emerge-expand`/`emerge-collapse` up from the
`emerge` family base and `commit-select` from `commit` (no pack; family
defaults — the verb tier gives expand/collapse their own sound).
- **sema** (`$uix/sema/components/onion-menu`) — the radial pack (S3b) holds
cascade rules for `emerge-open` / `emerge-close` on the surface and adds a
`tap` haptic to `commit-select` on the item. `emerge-expand` /
`emerge-collapse` carry no rule and fall to the `emerge` family base, where
the verb tier gives them their own sound.
### Pure engines

@ -271,7 +271,7 @@
function openMenu() {
if (open) return;
open = true;
runtime.trigger('open', { targetOverride: asTarget(surfaceEl) });
runtime.trigger('emerge-open', { targetOverride: asTarget(surfaceEl) });
pendingFocus = onionNavFirst(currentRingNav());
onTriggerClick?.();
}
@ -282,7 +282,7 @@
selected = null;
focusedIndex = 0;
pendingFocus = null;
runtime.trigger('close', { targetOverride: asTarget(surfaceEl) });
runtime.trigger('emerge-close', { targetOverride: asTarget(surfaceEl) });
if (restoreFocus) triggerEl?.focus();
}

@ -83,6 +83,18 @@ Referencias: [React Aria RangeCalendar](https://react-spectrum.adobe.com/react-a
`commit-select-start` (**affirm** — el rango se abre, confirmación suave) →
`commit-select-range` (**fulfill** — el sellado del PAR es el momento de
cierre) → `commit-reset` (neutral) + `shift-navigate` (paginación de mes).
- **`shift-navigate` sella el `grid`, no el provider ni la flecha** (igual que
el donante, 2026-08-11): el grid es el sujeto del cruce y lo único capaz de
lucir una firma de `shift` — un mes se desliza, un botón no. Medido en el
donante: con el sello en la flecha, su propio `contact-activate` quedaba
pisado 8,5 ms después y el `press-squeeze` moría sin pintar. Una superficie,
una ranura.
- **El anillo de evento perdió el provider en el recipe**
(`box-shadow: var(--calendar-event-shadow)`): nada apunta ya a
`[data-range-calendar]`, así que la regla era selección muerta. Se quedan el
día (`commit-select-*`) y las flechas y los dos selects, que comparten el
`CalendarNavButton` del donante y conservan el anillo por su propio
`contact-activate`. Verificado en navegador.
- **Pack sema propio** (`expression: 'pack'`, ascenso S3b): la riqueza ya
declarada dejó de sonar a default genérico — el fulfill del rango completo
tiene carácter distinto del affirm del start.
@ -101,7 +113,7 @@ Referencias: [React Aria RangeCalendar](https://react-spectrum.adobe.com/react-a
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Marcas holiday/event MUERTAS: las reglas existen (`range-calendar.css:405,428`) pero `--calendar-day-holiday-shadow` y `--calendar-event-shadow` se emiten bajo `[data-calendar]` (`generated/base.css`) y no resuelven bajo `[data-range-calendar]` (ficha F-4; S8 solo formalizó los tokens de RANGO) | **implementar** | Mover esos 2 tokens al scope `:root` como el resto del préstamo (decisión de la capa calendar-surface), o superponer la identidad del donante. |
| Marcas holiday/event MUERTAS: las reglas existen (`range-calendar.css:347,379`) pero `--calendar-day-holiday-shadow` y `--calendar-event-shadow` se emiten bajo `[data-calendar]` (`generated/base.css`) y no resuelven bajo `[data-range-calendar]` (ficha F-4; S8 solo formalizó los tokens de RANGO) | **implementar** | Mover esos 2 tokens al scope `:root` como el resto del préstamo (decisión de la capa calendar-surface), o superponer la identidad del donante. |
| `data-readonly` declarado sin estilo (R-1.3; el donante SÍ lo pinta bajo `[data-calendar][data-readonly]`, que aquí no aplica) | **implementar** | Censo de familia (con date-picker): espejar las 3 reglas readonly del donante bajo `[data-range-calendar]`. |
| `deselectable` (N5) no aplica al rango | **descartar** | Deshacer un rango = `commit-reset`; deseleccionar un endpoint suelto no tiene semántica de rango coherente. |
| Tests del wrapper eidos | **diferir** | El soma tiene suite ✓; el wrapper entra en la pasada SYS-2. |

@ -366,7 +366,11 @@
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar][data-event],
/* Event ring — same shape as calendar.css, same 2026-08-11 prune: the provider
left the list when `shift-navigate` moved from the pressed control to `grid`.
Nothing aims at `[data-range-calendar]` any more (measured). The controls
keep the ring through their own `contact-activate` — they share calendar's
`CalendarNavButton` — and the day through `commit-select-*`. */
[data-range-calendar-day][data-event],
[data-range-calendar-prev-button][data-event],
[data-range-calendar-next-button][data-event],

@ -135,7 +135,7 @@ components**, not defects:
## Sema events
0 events on SplitButton's own morfo. It is interactive **by composition**: the
primary fires `contact-activate` (Button), the ▾ fires `emerge.open`/`close`
primary fires `contact-activate` (Button), the ▾ fires `emerge-open`/`emerge-close`
(DropdownMenu), and each item fires `commit-select` (DropdownMenu) — visible in
the demo trace strip.

@ -3,10 +3,10 @@
Two-or-more resizable panels separated by draggable handles. The
headless Soma layer owns the resize math (panel sizes as
percentages, min / max clamping, collapsed state, keyboard arrow
steps); the morfo declares a single semantic event,
`commit-resize`, fired on drag-end (mouse-up). Eidos paints chrome:
panels, the handle, its hover / drag affordance, and the center
grip.
steps); the morfo declares the three-event grip / drag / commit
shape — `handle-pick`, `handle-drag`, `commit-set`. Eidos paints
chrome: panels, the handle, its hover / drag affordance, and the
center grip.
## Superficie
@ -53,11 +53,10 @@ Adjustments applied during the port:
- `--splitter-handle-bg-active` → `--color-primary-solid`
- `--splitter-handle-size` → `--space-2`
- `--splitter-grip-bg` → `--color-content-muted`
- Wire the `commit-resize` sema event in the recipe: when the
morfo's signal hold stamps `data-event='commit-resize'` on the
resize trigger, the handle paints in the active color until the
hold expires. Air had no equivalent because air predates the sema
visual channel.
- Wire the commit sema event in the recipe: when the hold stamps
`data-event-family='commit'` on the provider, the handle paints
in the active color until the hold expires. Air had no equivalent
because air predates the sema visual channel.
- Soma is the source of state. The eidos wrappers are pure
pass-through; min / max / collapsible / collapsedSize / keyboard
step all live on `<Splitter.Panel>` props (PanelProps) or
@ -68,17 +67,17 @@ Adjustments applied during the port:
| Capability | UIX (eidos) | Radix Themes Splitter | Ark UI Splitter | react-resizable-panels |
| --- | --- | --- | --- | --- |
| Compound API (Panel / ResizeTrigger) | Yes | Yes | Yes | Yes |
| `orientation` prop (horizontal / vertical) | Yes (responsive via soma `dir`) | Yes | Yes | Yes |
| `orientation` prop (horizontal / vertical) | Yes (`dir`-aware keyboard) | Yes | Yes | Yes |
| `defaultSize` per panel (%) | Yes | Yes | Yes | Yes |
| `minSize` / `maxSize` per panel | Yes | Yes | Yes | Yes |
| `collapsible` + `collapsedSize` | Yes | No | Yes | Yes |
| Controlled sizes (`bind:sizes`) | Yes (seeds layout + live sync + external writes) | No | No | Partial (`defaultLayout`/ref API) |
| `onSizesChange` callback (live during drag) | Yes | No (only commit) | Yes (`onResize`) | Yes (`onLayout`) |
| `onResizeEnd` callback (drag commit) | Yes — also raised as sema `commit-resize` | Yes | Yes | Yes |
| `onResizeEnd` callback (drag commit) | Yes — fired by the sema `commit-set` handler | Yes | Yes | Yes |
| Keyboard arrow resize | Yes (configurable step via `keyboardStep`) | Yes | Yes | Yes |
| Home / End jump to min / max | Yes | Yes | Yes | No |
| Enter to toggle collapse | Yes | No | Yes | No |
| Sema event surface | Yes (`commit-resize` in `commit` family) | No | No | No |
| Sema event surface | Yes (`handle-pick` / `handle-drag` / `commit-set`) | No | No | No |
| Drag affordance (center grip) | Yes (eidos pseudo) | Yes | Yes | n/a (consumer styles) |
## Decisiones
@ -88,21 +87,37 @@ Adjustments applied during the port:
sizing belongs to the morfo's `propRef('size')` mapping, the
active drag belongs to `data-dragging`, keyboard belongs to the
morfo's keyboard table.
- **Single sema event: `commit-resize`.** Live drag (every pixel
movement) is NOT a sema event — it would saturate the perceptual
channels with high-frequency noise. Only the drag commit (mouse
up, key release after arrow resize) fires the sema verb. This
matches the `feedback_per_event_intent_intrinsic` principle:
per-event intent reflects the act's own evaluative load — a
resize commit is `commit.set` with `neutral` intent (the user
set a new value; the result is geometric, neither affirm nor
threat).
- **Eidos pulses the handle on `commit-resize`.** The recipe
selector
`[data-splitter-resize-trigger][data-event='commit-resize']`
paints the handle in the active color during the signal hold,
giving the user visual confirmation that the drag was committed.
The pulse fades automatically when the hold expires.
- **Three sema events, mirroring Slider.** The splitter is a
direct-manipulation primitive — grab, drag, release — so the
perceptual rendering follows the same grip / drag / commit shape
Slider ships. Live drag IS a sema event (`handle-drag`), and it
does not saturate the channels because
`SplitterResizeTriggerProvider` throttles it to one signal per
rAF with a ~72 ms floor (~14 Hz); the gesture then sounds by
REPETITION — the speed of the drag is the speed of the ratchet.
Only the release carries an intent, and it is `neutral` per
`feedback_per_event_intent_intrinsic`: the user set a new value
and the result is geometric, neither affirm nor threat.
- **The gesture is stamped where the hand is, the terminal where
the value lives.** `handle-pick` and `handle-drag` target
`resize-trigger` — the element under the pointer. `commit-set`
targets `provider`, because what gets committed are the layout
sizes of the whole splitter, not a property of the one handle
that happened to be grabbed (an arrow-key resize commits the
same sizes with no pointer anywhere). This split is the
catalogue's law, not a splitter quirk: 10 of the 14
manipulation components stamp it this way.
- **The recipe pulses the handle through a descendant selector.**
The subject of the event is the provider, but the element worth
painting is the handle, so the rule is
`[data-splitter][data-event-family='commit'][data-event-phase='active'] [data-splitter-resize-trigger]`.
The handle is a direct child of the provider, and (0,4,0) beats
every other handle rule in the recipe (max (0,2,0)), so the
pulse wins unambiguously. It is keyed by FAMILY, not by event
name: the old `data-event='commit-resize'` form died silently
when the event was renamed to `commit-set` (`bd2e40366`) and
nothing caught it. Slider and Knob key by family for the same
reason.
- **`Indicator` is not implemented as a separate part.** The
morfo lists `Provider` / `Panel` / `ResizeTrigger`; the brief
mentioned an optional `Indicator` part for the grip / handle
@ -115,13 +130,35 @@ Adjustments applied during the port:
| Name | Family | Verb | Sequence | Intent | Target |
| --- | --- | --- | --- | --- | --- |
| `commit-resize` | `commit` | `set` | `post` | `neutral` | `resize-trigger` |
The event fires on drag-end (mouse-up) and on keyboard arrow-key
release. The signal hold is short (`brief` band from
`SEMA_DURATIONS`) so the visual pulse on the handle disappears
quickly. Sound and haptic channels project a low-intensity feedback
suitable for a layout adjustment.
| `handle-pick` | `handle` | `pick` | `coincident` | — | `resize-trigger` |
| `handle-drag` | `handle` | `drag` | `coincident` | — | `resize-trigger` |
| `commit-set` | `commit` | `set` | `post` | `neutral` | **`provider`** |
When each one fires:
- `handle-pick` — `pointerdown` on the handle. Coincident with the
gesture start; no settled value yet.
- `handle-drag` — `pointermove` while dragging, throttled to one
signal per rAF with a ~72 ms floor. The value is in flux.
- `commit-set` — `pointerup`, and also on every arrow / Home / End
keydown (each keystroke already leaves a settled layout, so it
commits right away — there is no key-release step). `Enter`
toggles collapse and fires nothing.
The two `handle-*` events are stamped on the resize trigger, where
the hand is. `commit-set` is stamped on the provider, where the
value lives — what is committed are the panel sizes of the whole
splitter. That is why the recipe reaches the handle with a
descendant selector instead of matching it directly; see
**Decisiones**.
The commit hold is short (`brief`, 240 ms, `transient` — the canon
for `commit` + `neutral` in `SEMA_HOLDS_BY_INTENT`) so the visual
pulse on the handle disappears quickly. Per-component sound and
haptic defaults live in `src/uix/sema/components/splitter.ts`:
`air` plus a light tick on pick, channels-only on drag (soma
supplies the per-emit `step` sound and a velocity-driven tick),
`snap` plus a tap on commit.
## Gaps

@ -1,10 +1,10 @@
/*
* Splitter recipe — paints two-or-more resizable panels separated by
* draggable handles. Soma owns the resize math (panel sizes, min /
* max clamping, collapsed state, keyboard arrow steps) and the
* `commit-resize` sema event; the recipe consumes the morfo's
* data-attrs to draw orientation-aware geometry plus hover / drag
* affordances on the resize trigger.
* max clamping, collapsed state, keyboard arrow steps) and fires the
* three sema events (`handle-pick`, `handle-drag`, `commit-set`); the
* recipe consumes the morfo's data-attrs to draw orientation-aware
* geometry plus hover / drag affordances on the resize trigger.
*
* [data-splitter] → flex container (row / column)
* [data-splitter-panel] → individual panel (flex item)
@ -107,11 +107,35 @@
block-size: var(--splitter-grip-cross, 2px);
}
/* Sema event hook — when commit-resize fires, the eidos events layer
stamps `data-event="commit-resize"` for `signal hold` duration.
Pulse the handle so the user gets visual confirmation that the
drag was committed. */
/* Sema event hook — on release the morfo fires `commit-set` (commit
family), whose target is the PROVIDER: what gets committed are the
layout sizes, and those live on the container, not on the handle
the hand happened to grab. So the stamp lands on [data-splitter]
and the recipe reaches down to paint the handle, which is a direct
child — a descendant selector at (0,4,0) beats every other handle
rule here (max (0,2,0)), so the pulse wins unambiguously.
[data-splitter-resize-trigger][data-event='commit-resize'][data-event-phase='active'] {
Keyed by FAMILY, not by event name: this rule matched
`data-event='commit-resize'` and died silently when the event was
renamed to `commit-set` (bd2e40366). `data-event-family='commit'`
survives the next rename, and it is what the siblings do
(slider.css, knob.css). */
[data-splitter][data-event-family='commit'][data-event-phase='active']
[data-splitter-resize-trigger] {
background: var(--splitter-handle-bg-active, var(--color-primary-solid));
}
/* …and the flip side of that stamp landing on the container: the global
`commit` firma pulses a 3px primary box-shadow RING on whatever the commit
targets, so it framed the WHOLE splitter on every arrow step and every drag
release. Same geometry slider.css:53-64 already rejected — a ring reads as
feedback on a small box, as a stray border on a wide thin rail — and the
handle pulse above is the confirmation this component wants instead. Sound
and haptic are unaffected: they run in JS, not CSS. `animation` is the lever,
not `box-shadow`: keyframed declarations outrank normal ones, so only
resetting the animation NAME stops the paint. Same opt-out as slider,
gradient-builder and media-player. */
[data-splitter][data-event-family='commit'][data-event-phase='active'] {
animation: none;
}

@ -81,7 +81,7 @@ Sources: [Radix](https://www.radix-ui.com/primitives/docs/components/toast),
## Ownership
- Morfo declares live-region parts, intent-driven roles, swipe attrs and
dismiss/present/announce events.
emerge-dismiss/emerge-present/signal-announce events.
- Soma owns the toaster store, timers, pause/resume, hotkey focus, swipe
gesture, promise lifecycle and translated close/viewport labels.
- Eidos owns status glyphs, placement/gap styling, `data-icon-only` close

@ -62,7 +62,7 @@
</Main>
<!-- ToastCloseProvider's onclick already routes through
runtime.trigger('dismiss') so Sema fires before the
runtime.trigger('emerge-dismiss') so Sema fires before the
toast leaves. Passing our own onclick here would race
ahead via mergeProps' handler composition. -->
<Close />

@ -45,9 +45,9 @@ Fuentes externas consultadas:
## Eventos Sema
El morfo declara tres eventos `emerge` minimos para que Sema pueda emitir
percepcion en hover/focus reveal: `present`, `dismiss` y `dismiss-escape`
(Escape) — verbos del libro cap. 26 §8/§9: el tooltip se OFRECE (present),
no se invoca como lugar (open).
percepcion en hover/focus reveal: `emerge-present`, `emerge-dismiss` y
`emerge-dismiss-escape` (Escape) — verbos del libro cap. 26 §8/§9: el tooltip
se OFRECE (present), no se invoca como lugar (open).
Ninguno carga intent — la aparicion de un tooltip no tiene peso evaluativo
propio. Si una experiencia necesita reforzar percepcion al mostrar ayuda
contextual con un tinte concreto, esa politica debe vivir en el componente

@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`.
- `shift-navigate-step` — `shift.navigate` on `provider`, sequence `post`.
- `shift-navigate-step` — `shift.navigate` on `grid`, sequence `post`.
## Baseline
@ -65,7 +65,17 @@ nuevo en UIX; sirve para escenarios de selección de año aislada
natural) o 20 (dos décadas). UIX permite ambos sin nuevo componente.
- **Mismo modelo de eventos que MonthGrid** — `commit-set` y
`shift-navigate-step`, para que la pareja month-grid/year-grid sea
sema-simétrica.
sema-simétrica. También el sello: `shift-navigate-step` cae en el `grid`
(2026-08-11), no en el provider ni en la flecha — el grid es el sujeto del
cruce (cambia de tramo de años; la cabecera no se mueve) y es lo único que
puede lucir una firma de `shift`. El sentido del paso viaja por emisión en
`data-event-direction` (`forward`/`backward`).
- **El anillo de evento se quedó sólo en la celda**
(`box-shadow: var(--calendar-event-shadow)`): con el sello en el grid, ni el
provider ni las dos flechas reciben ya `data-event`, y estas flechas son
botones NATIVOS de soma —no `<Button>` compuestos— así que no tienen un
`contact-activate` propio que mantuviera el anillo vivo. La celda lo conserva
por `commit-set`. Verificado en navegador.
- **`select` único evento mutante; resto son focus moves** — el audit
script reconoce `next-year`/`prev-year`/`next-row`/`prev-row`/
`first-year`/`last-year`/`next-page`/`prev-page` como focus moves

@ -206,10 +206,12 @@
color: var(--calendar-day-color);
}
[data-year-grid][data-event],
[data-year-grid-cell][data-event],
[data-year-grid-prev-button][data-event],
[data-year-grid-next-button][data-event] {
/* Event ring — only the cell is left since 2026-08-11, same prune as
month-grid.css: `shift-navigate-step` now targets `grid`, and these arrows
are native soma buttons with no `contact-activate` of their own, so neither
they nor the provider are ever stamped (measured). The cell keeps the ring
through `commit-set`. */
[data-year-grid-cell][data-event] {
box-shadow: var(--calendar-event-shadow);
}

@ -5720,6 +5720,14 @@
--motion-origin-inline-start: 100%;
}
[data-event-direction]:dir(ltr) {
--motion-shift-sign: 1;
}
[data-event-direction]:dir(rtl) {
--motion-shift-sign: -1;
}
@property --motion-stagger-index {
syntax: '<integer>';
inherits: false;
@ -6466,6 +6474,24 @@
}
}
@keyframes shift-cross-forward {
from {
translate: calc(var(--motion-distance-xl) * var(--motion-shift-sign, 1)) 0;
}
to {
translate: 0 0;
}
}
@keyframes shift-cross-backward {
from {
translate: calc(var(--motion-distance-xl) * var(--motion-shift-sign, 1) * -1) 0;
}
to {
translate: 0 0;
}
}
@keyframes block-shake {
0%, 100% {
transform: translateX(0);
@ -6490,41 +6516,41 @@
}
}
[data-event^='present'][data-event-phase='active'],
[data-event^='open'][data-event-phase='active'] {
[data-event^='emerge-present'][data-event-phase='active'],
[data-event^='emerge-open'][data-event-phase='active'] {
animation: present-rise var(--duration-slow) var(--ease-out);
}
[data-event^='dismiss'][data-event-phase='active'],
[data-event^='close'][data-event-phase='active'] {
[data-event^='emerge-dismiss'][data-event-phase='active'],
[data-event^='emerge-close'][data-event-phase='active'] {
animation: dismiss-fade var(--duration-slow) var(--ease-default) forwards;
}
[data-event^='announce'][data-event-intent='neutral'][data-event-phase='active'] {
[data-event^='signal-announce'][data-event-intent='neutral'][data-event-phase='active'] {
animation: announce-pulse-neutral var(--duration-moderate) var(--ease-default);
}
[data-event^='announce'][data-event-intent='affirm'][data-event-phase='active'] {
[data-event^='signal-announce'][data-event-intent='affirm'][data-event-phase='active'] {
animation: announce-pulse-affirm var(--duration-moderate) var(--ease-default);
}
[data-event^='announce'][data-event-intent='fulfill'][data-event-phase='active'] {
[data-event^='signal-announce'][data-event-intent='fulfill'][data-event-phase='active'] {
animation: announce-pulse-fulfill var(--duration-slower) var(--ease-default);
}
[data-event^='announce'][data-event-intent='risk'][data-event-phase='active'] {
[data-event^='signal-announce'][data-event-intent='risk'][data-event-phase='active'] {
animation: announce-pulse-risk var(--duration-emphatic) var(--ease-spring);
}
[data-event^='announce'][data-event-intent='threat'][data-event-phase='active'] {
[data-event^='signal-announce'][data-event-intent='threat'][data-event-phase='active'] {
animation: announce-pulse-threat var(--duration-sustained) var(--ease-spring);
}
[data-event^='expand'][data-event-phase='active'] {
[data-event^='emerge-expand'][data-event-phase='active'] {
animation: expand-reveal var(--duration-moderate) var(--ease-out);
}
[data-event^='collapse'][data-event-phase='active'] {
[data-event^='emerge-collapse'][data-event-phase='active'] {
animation: collapse-conceal var(--duration-fast) var(--ease-default) forwards;
}
@ -6532,6 +6558,14 @@
animation: press-squeeze var(--duration-fast) var(--ease-default);
}
[data-event-family='shift'][data-event-direction='forward'][data-event-phase='active'] {
animation: shift-cross-forward var(--duration-slow) var(--ease-emphasized);
}
[data-event-family='shift'][data-event-direction='backward'][data-event-phase='active'] {
animation: shift-cross-backward var(--duration-slow) var(--ease-emphasized);
}
[data-event-family='commit'][data-event-phase='active'] {
animation: commit-settle var(--duration-moderate) var(--ease-out);
}

@ -229,7 +229,9 @@ function validateMotion(options: EidosConfig, issues: EidosValidationIssue[]): v
}
// Momento --event: cada firma debe declarar un matcher y referenciar
// keyframes existentes.
// keyframes existentes. `direction` does NOT count as a matcher: it refines a
// family/intent/event with the sense of travel, and on its own it would paint
// every 'forward' crossing in the document, whatever the family.
for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
const base = `motion.signatures.${name}`
if (!signature.family && !signature.intent && !signature.event) {

@ -260,6 +260,27 @@ export const BUILTIN_KEYFRAMES: Readonly<Record<string, KeyframeStops>> = {
'100%': { translate: '0 0', opacity: '1' }
},
// Shift — the THRESHOLD CROSSING (cap. 27 §5: "página A → página B"). The
// sealed element is one that PERSISTS through the crossing (a month grid, a
// carousel item) — never one that appears — so there is NO opacity here: a
// fade from 0 would flash a visible element (same reason `select-pop-in`
// avoids it). It arrives displaced along the inline axis, from the side it
// came from, and settles: the new frame slid in.
//
// `translate` is PHYSICAL, so the distance is multiplied by
// `--motion-shift-sign` (+1 ltr / -1 rtl, emitted by `render-css` from a
// `:dir()` pair). Forward travels from the inline-END edge; backward mirrors
// it. Distance is the shared-axis token (`xl`) — the frame-change idiom this
// crossing IS, so it retunes with it instead of inventing a value.
'shift-cross-forward': {
from: { translate: 'calc(var(--motion-distance-xl) * var(--motion-shift-sign, 1)) 0' },
to: { translate: '0 0' }
},
'shift-cross-backward': {
from: { translate: 'calc(var(--motion-distance-xl) * var(--motion-shift-sign, 1) * -1) 0' },
to: { translate: '0 0' }
},
// Commit denied — the lateral deny shake (the login-form idiom): the element
// refuses sideways and settles. Distance is a design literal (cf.
// press-squeeze's 0.96); the risk tint rides the SOUND/HAPTIC of the same
@ -296,9 +317,14 @@ export const BUILTIN_KEYFRAMES: Readonly<Record<string, KeyframeStops>> = {
* and a design lint fails any transitory signature declared above that cap.
*/
export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
present: { event: ['present', 'open'], keyframes: 'present-rise', duration: 'slow', ease: 'out' },
present: {
event: ['emerge-present', 'emerge-open'],
keyframes: 'present-rise',
duration: 'slow',
ease: 'out'
},
dismiss: {
event: ['dismiss', 'close'],
event: ['emerge-dismiss', 'emerge-close'],
keyframes: 'dismiss-fade',
duration: 'slow',
ease: 'default',
@ -308,14 +334,14 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
// Neutral/affirm: "breve o contextual, intensidad baja" (TABLA 32.2) —
// 600 was too much for the region (user verdict 2026-07-06).
'announce-neutral': {
event: 'announce',
event: 'signal-announce',
intent: 'neutral',
keyframes: 'announce-pulse-neutral',
duration: 'moderate',
ease: 'default'
},
'announce-affirm': {
event: 'announce',
event: 'signal-announce',
intent: 'affirm',
keyframes: 'announce-pulse-affirm',
duration: 'moderate',
@ -323,21 +349,21 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
},
// Fulfill: "breve-media, más resolutivo" — the 400 step (slower).
'announce-fulfill': {
event: 'announce',
event: 'signal-announce',
intent: 'fulfill',
keyframes: 'announce-pulse-fulfill',
duration: 'slower',
ease: 'default'
},
'announce-risk': {
event: 'announce',
event: 'signal-announce',
intent: 'risk',
keyframes: 'announce-pulse-risk',
duration: 'emphatic',
ease: 'spring'
},
'announce-threat': {
event: 'announce',
event: 'signal-announce',
intent: 'threat',
keyframes: 'announce-pulse-threat',
duration: 'sustained',
@ -347,9 +373,9 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
// Disclosure (emerge.expand / emerge.collapse): the reveal runs AFTER the
// state opens (sequence post); the conceal runs BEFORE it closes (pre) and
// holds its end state (`forwards`) until soma flips `hidden`.
expand: { event: 'expand', keyframes: 'expand-reveal', duration: 'moderate', ease: 'out' },
expand: { event: 'emerge-expand', keyframes: 'expand-reveal', duration: 'moderate', ease: 'out' },
collapse: {
event: 'collapse',
event: 'emerge-collapse',
keyframes: 'collapse-conceal',
duration: 'fast',
ease: 'default',
@ -358,6 +384,33 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
press: { family: 'contact', keyframes: 'press-squeeze', duration: 'fast', ease: 'default' },
// Shift — the crossing must be PERCEIVED (cap. 27; the antipattern catalogue
// types the "invisible shift"). The family already SOUNDS like a slide
// (`SEMA_MAP.families.shift.sounds.default = 'slide'`) and did not slide: the
// visual channel was mute. Direction is the only thing the event NAME cannot
// carry (`shift-enter-mode` vs `shift-exit-mode` is the mode, not the sense),
// so it rides `data-event-direction`, decided per emit. An emission without a
// direction matches neither entry and stays visually silent — deliberate:
// there is no neutral sense of travel to draw.
// `slow` (320ms) is the middle of the crossing band: past `moderate`'s nudge,
// short of `slower`'s resolution. `emphasized` is the M3 frame-change curve.
// Reduced motion needs no per-signature policy — `events.css` caps EVERY
// active firma at 1ms globally, and with no `fill` the element lands at rest.
'shift-forward': {
family: 'shift',
direction: 'forward',
keyframes: 'shift-cross-forward',
duration: 'slow',
ease: 'emphasized'
},
'shift-backward': {
family: 'shift',
direction: 'backward',
keyframes: 'shift-cross-backward',
duration: 'slow',
ease: 'emphasized'
},
commit: { family: 'commit', keyframes: 'commit-settle', duration: 'moderate', ease: 'out' },
'commit-fulfill': {
family: 'commit',

@ -927,6 +927,21 @@ function renderMotionBlocks(motion: MotionConfig): string {
blocks.push('[data-animation-style]:dir(ltr) {\n\t--motion-origin-inline-start: 0%;\n}');
blocks.push('[data-animation-style]:dir(rtl) {\n\t--motion-origin-inline-start: 100%;\n}');
// Same problem, the other logical axis: `translate` takes PHYSICAL lengths, so
// a firma that travels "in the sense of the navigation" (the `shift` crossing)
// would run backwards in RTL. This var carries the sign of the inline axis;
// directional keyframes multiply their distance by it instead of hard-coding a
// physical direction.
//
// Scoped to `[data-event-direction]` — the node carrying a direction IS the one
// whose firma reads the sign, so a bare `:dir()` would declare an inherited
// custom property on EVERY element for nothing. Both directions are emitted
// because `:dir()` resolves against that node (a subtree re-declaring its own
// direction still gets the right value), which is why `[dir='rtl']` is
// forbidden catalogue-wide.
blocks.push('[data-event-direction]:dir(ltr) {\n\t--motion-shift-sign: 1;\n}');
blocks.push('[data-event-direction]:dir(rtl) {\n\t--motion-shift-sign: -1;\n}');
// Stagger index is per-item — `inherits: false` stops it leaking into nested
// groups; the rhythm (`--motion-stagger-each`) inherits from the container.
blocks.push(
@ -1183,6 +1198,7 @@ function signatureSelectorList(sig: EventSignature): string[] {
const suffix =
`${sig.family ? `[data-event-family='${sig.family}']` : ''}` +
`${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` +
`${sig.direction ? `[data-event-direction='${sig.direction}']` : ''}` +
`[data-event-phase='active']`;
const events =
sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event;

@ -223,17 +223,17 @@ describe('compileMorfo — keyboard plans', () => {
describe('compileMorfo — actions', () => {
it('compiles morfo events into actions.byName indexed by name', () => {
const compiled = compileMorfo(dialogMorfo)
expect(compiled.actions.byName.has('open')).toBe(true)
expect(compiled.actions.byName.has('emerge-open')).toBe(true)
// Dialog uses the polymorphic close (book §5.3) — one event named
// 'close' replaces the prior five close-* events. Provider's
// dismissWith concretes the cause via opts.semantic + imperative
// data-last-action.
expect(compiled.actions.byName.has('close')).toBe(true)
expect(compiled.actions.byName.has('emerge-close')).toBe(true)
})
it('extracts target part kebab from the partRef', () => {
const compiled = compileMorfo(dialogMorfo)
const close = compiled.actions.byName.get('close')!
const close = compiled.actions.byName.get('emerge-close')!
expect(close.target).toBe('content')
})

@ -25,7 +25,7 @@ export const accordionMorfo = {
// is emerge.expand — "accordion, sección expandible" is the book's
// own example (was open/close; C2 verbs pass, 2026-07-07). The
// data-state vocabulary (open/closed) is state, not event — unchanged.
name: 'expand',
name: 'emerge-expand',
semantic: {
family: 'emerge',
verb: 'expand',
@ -43,7 +43,7 @@ export const accordionMorfo = {
* Item collapses — content folds back in place. Symmetric to
* `expand`: the signal marks the resolved state, not a prelude.
*/
name: 'collapse',
name: 'emerge-collapse',
semantic: {
family: 'emerge',
verb: 'collapse',

@ -36,19 +36,19 @@ export const calendarMorfo = {
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post',
// The provider hands the surface that CAUSED the navigation — the
// arrow pressed, the select changed, the day whose month spilled
// over — so the frame change is felt where it was asked for. The
// provider is the target only when nothing concrete caused it.
allowedTargets: [
v.partRef('prev-button'),
v.partRef('next-button'),
v.partRef('month-select'),
v.partRef('year-select'),
v.partRef('day')
]
// The GRID is what navigated — the header does not move, and the
// arrow is the instrument, not the subject. Until 2026-08-11 this
// targeted `provider` and the runtime redirected the stamp onto
// whichever surface CAUSED the navigation (arrow, select, spilled
// day). Measured on the prev-button: `contact-activate` stamped
// and started `press-squeeze`, then `shift-navigate` overwrote the
// same slot 8.5 ms later — half a frame — and the squeeze never
// painted. One surface, one slot (A-36): an instrument that owns
// its own event cannot also carry someone else's. Naming the grid
// is also what makes a `shift` firma expressible at all — a month
// can slide, a button cannot.
target: v.partRef('grid'),
sequence: 'post'
}
}
],

@ -8,9 +8,10 @@ import { v } from '../types';
* emerge.expand): the disclosure flip has two perceptual directions
* with distinct signatures, so it declares TWO events instead of one
* `commit-toggle`. Both belong to the `emerge` family (transitional —
* no intent). `expand` is post-sequence so the content exists visually
* before Eidos reacts to `data-event='expand'`; `collapse` remains pre-
* sequence so the exit signal can play while the content is still visible.
* no intent). `emerge-expand` is post-sequence so the content exists
* visually before Eidos reacts to `data-event='emerge-expand'`;
* `emerge-collapse` is post too (collapsible-NEW-001) so the conceal
* plays against a content the flip has not yet hidden.
*
* No intent / color subset — disclosure is non-evaluative. Air had a
* manual `air.interaction.play('expansion', enter|exit)` that the
@ -31,7 +32,7 @@ export const collapsibleMorfo = {
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/',
events: [
{
name: 'expand',
name: 'emerge-expand',
semantic: {
family: 'emerge',
verb: 'expand',
@ -40,7 +41,7 @@ export const collapsibleMorfo = {
}
},
{
name: 'collapse',
name: 'emerge-collapse',
semantic: {
family: 'emerge',
verb: 'collapse',

@ -34,7 +34,7 @@ export const contextMenuMorfo = {
// under `pre`: two `contact-activate` stamps 1 ms apart on the
// trigger and the `open` LOST entirely — its target was not mounted
// yet, so it never landed at all.
name: 'open',
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
@ -44,7 +44,7 @@ export const contextMenuMorfo = {
commits: { part: v.partRef('content'), attr: 'data-state', value: 'open' }
},
{
name: 'close',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',
@ -63,7 +63,7 @@ export const contextMenuMorfo = {
// sub's own `data-state` — the root's `open` would land on the
// wrong panel. Fixed in both twins at once; leaving one mute is the
// inconsistency this axis exists to kill.
name: 'sub-open',
name: 'emerge-open-sub',
semantic: {
family: 'emerge',
verb: 'open',
@ -73,7 +73,7 @@ export const contextMenuMorfo = {
commits: { part: v.partRef('sub-content'), attr: 'data-state', value: 'open' }
},
{
name: 'sub-close',
name: 'emerge-close-sub',
semantic: {
family: 'emerge',
verb: 'close',

@ -22,11 +22,11 @@ describe('dialogMorfo', () => {
it('declares two semantic events: open + polymorphic close', () => {
const events = dialogMorfo.events?.map((event) => event.name).sort()
expect(events).toEqual(['close', 'open'].sort())
expect(events).toEqual(['emerge-close', 'emerge-open'].sort())
})
it('declares the close event as polymorphic with allowedFamilies', () => {
const close = dialogMorfo.events?.find((e) => e.name === 'close')!
const close = dialogMorfo.events?.find((e) => e.name === 'emerge-close')!
expect(close).toBeDefined()
// Default family is emerge; allowedFamilies opens commit + signal.
expect('family' in close.semantic ? close.semantic.family : null).toBe('emerge')
@ -98,7 +98,7 @@ describe('dialogMorfo', () => {
it('fails when a prewrite attr is not declared on the target part', () => {
const broken = cloneMorfo(dialogMorfo)
const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')!
const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'emerge-close')!
// Synthesize a prewrite for the test — the production morfo has
// none on `close` (provider sets data-last-action imperatively).
close.prewrite = [{ part: close.semantic.target, attr: 'data-bogus', value: 'saved' }]
@ -107,7 +107,7 @@ describe('dialogMorfo', () => {
it('fails when a prewrite writes a value outside the declared enum', () => {
const broken = cloneMorfo(dialogMorfo)
const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')!
const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'emerge-close')!
close.prewrite = [
{ part: close.semantic.target, attr: 'data-last-action', value: 'not-in-enum' }
]
@ -116,7 +116,7 @@ describe('dialogMorfo', () => {
it('fails when an event commits a non-existent state', () => {
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')!
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'emerge-close')!
if (!action.commits) throw new Error('close commits missing')
action.commits.value = 'zombied'
expect(() => validateMorfo(broken)).toThrow(/zombied.*not declared/)
@ -124,8 +124,8 @@ describe('dialogMorfo', () => {
it('fails when two events share the same name', () => {
const broken = cloneMorfo(dialogMorfo)
;(broken.events as MorfoEvent[])[1].name = 'open'
expect(() => validateMorfo(broken)).toThrow(/duplicate event name "open"/)
;(broken.events as MorfoEvent[])[1].name = 'emerge-open'
expect(() => validateMorfo(broken)).toThrow(/duplicate event name "emerge-open"/)
})
// NOTE: the previous bidirectional check ("data-last-action declares a

@ -25,7 +25,7 @@ export const dialogMorfo = {
},
events: [
{
name: 'open',
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
@ -82,7 +82,7 @@ export const dialogMorfo = {
// surface disappears as the close animation unmounts the node.
// Application-level "operation failed" lifecycle belongs in a
// Toast / Announce with `untilAction`, not on the dialog itself.
name: 'close',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',

@ -16,7 +16,7 @@ export const drawerMorfo = {
{
// Verb per book cap. 26 §5: a drawer opens "con marco propio" —
// emerge.open (was `present`; C2 verbs pass, 2026-07-07).
name: 'open',
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
@ -42,7 +42,7 @@ export const drawerMorfo = {
// load). DrawerProvider concretes the cause via
// `dismissWith(action, opts?)` which:
// - sets `data-last-action` imperatively on Content;
// - passes `opts.semantic` to `runtime.trigger('close', ...)`.
// - passes `opts.semantic` to `runtime.trigger('emerge-close', ...)`.
//
// Allowed concretions:
// - `emerge.close` — cancel / dismiss / dismiss-outside
@ -53,7 +53,7 @@ export const drawerMorfo = {
// (canonically `untilAction` per book §6.2), the drawer surface
// disappears as the close animation unmounts. "Operation failed"
// lifecycle belongs in a Toast / Announce, not the drawer.
name: 'close',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',
@ -80,7 +80,7 @@ export const drawerMorfo = {
// silence the perceptual signal where it would be noisy (e.g. drop
// haptic from drag-progress) without the morfo deciding for them.
{
name: 'drag-start',
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
@ -89,7 +89,7 @@ export const drawerMorfo = {
}
},
{
name: 'drag-progress',
name: 'handle-drag-progress',
semantic: {
family: 'handle',
verb: 'drag',
@ -101,7 +101,7 @@ export const drawerMorfo = {
}
},
{
name: 'drag-end',
name: 'handle-drop',
semantic: {
family: 'handle',
verb: 'drop',
@ -110,7 +110,7 @@ export const drawerMorfo = {
}
},
{
name: 'resize',
name: 'handle-resize',
semantic: {
family: 'handle',
verb: 'resize',

@ -37,7 +37,7 @@ export const dropdownMenuMorfo = {
// under `pre`: two `contact-activate` stamps 1 ms apart on the
// trigger and the `open` LOST entirely — its target was not mounted
// yet, so it never landed at all.
name: 'open',
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
@ -47,7 +47,7 @@ export const dropdownMenuMorfo = {
commits: { part: v.partRef('content'), attr: 'data-state', value: 'open' }
},
{
name: 'close',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',
@ -72,7 +72,7 @@ export const dropdownMenuMorfo = {
// sema pack must be able to tell "menu opened" from "submenu
// opened". `sub-content` already declared `states: ['open',
// 'closed']` in anticipation.
name: 'sub-open',
name: 'emerge-open-sub',
semantic: {
family: 'emerge',
verb: 'open',
@ -82,7 +82,7 @@ export const dropdownMenuMorfo = {
commits: { part: v.partRef('sub-content'), attr: 'data-state', value: 'open' }
},
{
name: 'sub-close',
name: 'emerge-close-sub',
semantic: {
family: 'emerge',
verb: 'close',

@ -16,7 +16,7 @@ export const fileUploadMorfo = {
},
events: [
{
name: 'trigger-picker',
name: 'contact-trigger-picker',
semantic: {
family: 'contact',
verb: 'trigger',

@ -16,7 +16,7 @@ export const floatPanelMorfo = {
{
// Verb per book cap. 26 §5: a floating panel opens "con marco
// propio" — emerge.open (was `present`; C2 verbs pass, 2026-07-07).
name: 'open',
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
@ -38,7 +38,7 @@ export const floatPanelMorfo = {
// Polymorphic close (book §5.3) — mirror of Popover / Dialog / Drawer.
// Provider concretes the cause via `dismissWith(action, opts?)`:
// sets `data-last-action` imperatively + passes `opts.semantic`.
name: 'close',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',
@ -57,7 +57,7 @@ export const floatPanelMorfo = {
},
{
// Pickup cue when a drag (move) gesture starts on the Header handle.
name: 'drag-start',
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
@ -69,7 +69,7 @@ export const floatPanelMorfo = {
{
// Drop cue when the move gesture ends. Continuous movement does NOT
// emit per-frame (would buzz haptics nonstop — virtual-list doctrine).
name: 'drag-end',
name: 'handle-drop',
semantic: {
family: 'handle',
verb: 'drop',
@ -79,7 +79,7 @@ export const floatPanelMorfo = {
}
},
{
name: 'resize-start',
name: 'handle-resize-start',
semantic: {
family: 'handle',
verb: 'resize',
@ -89,7 +89,7 @@ export const floatPanelMorfo = {
}
},
{
name: 'resize-end',
name: 'handle-resize-end',
semantic: {
family: 'handle',
verb: 'resize',

@ -37,12 +37,12 @@ export const menuDialMorfo = {
events: [
{
name: 'open',
name: 'emerge-open',
semantic: { family: 'emerge', verb: 'open', target: v.partRef('list'), sequence: 'pre' },
commits: { part: v.partRef('list'), attr: 'data-state', value: 'open' }
},
{
name: 'close',
name: 'emerge-close',
semantic: { family: 'emerge', verb: 'close', target: v.partRef('list'), sequence: 'pre' },
commits: { part: v.partRef('list'), attr: 'data-state', value: 'closed' }
},

@ -33,7 +33,13 @@ export const monthGridMorfo = {
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
// Target is the `grid`, not the `provider`: the grid is the SUBJECT
// of the reframing — it is the cell surface that swaps to another
// year, while the header stays put and the arrow is only the
// instrument. The provider would drag the header into the stamp.
// Aligned with the rest of the calendar family so the
// directional `shift` signature slides the grid in all four alike.
target: v.partRef('grid'),
sequence: 'post'
}
}

@ -37,12 +37,12 @@ export const onionMenuMorfo = {
events: [
{
name: 'open',
name: 'emerge-open',
semantic: { family: 'emerge', verb: 'open', target: v.partRef('surface'), sequence: 'pre' },
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'open' }
},
{
name: 'close',
name: 'emerge-close',
semantic: { family: 'emerge', verb: 'close', target: v.partRef('surface'), sequence: 'pre' },
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'closed' }
},

@ -16,7 +16,7 @@ export const popoverMorfo = {
// Verb per book cap. 26 §5/§6: a popover opens "con marco propio"
// — emerge.open, anchored appearance (was `present`; C2 verbs pass,
// 2026-07-07: present/dismiss belongs to OFFERED surfaces like toast).
name: 'open',
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
@ -46,7 +46,7 @@ export const popoverMorfo = {
// PopoverProvider concretes the cause via
// `dismissWith(action, opts?)`:
// - sets `data-last-action` imperatively on Content;
// - passes `opts.semantic` to `runtime.trigger('close', ...)`.
// - passes `opts.semantic` to `runtime.trigger('emerge-close', ...)`.
//
// Allowed concretions:
// - `emerge.close` — cancel / dismiss / dismiss-outside
@ -55,7 +55,7 @@ export const popoverMorfo = {
//
// Persistence: `transient`. The popover content unmounts; any
// long-lived feedback belongs in a Toast / Announce.
name: 'close',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',

@ -51,7 +51,15 @@ export const rangeCalendarMorfo = {
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
// The GRID is the subject — the header stays put and the arrow is
// only the instrument. Until 2026-08-11 this declared `provider`
// while the provider redirected the stamp onto the arrow / select /
// spilled day: off-contract (no `allowedTargets`, so it warned in
// dev and shipped anyway), and it made the arrow carry two events
// in one slot — its own `contact-activate` press died half a frame
// later, unpainted (A-36). Naming the grid also makes a `shift`
// signature expressible: a month can slide, a button cannot.
target: v.partRef('grid'),
sequence: 'post'
}
}
@ -161,7 +169,16 @@ export const rangeCalendarMorfo = {
defaultElement: 'select',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: []
aria: [
{
// Same ref as the Calendar twin — the label is shared vocabulary,
// and declaring it here (naming default, consumer-first) is what
// lets the provider stop hand-writing it.
attr: 'aria-label',
value: v.commonRef('calendar.month-select', 'Select month'),
severity: 'recommended'
}
]
},
{
name: 'YearSelect',
@ -171,7 +188,13 @@ export const rangeCalendarMorfo = {
defaultElement: 'select',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: []
aria: [
{
attr: 'aria-label',
value: v.commonRef('calendar.year-select', 'Select year'),
severity: 'recommended'
}
]
},
{
name: 'Grid',

@ -12,7 +12,7 @@ export const tabsMorfo = {
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/tabs/',
events: [
{
name: 'select',
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',

@ -15,7 +15,7 @@ describe('toastMorfo semantic contract', () => {
})
it('declares announce semantics and supported intents in morfo', () => {
const announce = toastMorfo.events?.find((event) => event.name === 'announce')
const announce = toastMorfo.events?.find((event) => event.name === 'signal-announce')
expect(announce).toBeDefined()
expect(announce?.semantic).toMatchObject({
family: 'signal',
@ -67,7 +67,7 @@ describe('toastMorfo semantic contract', () => {
it('fails when the default intent is not in the supported list', () => {
const broken = cloneMorfo(toastMorfo)
const announce = broken.events?.find((event) => event.name === 'announce')
const announce = broken.events?.find((event) => event.name === 'signal-announce')
if (
!announce ||
!('intent' in announce.semantic) ||

@ -13,7 +13,7 @@ export const toastMorfo = {
},
events: [
{
name: 'present',
name: 'emerge-present',
semantic: {
family: 'emerge',
verb: 'present',
@ -30,7 +30,7 @@ export const toastMorfo = {
// when the toast enters. Persistence of the toast surface
// itself lives in `data-state` (open/closed) on the Item, not
// in the signal projection.
name: 'announce',
name: 'signal-announce',
semantic: {
family: 'signal',
verb: 'announce',
@ -45,7 +45,7 @@ export const toastMorfo = {
}
},
{
name: 'dismiss',
name: 'emerge-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',

@ -27,7 +27,7 @@ export const tooltipMorfo = {
// "el tooltip es emerge.present" — the system OFFERS auxiliary
// presence; the user never invokes it as a place (was open/close;
// C2 verbs pass, 2026-07-07).
name: 'present',
name: 'emerge-present',
semantic: {
family: 'emerge',
verb: 'present',
@ -42,7 +42,7 @@ export const tooltipMorfo = {
},
{
// Pointer-leave or blur — the offered surface retires.
name: 'dismiss',
name: 'emerge-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
@ -57,7 +57,7 @@ export const tooltipMorfo = {
},
{
// Escape key — same retreat, keyboard variant kept for diagnostics.
name: 'dismiss-escape',
name: 'emerge-dismiss-escape',
semantic: {
family: 'emerge',
verb: 'dismiss',

@ -32,7 +32,13 @@ export const yearGridMorfo = {
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
// Target is the `grid`, not the `provider`: the grid is the SUBJECT
// of the reframing — it is the cell surface that swaps to another
// range of years, while the header stays put and the arrow is only
// the instrument. The provider would drag the header into the stamp.
// Aligned with the rest of the calendar family so the
// directional `shift` signature slides the grid in all four alike.
target: v.partRef('grid'),
sequence: 'post'
}
}

@ -37,7 +37,9 @@ function withEvent(semanticExtra: Record<string, unknown>) {
scope: ['sema'],
events: [
{
name: 'evt',
// Names follow the catalogue convention `{family}-{verb}` — the
// fixture obeys the invariant it is not the subject of.
name: `${semanticExtra.family}-${semanticExtra.verb}`,
semantic: {
target: { kind: 'partRef', target: 'provider' },
...semanticExtra
@ -105,6 +107,78 @@ describe('validateMorfo — frame-intent guardrail (BK-FRAME-NO-INTENT / A-1)',
});
});
// ── Event-name convention ───────────────────────────────────────────────────
/**
* `{family}-{verb}[-{nuance}]`, always. The framework shipped two dialects for
* the same event — `open` in eight components against `emerge-open` in three —
* and the split was not cosmetic: an eidos motion signature keys on the name
* with `^=`, so the bare dialect matched no signature and simply never
* animated, without failing a single test. Three audits found it; none closed
* it. The invariant is the door.
*/
describe('validateMorfo — the event name declares its family', () => {
function named(name: string) {
return {
...baseMorfo,
scope: ['sema'],
events: [
{
name,
semantic: {
family: 'emerge',
verb: 'open',
target: { kind: 'partRef', target: 'provider' }
}
}
]
} as const;
}
it('rejects a bare verb', () => {
expect(() => validateMorfo(named('open'))).toThrow(MorfoInvariantError);
expect(() => validateMorfo(named('open'))).toThrow(/must start with its family "emerge"/);
});
it('rejects another family as prefix', () => {
expect(() => validateMorfo(named('commit-open'))).toThrow(MorfoInvariantError);
});
it('accepts family-verb and family-verb-nuance', () => {
expect(() => validateMorfo(named('emerge-open'))).not.toThrow();
expect(() => validateMorfo(named('emerge-open-sub'))).not.toThrow();
});
it('does not accept the family as a bare word prefix (emerge-open, not emergeopen)', () => {
expect(() => validateMorfo(named('emergeopen'))).toThrow(MorfoInvariantError);
});
it('every shipped morfo obeys it', () => {
const modules = import.meta.glob('./components/*.ts', { eager: true }) as Record<
string,
Record<string, unknown>
>;
const offenders: string[] = [];
for (const mod of Object.values(modules)) {
for (const value of Object.values(mod)) {
const morfo = value as {
kebab?: string;
parts?: unknown;
events?: { name: string; semantic: { family: string } }[];
};
if (typeof morfo?.kebab !== 'string' || !Array.isArray(morfo.parts)) continue;
for (const event of morfo.events ?? []) {
const family = event.semantic.family;
if (event.name !== family && !event.name.startsWith(`${family}-`)) {
offenders.push(`${morfo.kebab}: "${event.name}" (family "${family}")`);
}
}
}
}
expect(offenders, 'an event name that does not declare its family').toEqual([]);
});
});
describe('validateMorfo — data attrs', () => {
it('accepts public lowercase data attrs', () => {
expect(() => validateMorfo(withDataAttr('data-state'))).not.toThrow();
@ -217,7 +291,7 @@ describe('validateMorfo — targetFallback invariants', () => {
parts: [...baseMorfo.parts, triggerPart],
events: [
{
name: 'evt',
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',

@ -642,6 +642,19 @@ function validateInvariants(morfo: Morfo): void {
}
eventNames.add(event.name);
// The name declares the family the engine will resolve — `{family}-{verb}
// [-{nuance}]`. Without this the framework drifts into two dialects for
// the same event (`open` in eight components vs `emerge-open` in three),
// which is not cosmetic: a motion signature keys on the name with `^=`,
// so a bare name silently matches nothing and simply stops animating.
// Three audits found that split without closing it; this closes it.
const { family } = event.semantic;
if (event.name !== family && !event.name.startsWith(`${family}-`)) {
throw new MorfoInvariantError(
`event "${event.name}" in morfo "${morfo.kebab}" must start with its family "${family}" — the convention is \`{family}-{verb}[-{nuance}]\``
);
}
if (!kebabs.has(event.semantic.target.target)) {
throw new MorfoInvariantError(
`event "${event.name}" targets unknown part "${event.semantic.target.target}"`

@ -171,6 +171,30 @@ export const prewriteSplitFixtureMorfo = {
sequence: 'pre'
},
prewrite: [{ part: v.partRef('header'), attr: 'data-last-action', value: 'cancelled' }]
},
{
// The targetOverride contract has three outcomes and needs a morfo
// that expresses all three. `close-cancel` above covers the warning
// one (`header` is off-contract there, no `allowedTargets`); this
// event covers the two SILENT ones — the override landing on the
// declared `target`, and on a declared `allowedTargets` part.
//
// It exists because those two assertions used to ride on
// `calendar.shift-navigate`, and on 2026-08-11 the calendar retargeted
// from `provider` to `grid` and dropped its `allowedTargets` (so a
// month grid could carry the `shift` firma — a table can slide, a
// button cannot). Both tests went red for a reason that had nothing
// to do with the mechanism they guard. That is the second time a
// catalogue morfo took this describe block down with it; the sibling
// warning test already carries the same lesson in its comment.
name: 'emerge-close-redirect',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
allowedTargets: [v.partRef('header')],
sequence: 'pre'
}
}
],
parts: [

@ -385,7 +385,9 @@ export function isPolymorphicSemantic(
* of alternative families on top of its canonical declaration:
*
* events: [{
* name: 'close',
* // The name carries its default family (`{family}-{verb}[-{nuance}]`);
* // polymorphism does NOT rename it when the provider concretes another.
* name: 'shift-exit-mode',
* semantic: {
* family: 'shift', // default family
* verb: 'exit-mode',

@ -10,12 +10,13 @@ import type { Sema } from '../sema-map';
* panel reveals" / "this panel collapses" without competing with the
* surrounding content.
*
* - `open` (emerge.open) — soft chime, low gain (~0.08). Same gain
* bracket as Toast `present`, since neither demands attention.
* - `close` (emerge.close) — descending contour + lower pitch + even
* lower gain. Mirrors the close-direction discipline of Dialog,
* Drawer, Popover and Toast — every dismissal in the system goes
* descending so the auditory map is consistent.
* - `emerge-expand` (emerge.expand) — soft chime, low gain (~0.08).
* Same gain bracket as Toast `emerge-present`, since neither demands
* attention.
* - `emerge-collapse` (emerge.collapse) — descending contour + lower
* pitch + even lower gain. Mirrors the close-direction discipline
* of Dialog, Drawer, Popover and Toast — every dismissal in the
* system goes descending so the auditory map is consistent.
*
* No haptic: tapping a header to open an accordion is a deliberate,
* already-acknowledged gesture. A tactile pulse on top would be noise.
@ -28,10 +29,10 @@ export const accordionSema: Sema = {
name: 'accordion',
cascade: [
{
selector: onItem({ eventFamily: 'emerge', eventName: 'expand' })
selector: onItem({ eventFamily: 'emerge', eventName: 'emerge-expand' })
},
{
selector: onItem({ eventFamily: 'emerge', eventName: 'collapse' })
selector: onItem({ eventFamily: 'emerge', eventName: 'emerge-collapse' })
}
]
};

@ -7,9 +7,12 @@ import type { Sema } from '../sema-map';
*
* Almost nothing, and that is the point. `commit-select` / `commit-unselect`
* take the commit family's `tick` (and its intent variants, by name);
* `shift-navigate` takes the shift family's `slide` on whichever surface caused
* it — arrow, select, or a day that spilled the month. None of that is written
* here any more: the map's verb table says it once for the whole framework.
* `shift-navigate` takes the shift family's `slide` on the `grid` — the surface
* that actually navigated. It used to be redirected onto whichever control
* caused it (arrow, select, a day that spilled the month); that stopped on
* 2026-08-11, because the arrow already owns its own `contact-activate` and one
* surface holds one stamp. None of that is written here any more: the map's
* verb table says it once for the whole framework.
*
* Until 2026-08-06 this file carried eight sound rules, one of which set
* `gain: 0.18` — the loudest value in the system, louder than an alert, for

@ -11,12 +11,12 @@ import type { Sema } from '../sema-map';
* the surface is grouped (accordion) or standalone (collapsible).
*
* Per `src/uix/sema/components/accordion.ts`:
* - `expand` (emerge.open / verb=expand in our case): `emerge.soft`
* — soft chime at gain 0.08. The family base ascending contour
* reads as "panel reveals".
* - `collapse` (emerge.close / verb=collapse): `emerge.exit` at
* gain 0.06 — descending pitch, same direction discipline as the
* rest of the system (dialog, drawer, popover, menus).
* - `emerge-expand` (emerge.expand): `emerge.soft` — soft chime at
* gain 0.08. The family base ascending contour reads as "panel
* reveals".
* - `emerge-collapse` (emerge.collapse): `emerge.exit` at gain 0.06
* — descending pitch, same direction discipline as the rest of the
* system (dialog, drawer, popover, menus).
*
* No haptic — disclosure is a deliberate, already-acknowledged gesture
* on a trigger button; tactile feedback on top would be noise.
@ -29,10 +29,10 @@ export const collapsibleSema: Sema = {
name: 'collapsible',
cascade: [
{
selector: onContent({ eventName: 'expand' })
selector: onContent({ eventName: 'emerge-expand' })
},
{
selector: onContent({ eventName: 'collapse' })
selector: onContent({ eventName: 'emerge-collapse' })
}
]
};

@ -5,13 +5,13 @@ import type { Sema } from '../sema-map';
/**
* ContextMenu perceptual defaults — SOFT EMERGE + COMMIT.
*
* ContextMenu shares the event surface of DropdownMenu (open / close /
* commit-select), but the gesture is different: triggered by right-click
* or long-press on arbitrary content rather than by a dedicated trigger
* button. Frequency is bursty (one menu per investigative gesture) vs
* DropdownMenu's steady traversal, but the perceptual signature stays
* identical for system coherence — a user shouldn't have to learn two
* different "menu sounds".
* ContextMenu shares the event surface of DropdownMenu (emerge-open /
* emerge-close / emerge-open-sub / emerge-close-sub / commit-select), but
* the gesture is different: triggered by right-click or long-press on
* arbitrary content rather than by a dedicated trigger button. Frequency
* is bursty (one menu per investigative gesture) vs DropdownMenu's steady
* traversal, but the perceptual signature stays identical for system
* coherence — a user shouldn't have to learn two different "menu sounds".
*
* Strategy mirrors DropdownMenu — see `dropdown-menu.ts` for the
* doctrinal rationale.
@ -26,10 +26,10 @@ export const contextMenuSema: Sema = {
name: 'context-menu',
cascade: [
{
selector: onContent({ eventName: 'open' })
selector: onContent({ eventName: 'emerge-open' })
},
{
selector: onContent({ eventName: 'close' })
selector: onContent({ eventName: 'emerge-close' })
},
{
selector: onItem({ eventName: 'commit-select' }),

@ -64,7 +64,7 @@ export const dialogSema: Sema = {
// sets before `close` fires.
{
selector: onContent({
eventName: 'close',
eventName: 'emerge-close',
state: { attr: 'data-last-action', value: 'dismissed-outside' }
})
},
@ -77,7 +77,7 @@ export const dialogSema: Sema = {
// sus propias firmas y NO se ven afectadas. La parte VISUAL del
// close (easing, animation) vive en `dialog.css`.
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
selector: onContent({ eventName: 'emerge-close', eventFamily: 'emerge' })
},
// ─── Variant funcional (alertdialog vs dialog) ─────────────────────

@ -27,16 +27,16 @@ const onContent = (matchers?: Parameters<typeof semaSelector<typeof drawerMorfo>
export const drawerSema: Sema = {
name: 'drawer',
cascade: [
// ─── drag-start: grab acknowledgement ──────────────────────────────
// ─── handle-pick: grab acknowledgement ─────────────────────────────
// High, breathy pickup cue. It should read as a soft whistle,
// not as a click or alert.
{
selector: onContent({ eventName: 'drag-start' }),
selector: onContent({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air'
},
// ─── drag-progress: dynamic per-emit signature ─────────────────────
// ─── handle-drag-progress: dynamic per-emit signature ──────────────
// The cascade only activates the `sound` channel here. The actual
// `pitch / gain / centroid / duration / contour` come from per-emit
// `signal.overrides` computed by soma's drawer-provider against the
@ -44,29 +44,29 @@ export const drawerSema: Sema = {
// run AFTER signal.overrides in the layer order — adding `sound: {…}`
// here would clobber the dynamic values.
{
selector: onContent({ eventName: 'drag-progress' }),
selector: onContent({ eventName: 'handle-drag-progress' }),
channels: ['sound', 'haptic']
},
// ─── drag-end: release / soft landing ──────────────────────────────
// ─── handle-drop: release / soft landing ───────────────────────────
{
selector: onContent({ eventName: 'drag-end' }),
selector: onContent({ eventName: 'handle-drop' }),
channels: ['sound', 'haptic'],
sound: 'settle',
haptic: { kind: 'tap', intensity: 0.45, duration: 18 }
},
// ─── resize: snap-point landed ─────────────────────────────────────
// ─── handle-resize: snap-point landed ──────────────────────────────
// A slightly brighter air-chime that disambiguates "I committed to a
// new size" from "I just released without snapping".
{
selector: onContent({ eventName: 'resize' }),
selector: onContent({ eventName: 'handle-resize' }),
channels: ['sound', 'haptic'],
sound: 'snap',
haptic: { kind: 'tap', intensity: 0.55, duration: 24 }
},
// ─── close causal sonido (mirrors dialog) ─────────────────────────
// ─── emerge-close causal sonido (mirrors dialog) ──────────────────
// Polymorphic close (book §5.3) — provider concretes the family
// at trigger time via opts.semantic. Matching by family captures
// the "user backed out" path (emerge.close / emerge.dismiss) and
@ -76,7 +76,7 @@ export const drawerSema: Sema = {
// La parte VISUAL del close (easing, anim, backdrop) vive en
// `eidos/components/drawer/drawer.css`.
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
selector: onContent({ eventName: 'emerge-close', eventFamily: 'emerge' })
}
]
};

@ -5,9 +5,11 @@ import type { Sema } from '../sema-map';
/**
* DropdownMenu perceptual defaults — SOFT EMERGE + COMMIT.
*
* DropdownMenu has three event surfaces:
* - `open` (emerge.open on `content`) — menu reveals
* - `close` (emerge.close on `content`) — menu retracts
* DropdownMenu has five event surfaces:
* - `emerge-open` (emerge.open on `content`) — menu reveals
* - `emerge-close` (emerge.close on `content`) — menu retracts
* - `emerge-open-sub` (emerge.open on `sub-content`) — submenu reveals
* - `emerge-close-sub` (emerge.close on `sub-content`) — submenu retracts
* - `commit-select` (commit.select on `item`, affirm) — user picks a
* menu item
*
@ -40,10 +42,10 @@ export const dropdownMenuSema: Sema = {
name: 'dropdown-menu',
cascade: [
{
selector: onContent({ eventName: 'open' })
selector: onContent({ eventName: 'emerge-open' })
},
{
selector: onContent({ eventName: 'close' })
selector: onContent({ eventName: 'emerge-close' })
},
{
selector: onItem({ eventName: 'commit-select' }),

@ -15,7 +15,7 @@ export const fileUploadSema: Sema = {
name: 'file-upload',
cascade: [
{
selector: onTrigger({ eventName: 'trigger-picker' }),
selector: onTrigger({ eventName: 'contact-trigger-picker' }),
haptic: { kind: 'tick' }
},

@ -8,7 +8,8 @@ import type { Sema } from '../sema-map';
* Direct-manipulation family `handle`: a breathy pickup on grab, a soft landing
* on release, a brighter chime when a resize settles. Continuous move/resize do
* NOT emit per-frame (would buzz haptics — virtual-list doctrine), so only the
* start/end boundaries carry cues. Polymorphic `close` mirrors dialog/popover.
* start/end boundaries carry cues. Polymorphic `emerge-close` mirrors
* dialog/popover.
*/
const onContent = (matchers?: Parameters<typeof semaSelector<typeof floatPanelMorfo>>[2]) =>
@ -18,29 +19,29 @@ export const floatPanelSema: Sema = {
name: 'float-panel',
cascade: [
{
selector: onContent({ eventName: 'drag-start' }),
selector: onContent({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: 'air'
},
{
selector: onContent({ eventName: 'drag-end' }),
selector: onContent({ eventName: 'handle-drop' }),
channels: ['sound', 'haptic'],
sound: 'settle',
haptic: { kind: 'tap', intensity: 0.4, duration: 16 }
},
{
selector: onContent({ eventName: 'resize-start' }),
selector: onContent({ eventName: 'handle-resize-start' }),
channels: ['haptic'],
haptic: { kind: 'tick', intensity: 0.3, duration: 8 }
},
{
selector: onContent({ eventName: 'resize-end' }),
selector: onContent({ eventName: 'handle-resize-end' }),
channels: ['sound', 'haptic'],
sound: 'snap',
haptic: { kind: 'tap', intensity: 0.5, duration: 20 }
},
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
selector: onContent({ eventName: 'emerge-close', eventFamily: 'emerge' })
}
]
};

@ -13,10 +13,10 @@ export const menuDialSema: Sema = {
name: 'menu-dial',
cascade: [
{
selector: onList({ eventName: 'open' })
selector: onList({ eventName: 'emerge-open' })
},
{
selector: onList({ eventName: 'close' })
selector: onList({ eventName: 'emerge-close' })
},
{
selector: onAction({ eventName: 'commit-select' }),

@ -14,10 +14,10 @@ export const onionMenuSema: Sema = {
name: 'onion-menu',
cascade: [
{
selector: onSurface({ eventName: 'open' })
selector: onSurface({ eventName: 'emerge-open' })
},
{
selector: onSurface({ eventName: 'close' })
selector: onSurface({ eventName: 'emerge-close' })
},
{
selector: onItem({ eventName: 'commit-select' }),

@ -51,7 +51,7 @@ export const popoverSema: Sema = {
// as Dialog/Drawer. Scoped a `close` polymorphic + family emerge —
// commit.save y signal.alert mantienen sus propias firmas.
{
selector: onContent({ eventName: 'close', eventFamily: 'emerge' })
selector: onContent({ eventName: 'emerge-close', eventFamily: 'emerge' })
},
// ─── close + dismiss-outside cause: even more subtle ──────────────
@ -61,7 +61,7 @@ export const popoverSema: Sema = {
// nombre de evento.
{
selector: onContent({
eventName: 'close',
eventName: 'emerge-close',
state: { attr: 'data-last-action', value: 'dismissed-outside' }
})
}

@ -12,7 +12,7 @@ import type { Sema } from '../sema-map';
* than slider's 0.12 — splitters get grabbed more often)
* - `handle-drag` → channel-only rule. Per-emit `signal.overrides`
* are computed by soma (`SplitterResizeTriggerProvider`) via
* `resolveSplitterDragSound({ value01, velocity01, direction })`
* `resolveSplitterDragSound({ value01, velocity01, contour })`
* so the dynamic curve survives the cascade.
* - `commit-set` → `handle.snap.chime` + tap haptic for the
* "I committed to this layout" landing cue.

@ -32,7 +32,7 @@ export const tabsSema: Sema = {
name: 'tabs',
cascade: [
{
selector: onTrigger({ eventName: 'select' }),
selector: onTrigger({ eventName: 'commit-select' }),
haptic: { kind: 'tap' }
}

@ -10,15 +10,15 @@ import type { Sema } from '../sema-map';
* `playExit`. Sema's standard channel pipeline reproduces that
* affordance and adds per-intent shaping that Air didn't have:
*
* - `present` (emerge.present) — soft "appeared" chime; no haptic.
* Toast appearance is informational, not alarming.
* - `announce` (signal.announce, intent fromProp) — coincident with
* - `emerge-present` (emerge.present) — soft "appeared" chime; no
* haptic. Toast appearance is informational, not alarming.
* - `signal-announce` (signal.announce, intent fromProp) — coincident with
* signature: `threat`/`risk` get the alert profile (assertive,
* louder, haptic alert), `fulfill`/`affirm` get a soft positive
* chime, `loss` runs grave (low pitch). Cascade rules below add
* character (haptic kind, sound character) WITHOUT overriding the
* them collapses the per-intent perceptual difference.
* - `dismiss` (emerge.dismiss) — descending pitch + lower gain.
* - `emerge-dismiss` (emerge.dismiss) — descending pitch + lower gain.
* Mirrors dialog/drawer/popover close direction discipline.
*
* Sound is chosen, never authored: the family's verb table names it and the
@ -36,13 +36,13 @@ export const toastSema: Sema = {
// Toast appearance is ambient. Lower gain than dialog/drawer because
// the user didn't request it; we shouldn't compete with whatever
// they were doing. No haptic on present — only the alert intents
// get tactile feedback (via `announce`).
// get tactile feedback (via `signal-announce`).
{
selector: onItem({ eventFamily: 'emerge', eventName: 'present' })
selector: onItem({ eventFamily: 'emerge', eventName: 'emerge-present' })
},
// ─── Announce — character per intent ──────────────────────────────
// `announce` runs coincident with `present` — the perceptual
// `signal-announce` runs coincident with `emerge-present` — the perceptual
// sound comes from the intent, which selects a whole entry. This rule:
// - threat/risk: tactile alert so the user notices when they're
// not looking.
@ -71,11 +71,11 @@ export const toastSema: Sema = {
},
// ─── Dismiss — direction discipline (descending) ──────────────────
// emerge.base sound is ascending. `dismiss` inverts the contour
// emerge.base sound is ascending. `emerge-dismiss` inverts the contour
// (toast leaving the surface) and drops pitch. Gain stays low
// because dismissal is even more passive than appearance.
{
selector: onItem({ eventFamily: 'emerge', eventName: 'dismiss' })
selector: onItem({ eventFamily: 'emerge', eventName: 'emerge-dismiss' })
}
]
};

@ -17,14 +17,14 @@ import type { Sema } from '../sema-map';
* - Silence is a valid canonical signature.
*
* Strategy:
* - `present` / `dismiss` / `dismiss-escape` (all emerge family on content):
* all declared `SILENT`. The resolver drops the sound channel, so nothing
* reaches the engine — a tooltip reveals on hover and would puff on every
* mouse cross. The silence is ABSOLUTE: no intent lifts it, because there
* is no gain left to raise. (Until 2026-08-06 this was a tuning that
* subtracted the family gain and let `threat` / `fulfill` through; that
* arithmetic was measurably not silence — see the naming law in
* `docs/architecture/sema.md`.)
* - `emerge-present` / `emerge-dismiss` / `emerge-dismiss-escape` (all
* emerge family on content): all declared `SILENT`. The resolver drops
* the sound channel, so nothing reaches the engine — a tooltip reveals
* on hover and would puff on every mouse cross. The silence is
* ABSOLUTE: no intent lifts it, because there is no gain left to raise.
* (Until 2026-08-06 this was a tuning that subtracted the family gain
* and let `threat` / `fulfill` through; that arithmetic was measurably
* not silence — see the naming law in `docs/architecture/sema.md`.)
* - NO haptic. Hover is not a tactile gesture; tactile feedback on
* hover would be perceptually wrong.
* - NO pack on `Trigger` — the trigger does not emit semantic

@ -229,6 +229,7 @@ describe('EngineSemantic', () => {
expect(target.hasAttribute('data-event-phase')).toBe(false);
expect(target.hasAttribute('data-event-family')).toBe(false);
expect(target.hasAttribute('data-event-intent')).toBe(false);
expect(target.hasAttribute('data-event-direction')).toBe(false);
});
it('the cascade sees data-event-* tokens during resolution', async () => {

@ -5,6 +5,7 @@ export type {
IntentExpectedFamily,
IntentOptionalFamily,
SemaMode,
SemaDirection,
SemaRegime,
SemaScope,
SemaCause,
@ -118,7 +119,7 @@ export {
type DragSoundParams,
type SampleSoundDefinition,
type SoundDefinition,
type SoundDirection,
type SoundContour,
type SoundOverride,
type SynthSoundDefinition
} from './sounds';

@ -123,6 +123,16 @@ interface ParsedSelector {
readonly eventNamePrefix: string | undefined;
readonly eventFamily: SemaFamily | undefined;
readonly eventIntent: string | undefined;
/**
* `data-event-*` axes this parser does not model. The chain below used to
* end in nothing: an axis it did not name was DROPPED without a word, so
* every check downstream silently censused a rule wider than the one the
* author wrote. `data-event-direction` (2026-08-11) was the first new axis
* in the stamp since the parser was written and it walked straight into
* that hole. Collected here and asserted empty by the census, so the
* SEVENTH axis has to be decided rather than discovered.
*/
readonly unmodelledEventAttrs: readonly string[];
}
function parseSelector(selector: string): ParsedSelector {
@ -133,6 +143,7 @@ function parseSelector(selector: string): ParsedSelector {
let eventNamePrefix: string | undefined;
let eventFamily: SemaFamily | undefined;
let eventIntent: string | undefined;
const unmodelledEventAttrs: string[] = [];
for (const match of compound.matchAll(SEGMENT_RE)) {
const [, attr, operator, value] = match;
@ -147,10 +158,21 @@ function parseSelector(selector: string): ParsedSelector {
eventFamily = value as SemaFamily;
} else if (attr === 'data-event-intent') {
eventIntent = value;
} else if (attr === 'data-event-direction') {
// Recognized and deliberately NOT a narrower. Direction is decided per
// EMISSION, so no morfo declares it and there is nothing here to check
// it against: the census over-approximates the reachable events, which
// costs a false negative (a direction rule on an event nobody ever
// emits with a sense would read as live) and never a false positive.
// The honest check for that lives in the browser, not in a morfo.
} else if (attr === 'data-event-id' || attr === 'data-event-phase') {
// Stamped on every occurrence — they narrow nothing.
} else if (attr.startsWith('data-event')) {
unmodelledEventAttrs.push(attr);
}
}
return { marker, eventName, eventNamePrefix, eventFamily, eventIntent };
return { marker, eventName, eventNamePrefix, eventFamily, eventIntent, unmodelledEventAttrs };
}
function resolveMarker(
@ -285,11 +307,11 @@ const EMISSION_EXCEPTIONS: Record<string, string> = {
// PENDING AUTHOR DECISION (found by this census 2026-08-06, not in the 44):
// tooltip declares three events NOBODY emits, so its SILENT rule never
// matches and no data-event-* ever stamps (the eidos motion preset that
// reads `present` included). Today's silence is accidental, not the
// reads `emerge-present` included). Today's silence is accidental, not the
// declared design. Either the provider emits, or the events retire.
'tooltip:present': 'dead declared contract — pending: emit, or retire the events',
'tooltip:dismiss': 'dead declared contract — pending: emit, or retire the events',
'tooltip:dismiss-escape': 'dead declared contract — pending: emit, or retire the events'
'tooltip:emerge-present': 'dead declared contract — pending: emit, or retire the events',
'tooltip:emerge-dismiss': 'dead declared contract — pending: emit, or retire the events',
'tooltip:emerge-dismiss-escape': 'dead declared contract — pending: emit, or retire the events'
};
/**
@ -303,7 +325,7 @@ const SOMA_SOURCE_CACHE = new Map<string, string | undefined>();
/**
* Strip comments so prose cannot satisfy the census. menu-dial's soma file
* documents `trigger('open' | 'close' | 'commit-select', …)` in a doc block
* documents `trigger('emerge-open' | 'emerge-close' | 'commit-select', …)` in a doc block
* while every real call lives in its eidos wrapper — a raw substring search
* passed it for the wrong reason.
*/
@ -360,6 +382,20 @@ describe('pack census — every cascade rule can fire, and fires where the morfo
expect(unresolved, 'a selector whose marker no morfo emits can never match').toEqual([]);
});
it('models every data-event-* axis its rules select on', () => {
const unmodelled = RULES.filter(({ parsed }) => parsed.unmodelledEventAttrs.length > 0).map(
({ id, rule, parsed }) =>
`${id}: ${parsed.unmodelledEventAttrs.join(', ')} — ${rule.selector}`
);
expect(
unmodelled,
'this parser is what every check below reads the rule THROUGH: an axis it does not name is ' +
'dropped, and the rule is censused wider than it was written. Name the axis in ' +
'`parseSelector` — as a narrower if the morfo can declare it, or as a written no-op if ' +
'(like `data-event-direction`) it is decided per emit'
).toEqual([]);
});
it('selects a part the morfo declares as a stamp target', () => {
const offenders: string[] = [];
for (const { id, rule, parsed, pack } of RULES) {
@ -378,8 +414,10 @@ describe('pack census — every cascade rule can fire, and fires where the morfo
}
expect(
offenders,
'the runtime stamps on the declared target (or a declared `allowedTargets` part); a rule ' +
'aimed anywhere else never matches. Fix the selector, or declare the redirection in the morfo'
'the runtime stamps on the declared target, a declared `allowedTargets` part (caller ' +
'choice) or a declared `targetFallback` part (mount-state degradation — stamped only ' +
'while the canonical target is unmounted); a rule aimed anywhere else never matches. ' +
'Fix the selector, or declare the redirection in the morfo'
).toEqual([]);
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.