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 That split is a *classification*; it no longer dictates the intent rule — the
policy (§4) does. 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 ## 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). 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` `morfo.events[].semantic.verb` MUST be in its family's set; `events[].name`
should follow `{family}-{verb}[-{variant}]` or `{verb}-{variant}` so sema / sound **declares the family** — `{family}-{verb}[-{nuance}]`, guaranteed by
/ haptic / eidos can subscribe transversally. `validateMorfo` — so sema / sound / haptic / eidos can subscribe transversally.
**Executable source of truth:** `SEMA_VERBS` in **Executable source of truth:** `SEMA_VERBS` in
[`src/uix/sema/verbs.ts`](../src/uix/sema/verbs.ts) — including the documented [`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). consequence).
4. **emerge does not absorb the content's intent** — `emerge.open → signal.warn + 4. **emerge does not absorb the content's intent** — `emerge.open → signal.warn +
risk`, not `emerge.open + risk` (frame ≠ message). 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. 6. **handle concentrates evaluation on the drop**, not the carry.
7. **every open process needs an exit** (commit / cancel / fail / persisted-warn). 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 [`src/uix/morfo/types.ts`](../src/uix/morfo/types.ts), emitted as
`data-archetype`. `data-archetype`.
- **The `data-event-*` tokens** — the contact surface sema stamps and eidos reads - **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', kebab: 'dialog',
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
events: [{ events: [{
name: 'close-cancel', name: 'emerge-close-cancel',
semantic: { semantic: {
family: 'emerge', family: 'emerge',
verb: 'close', 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 Each intent declares per-channel `deltas` applied over the family base when
the family is valenced. the family is valenced.
- **Action verbs** (`SEMA_VERBS` in `src/uix/sema/verbs.ts`) — `present`, - **Action verbs** (`SEMA_VERBS` in `src/uix/sema/verbs.ts`) — `present`,
`dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical names `dismiss`, `commit`, `cancel`, `announce`, `warn`, … — the canonical verbs
for `morfo.events[].name`. for `morfo.events[].semantic.verb`, and the tail of `morfo.events[].name`.
Sema **does not decide which event happened** — the provider decides. The Sema **does not decide which event happened** — the provider decides. The
`EngineSemantic` only: `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 the subsequent structural commit, and therefore the only one that blocks the
caller. `EngineSemantic` runs generic channel hooks; `VisualChannel.prepare()` caller. `EngineSemantic` runs generic channel hooks; `VisualChannel.prepare()`
projects `data-event` + `data-event-id` + `data-event-phase` (and optionally 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 `SignalProjector`. In `ActiveUix` that projector receives `uix.dom`, so attr
writes enter through the same DOM owner soma uses. `VisualChannel` holds the writes enter through the same DOM owner soma uses. `VisualChannel` holds the
configurable window and the cleanup removes the projection before resolving 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 1. Browser fires click → Svelte calls Close.onclick
2. Close.onclick runs: 2. Close.onclick runs:
void this.toastItem.runtime.trigger('dismiss') void this.toastItem.runtime.trigger('emerge-dismiss')
3. SomaRuntime.trigger('dismiss'): 3. SomaRuntime.trigger('emerge-dismiss'):
3.1. Looks up event 'dismiss' in morfo.events ✓ 3.1. Looks up event 'emerge-dismiss' in morfo.events ✓
3.2. Resolves target = the Item DOM element via partRef('item') 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: EngineSemantic dispatches the signal to ALL registered channels:
- VisualChannel.prepare(): SignalProjector applies data-event* - VisualChannel.prepare(): SignalProjector applies data-event*
via dom.apply(target, data-event-family=emerge) 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) (strict sequential semantics)
4. SomaRuntime invokes the provider's handler: 4. SomaRuntime invokes the provider's handler:
sources.events.dismiss() → sources.events['emerge-dismiss']() →
this.provider.toaster.dismiss(opts.toast.current.id) → this.provider.toaster.dismiss(opts.toast.current.id) →
toast.dismissing = true (state mutation) 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 dom.apply(target, { 'data-state': 'closed' }) on the next tick
6. Eidos (CSS) has been reacting throughout the sequence: 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 (CSS animation, not transition: it runs full-duration even if the attr
disappears afterwards) disappears afterwards)
- at t≈245ms: [data-state="closed"] takes over - 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 | | `aria-*` | dom.apply (effect) | Screen readers, Eidos |
| `data-state="open"` | dom.apply (effect) | Eidos (variant selector) | | `data-state="open"` | dom.apply (effect) | Eidos (variant selector) |
| `data-disabled` | dom.apply (effect) | Eidos (state 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-phase="active"` | sema.emit (transient) | Eidos |
| `data-event-id="sig-N"` | sema.emit (transient) | Future sound/haptic | | `data-event-id="sig-N"` | sema.emit (transient) | Future sound/haptic |
| `data-event-family="commit"` | sema.emit (transient) | Eidos (family selector) | | `data-event-family="commit"` | sema.emit (transient) | Eidos (family selector) |
| `data-event-intent="risk"` | sema.emit (transient) | Eidos (signal tinting) | | `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-color="primary"` | dom.apply (effect) | Eidos (per-token recipe) |
| `data-intent="risk"` | dom.apply (effect, optional per morfo) | Eidos (persistent state) | | `data-intent="risk"` | dom.apply (effect, optional per morfo) | Eidos (persistent state) |
| `dir` | prefs projection (page) / provider | Browser bidi, Eidos `:dir()` | | `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 **select / toggle / acknowledge** are `commit` (they fix state; they are not
mere contact); **edit** is `shift.enter-mode` (it changes the regime). mere contact); **edit** is `shift.enter-mode` (it changes the regime).
`morfo.events[].name` should align with this vocabulary in one of two `morfo.events[].name` **declares its family**: the shape is
shapes: `{verb}-{variant}` (`dismiss-outside`, `close-cancel`) or `{family}-{verb}[-{nuance}]` (`commit-toggle`, `emerge-close-cancel`), and
`{family}-{verb}` (`commit-toggle`, `commit-save`). That lets `validateMorfo` rejects a name that does not start with its own family. That
Sema/Sound/Haptic/Eidos subscribe or style by verb or family without lets Sema/Sound/Haptic/Eidos subscribe or style by family or verb without
enumerating components. `validateEventName` recognizes both shapes. enumerating components.
**Intents** (`src/uix/sema/sema-map.ts:SEMA_MAP.intents`), 6 values: **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[].archetype` | transversal rules `[data-archetype=trigger]` |
| `parts[].states` + `data[].values` | variants `[data-state=open]` | | `parts[].states` + `data[].values` | variants `[data-state=open]` |
| `parts[].data` with `data-starting-style` / `data-ending-style` | enter/exit animation hooks | | `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[].semantic.family` + `.intent` | semantic tinting of transitions |
| `events[].prewrite[]` (e.g. `data-last-action`) | tinting the exit anim by cause | | `events[].prewrite[]` (e.g. `data-last-action`) | tinting the exit anim by cause |
| `focus.trap` | a layout hint for overlays | | `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'] { [data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
animation: eidos-announce-pulse-threat 400ms var(--ease-spring); 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 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 compositor hint + the reduced-motion cap (see
[`eidos-motion.md`](../theming/motion.md) §15). [`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 ## What it does NOT consume
- **The provider's logical computed state** (e.g. the composition of a - **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 - **invalid** — references a declared attr with a value outside the enum. A
bug. 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 **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 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 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 }, props: { disabled: () => this.opts.disabled.current },
parts: { content: () => this.contentId.current }, parts: { content: () => this.contentId.current },
events: { events: {
open: () => { 'emerge-open': () => {
this.opts.open.current = true; this.opts.open.current = true;
}, },
'close-cancel': () => { 'emerge-close-cancel': () => {
this.opts.open.current = false; this.opts.open.current = false;
} }
} }
@ -297,17 +297,18 @@ directional `emerge` events:
```ts ```ts
{ {
name: 'expand', name: 'emerge-expand',
semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' } semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' }
}, },
{ {
name: 'collapse', name: 'emerge-collapse',
semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'pre' } semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'post' }
} }
``` ```
`expand` is `post` so Eidos reacts after content exists. `collapse` is `pre` so `emerge-expand` is `post` so Eidos reacts after content exists. `emerge-collapse` is `post`
the exit signal can play while content is still visible. Any visual color, 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. motion or density response belongs to Eidos recipes, not to the morfo event.
--- ---
@ -614,10 +615,10 @@ events: [
Field rules: Field rules:
- **`name`** — the addressable id used by `runtime.trigger(name)`. - **`name`** — the addressable id used by `runtime.trigger(name)`. The name
Convention: `{verb}-{variant}` (e.g. `dismiss-outside`, **declares the family**: `{family}-{verb}[-{nuance}]` (e.g. `commit-toggle`,
`close-cancel`) or `{family}-{verb}` (e.g. `commit-toggle`, `emerge-dismiss-outside`). `validateMorfo` rejects a name that does not
`commit-save`). The validator accepts both shapes. start with its own `semantic.family`.
- **`semantic.family`** — one of the 8: `contact`, `commit`, `signal`, - **`semantic.family`** — one of the 8: `contact`, `commit`, `signal`,
`handle`, `emerge`, `shift`, `sustain`, `delegate` (per `SEMA_MAP`). `handle`, `emerge`, `shift`, `sustain`, `delegate` (per `SEMA_MAP`).
Whether `intent` is required is set per-family by `SEMA_FAMILY_POLICY` 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 declared `target` nor one of these. Runtime rather than static because the
override is an expression (`e.currentTarget`) and only the live element can override is an expression (`e.currentTarget`) and only the live element can
answer which part it is. 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 - **`regime`** — what this event does when it arrives and the target surface
already carries a live occurrence: `replace` (default) or `queue`. The already carries a live occurrence: `replace` (default) or `queue`. The
`data-event-*` projection is ONE SLOT per element. Declare it only for pairs `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 (commit pulses on completed actions). `'coincident'` is for
in-flight processes (sustain). 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. For a comprehensive worked example see the toggle and dialog morfos.
### Step 6 — Wire the provider ### 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` ## 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: 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"] // [data-dialog-content][data-event-family="commit"]
semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' }); semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' });
// [data-dialog-content][data-event="close-after-fail"] // [data-dialog-content][data-event="signal-alert-close-fail"]
semaSelector(dialogMorfo, 'content', { eventName: 'close-after-fail' }); semaSelector(dialogMorfo, 'content', { eventName: 'signal-alert-close-fail' });
// [data-dialog-content][data-event^="close-"][data-event-family="emerge"] // [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"]
semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-', eventFamily: 'emerge' }); semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-close', eventFamily: 'emerge' });
``` ```
### What it guarantees ### 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 `src/uix/morfo/types.ts:ARCHETYPE_VOCABULARY`. Emitted as
`data-archetype="..."` by `runtime.partProps`. `data-archetype="..."` by `runtime.partProps`.
- **Verbs**: the canonical action verbs for `morfo.events[].name`. Defined in - **Verbs**: the canonical action verbs for `morfo.events[].name`. Defined in
`src/uix/sema/verbs.ts:SEMA_VERBS`. Convention for composite names: `src/uix/sema/verbs.ts:SEMA_VERBS`. Name convention:
`{verb}-{variant}` (e.g. `commit-save`, `dismiss-outside`). `{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 1. Runs the channels' `prepare` hooks. The `VisualChannel` projects the
semantic tokens `data-event`, `data-event-family`, `data-event-intent`, semantic tokens `data-event`, `data-event-family`, `data-event-intent`,
`data-event-phase`, `data-event-id` onto `signal.target`. These are the `data-event-direction`, `data-event-phase`, `data-event-id` onto
tokens the cascade and eidos's CSS read. `signal.target`. These are the tokens the cascade and eidos's CSS read.
2. Calls `resolveSignature(signal, opts)`, which applies the cascade 2. Calls `resolveSignature(signal, opts)`, which applies the cascade
(canonical numbering **1 · 2 · 3 · 4 · 5a · 5b** — the same in (canonical numbering **1 · 2 · 3 · 4 · 5a · 5b** — the same in
`engine.ts`, `resolver.ts` and CLAUDE.md; each layer overrides the `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 BEFORE the cascade resolves. Rules with selectors over these attrs match
natively via `target.matches()` / `target.closest()`: natively via `target.matches()` / `target.closest()`:
| Attr | Value | Origin | | Attr | Value | Origin |
| ------------------- | -------------------- | ------------------------------ | | ---------------------- | -------------------- | --------------------------------- |
| `data-event` | `'close-after-fail'` | `signal.name` | | `data-event` | `'signal-alert-close-fail'` | `signal.name` |
| `data-event-family` | `'signal'` | `signal.family` | | `data-event-family` | `'signal'` | `signal.family` |
| `data-event-intent` | `'threat'` | `signal.intent` (when present) | | `data-event-intent` | `'threat'` | `signal.intent` (when present) |
| `data-event-phase` | `'active'` | while the hold lasts | | `data-event-direction` | `'forward'` | `signal.direction` (when present) |
| `data-event-id` | `'sig-42'` | occurrence id | | `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 Those tokens are the **cross-channel contact surface**: sema's cascade
(`sound`, `haptic` and future channels) reads them with CSS selectors, the (`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` ### 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 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** 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 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` 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 (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: 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` that is how A-36 closed, with the overlays' `emerge-open` giving up a
left over from when they were `sequence: 'pre'`. Measured after: the Button's `targetOverride` left over from when they were `sequence: 'pre'`. Measured
`contact-activate` stamps the trigger and the Drawer's `open` stamps its after: the Button's `contact-activate` stamps the trigger and the Drawer's
content — two surfaces, both expressing. `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 `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 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"] // [data-dialog-content][data-event-family="commit"]
{ selector: onContent({ eventFamily: 'commit' }), haptic: { kind: 'tap' } }, { selector: onContent({ eventFamily: 'commit' }), haptic: { kind: 'tap' } },
// [data-dialog-content][data-event="close-after-fail"] // [data-dialog-content][data-event="signal-alert-close-fail"]
{ selector: onContent({ eventName: 'close-after-fail' }), sound: { sampleUrl: '/fail.wav' } }, { 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 } } 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 `VisualChannel.prepare()` projects **only** attributes under the
`data-event-*` prefix: `data-event-*` prefix:
| Attr | When | | Attr | When |
| ------------------- | ------------------- | | ---------------------- | --------------------- |
| `data-event` | always | | `data-event` | always |
| `data-event-id` | always | | `data-event-id` | always |
| `data-event-phase` | always (`'active'`) | | `data-event-phase` | always (`'active'`) |
| `data-event-family` | if `signal.family` | | `data-event-family` | if `signal.family` |
| `data-event-intent` | if `signal.intent` | | `data-event-intent` | if `signal.intent` |
| `data-event-direction` | if `signal.direction` |
**Rule**: the channel never touches state attrs (`data-state`, **Rule**: the channel never touches state attrs (`data-state`,
`data-intent`, `data-disabled`, ...). State is managed by the runtime/morfo. `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. (direct unit tests); production always injects the scheduler.
> **Overlay openings — `sequence: 'post'`, not `'pre'`.** An appearance event > **Overlay openings — `sequence: 'post'`, not `'pre'`.** An appearance event
> whose provider sets `open` in the HANDLER (Popover `present`, Dialog > whose provider sets `open` in the HANDLER (Popover, Dialog and Drawer's
> `open`, Drawer `present`) MUST declare `sequence: 'post'`. With `'pre'` the > `emerge-open`) MUST declare `sequence: 'post'`. With `'pre'` the
> runtime awaits the emit — and therefore the ~240ms hold — BEFORE the > runtime awaits the emit — and therefore the ~240ms hold — BEFORE the
> handler, gating the content mount behind the hold: the overlay opens late > handler, gating the content mount behind the hold: the overlay opens late
> and its first render lands inside the hold's `setTimeout` turn (the > and its first render lands inside the hold's `setTimeout` turn (the
> "setTimeout handler took N ms" violation). Same doctrine as 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. > and the signal MUST precede the unmount.
```ts ```ts
@ -731,7 +750,7 @@ const semantic = new EngineSemantic({
// src/uix/morfo/components/dialog.ts // src/uix/morfo/components/dialog.ts
{ {
name: 'close-after-fail', name: 'signal-alert-close-fail',
semantic: { semantic: {
family: 'signal', family: 'signal',
verb: 'alert', 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 } } { selector: semaSelector(toastMorfo, 'item', { eventFamily: 'signal' }), sound: { gain: 0.4 } }
// Vary by exact event name (typed against the morfo's declared events) // 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 // 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` ### 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 - `void runtime.trigger('handle-drag')` — **fire-and-forget**. The signal
starts; the caller does not wait for the hold. Correct for high-frequency 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. emits, where blocking the gesture loop on a ~240ms hold would be absurd.
- `await runtime.trigger('close', …)` — **blocking**. The caller waits for the - `await runtime.trigger('emerge-close', …)` — **blocking**. The caller waits for
hold to finish before its next step. Correct when a structural change must 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 observe the resolved signal (e.g. an `emerge-close` whose element unmounts after
pulse — see the `sequence: 'post'` note under _Hold_). the pulse — see the `sequence: 'post'` note under _Hold_).
Slider and Drawer go through `trigger` exclusively — `handle-pick`, Slider and Drawer go through `trigger` exclusively — `handle-pick`,
`handle-drag`, `commit-set` on the slider; `drag-start`, `drag-progress`, `handle-drag`, `commit-set` on the slider; `handle-pick`,
`drag-end` on the drawer. Every continuous one is `void`. `handle-drag-progress`, `handle-drop` on the drawer. Every continuous one is
`void`.
### `coincident` vs `post` for a moving value ### `coincident` vs `post` for a moving value
A continuous gesture has two temporally distinct moments, and the morfo encodes A continuous gesture has two temporally distinct moments, and the morfo encodes
each with its `sequence`: 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 the perceptual signal and the value update are indivisible. The user _is_ the
value changing, so the signal fires alongside the mutation, neither value changing, so the signal fires alongside the mutation, neither
anticipating nor trailing it. (`coincident` is emit-then-handler, like `pre`; 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 [`verbs.ts`](../../src/uix/sema/verbs.ts) and reflects the book _Diseñando lo
que ocurre_ ch. 22–29 (families) + ch. 10 (intents). que ocurre_ ch. 22–29 (families) + ch. 10 (intents).
`morfo.events[].semantic.verb` MUST be in this vocabulary; `morfo.events[].semantic.verb` MUST be in this vocabulary;
`morfo.events[].name` should follow the `{family}-{verb}[-{variant}]` shape `morfo.events[].name` MUST follow the `{family}-{verb}[-{nuance}]` shape
so sema/sound/haptic can subscribe by verb and eidos can write transversal (guaranteed by `validateMorfo`) so sema/sound/haptic can subscribe by verb and
selectors (`[data-event^="dismiss"]`). eidos can write transversal selectors (`[data-event^="emerge-dismiss"]`).
> **The literal table used to live here, and it rotted.** It was missing > **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 > `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 ### 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 ```ts
// Shape 1: {verb}-{variant} — head is the verb, tail explains the nuance. // {family}-{verb}[-{nuance}] — head is the family, then the canonical verb,
'dismiss'; // bare verb // then the nuance when the component needs one.
'dismiss-outside'; // verb + variant 'emerge-dismiss'; // family=emerge, verb=dismiss
'close-cancel'; // verb (close) + variant (cancel) 'emerge-dismiss-outside'; // + nuance (outside)
// Shape 2: {family}-{verb} — head is the family, tail the canonical verb.
'commit-toggle'; // family=commit, verb=toggle 'commit-toggle'; // family=commit, verb=toggle
'commit-save'; // family=commit, verb=save 'commit-save'; // family=commit, verb=save
``` ```
`validateEventName(name)` recognizes both shapes and returns `{ family, `validateEventName(name)` parses a name — it still accepts the retired
verb, variant, matchesCanonical }`. Used by bare-verb shape — and returns `{ family, verb, variant, matchesCanonical }`.
Used by
`scripts/morfo-vocabulary-check.ts` (`npm run morfo:vocabulary`), which `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 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 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. allowlist in the script covers deliberate divergences.
```ts ```ts

@ -370,7 +370,7 @@ therefore what the runtime emits:
archetype. archetype.
- **`data-event*`** — emitted by `events.emit` (through the VisualChannel) - **`data-event*`** — emitted by `events.emit` (through the VisualChannel)
during a configurable hold. Eidos uses it to tint event transitions 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`. `SEMA_MAP.families[*].hold`.
- **`data-{component}` / `data-{component}-{part}`** — the classic structural - **`data-{component}` / `data-{component}-{part}`** — the classic structural
markers. Eidos uses them for per-component selectors. markers. Eidos uses them for per-component selectors.

@ -144,7 +144,8 @@ motion channel**:
`delayed-open` alias), content mounts (`motionAttrs`). `delayed-open` alias), content mounts (`motionAttrs`).
2. **Event signature** (`EidosConfig.motion.signatures`) when the animation 2. **Event signature** (`EidosConfig.motion.signatures`) when the animation
reacts to sema's `data-event-*` and is generic per verb/family 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 3. **Materials pattern** (the rule lives in the recipe but consumes ONLY
registered keyframes + `--motion-*` hooks) when the trigger is registered keyframes + `--motion-*` hooks) when the trigger is
irreducible to the generic: a semantic `data-state` of its own (card), 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` `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 ## Hold / perceptual durations
The named perceptual scale (`SEMA_DURATIONS`, `src/uix/sema/durations.ts`); 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. | | **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. | | **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). | | **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`). | | **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) | | **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. | | **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 | | **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 | | **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 | | **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 | | **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). | | **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) | | **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.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.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.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.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.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 | | 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** - **Sema's moment (emission)** — sema evaluates the occurrence and **emits**
it. Sound + haptics it **executes right there** (runtime channels); for the it. Sound + haptics it **executes right there** (runtime channels); for the
visual, it **stamps it as tokens** `data-event-*` (family · intent · 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 - **Eidos's moment (materialization)** — eidos **reads** those tokens
(+ `data-state`) and **materializes** them in CSS (the visual channel). It (+ `data-state`) and **materializes** them in CSS (the visual channel). It
is the **sole visual owner**. is the **sole visual owner**.
@ -92,6 +92,38 @@ a shim:
> (family / semantic intent), eidos contributes the **how** (the visual > (family / semantic intent), eidos contributes the **how** (the visual
> vocabulary and its materialization). Co-layers, not one subordinate. > 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 (The full canonical narrative lives in `CLAUDE.md` → "Sema: open channel
registry".) registry".)

@ -269,7 +269,7 @@ axes.
| Layer | What it contributes | Moment | | 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 | | **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) | | **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 | | **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 ├── keyframes: { 'fade-in': {...}, 'scale-in': {...}, ... } registered @keyframes
│ │
├── signatures: { ← the --event MOMENT (the signature, generic per event) ├── 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'], ... }, │ 'commit-settle': { family:'commit', keyframes:['settle'], ... },
│ 'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... } │ 'announce-threat':{ family:'signal', intent:'threat', keyframes:['pulse'], ... }
│ } → generates [data-event-*][data-event-phase='active'] rules │ } → generates [data-event-*][data-event-phase='active'] rules
@ -342,7 +342,9 @@ interface CssPhase {
interface EventSignature { interface EventSignature {
family?: string // data-event-family ('emerge' | 'commit' | 'signal' | …) family?: string // data-event-family ('emerge' | 'commit' | 'signal' | …)
intent?: string // data-event-intent (valenced families) 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[] keyframes: KeyframeName | KeyframeName[]
duration?: string // token key OR raw hold ('600ms', outside the scale) duration?: string // token key OR raw hold ('600ms', outside the scale)
ease?: EaseKey ease?: EaseKey
@ -467,11 +469,19 @@ to the driver, not to `ActiveDom`.
**The `--event` moment** (sema writes it during the hold; eidos reacts): **The `--event` moment** (sema writes it during the hold; eidos reacts):
```css ```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='commit'][data-event-phase='active'] { animation: settle …; }
[data-event-family='signal'][data-event-intent='threat'][data-event-phase='active'] { animation: pulse …; } [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 **The `--state` moment** (soma writes it; eidos reacts). The wrapper sets
`data-animation-style` (the `motion` prop); the rest state comes from the `data-animation-style` (the `motion` prop); the rest state comes from the
recipe over the same `data-state`: recipe over the same `data-state`:

@ -1,10 +1,10 @@
/** /**
* docs:vocabularies — generate the canonical-vocabulary appendix from the code * docs:vocabularies — generate the canonical-vocabulary appendix from the code
* consts, so agents building from the docs-book can SEE the closed sets they * consts, so agents building from the docs-book can SEE the closed sets they
* must draw from (archetypes, sema families + holds + verbs, intents, haptic * must draw from (archetypes, sema families + holds + verbs, intents,
* kinds, palette scales, sizes, variants, shared strings) without any * directions, haptic kinds, palette scales, sizes, variants, shared strings)
* copy-the-list drift objection. Generated = the ONE sanctioned place these * without any copy-the-list drift objection. Generated = the ONE sanctioned
* lists are spelled out; every other doc links here. * place these lists are spelled out; every other doc links here.
* *
* Closes STUMBLES #1 (invisible canonical vocabularies — an agent could not * Closes STUMBLES #1 (invisible canonical vocabularies — an agent could not
* assign archetypes / holds from the docs alone). * 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 { SEMA_VERBS } from '../src/uix/sema/verbs';
import { SOUNDS } from '../src/uix/sema/sound-names'; import { SOUNDS } from '../src/uix/sema/sound-names';
import { SEMA_DURATIONS } from '../src/uix/sema/durations'; import { SEMA_DURATIONS } from '../src/uix/sema/durations';
import { SEMA_DIRECTIONS } from '../src/uix/sema/types';
import { INTENTS } from '../src/uix/intent'; import { INTENTS } from '../src/uix/intent';
import { commonLangs } from '../src/uix/langs/common'; import { commonLangs } from '../src/uix/langs/common';
@ -161,6 +162,26 @@ export function generateVocabulariesDoc(): string {
L.push(INTENTS.map((i) => `\`${i}\``).join(' · ')); L.push(INTENTS.map((i) => `\`${i}\``).join(' · '));
L.push(''); 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 // Hold durations
L.push('## Hold / perceptual durations'); L.push('## Hold / perceptual durations');
L.push(''); 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 * Prints a per-component summary so the drift between eidos and the
* morfo declarations is visible at a glance. * 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'; import { existsSync, readFileSync, readdirSync } from 'node:fs';
@ -13,6 +16,11 @@ import { resolve } from 'node:path';
import { compileMorfo } from '../src/uix/morfo/compile'; import { compileMorfo } from '../src/uix/morfo/compile';
import { lintEidosCss } from '../src/uix/eidos/lint'; import { lintEidosCss } from '../src/uix/eidos/lint';
import type { Morfo } from '../src/uix/morfo/types'; import type { Morfo } from '../src/uix/morfo/types';
import {
formatEventFindings,
lintEventValues,
loadEventVocabulary
} from './eidos-event-vocabulary';
const EIDOS_ONLY_ATTRS = new Set([ const EIDOS_ONLY_ATTRS = new Set([
'data-archetype', 'data-archetype',
@ -22,7 +30,11 @@ const EIDOS_ONLY_ATTRS = new Set([
'data-color', 'data-color',
'data-columns', 'data-columns',
'data-dragging', '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',
'data-event-direction',
'data-event-family', 'data-event-family',
'data-event-id', 'data-event-id',
'data-event-intent', 'data-event-intent',
@ -157,7 +169,26 @@ async function main() {
console.log('\nNo drift hotspots beyond documented Eidos-only attrs/parts.'); 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 { function isAllowedEidosOnlyAttr(attr: string): boolean {

@ -9,6 +9,9 @@
* `components/{name}.css`), compiles the morfo, and reports which * `components/{name}.css`), compiles the morfo, and reports which
* selectors are morfo-backed, eidos-only DOM signals/wrapper attrs, or * selectors are morfo-backed, eidos-only DOM signals/wrapper attrs, or
* invalid (value not in declared enum). * 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' import { readFileSync, existsSync } from 'node:fs'
@ -17,6 +20,11 @@ import { resolve } from 'node:path'
import { compileMorfo } from '../src/uix/morfo/compile' import { compileMorfo } from '../src/uix/morfo/compile'
import { formatLintReport, lintEidosCss } from '../src/uix/eidos/lint' import { formatLintReport, lintEidosCss } from '../src/uix/eidos/lint'
import type { Morfo } from '../src/uix/morfo/types' import type { Morfo } from '../src/uix/morfo/types'
import {
formatEventFindings,
lintEventValues,
loadEventVocabulary
} from './eidos-event-vocabulary'
async function main() { async function main() {
const name = process.argv[2] const name = process.argv[2]
@ -51,7 +59,17 @@ async function main() {
const report = lintEidosCss(css, compiled) const report = lintEidosCss(css, compiled)
console.log(formatLintReport(report)) 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) => { main().catch((err) => {

@ -73,6 +73,15 @@ export interface EventSignature {
readonly intent?: string readonly intent?: string
/** Name(s) / prefix(es) of `data-event` (`['present','open']`, …). */ /** Name(s) / prefix(es) of `data-event` (`['present','open']`, …). */
readonly event?: string | readonly string[] 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[] readonly keyframes: KeyframeName | readonly KeyframeName[]
/** Duration token key or raw hold (`'600ms'`). */ /** Duration token key or raw hold (`'600ms'`). */
readonly duration?: string 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 the Action / Cancel button parts (Provider is virtual). All
behavioural events (open/close presence, escape, focus trap, …) come behavioural events (open/close presence, escape, focus trap, …) come
from the Dialog runtime that soma's AlertDialog delegates to. There from the Dialog runtime that soma's AlertDialog delegates to. There
is no alert-dialog-specific sema event to fire; the is no alert-dialog-specific sema event to fire; the confirm / cancel
`commit-confirm` and `close-cancel` flows live inside the Dialog flows live inside the Dialog morfo's vocabulary — the polymorphic
morfo's vocabulary. `emerge-close` (book §5.3) and its `data-last-action` cause.
The component IS interactive from the user's perspective. "Passive" The component IS interactive from the user's perspective. "Passive"
here is a contract-layer classification, not a UX one. here is a contract-layer classification, not a UX one.
@ -128,7 +128,7 @@ behavioural locking.
| Gap | Disposición | Detalle | | 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. | | 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. | | `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-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 | | `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 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 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 fecha es un compromiso evaluativamente positivo (suave). Un commit
destructivo correspondería a `commit-unselect` con `intent: 'neutral'`. destructivo correspondería a `commit-unselect` con `intent: 'neutral'`.
- **`shift-navigate` cubre todo el movimiento de mes/año** (prev/next, - **`shift-navigate` cubre todo el movimiento de mes/año** (prev/next,
selects, atajos de teclado). No se subdivide por dirección — la selects, atajos de teclado). No se subdivide en variantes de verbo: el
perceptiva de Sema se modula con `target` y `sequence`, no con sentido del paso viaja POR EMISIÓN en `data-event-direction`
variantes de verbo. (`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 - **Las flechas de teclado son focus moves puros**: mueven el día
enfocado pero no mutan `value`. Por eso el morfo declara 10 enfocado pero no mutan `value`. Por eso el morfo declara 10
keyboards y sólo 3 eventos: Enter/Space (mutación) → `commit-select`, keyboards y sólo 3 eventos: Enter/Space (mutación) → `commit-select`,

@ -246,7 +246,14 @@
color: var(--calendar-day-color); 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-day][data-event],
[data-calendar-prev-button][data-event], [data-calendar-prev-button][data-event],
[data-calendar-next-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. `commit-toggle` / `commit-block` sema all come from ToggleGroup.
- **Disclosure → [`Collapsible`](../collapsible/README.md)** — the Title is a - **Disclosure → [`Collapsible`](../collapsible/README.md)** — the Title is a
`Collapsible.Trigger` (rendered as the system `<Button>`); the Content `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. Collapsible's.
- **Card visual → [`Card`](../card/README.md)** — each item IS a card via - **Card visual → [`Card`](../card/README.md)** — each item IS a card via
structural identity (`data-card`), reusing the entire Card recipe 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 | | 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-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. | | `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 Collapsible no existe como componente visual en la rama Air anterior
(`glm-5:src/uix/air/components`). Air solo tenia la familia semantica/motion (`glm-5:src/uix/air/components`). Air solo tenia la familia semantica/motion
`expansion` para Accordion/Collapsible; UIX la reemplaza por eventos Morfo/Sema `expansion` para Accordion/Collapsible; UIX la reemplaza por eventos Morfo/Sema
`expand` y `collapse`. `emerge-expand` y `emerge-collapse`.
## Superficie ## Superficie
@ -44,9 +44,10 @@ Partes publicas: `Trigger`, `Content`.
se resuelve con CSS o con un wrapper de producto. se resuelve con CSS o con un wrapper de producto.
- `Content` esta siempre montado. Eso cubre el caso `forceMount` que Radix/Bits - `Content` esta siempre montado. Eso cubre el caso `forceMount` que Radix/Bits
necesitan para transiciones; Soma usa `hidden` cuando esta cerrado. necesitan para transiciones; Soma usa `hidden` cuando esta cerrado.
- `expand` es `sequence: 'post'`: primero abre el contenido y luego Sema/Eidos - `emerge-expand` es `sequence: 'post'`: primero abre el contenido y luego
emiten la senal de entrada. `collapse` es `sequence: 'pre'`: la senal de Sema/Eidos emiten la senal de entrada. `emerge-collapse` es `post` tambien
salida se reproduce mientras el contenido aun es visible. (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 - 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 del Trigger; Ark si lo incluye, pero en UIX un indicador sin ARIA ni estado
propio seria SVG/composicion visual dentro de `Trigger`. propio seria SVG/composicion visual dentro de `Trigger`.

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

@ -12,8 +12,8 @@ Eidos Combobox wraps Soma Combobox and keeps the component compound:
state. Es la referencia ergonomic. state. Es la referencia ergonomic.
- **soma actual**: cubre input value + open state + single/multiple values + - **soma actual**: cubre input value + open state + single/multiple values +
hidden inputs + groups + floating + dismissal + keyboard nav. hidden inputs + groups + floating + dismissal + keyboard nav.
- **morfo**: declara las parts y el contrato data-/aria-; eventos sema sólo - **morfo**: declara las parts y el contrato data-/aria-; eventos sema para
para `commit-select` y `commit-unselect`. `commit-select`, `commit-unselect`, `emerge-open` y `emerge-close`.
## Comparativa ## Comparativa
@ -49,9 +49,9 @@ References:
| Layer | Owns | | 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. | | 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. | | Eidos | Control layout, size, variant, intent color, trigger icon, selected item indicator, panel recipe and animation. |
## Morfo/Sema ## 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-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`. | | `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, Typing, filtering and highlighting are navigation mechanics, not committed
not committed choices. They do not emit Sema events. choices: they emit no Sema event. Appearing and receding do — a dropdown list
is `emerge`, not `present` (option-list pass, 2026-06-19).
## Decisiones ## Decisiones
- **No se emiten eventos sema en open/close/typing/filter/highlight**. Son - **No se emiten eventos sema en typing/filter/highlight**. Son mecánicas de
mecánicas de navegación, no compromisos del usuario. El sema sólo viaja navegación, no compromisos del usuario. El compromiso viaja con
con `commit-select` y `commit-unselect` (la elección concreta). `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**: - **Las teclas de navegación interna de la listbox son focus moves**:
ArrowDown/ArrowUp/Home/End mueven el highlight, no mutan el valor. El ArrowDown/ArrowUp/Home/End mueven el highlight, no mutan el valor. El
audit las cuenta como focus-only — el único keystroke que mutea es audit las cuenta como focus-only — el único keystroke que mutea es
Enter (mapped a `select` → covered por `commit-select`). Enter (mapped a `select` → covered por `commit-select`).
- **Escape delega al popover**: la tecla cierra cerrando la layer - **Escape cierra por la capa `Dismissal`**: su `onEscapeKeydown` llama a
popover/dismissal; combobox no duplica el evento `close-dismiss` que ya `handleClose()`, que emite `emerge-close` y devuelve el foco al input. El
emite el popover. 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 - **`Combobox.Control` es composición Eidos-only**: agrupa Input + Trigger
+ Indicator dentro de un único chrome. No tiene contraparte en + Indicator dentro de un único chrome. No tiene contraparte en
morfo/soma porque es puramente layout visual. 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 - `size`, `variant` y `color` pertenecen a Eidos porque solo afectan receta
visual; `value`, `placeholder`, `granularity`, `locale`, `dir`, validacion y visual; `value`, `placeholder`, `granularity`, `locale`, `dir`, validacion y
navegacion de segmentos pertenecen a Soma. 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 edicion continua de segmentos no se modela como evento perceptivo por cada
pulsacion. 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. | | 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. | | 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 ## 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). - `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` `secondary` cuando el `data-color` colisiona con `affirm/fulfill`
(incidencia #4). (incidencia #4).
- ✓ Raw hex purgados; tokens de eidos regenerados. - ✓ Raw hex purgados; tokens de eidos regenerados.
- ✓ Morfo declara eventos (`open`, `close-range-commit`, `close-cancel`, - ✓ Morfo declara `commit-reset` — su único evento propio — y
`close-dismiss`, `close-dismiss-outside`, `commit-clear`) y `data-last-action`. `data-last-action`; abrir y cerrar pertenecen al Popover compuesto
- ⚠️ Modo modal/no-modal con acciones explícitas (incidencia #5) — todavía (`expression: 'delegated'`).
no implementado. - ✓ Modo modal/no-modal con acciones explícitas (incidencia #5): prop
- ⚠️ Botón `clear` explícito en la UI — todavía no implementado. `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` — - ⚠️ Controles de demo `start segments` / `end segments` / `paged nav` —
pendiente decidir si exponer en demo o sólo en API doc. pendiente decidir si exponer en demo o sólo en API doc.
@ -42,9 +44,9 @@ Incidencia 2026-05-20 (parcialmente atendida):
| `allowSingleDay` | ✓ | ✗ | ✗ | ✗ | | `allowSingleDay` | ✓ | ✗ | ✗ | ✗ |
| `isDateHoliday` | ✓ | ✗ | ✗ | ✗ | | `isDateHoliday` | ✓ | ✗ | ✗ | ✗ |
| Segmentos `readonly` por endpoint | ✓ | ✗ | parcial | ✗ | | 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 | ✓ | ✗ | ✗ | ✗ | | `data-last-action` para tinte de salida | ✓ | ✗ | ✗ | ✗ |
| Modos modal/no-modal | ✗ (pendiente) | ✓ | ✗ | ✗ | | Modos modal/no-modal | ✓ | ✓ | ✗ | ✗ |
| Auto-page al seleccionar en mes final | ✗ (deliberado) | ✗ | ✓ | ✓ | | Auto-page al seleccionar en mes final | ✗ (deliberado) | ✗ | ✓ | ✓ |
| Limpieza por endpoint individual | ✓ | parcial | ✗ | ✗ | | Limpieza por endpoint individual | ✓ | parcial | ✗ | ✗ |
| Distinción visual start/end | ✓ (stripe + swap) | ✓ | parcial | 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 - **Start usa `affirm` por defecto, end usa el `data-color` accent**. Cuando
el accent colisiona (`data-color="affirm"`/`"fulfill"`), el start cambia a el accent colisiona (`data-color="affirm"`/`"fulfill"`), el start cambia a
`secondary` para mantener distinción perceptiva (incidencia #4). `secondary` para mantener distinción perceptiva (incidencia #4).
- **El morfo declara eventos del picker** (`open`, `close-*`, `commit-clear`). - **El morfo del picker declara un solo evento propio** (`commit-reset`).
Eventos de celda (`commit-start`, `commit-range`, `shift-navigate`) los Abrir y cerrar los emite el Popover compuesto (`emerge-open` y el
emite `range-calendar`; el picker no los duplica. `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 ## Gaps
| ID | Disposición | Detalle | | 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. | | 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. | | `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. | | 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, focus trap/restore, escape/outside policies, scroll lock, force mount,
nested dialogs. Tamaños `sm/md/lg/full`. nested dialogs. Tamaños `sm/md/lg/full`.
- **soma actual**: cubre todo lo de Air en behavior (focus, dismissal, - **soma actual**: cubre todo lo de Air en behavior (focus, dismissal,
scroll lock, modal/non-modal, alertdialog variant) más outcome-specific scroll lock, modal/non-modal, alertdialog variant) más cierre con causa
close events (`close-save` / `close-cancel` / `close-dismiss` / (`dismissWith('save' | 'cancel' | 'dismiss' | 'dismiss-outside' |
`close-dismiss-outside` / `close-after-fail`) que ningún competidor 'fail')`, que concreta el `emerge-close` polimórfico y sella
expone. `data-last-action`) que ningún competidor expone.
- **morfo**: declara 7 events (open + 5 close variants + open intent - **morfo**: declara 2 events (`emerge-open`, con intent `fromProp`, y el
fromProp), focus policy con trap/return/restore, parts Provider / `emerge-close` polimórfico del libro §5.3), focus policy con
Trigger / Content / Overlay / Title / Description / Close. Eidos trap/return/restore, parts Provider / Trigger / Content / Overlay /
añade Header / Footer visuales y dimensiones / position. Title / Description / Close. Eidos añade Header / Footer visuales y
dimensiones / position.
## Superficie ## Superficie
@ -91,9 +92,10 @@ Partes publicas: `Trigger`, `Portal`, `Overlay`, `Content`, `Title`,
(`trapFocus={true}` / `preventScroll={true}`). (`trapFocus={true}` / `preventScroll={true}`).
- `variant="alertdialog"` vive en Soma porque cambia comportamiento: role, - `variant="alertdialog"` vive en Soma porque cambia comportamiento: role,
escape/outside defaults y expectativa de accion explicita. escape/outside defaults y expectativa de accion explicita.
- Los eventos de cierre (`close-save`, `close-cancel`, `close-dismiss`, - El cierre con causa (`dismissWith('save' | 'cancel' | 'dismiss' |
`close-dismiss-outside`, `close-after-fail`) son ventaja propia de UIX: 'dismiss-outside' | 'fail')`, que concreta el `emerge-close` polimórfico y
permiten que Sema/Eidos distingan causa perceptiva sin inventar props visuales. sella `data-last-action`) es ventaja propia de UIX: permite que Sema/Eidos
distingan causa perceptiva sin inventar props visuales.
## Diferencias con Air ## 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, handle, snap points (`activeSnapPoint` controlled), drag-to-dismiss,
variantes `overlay/inline/persistent`, RTL-aware `start/end` variantes `overlay/inline/persistent`, RTL-aware `start/end`
direction, outcome-specific close events. direction, outcome-specific close events.
- **morfo**: declara `present` + 5 close variants (save/cancel/ - **morfo**: declara `emerge-open` + un `emerge-close` polimorfico
dismiss/dismiss-outside/after-fail), focus policy con (emerge.close / commit.save+fulfill / signal.alert+threat segun la
trap/return/restore, parts Provider/Trigger/Content/Overlay/Handle 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. + Header/Footer/Title/Description/Close.
## Superficie ## Superficie

@ -81,7 +81,7 @@ Soma owns:
When the menu opens, its items **fade in staggered by structural order** — the 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 first concrete consumer of the framework's *event-driven* cascade (vs the
explicit `<Cascade>` orchestrator). Nothing in the app wires it; the appearance 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]`. - `<DropdownMenu.Content>` marks its panel `[data-stagger]`.
- each item carries `data-animation-style="fade"`. - 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 - `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 side; the control's padding on that edge is zeroed so the switcher docks flush
against the border, nothing between. against the border, nothing between.
- Only three parts are FieldLangs' own (`provider`, `switcher`, `lang-option`); - Five parts are FieldLangs' own: `provider`, `switcher` and `lang-option`, plus
the composed `Field` and `Popover` surfaces keep their own morfo contracts. `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 ## Baseline

@ -128,9 +128,12 @@ Decision: si se necesita, se implementa en Soma/Form.
## Eventos Sema ## Eventos Sema
Field declara 0 eventos semanticos de forma deliberada. Es una envoltura de Field declara 1 evento semantico: `commit-submit` (familia `commit`, verbo
composicion para label, control, mensajes y estado de formulario; las acciones `submit`, intent `neutral`, `sequence: 'post'`), disparado por `Field.Input` al
del usuario pertenecen al control que vive dentro (`Input`, `SearchField`, 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. `NumberField`, `Select`, etc.) o al modulo `Form`, no al shell visual del campo.
## Gaps ## 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. | | 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. | | 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. | | 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 ## Morfo / Sema
`FileUpload` is interactive and mixed: some actions are contact-only `FileUpload` is interactive and mixed: some actions are contact-only
(`trigger-picker`), some are committed state changes and rejection is a (`contact-trigger-picker`), some are committed state changes and rejection
signal. is a signal.
| Event | Family | Verb | Intent | Target | Sequence | Soma trigger | | Event | Family | Verb | Intent | Target | Sequence | Soma trigger |
| ----- | ------ | ---- | ------ | ------ | -------- | ------------ | | ----- | ------ | ---- | ------ | ------ | -------- | ------------ |
| `trigger-picker` | `contact` | `trigger` | none | `trigger` | `coincident` | Trigger/Dropzone opens picker | | `contact-trigger-picker` | `contact` | `trigger` | none | `trigger` | `coincident` | Trigger/Dropzone opens picker |
| `commit-add` | `commit` | `set` | `affirm` | `provider` | `post` | Accepted files added | | `commit-set-add` | `commit` | `set` | `affirm` | `provider` | `post` | Accepted files added |
| `signal-reject` | `signal` | `warn` | `risk` | `provider` | `post` | File validation rejection | | `signal-warn-reject` | `signal` | `warn` | `risk` | `provider` | `post` | File validation rejection |
| `commit-remove` | `commit` | `remove` | `neutral` | `item` | `post` | ItemRemove | | `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`; 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. 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. reflexivamente desde el schema SIUM.
- **morfo**: declara `Provider` + actions (`Submit`, `Reset`, - **morfo**: declara `Provider` + actions (`Submit`, `Reset`,
`ErrorSummary`) + 11 parts de AutoFields para selectores de la receta. `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). `commit-reset` (neutral).
## API Shape ## API Shape
@ -74,7 +74,7 @@ invalid value, unless a demo explicitly documents an initially invalid state.
| Event | Family | Intent | Target | Decision | | Event | Family | Intent | Target | Decision |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `commit-submit` | `commit` | `fulfill` | provider | Valid submit confirms data. | | `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. | | `commit-reset` | `commit` | `neutral` | provider | Reset is a neutral commit to defaults. |
AutoFields structural parts are declared in `formMorfo` because their selectors 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 inmediatamente y mostraría errores antes de que el usuario edite, lo
cual es ruido perceptivo. Sólo demos que explícitamente quieran un cual es ruido perceptivo. Sólo demos que explícitamente quieran un
estado inválido inicial parten así. 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 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 los momentos donde el "todo" cambia (envío, fallo de validación
global, reset). global, reset).

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

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

@ -33,7 +33,7 @@ Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
## Sema events ## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`. - `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 ## 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 - **`shift-navigate-step` cubre toda la paginación** — un solo evento sema
para cualquier shift del header (PrevButton / NextButton / PageUp / PageDown). para cualquier shift del header (PrevButton / NextButton / PageUp / PageDown).
Family `shift.navigate` semánticamente correcto, variant `step` por paso 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 ## Gaps

@ -206,10 +206,14 @@
color: var(--calendar-day-color); color: var(--calendar-day-color);
} }
[data-month-grid][data-event], /* Event ring — only the cell is left since 2026-08-11. `shift-navigate-step`
[data-month-grid-cell][data-event], used to be redirected onto the pressed arrow (off-contract: the event
[data-month-grid-prev-button][data-event], declares no `allowedTargets`) and now targets `grid`, so the provider and the
[data-month-grid-next-button][data-event] { 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); 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), - **morfo** (`$uix/morfo/components/onion-menu`) — the contract: a Menu Button (APG),
`role="menu"` surface, `menuitem` sectors, the `focus` policy (non-modal: arrows `role="menu"` surface, `menuitem` sectors, the `focus` policy (non-modal: arrows
navigate, Tab / outside-click exit + close, focus returns to the trigger), the navigate, Tab / outside-click exit + close, focus returns to the trigger), the
keyboard map, and the `open` / `close` / `emerge-expand` / `emerge-collapse` keyboard map, and the `emerge-open` / `emerge-close` / `emerge-expand` /
(emerge) + `commit-select` (commit · affirm) events. `emerge-collapse` (emerge) + `commit-select` (commit · affirm) events.
`expression: 'family-default'` — no per-component sema pack. `expression: 'pack'` — the radial pack shipped 2026-07-07 (checkpoint S3b).
`emerge-expand` / `emerge-collapse` were added 2026-08-10: drilling into a `emerge-expand` / `emerge-collapse` were added 2026-08-10: drilling into a
submenu — the gesture that defines a radial menu — emitted NOTHING, because submenu — the gesture that defines a radial menu — emitted NOTHING, because
the surface only declared open/close for the whole menu and `commit-select` 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 for a leaf. Same family+verb as TreeView's node expansion (book cap. 26), on
the SECTOR that drilled; `item` already declared `states: ['expanded', the SECTOR that drilled; `item` already declared `states: ['expanded',
'collapsed']` in anticipation. Bottoming out of the drill is `close`, not a 'collapsed']` in anticipation. Bottoming out of the drill is `emerge-close`,
collapse. not a collapse.
- **soma** (`$soma/components/onion-menu`) — the headless behaviour bridge: - **soma** (`$soma/components/onion-menu`) — the headless behaviour bridge:
`createOnionMenuRuntime` emits the perceptual signals and dispatches the keyboard `createOnionMenuRuntime` emits the perceptual signals and dispatches the keyboard
contract; `onionNav*` are the pure radial focus maths. The root drives focus / drill contract; `onionNav*` are the pure radial focus maths. The root drives focus / drill
/ dismiss against them. / dismiss against them.
- **eidos** (here) — the SVG render + the two pure engines (geometry + colour) + the - **eidos** (here) — the SVG render + the two pure engines (geometry + colour) + the
recipe. The root owns `open` / `drill` state and paints. recipe. The root owns `open` / `drill` state and paints.
- **sema** — picks `open`/`close`/`emerge-expand`/`emerge-collapse` up from the - **sema** (`$uix/sema/components/onion-menu`) — the radial pack (S3b) holds
`emerge` family base and `commit-select` from `commit` (no pack; family cascade rules for `emerge-open` / `emerge-close` on the surface and adds a
defaults — the verb tier gives expand/collapse their own sound). `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 ### Pure engines

@ -271,7 +271,7 @@
function openMenu() { function openMenu() {
if (open) return; if (open) return;
open = true; open = true;
runtime.trigger('open', { targetOverride: asTarget(surfaceEl) }); runtime.trigger('emerge-open', { targetOverride: asTarget(surfaceEl) });
pendingFocus = onionNavFirst(currentRingNav()); pendingFocus = onionNavFirst(currentRingNav());
onTriggerClick?.(); onTriggerClick?.();
} }
@ -282,7 +282,7 @@
selected = null; selected = null;
focusedIndex = 0; focusedIndex = 0;
pendingFocus = null; pendingFocus = null;
runtime.trigger('close', { targetOverride: asTarget(surfaceEl) }); runtime.trigger('emerge-close', { targetOverride: asTarget(surfaceEl) });
if (restoreFocus) triggerEl?.focus(); 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-start` (**affirm** — el rango se abre, confirmación suave) →
`commit-select-range` (**fulfill** — el sellado del PAR es el momento de `commit-select-range` (**fulfill** — el sellado del PAR es el momento de
cierre) → `commit-reset` (neutral) + `shift-navigate` (paginación de mes). 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 - **Pack sema propio** (`expression: 'pack'`, ascenso S3b): la riqueza ya
declarada dejó de sonar a default genérico — el fulfill del rango completo declarada dejó de sonar a default genérico — el fulfill del rango completo
tiene carácter distinto del affirm del start. 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 | | 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]`. | | `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. | | `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. | | 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); 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-day][data-event],
[data-range-calendar-prev-button][data-event], [data-range-calendar-prev-button][data-event],
[data-range-calendar-next-button][data-event], [data-range-calendar-next-button][data-event],

@ -135,7 +135,7 @@ components**, not defects:
## Sema events ## Sema events
0 events on SplitButton's own morfo. It is interactive **by composition**: the 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 (DropdownMenu), and each item fires `commit-select` (DropdownMenu) — visible in
the demo trace strip. the demo trace strip.

@ -3,10 +3,10 @@
Two-or-more resizable panels separated by draggable handles. The Two-or-more resizable panels separated by draggable handles. The
headless Soma layer owns the resize math (panel sizes as headless Soma layer owns the resize math (panel sizes as
percentages, min / max clamping, collapsed state, keyboard arrow percentages, min / max clamping, collapsed state, keyboard arrow
steps); the morfo declares a single semantic event, steps); the morfo declares the three-event grip / drag / commit
`commit-resize`, fired on drag-end (mouse-up). Eidos paints chrome: shape — `handle-pick`, `handle-drag`, `commit-set`. Eidos paints
panels, the handle, its hover / drag affordance, and the center chrome: panels, the handle, its hover / drag affordance, and the
grip. center grip.
## Superficie ## Superficie
@ -53,11 +53,10 @@ Adjustments applied during the port:
- `--splitter-handle-bg-active` → `--color-primary-solid` - `--splitter-handle-bg-active` → `--color-primary-solid`
- `--splitter-handle-size` → `--space-2` - `--splitter-handle-size` → `--space-2`
- `--splitter-grip-bg` → `--color-content-muted` - `--splitter-grip-bg` → `--color-content-muted`
- Wire the `commit-resize` sema event in the recipe: when the - Wire the commit sema event in the recipe: when the hold stamps
morfo's signal hold stamps `data-event='commit-resize'` on the `data-event-family='commit'` on the provider, the handle paints
resize trigger, the handle paints in the active color until the in the active color until the hold expires. Air had no equivalent
hold expires. Air had no equivalent because air predates the sema because air predates the sema visual channel.
visual channel.
- Soma is the source of state. The eidos wrappers are pure - Soma is the source of state. The eidos wrappers are pure
pass-through; min / max / collapsible / collapsedSize / keyboard pass-through; min / max / collapsible / collapsedSize / keyboard
step all live on `<Splitter.Panel>` props (PanelProps) or 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 | | Capability | UIX (eidos) | Radix Themes Splitter | Ark UI Splitter | react-resizable-panels |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Compound API (Panel / ResizeTrigger) | Yes | Yes | Yes | Yes | | 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 | | `defaultSize` per panel (%) | Yes | Yes | Yes | Yes |
| `minSize` / `maxSize` per panel | Yes | Yes | Yes | Yes | | `minSize` / `maxSize` per panel | Yes | Yes | Yes | Yes |
| `collapsible` + `collapsedSize` | Yes | No | 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) | | 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`) | | `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 | | Keyboard arrow resize | Yes (configurable step via `keyboardStep`) | Yes | Yes | Yes |
| Home / End jump to min / max | Yes | Yes | Yes | No | | Home / End jump to min / max | Yes | Yes | Yes | No |
| Enter to toggle collapse | Yes | No | 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) | | Drag affordance (center grip) | Yes (eidos pseudo) | Yes | Yes | n/a (consumer styles) |
## Decisiones ## Decisiones
@ -88,21 +87,37 @@ Adjustments applied during the port:
sizing belongs to the morfo's `propRef('size')` mapping, the sizing belongs to the morfo's `propRef('size')` mapping, the
active drag belongs to `data-dragging`, keyboard belongs to the active drag belongs to `data-dragging`, keyboard belongs to the
morfo's keyboard table. morfo's keyboard table.
- **Single sema event: `commit-resize`.** Live drag (every pixel - **Three sema events, mirroring Slider.** The splitter is a
movement) is NOT a sema event — it would saturate the perceptual direct-manipulation primitive — grab, drag, release — so the
channels with high-frequency noise. Only the drag commit (mouse perceptual rendering follows the same grip / drag / commit shape
up, key release after arrow resize) fires the sema verb. This Slider ships. Live drag IS a sema event (`handle-drag`), and it
matches the `feedback_per_event_intent_intrinsic` principle: does not saturate the channels because
per-event intent reflects the act's own evaluative load — a `SplitterResizeTriggerProvider` throttles it to one signal per
resize commit is `commit.set` with `neutral` intent (the user rAF with a ~72 ms floor (~14 Hz); the gesture then sounds by
set a new value; the result is geometric, neither affirm nor REPETITION — the speed of the drag is the speed of the ratchet.
threat). Only the release carries an intent, and it is `neutral` per
- **Eidos pulses the handle on `commit-resize`.** The recipe `feedback_per_event_intent_intrinsic`: the user set a new value
selector and the result is geometric, neither affirm nor threat.
`[data-splitter-resize-trigger][data-event='commit-resize']` - **The gesture is stamped where the hand is, the terminal where
paints the handle in the active color during the signal hold, the value lives.** `handle-pick` and `handle-drag` target
giving the user visual confirmation that the drag was committed. `resize-trigger` — the element under the pointer. `commit-set`
The pulse fades automatically when the hold expires. 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 - **`Indicator` is not implemented as a separate part.** The
morfo lists `Provider` / `Panel` / `ResizeTrigger`; the brief morfo lists `Provider` / `Panel` / `ResizeTrigger`; the brief
mentioned an optional `Indicator` part for the grip / handle mentioned an optional `Indicator` part for the grip / handle
@ -115,13 +130,35 @@ Adjustments applied during the port:
| Name | Family | Verb | Sequence | Intent | Target | | Name | Family | Verb | Sequence | Intent | Target |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `commit-resize` | `commit` | `set` | `post` | `neutral` | `resize-trigger` | | `handle-pick` | `handle` | `pick` | `coincident` | — | `resize-trigger` |
| `handle-drag` | `handle` | `drag` | `coincident` | — | `resize-trigger` |
The event fires on drag-end (mouse-up) and on keyboard arrow-key | `commit-set` | `commit` | `set` | `post` | `neutral` | **`provider`** |
release. The signal hold is short (`brief` band from
`SEMA_DURATIONS`) so the visual pulse on the handle disappears When each one fires:
quickly. Sound and haptic channels project a low-intensity feedback
suitable for a layout adjustment. - `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 ## Gaps

@ -1,10 +1,10 @@
/* /*
* Splitter recipe — paints two-or-more resizable panels separated by * Splitter recipe — paints two-or-more resizable panels separated by
* draggable handles. Soma owns the resize math (panel sizes, min / * draggable handles. Soma owns the resize math (panel sizes, min /
* max clamping, collapsed state, keyboard arrow steps) and the * max clamping, collapsed state, keyboard arrow steps) and fires the
* `commit-resize` sema event; the recipe consumes the morfo's * three sema events (`handle-pick`, `handle-drag`, `commit-set`); the
* data-attrs to draw orientation-aware geometry plus hover / drag * recipe consumes the morfo's data-attrs to draw orientation-aware
* affordances on the resize trigger. * geometry plus hover / drag affordances on the resize trigger.
* *
* [data-splitter] → flex container (row / column) * [data-splitter] → flex container (row / column)
* [data-splitter-panel] → individual panel (flex item) * [data-splitter-panel] → individual panel (flex item)
@ -107,11 +107,35 @@
block-size: var(--splitter-grip-cross, 2px); block-size: var(--splitter-grip-cross, 2px);
} }
/* Sema event hook — when commit-resize fires, the eidos events layer /* Sema event hook — on release the morfo fires `commit-set` (commit
stamps `data-event="commit-resize"` for `signal hold` duration. family), whose target is the PROVIDER: what gets committed are the
Pulse the handle so the user gets visual confirmation that the layout sizes, and those live on the container, not on the handle
drag was committed. */ 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)); 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 ## Ownership
- Morfo declares live-region parts, intent-driven roles, swipe attrs and - 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 - Soma owns the toaster store, timers, pause/resume, hotkey focus, swipe
gesture, promise lifecycle and translated close/viewport labels. gesture, promise lifecycle and translated close/viewport labels.
- Eidos owns status glyphs, placement/gap styling, `data-icon-only` close - Eidos owns status glyphs, placement/gap styling, `data-icon-only` close

@ -62,7 +62,7 @@
</Main> </Main>
<!-- ToastCloseProvider's onclick already routes through <!-- 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 toast leaves. Passing our own onclick here would race
ahead via mergeProps' handler composition. --> ahead via mergeProps' handler composition. -->
<Close /> <Close />

@ -45,9 +45,9 @@ Fuentes externas consultadas:
## Eventos Sema ## Eventos Sema
El morfo declara tres eventos `emerge` minimos para que Sema pueda emitir El morfo declara tres eventos `emerge` minimos para que Sema pueda emitir
percepcion en hover/focus reveal: `present`, `dismiss` y `dismiss-escape` percepcion en hover/focus reveal: `emerge-present`, `emerge-dismiss` y
(Escape) — verbos del libro cap. 26 §8/§9: el tooltip se OFRECE (present), `emerge-dismiss-escape` (Escape) — verbos del libro cap. 26 §8/§9: el tooltip
no se invoca como lugar (open). se OFRECE (present), no se invoca como lugar (open).
Ninguno carga intent — la aparicion de un tooltip no tiene peso evaluativo Ninguno carga intent — la aparicion de un tooltip no tiene peso evaluativo
propio. Si una experiencia necesita reforzar percepcion al mostrar ayuda propio. Si una experiencia necesita reforzar percepcion al mostrar ayuda
contextual con un tinte concreto, esa politica debe vivir en el componente 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 ## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`. - `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 ## 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. natural) o 20 (dos décadas). UIX permite ambos sin nuevo componente.
- **Mismo modelo de eventos que MonthGrid** — `commit-set` y - **Mismo modelo de eventos que MonthGrid** — `commit-set` y
`shift-navigate-step`, para que la pareja month-grid/year-grid sea `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 - **`select` único evento mutante; resto son focus moves** — el audit
script reconoce `next-year`/`prev-year`/`next-row`/`prev-row`/ script reconoce `next-year`/`prev-year`/`next-row`/`prev-row`/
`first-year`/`last-year`/`next-page`/`prev-page` como focus moves `first-year`/`last-year`/`next-page`/`prev-page` como focus moves

@ -206,10 +206,12 @@
color: var(--calendar-day-color); color: var(--calendar-day-color);
} }
[data-year-grid][data-event], /* Event ring — only the cell is left since 2026-08-11, same prune as
[data-year-grid-cell][data-event], month-grid.css: `shift-navigate-step` now targets `grid`, and these arrows
[data-year-grid-prev-button][data-event], are native soma buttons with no `contact-activate` of their own, so neither
[data-year-grid-next-button][data-event] { 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); box-shadow: var(--calendar-event-shadow);
} }

@ -5720,6 +5720,14 @@
--motion-origin-inline-start: 100%; --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 { @property --motion-stagger-index {
syntax: '<integer>'; syntax: '<integer>';
inherits: false; 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 { @keyframes block-shake {
0%, 100% { 0%, 100% {
transform: translateX(0); transform: translateX(0);
@ -6490,41 +6516,41 @@
} }
} }
[data-event^='present'][data-event-phase='active'], [data-event^='emerge-present'][data-event-phase='active'],
[data-event^='open'][data-event-phase='active'] { [data-event^='emerge-open'][data-event-phase='active'] {
animation: present-rise var(--duration-slow) var(--ease-out); animation: present-rise var(--duration-slow) var(--ease-out);
} }
[data-event^='dismiss'][data-event-phase='active'], [data-event^='emerge-dismiss'][data-event-phase='active'],
[data-event^='close'][data-event-phase='active'] { [data-event^='emerge-close'][data-event-phase='active'] {
animation: dismiss-fade var(--duration-slow) var(--ease-default) forwards; 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); 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); 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); 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); 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); 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); 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; animation: collapse-conceal var(--duration-fast) var(--ease-default) forwards;
} }
@ -6532,6 +6558,14 @@
animation: press-squeeze var(--duration-fast) var(--ease-default); 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'] { [data-event-family='commit'][data-event-phase='active'] {
animation: commit-settle var(--duration-moderate) var(--ease-out); 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 // 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 ?? {})) { for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
const base = `motion.signatures.${name}` const base = `motion.signatures.${name}`
if (!signature.family && !signature.intent && !signature.event) { 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' } '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 // Commit denied — the lateral deny shake (the login-form idiom): the element
// refuses sideways and settles. Distance is a design literal (cf. // 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 // 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. * and a design lint fails any transitory signature declared above that cap.
*/ */
export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = { 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: { dismiss: {
event: ['dismiss', 'close'], event: ['emerge-dismiss', 'emerge-close'],
keyframes: 'dismiss-fade', keyframes: 'dismiss-fade',
duration: 'slow', duration: 'slow',
ease: 'default', ease: 'default',
@ -308,14 +334,14 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
// Neutral/affirm: "breve o contextual, intensidad baja" (TABLA 32.2) — // Neutral/affirm: "breve o contextual, intensidad baja" (TABLA 32.2) —
// 600 was too much for the region (user verdict 2026-07-06). // 600 was too much for the region (user verdict 2026-07-06).
'announce-neutral': { 'announce-neutral': {
event: 'announce', event: 'signal-announce',
intent: 'neutral', intent: 'neutral',
keyframes: 'announce-pulse-neutral', keyframes: 'announce-pulse-neutral',
duration: 'moderate', duration: 'moderate',
ease: 'default' ease: 'default'
}, },
'announce-affirm': { 'announce-affirm': {
event: 'announce', event: 'signal-announce',
intent: 'affirm', intent: 'affirm',
keyframes: 'announce-pulse-affirm', keyframes: 'announce-pulse-affirm',
duration: 'moderate', duration: 'moderate',
@ -323,21 +349,21 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
}, },
// Fulfill: "breve-media, más resolutivo" — the 400 step (slower). // Fulfill: "breve-media, más resolutivo" — the 400 step (slower).
'announce-fulfill': { 'announce-fulfill': {
event: 'announce', event: 'signal-announce',
intent: 'fulfill', intent: 'fulfill',
keyframes: 'announce-pulse-fulfill', keyframes: 'announce-pulse-fulfill',
duration: 'slower', duration: 'slower',
ease: 'default' ease: 'default'
}, },
'announce-risk': { 'announce-risk': {
event: 'announce', event: 'signal-announce',
intent: 'risk', intent: 'risk',
keyframes: 'announce-pulse-risk', keyframes: 'announce-pulse-risk',
duration: 'emphatic', duration: 'emphatic',
ease: 'spring' ease: 'spring'
}, },
'announce-threat': { 'announce-threat': {
event: 'announce', event: 'signal-announce',
intent: 'threat', intent: 'threat',
keyframes: 'announce-pulse-threat', keyframes: 'announce-pulse-threat',
duration: 'sustained', duration: 'sustained',
@ -347,9 +373,9 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
// Disclosure (emerge.expand / emerge.collapse): the reveal runs AFTER the // Disclosure (emerge.expand / emerge.collapse): the reveal runs AFTER the
// state opens (sequence post); the conceal runs BEFORE it closes (pre) and // state opens (sequence post); the conceal runs BEFORE it closes (pre) and
// holds its end state (`forwards`) until soma flips `hidden`. // 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: { collapse: {
event: 'collapse', event: 'emerge-collapse',
keyframes: 'collapse-conceal', keyframes: 'collapse-conceal',
duration: 'fast', duration: 'fast',
ease: 'default', ease: 'default',
@ -358,6 +384,33 @@ export const BUILTIN_SIGNATURES: Readonly<Record<string, EventSignature>> = {
press: { family: 'contact', keyframes: 'press-squeeze', duration: 'fast', ease: 'default' }, 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: { family: 'commit', keyframes: 'commit-settle', duration: 'moderate', ease: 'out' },
'commit-fulfill': { 'commit-fulfill': {
family: 'commit', 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(ltr) {\n\t--motion-origin-inline-start: 0%;\n}');
blocks.push('[data-animation-style]:dir(rtl) {\n\t--motion-origin-inline-start: 100%;\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 // Stagger index is per-item — `inherits: false` stops it leaking into nested
// groups; the rhythm (`--motion-stagger-each`) inherits from the container. // groups; the rhythm (`--motion-stagger-each`) inherits from the container.
blocks.push( blocks.push(
@ -1183,6 +1198,7 @@ function signatureSelectorList(sig: EventSignature): string[] {
const suffix = const suffix =
`${sig.family ? `[data-event-family='${sig.family}']` : ''}` + `${sig.family ? `[data-event-family='${sig.family}']` : ''}` +
`${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` + `${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` +
`${sig.direction ? `[data-event-direction='${sig.direction}']` : ''}` +
`[data-event-phase='active']`; `[data-event-phase='active']`;
const events = const events =
sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event; sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event;

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

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

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

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

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

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

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

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

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

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

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

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

@ -33,7 +33,13 @@ export const monthGridMorfo = {
semantic: { semantic: {
family: 'shift', family: 'shift',
verb: 'navigate', 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' sequence: 'post'
} }
} }

@ -37,12 +37,12 @@ export const onionMenuMorfo = {
events: [ events: [
{ {
name: 'open', name: 'emerge-open',
semantic: { family: 'emerge', verb: 'open', target: v.partRef('surface'), sequence: 'pre' }, semantic: { family: 'emerge', verb: 'open', target: v.partRef('surface'), sequence: 'pre' },
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'open' } 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' }, semantic: { family: 'emerge', verb: 'close', target: v.partRef('surface'), sequence: 'pre' },
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'closed' } 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" // Verb per book cap. 26 §5/§6: a popover opens "con marco propio"
// — emerge.open, anchored appearance (was `present`; C2 verbs pass, // — emerge.open, anchored appearance (was `present`; C2 verbs pass,
// 2026-07-07: present/dismiss belongs to OFFERED surfaces like toast). // 2026-07-07: present/dismiss belongs to OFFERED surfaces like toast).
name: 'open', name: 'emerge-open',
semantic: { semantic: {
family: 'emerge', family: 'emerge',
verb: 'open', verb: 'open',
@ -46,7 +46,7 @@ export const popoverMorfo = {
// PopoverProvider concretes the cause via // PopoverProvider concretes the cause via
// `dismissWith(action, opts?)`: // `dismissWith(action, opts?)`:
// - sets `data-last-action` imperatively on Content; // - sets `data-last-action` imperatively on Content;
// - passes `opts.semantic` to `runtime.trigger('close', ...)`. // - passes `opts.semantic` to `runtime.trigger('emerge-close', ...)`.
// //
// Allowed concretions: // Allowed concretions:
// - `emerge.close` — cancel / dismiss / dismiss-outside // - `emerge.close` — cancel / dismiss / dismiss-outside
@ -55,7 +55,7 @@ export const popoverMorfo = {
// //
// Persistence: `transient`. The popover content unmounts; any // Persistence: `transient`. The popover content unmounts; any
// long-lived feedback belongs in a Toast / Announce. // long-lived feedback belongs in a Toast / Announce.
name: 'close', name: 'emerge-close',
semantic: { semantic: {
family: 'emerge', family: 'emerge',
verb: 'close', verb: 'close',

@ -51,7 +51,15 @@ export const rangeCalendarMorfo = {
semantic: { semantic: {
family: 'shift', family: 'shift',
verb: 'navigate', 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' sequence: 'post'
} }
} }
@ -161,7 +169,16 @@ export const rangeCalendarMorfo = {
defaultElement: 'select', defaultElement: 'select',
optional: true, optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }], 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', name: 'YearSelect',
@ -171,7 +188,13 @@ export const rangeCalendarMorfo = {
defaultElement: 'select', defaultElement: 'select',
optional: true, optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }], data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [] aria: [
{
attr: 'aria-label',
value: v.commonRef('calendar.year-select', 'Select year'),
severity: 'recommended'
}
]
}, },
{ {
name: 'Grid', name: 'Grid',

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

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

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

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

@ -32,7 +32,13 @@ export const yearGridMorfo = {
semantic: { semantic: {
family: 'shift', family: 'shift',
verb: 'navigate', 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' sequence: 'post'
} }
} }

@ -37,7 +37,9 @@ function withEvent(semanticExtra: Record<string, unknown>) {
scope: ['sema'], scope: ['sema'],
events: [ 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: { semantic: {
target: { kind: 'partRef', target: 'provider' }, target: { kind: 'partRef', target: 'provider' },
...semanticExtra ...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', () => { describe('validateMorfo — data attrs', () => {
it('accepts public lowercase data attrs', () => { it('accepts public lowercase data attrs', () => {
expect(() => validateMorfo(withDataAttr('data-state'))).not.toThrow(); expect(() => validateMorfo(withDataAttr('data-state'))).not.toThrow();
@ -217,7 +291,7 @@ describe('validateMorfo — targetFallback invariants', () => {
parts: [...baseMorfo.parts, triggerPart], parts: [...baseMorfo.parts, triggerPart],
events: [ events: [
{ {
name: 'evt', name: 'emerge-close',
semantic: { semantic: {
family: 'emerge', family: 'emerge',
verb: 'close', verb: 'close',

@ -642,6 +642,19 @@ function validateInvariants(morfo: Morfo): void {
} }
eventNames.add(event.name); 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)) { if (!kebabs.has(event.semantic.target.target)) {
throw new MorfoInvariantError( throw new MorfoInvariantError(
`event "${event.name}" targets unknown part "${event.semantic.target.target}"` `event "${event.name}" targets unknown part "${event.semantic.target.target}"`

@ -171,6 +171,30 @@ export const prewriteSplitFixtureMorfo = {
sequence: 'pre' sequence: 'pre'
}, },
prewrite: [{ part: v.partRef('header'), attr: 'data-last-action', value: 'cancelled' }] 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: [ parts: [

@ -385,7 +385,9 @@ export function isPolymorphicSemantic(
* of alternative families on top of its canonical declaration: * of alternative families on top of its canonical declaration:
* *
* events: [{ * 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: { * semantic: {
* family: 'shift', // default family * family: 'shift', // default family
* verb: 'exit-mode', * verb: 'exit-mode',

@ -10,12 +10,13 @@ import type { Sema } from '../sema-map';
* panel reveals" / "this panel collapses" without competing with the * panel reveals" / "this panel collapses" without competing with the
* surrounding content. * surrounding content.
* *
* - `open` (emerge.open) — soft chime, low gain (~0.08). Same gain * - `emerge-expand` (emerge.expand) — soft chime, low gain (~0.08).
* bracket as Toast `present`, since neither demands attention. * Same gain bracket as Toast `emerge-present`, since neither demands
* - `close` (emerge.close) — descending contour + lower pitch + even * attention.
* lower gain. Mirrors the close-direction discipline of Dialog, * - `emerge-collapse` (emerge.collapse) — descending contour + lower
* Drawer, Popover and Toast — every dismissal in the system goes * pitch + even lower gain. Mirrors the close-direction discipline
* descending so the auditory map is consistent. * 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, * No haptic: tapping a header to open an accordion is a deliberate,
* already-acknowledged gesture. A tactile pulse on top would be noise. * already-acknowledged gesture. A tactile pulse on top would be noise.
@ -28,10 +29,10 @@ export const accordionSema: Sema = {
name: 'accordion', name: 'accordion',
cascade: [ 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` * Almost nothing, and that is the point. `commit-select` / `commit-unselect`
* take the commit family's `tick` (and its intent variants, by name); * take the commit family's `tick` (and its intent variants, by name);
* `shift-navigate` takes the shift family's `slide` on whichever surface caused * `shift-navigate` takes the shift family's `slide` on the `grid` — the surface
* it — arrow, select, or a day that spilled the month. None of that is written * that actually navigated. It used to be redirected onto whichever control
* here any more: the map's verb table says it once for the whole framework. * 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 * 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 * `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). * the surface is grouped (accordion) or standalone (collapsible).
* *
* Per `src/uix/sema/components/accordion.ts`: * Per `src/uix/sema/components/accordion.ts`:
* - `expand` (emerge.open / verb=expand in our case): `emerge.soft` * - `emerge-expand` (emerge.expand): `emerge.soft` — soft chime at
* — soft chime at gain 0.08. The family base ascending contour * gain 0.08. The family base ascending contour reads as "panel
* reads as "panel reveals". * reveals".
* - `collapse` (emerge.close / verb=collapse): `emerge.exit` at * - `emerge-collapse` (emerge.collapse): `emerge.exit` at gain 0.06
* gain 0.06 — descending pitch, same direction discipline as the * — descending pitch, same direction discipline as the rest of the
* rest of the system (dialog, drawer, popover, menus). * system (dialog, drawer, popover, menus).
* *
* No haptic — disclosure is a deliberate, already-acknowledged gesture * No haptic — disclosure is a deliberate, already-acknowledged gesture
* on a trigger button; tactile feedback on top would be noise. * on a trigger button; tactile feedback on top would be noise.
@ -29,10 +29,10 @@ export const collapsibleSema: Sema = {
name: 'collapsible', name: 'collapsible',
cascade: [ 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 perceptual defaults — SOFT EMERGE + COMMIT.
* *
* ContextMenu shares the event surface of DropdownMenu (open / close / * ContextMenu shares the event surface of DropdownMenu (emerge-open /
* commit-select), but the gesture is different: triggered by right-click * emerge-close / emerge-open-sub / emerge-close-sub / commit-select), but
* or long-press on arbitrary content rather than by a dedicated trigger * the gesture is different: triggered by right-click or long-press on
* button. Frequency is bursty (one menu per investigative gesture) vs * arbitrary content rather than by a dedicated trigger button. Frequency
* DropdownMenu's steady traversal, but the perceptual signature stays * is bursty (one menu per investigative gesture) vs DropdownMenu's steady
* identical for system coherence — a user shouldn't have to learn two * traversal, but the perceptual signature stays identical for system
* different "menu sounds". * coherence — a user shouldn't have to learn two different "menu sounds".
* *
* Strategy mirrors DropdownMenu — see `dropdown-menu.ts` for the * Strategy mirrors DropdownMenu — see `dropdown-menu.ts` for the
* doctrinal rationale. * doctrinal rationale.
@ -26,10 +26,10 @@ export const contextMenuSema: Sema = {
name: 'context-menu', name: 'context-menu',
cascade: [ cascade: [
{ {
selector: onContent({ eventName: 'open' }) selector: onContent({ eventName: 'emerge-open' })
}, },
{ {
selector: onContent({ eventName: 'close' }) selector: onContent({ eventName: 'emerge-close' })
}, },
{ {
selector: onItem({ eventName: 'commit-select' }), selector: onItem({ eventName: 'commit-select' }),

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

@ -27,16 +27,16 @@ const onContent = (matchers?: Parameters<typeof semaSelector<typeof drawerMorfo>
export const drawerSema: Sema = { export const drawerSema: Sema = {
name: 'drawer', name: 'drawer',
cascade: [ cascade: [
// ─── drag-start: grab acknowledgement ────────────────────────────── // ─── handle-pick: grab acknowledgement ─────────────────────────────
// High, breathy pickup cue. It should read as a soft whistle, // High, breathy pickup cue. It should read as a soft whistle,
// not as a click or alert. // not as a click or alert.
{ {
selector: onContent({ eventName: 'drag-start' }), selector: onContent({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'], channels: ['sound', 'haptic'],
sound: 'air' 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 // The cascade only activates the `sound` channel here. The actual
// `pitch / gain / centroid / duration / contour` come from per-emit // `pitch / gain / centroid / duration / contour` come from per-emit
// `signal.overrides` computed by soma's drawer-provider against the // `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: {…}` // run AFTER signal.overrides in the layer order — adding `sound: {…}`
// here would clobber the dynamic values. // here would clobber the dynamic values.
{ {
selector: onContent({ eventName: 'drag-progress' }), selector: onContent({ eventName: 'handle-drag-progress' }),
channels: ['sound', 'haptic'] 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'], channels: ['sound', 'haptic'],
sound: 'settle', sound: 'settle',
haptic: { kind: 'tap', intensity: 0.45, duration: 18 } 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 // A slightly brighter air-chime that disambiguates "I committed to a
// new size" from "I just released without snapping". // new size" from "I just released without snapping".
{ {
selector: onContent({ eventName: 'resize' }), selector: onContent({ eventName: 'handle-resize' }),
channels: ['sound', 'haptic'], channels: ['sound', 'haptic'],
sound: 'snap', sound: 'snap',
haptic: { kind: 'tap', intensity: 0.55, duration: 24 } 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 // Polymorphic close (book §5.3) — provider concretes the family
// at trigger time via opts.semantic. Matching by family captures // at trigger time via opts.semantic. Matching by family captures
// the "user backed out" path (emerge.close / emerge.dismiss) and // 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 // La parte VISUAL del close (easing, anim, backdrop) vive en
// `eidos/components/drawer/drawer.css`. // `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 perceptual defaults — SOFT EMERGE + COMMIT.
* *
* DropdownMenu has three event surfaces: * DropdownMenu has five event surfaces:
* - `open` (emerge.open on `content`) — menu reveals * - `emerge-open` (emerge.open on `content`) — menu reveals
* - `close` (emerge.close on `content`) — menu retracts * - `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 * - `commit-select` (commit.select on `item`, affirm) — user picks a
* menu item * menu item
* *
@ -40,10 +42,10 @@ export const dropdownMenuSema: Sema = {
name: 'dropdown-menu', name: 'dropdown-menu',
cascade: [ cascade: [
{ {
selector: onContent({ eventName: 'open' }) selector: onContent({ eventName: 'emerge-open' })
}, },
{ {
selector: onContent({ eventName: 'close' }) selector: onContent({ eventName: 'emerge-close' })
}, },
{ {
selector: onItem({ eventName: 'commit-select' }), selector: onItem({ eventName: 'commit-select' }),

@ -15,7 +15,7 @@ export const fileUploadSema: Sema = {
name: 'file-upload', name: 'file-upload',
cascade: [ cascade: [
{ {
selector: onTrigger({ eventName: 'trigger-picker' }), selector: onTrigger({ eventName: 'contact-trigger-picker' }),
haptic: { kind: 'tick' } 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 * 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 * 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 * 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]) => const onContent = (matchers?: Parameters<typeof semaSelector<typeof floatPanelMorfo>>[2]) =>
@ -18,29 +19,29 @@ export const floatPanelSema: Sema = {
name: 'float-panel', name: 'float-panel',
cascade: [ cascade: [
{ {
selector: onContent({ eventName: 'drag-start' }), selector: onContent({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'], channels: ['sound', 'haptic'],
sound: 'air' sound: 'air'
}, },
{ {
selector: onContent({ eventName: 'drag-end' }), selector: onContent({ eventName: 'handle-drop' }),
channels: ['sound', 'haptic'], channels: ['sound', 'haptic'],
sound: 'settle', sound: 'settle',
haptic: { kind: 'tap', intensity: 0.4, duration: 16 } haptic: { kind: 'tap', intensity: 0.4, duration: 16 }
}, },
{ {
selector: onContent({ eventName: 'resize-start' }), selector: onContent({ eventName: 'handle-resize-start' }),
channels: ['haptic'], channels: ['haptic'],
haptic: { kind: 'tick', intensity: 0.3, duration: 8 } haptic: { kind: 'tick', intensity: 0.3, duration: 8 }
}, },
{ {
selector: onContent({ eventName: 'resize-end' }), selector: onContent({ eventName: 'handle-resize-end' }),
channels: ['sound', 'haptic'], channels: ['sound', 'haptic'],
sound: 'snap', sound: 'snap',
haptic: { kind: 'tap', intensity: 0.5, duration: 20 } 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', name: 'menu-dial',
cascade: [ cascade: [
{ {
selector: onList({ eventName: 'open' }) selector: onList({ eventName: 'emerge-open' })
}, },
{ {
selector: onList({ eventName: 'close' }) selector: onList({ eventName: 'emerge-close' })
}, },
{ {
selector: onAction({ eventName: 'commit-select' }), selector: onAction({ eventName: 'commit-select' }),

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

@ -51,7 +51,7 @@ export const popoverSema: Sema = {
// as Dialog/Drawer. Scoped a `close` polymorphic + family emerge — // as Dialog/Drawer. Scoped a `close` polymorphic + family emerge —
// commit.save y signal.alert mantienen sus propias firmas. // 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 ────────────── // ─── close + dismiss-outside cause: even more subtle ──────────────
@ -61,7 +61,7 @@ export const popoverSema: Sema = {
// nombre de evento. // nombre de evento.
{ {
selector: onContent({ selector: onContent({
eventName: 'close', eventName: 'emerge-close',
state: { attr: 'data-last-action', value: 'dismissed-outside' } 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) * than slider's 0.12 — splitters get grabbed more often)
* - `handle-drag` → channel-only rule. Per-emit `signal.overrides` * - `handle-drag` → channel-only rule. Per-emit `signal.overrides`
* are computed by soma (`SplitterResizeTriggerProvider`) via * are computed by soma (`SplitterResizeTriggerProvider`) via
* `resolveSplitterDragSound({ value01, velocity01, direction })` * `resolveSplitterDragSound({ value01, velocity01, contour })`
* so the dynamic curve survives the cascade. * so the dynamic curve survives the cascade.
* - `commit-set` → `handle.snap.chime` + tap haptic for the * - `commit-set` → `handle.snap.chime` + tap haptic for the
* "I committed to this layout" landing cue. * "I committed to this layout" landing cue.

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

@ -10,15 +10,15 @@ import type { Sema } from '../sema-map';
* `playExit`. Sema's standard channel pipeline reproduces that * `playExit`. Sema's standard channel pipeline reproduces that
* affordance and adds per-intent shaping that Air didn't have: * affordance and adds per-intent shaping that Air didn't have:
* *
* - `present` (emerge.present) — soft "appeared" chime; no haptic. * - `emerge-present` (emerge.present) — soft "appeared" chime; no
* Toast appearance is informational, not alarming. * haptic. Toast appearance is informational, not alarming.
* - `announce` (signal.announce, intent fromProp) — coincident with * - `signal-announce` (signal.announce, intent fromProp) — coincident with
* signature: `threat`/`risk` get the alert profile (assertive, * signature: `threat`/`risk` get the alert profile (assertive,
* louder, haptic alert), `fulfill`/`affirm` get a soft positive * louder, haptic alert), `fulfill`/`affirm` get a soft positive
* chime, `loss` runs grave (low pitch). Cascade rules below add * chime, `loss` runs grave (low pitch). Cascade rules below add
* character (haptic kind, sound character) WITHOUT overriding the * character (haptic kind, sound character) WITHOUT overriding the
* them collapses the per-intent perceptual difference. * 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. * Mirrors dialog/drawer/popover close direction discipline.
* *
* Sound is chosen, never authored: the family's verb table names it and the * 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 // Toast appearance is ambient. Lower gain than dialog/drawer because
// the user didn't request it; we shouldn't compete with whatever // the user didn't request it; we shouldn't compete with whatever
// they were doing. No haptic on present — only the alert intents // 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 — 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: // sound comes from the intent, which selects a whole entry. This rule:
// - threat/risk: tactile alert so the user notices when they're // - threat/risk: tactile alert so the user notices when they're
// not looking. // not looking.
@ -71,11 +71,11 @@ export const toastSema: Sema = {
}, },
// ─── Dismiss — direction discipline (descending) ────────────────── // ─── 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 // (toast leaving the surface) and drops pitch. Gain stays low
// because dismissal is even more passive than appearance. // 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. * - Silence is a valid canonical signature.
* *
* Strategy: * Strategy:
* - `present` / `dismiss` / `dismiss-escape` (all emerge family on content): * - `emerge-present` / `emerge-dismiss` / `emerge-dismiss-escape` (all
* all declared `SILENT`. The resolver drops the sound channel, so nothing * emerge family on content): all declared `SILENT`. The resolver drops
* reaches the engine — a tooltip reveals on hover and would puff on every * the sound channel, so nothing reaches the engine — a tooltip reveals
* mouse cross. The silence is ABSOLUTE: no intent lifts it, because there * on hover and would puff on every mouse cross. The silence is
* is no gain left to raise. (Until 2026-08-06 this was a tuning that * ABSOLUTE: no intent lifts it, because there is no gain left to raise.
* subtracted the family gain and let `threat` / `fulfill` through; that * (Until 2026-08-06 this was a tuning that subtracted the family gain
* arithmetic was measurably not silence — see the naming law in * and let `threat` / `fulfill` through; that arithmetic was measurably
* `docs/architecture/sema.md`.) * not silence — see the naming law in `docs/architecture/sema.md`.)
* - NO haptic. Hover is not a tactile gesture; tactile feedback on * - NO haptic. Hover is not a tactile gesture; tactile feedback on
* hover would be perceptually wrong. * hover would be perceptually wrong.
* - NO pack on `Trigger` — the trigger does not emit semantic * - 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-phase')).toBe(false);
expect(target.hasAttribute('data-event-family')).toBe(false); expect(target.hasAttribute('data-event-family')).toBe(false);
expect(target.hasAttribute('data-event-intent')).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 () => { it('the cascade sees data-event-* tokens during resolution', async () => {

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

@ -123,6 +123,16 @@ interface ParsedSelector {
readonly eventNamePrefix: string | undefined; readonly eventNamePrefix: string | undefined;
readonly eventFamily: SemaFamily | undefined; readonly eventFamily: SemaFamily | undefined;
readonly eventIntent: string | 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 { function parseSelector(selector: string): ParsedSelector {
@ -133,6 +143,7 @@ function parseSelector(selector: string): ParsedSelector {
let eventNamePrefix: string | undefined; let eventNamePrefix: string | undefined;
let eventFamily: SemaFamily | undefined; let eventFamily: SemaFamily | undefined;
let eventIntent: string | undefined; let eventIntent: string | undefined;
const unmodelledEventAttrs: string[] = [];
for (const match of compound.matchAll(SEGMENT_RE)) { for (const match of compound.matchAll(SEGMENT_RE)) {
const [, attr, operator, value] = match; const [, attr, operator, value] = match;
@ -147,10 +158,21 @@ function parseSelector(selector: string): ParsedSelector {
eventFamily = value as SemaFamily; eventFamily = value as SemaFamily;
} else if (attr === 'data-event-intent') { } else if (attr === 'data-event-intent') {
eventIntent = value; 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( 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): // 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 // tooltip declares three events NOBODY emits, so its SILENT rule never
// matches and no data-event-* ever stamps (the eidos motion preset that // 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. // declared design. Either the provider emits, or the events retire.
'tooltip:present': 'dead declared contract — pending: emit, or retire the events', 'tooltip:emerge-present': 'dead declared contract — pending: emit, or retire the events',
'tooltip:dismiss': 'dead declared contract — pending: emit, or retire the events', 'tooltip:emerge-dismiss': 'dead declared contract — pending: emit, or retire the events',
'tooltip:dismiss-escape': '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 * 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 * while every real call lives in its eidos wrapper — a raw substring search
* passed it for the wrong reason. * 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([]); 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', () => { it('selects a part the morfo declares as a stamp target', () => {
const offenders: string[] = []; const offenders: string[] = [];
for (const { id, rule, parsed, pack } of RULES) { 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( expect(
offenders, offenders,
'the runtime stamps on the declared target (or a declared `allowedTargets` part); a rule ' + 'the runtime stamps on the declared target, a declared `allowedTargets` part (caller ' +
'aimed anywhere else never matches. Fix the selector, or declare the redirection in the morfo' '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([]); ).toEqual([]);
}); });

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

Loading…
Cancel
Save

Powered by TurnKey Linux.