fix(sema,morfo,soma): una superficie no admite dos ocurrencias, y el nombre ya no miente

`data-event-*` es UNA RANURA por elemento. Tres auditorias independientes
encontraron el mismo defecto y ninguna lo cerro: fable S1 (2026-07-01, con el
README diciendo «findings executed»), sema S-17 (2026-08-05, midio el estampado
del knob muriendo a los 24ms de un hold de 240) y blocks A-36/A-65 (reproducido
en navegador, dos estampados a 1,1ms). Lo que faltaba no era diagnostico: nadie
podia distinguir una redireccion legitima de una deriva, porque la opcion que
redirigia se llamaba `fallbackTarget` y hacia lo contrario de lo que decia.

EL NOMBRE PRIMERO. `fallbackTarget` -> `targetOverride` (202 apariciones, 64
ficheros, dirigido con `git ls-files`). Siempre GANO sobre el ref registrado; el
nombre honesto es la precondicion para auditarlo. Las cronicas conservan el viejo
por diseño.

LA PROPIEDAD. `unstampEventAttrs` recibia el signal y lo DESCARTABA, asi que la
ocurrencia que terminaba antes borraba a la que tenia la ranura. Ahora comprueba
`data-event-id`. Con eso, `replace` deja de ser el comportamiento roto y pasa a
ser el suelo correcto.

A-36, SIN `regime`. Los cuatro overlays cedian el estampado del `open` al
trigger por un workaround de la era `sequence: 'pre'`; con `post` el content ya
esta montado. Retirado el override (float-panel migrado `pre`->`post`, ultimo
fuera de la doctrina que sema.md escribe). Medido en /blocks/site-header/preview
a 375px: contact-activate en el TRIGGER +18,7ms y open en el CONTENT +20,6ms,
con press-squeeze y slide-from-right-full corriendo. Antes: los dos en el
trigger, press-squeeze jamas.

`regime` DEFINIDO — y son dos valores, no cuatro. `replace` (default) y `queue`,
este solo para las 4 parejas irreducibles: los tres toggles (el provider ES el
boton) y el knob. `collapse` retirado (su caso murio con la propiedad: medido, 0
huecos en 5 emisiones a 72ms) y `lock` retirado (significaria «un cierre que no
se anuncia»), con sus 4 declaraciones muertas.

  El guard de `queue` PASO EN VERDE SIENDO INCORRECTO: con timers falsos no hay
  animaciones, asi que awaitExpression volvia al instante. El navegador midio el
  commit encolado llegando 1,6s tarde. `queue` espera el HOLD, no la expresion.
  Medir la envolvente no es medir la salida.

`renderAttrs` — los attrs que no son partes. Vaciar ACTIVE_DEV_TRACK (palabras y
chronos ya no estan excluidos) destapo 23 `data-palabras-*` que ningun morfo
declaraba y que palabras.css SI estiliza: aterrizan en el arbol que el usuario
escribio, asi que no pueden ser partes. Campo nuevo con la linea afilada —si el
consumidor puede componerlo, es una PARTE— y 3 consumidores (palabras, waveform,
aura). Declarado en types.ts Y en el esquema sium: la leccion de MorfoElement.

Ademas: 5 selectores muertos en el predicado del focus-scope de palabras
(barrido de todo el repo: aparecian solo ahi) · RTL-1 real en palabras-chrome
(inset logico + translate fisico = doble volteo; rtl:check 1->0) · el union
inline al `Direction` canonico, que destapo que el censo de direccion grepea el
NOMBRE del tipo · deuda de chronos (scope, barrel, README).

Y de la cola: S-36 (barrel 68->71 + guard), S-31 (vibrate(NaN)), S5 (la
preferencia de reduced-motion del usuario ya gana a los channels del morfo), SO2
(el warn que el JSDoc prometia), SO4 (validar el target antes del prewrite).

Todos los guards nuevos vistos fallar antes de arreglar nada.

Base: check 74 (= base) · sema+morfo+contracts 454 verdes / 6 ajenos
preexistentes · soma navegador 1258/1259 (el rojo es un timeout ajeno,
reproducido con los cambios en stash) · docs:check 0/618 · rtl:check 0/177.

Handoff: docs/process/CONTINUE-perceptual-surface.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 49362d89e9
commit b1b48fb3aa

@ -177,6 +177,45 @@ See [overview.md](./overview.md) §2.bis for the cross-layer view.
---
## `renderAttrs` — the attrs that are not parts
Some components write `data-{component}-*` onto elements that **are not parts
and never could be**:
- a document engine marks the user's OWN tree — `palabras` stamps block kind,
heading level, syntax tokens and i18n state on `<blockquote>` / `<table>` /
`<figure>` nodes whose shape depends on what the user wrote;
- a component marks its internal render nodes — `waveform`'s two `<path>`s,
which its own source already calls "eidos-only hooks".
They cannot be `parts[]` (the morfo could never enumerate them) and they cannot
be `data-_*` (the private prefix is outside morfo by design, and eidos STYLES
these — `palabras.css` alone has rules for 23). That left a real cross-layer
contract, soma writing and eidos reading, that **nothing declared** — which is
what morfo exists to prevent.
```ts
renderAttrs: [
{ attr: 'data-palabras-block' },
{ attr: 'data-palabras-heading-level' },
{ attr: 'data-palabras-untranslated' }
]
```
**Where the line is drawn, and it is sharp: if a consumer can compose it or
address it, it is a PART.** `renderAttrs` is for what the component renders
internally and the consumer never composes. It buys no runtime writer — the
component emits them itself, as it always did; what it buys is that the
contract is declared, greppable and guarded (`contracts.test.ts` reads this
file, so an attr that is neither a part nor declared here fails).
Added 2026-08-10, when lifting `palabras`' catalogue exemption exposed 23
undeclared ones. Like `MorfoElement`, the vocabulary lives in TWO places —
the TypeScript shape in [`types.ts`](../../src/uix/morfo/types.ts) and the sium
schema in [`schema.ts`](../../src/uix/morfo/schema.ts). Touch both in one edit.
---
## Archetypes — cross-component part classification
Beyond the component-specific `kebab`, a part can declare an `archetype`
@ -591,7 +630,7 @@ Field rules:
`data-event-*` attrs during the visual hold. Lives inside `semantic`
per the doctrinal shape (was at event-level pre-2026-05-08).
- **`semantic.allowedTargets`** — optional list of `partRef`s the provider
may redirect the stamp to at trigger time (the `fallbackTarget` trigger
may redirect the stamp to at trigger time (the `targetOverride` trigger
option wins over the registered target ref). The exact counterpart of
`allowedFamilies` on the target axis: `target` stays the default the
runtime resolves; this declares the OTHER surfaces an emission may land
@ -601,6 +640,18 @@ Field rules:
declared target or a part listed here, and nothing else (the TextArea
class of bug — rules aimed at a part the stamp never visits — ran 6×/13×
above its written gain for months without any guard seeing it).
Since 2026-08-10 the runtime asserts it too: `assertTargetOverride` reads the
overriding element's own part marker and warns when it is neither 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
answer which part it is.
- **`regime`** — what this event does when it arrives and the target surface
already carries a live occurrence: `replace` (default) or `queue`. The
`data-event-*` projection is ONE SLOT per element. Declare it only for pairs
that genuinely share a node — when the collision comes from a redirection,
retire the redirection instead. Detail:
[`architecture/sema.md` §The surface is ONE SLOT](./sema.md).
- **`semantic.sequence`** — when the perceptual signal happens
relative to the structural state change. Default `'pre'` preserves
the runtime semantics where the signal completes before the commit.

@ -252,6 +252,48 @@ Those tokens are the **cross-channel contact surface**: sema's cascade
same way eidos's CSS reads them to tint borders / animate states during the
hold. One perceptual surface, separate owners.
### The surface is ONE SLOT — ownership and `regime`
Those five attrs are a **single slot per element**. Two occurrences on one node
cannot both express, and the framework spent a year not saying so: three
independent audits found the same defect and none closed it (fable **S1**
2026-07-01 · sema **S-17** 2026-08-05 · blocks **A-36 / A-65**, reproduced in
the browser). Two rules now hold it shut.
**1 · The unstamp checks ownership.** `unstampEventAttrs` retires the
projection only when `data-event-id` still matches the signal that wrote it.
Before 2026-08-10 it took the signal and discarded it, so the occurrence
finishing FIRST wiped whichever one currently held the slot — measured on the
knob, a `commit-set` projection died 24 ms into a 240 ms hold.
**2 · `regime` says what an ARRIVING occurrence does to a busy surface**
(`SemaRegime`, declared per morfo event, default `replace`):
| Value | Behaviour |
| --- | --- |
| `replace` | Takes the surface at once, displacing the live projection. **Default** — and correct only since rule 1. |
| `queue` | Waits for the live occurrence's HOLD to elapse, then takes it. |
`queue` waits for the **hold**, never for the whole expression.
`awaitExpression` keeps the projection alive until the target's animations
finish (capped at `MAX_EXPRESSION_WAIT_MS`), which is right for unstamping and
wrong for queueing: gating on it delayed a Toggle's queued `commit-toggle` by
**1.6 s**, because unrelated transitions kept the node busy. The engine and the
channel resolve that number through one path (`VisualChannel.holdMsFor`).
**Reach for `regime` last.** It is for pairs the doctrine REQUIRES on one node
and no re-targeting can separate — a toggle's `contact-press` + `commit-toggle`
(its provider IS the button), the knob's `handle-drop` + `commit-set`. When the
collision comes from a **redirection** instead, the fix is to stop redirecting:
that is how A-36 closed, with the overlays' `open` giving up a `targetOverride`
left over from when they were `sequence: 'pre'`. Measured after: the Button's
`contact-activate` stamps the trigger and the Drawer's `open` stamps its
content — two surfaces, both expressing.
`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
that lies. The reasons are in `SemaRegime`'s own docblock.
### Cascade rules — flat CSS-like shape
```ts

@ -21,7 +21,7 @@
| 10 | Theming | ✓ | Focus outline (o1) → censo SYS-5; vocabulario de estado de celda rico (selected/today/weekend/holiday/unavailable/outside-month) |
| 11 | Composición interna | **el mandato de familia** | Es el DONANTE informal: range-calendar (79 tokens), month/year-grid (65), pickers (38+4) chupan su vocabulario cross-component → mandato calendar-surface (SYS-8, ya en next-features); chronos beberá de ella |
| 12 | Estados | ✓ | 8 data-attrs por celda/día; readonly/disabled/invalid en provider |
| 13 | Runtime | ✓ | `runtime.trigger('shift-navigate', {fallbackTarget})` (:328) — el patrón fallbackTarget correcto para partes doble-registradas |
| 13 | Runtime | ✓ | `runtime.trigger('shift-navigate', {targetOverride})` (:328) — el patrón targetOverride correcto para partes doble-registradas |
| 14 | Docs+tests | ✓ | README ×2; tests ✓ |
| 15 | i18n | **hallazgo** | texts + commonRefs localizados ✓; PERO **F-4**: `weekend` = "Saturday or Sunday (weekend in Gregorian)" HARDCODEADO (types :53-54) — el weekend es por-LOCALE, no por calendario (ar-SA/IL = vie/sáb): `Intl.Locale.getWeekInfo()` existe; un framework con locale reactivo pintando `data-weekend` incorrecto en media región del mundo |

@ -2,7 +2,7 @@
- **Familia**: fields (trío de segmentos) · **Método**: solo análisis; evidencia por dimensión · demos fuera
- **Capas leídas**: [morfo](../../../src/uix/morfo/components/color-field.ts) (eventos+expression verificados) · [soma types](../../../src/uix/soma/components/color-field/types.ts) completo (1-90+) · css (censo: hex 3ch, focus o3/b1) · sema: sin pack, `expression: "family-default"` declarado
- **Antecedentes resueltos**: `fallbackTarget` (fix 2026-06-16 — el commit del `.hex` sonaba desde la parte equivocada) · fantasma `segment-height` (B.1)
- **Antecedentes resueltos**: `targetOverride` (fix 2026-06-16 — el commit del `.hex` sonaba desde la parte equivocada) · fantasma `segment-height` (B.1)
- **Máquina**: PASS limpio · tests ✓ · README ×2 ✓
## Dimensiones (evidencia leída)
@ -21,7 +21,7 @@
| 10 | Theming | censo | Focus outline → SYS-5 |
| 11 | Composición interna | mandato | Sin wrapper `[data-field]` (SYS-6/B.1) |
| 12 | Estados | ✓ | readonlySegments granular; hidden-input; `allowedFormats` de una entrada = select bloqueado (documentado) |
| 13 | Runtime | ✓ | fallbackTarget aplicado (antecedente) |
| 13 | Runtime | ✓ | targetOverride aplicado (antecedente) |
| 14 | Docs+tests | ✓ | README ×2, suite ✓ |
| 15 | i18n | ✓ | texts morfo; formatos por segmento localizados |

@ -13,14 +13,14 @@
| 3 | Coherencia semántica | **hallazgo de trío** | Solo `commit-set` (morfo :14-25) — **date-field además emite `commit-reset`** al limpiar: vaciar un time-field no produce señal semántica → F-1. `intent: neutral`, `sequence: post` ✓ canon |
| 4 | API | ✓ | Espejo exacto de date-field (sin `kind`, correcto: no aplica a horas); bindables declarados; fallbacks reactivos documentados (types :59-67) |
| 5 | Tokens | ✓ con dup | Recipe canónico; `height-{k}` re-declara el bundle (SYS-6, evidencia matriz familia) |
| 6 | Sema | declarado, cuestionado | `family-default` EXPLÍCITO en morfo — el trío roto es elección declarada, no olvido; la pregunta SYS-3 pasa a ser: ¿debe el trío compartir UNA expression (los tres pack o los tres default)? El provider usa `fallbackTarget` en el trigger (:436 ✓ el fix de partes doble-registradas) |
| 6 | Sema | declarado, cuestionado | `family-default` EXPLÍCITO en morfo — el trío roto es elección declarada, no olvido; la pregunta SYS-3 pasa a ser: ¿debe el trío compartir UNA expression (los tres pack o los tres default)? El provider usa `targetOverride` en el trigger (:436 ✓ el fix de partes doble-registradas) |
| 7 | Animaciones | ✓ | Sin keyframes; transición solo-background heredada del patrón de familia |
| 8 | Contrato morfo | ✓ más rico que su hermano | APG spinbutton ✓; teclado 5 teclas (verificado — sin Home/End, F-2); **su bloque ARIA del Input es MÁS completo que el de date-field**: añade `aria-readonly` ariaBoolean + `aria-invalid` condicional (morfo :76-90) que date-field no tiene → F-3 (unificar el trío al bloque rico) |
| 9 | A11y | ✓ con F-2 | aria-value\* por segmento, labelledby condicional; falta Home/End |
| 10 | Theming | censo | Focus outline (o2/b1) → SYS-5; segment marker compartido ✓ (state-layer llega) |
| 11 | Composición interna | mandato | Sin wrapper `[data-field]` (SYS-6/B.1 — fue LA sonda en vivo de esta familia); wrapper eidos espejo exacto de date-field (leído) |
| 12 | Estados | ✓ | invalid/disabled/readonly/required + readonlySegments granular; hidden-input forms ✓ |
| 13 | Runtime | ✓ | Sin timers/listeners crudos en lo dirigido; fallbackTarget ✓ |
| 13 | Runtime | ✓ | Sin timers/listeners crudos en lo dirigido; targetOverride ✓ |
| 14 | Docs+tests | ✓ | README ×2, suite ✓ |
| 15 | i18n | ✓ | texts morfo; formatter por locale reactivo; `$libs/days` ✓ |

@ -381,7 +381,7 @@ type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none';
- **commit.select (item de menú, nodo de árbol)**: `commit.subtle` (gain 0.03) + `tap` leve. Más sobrio que radio-group porque la cascada típica es "menú cierra + item commit + nueva superficie aparece" — tres señales en milisegundos, hay que repartir intensidad.
- **Coherencia**: dropdown-menu y context-menu comparten firma idéntica (el usuario no debe aprender dos "sonidos de menú"). Tree-view y tree-grid idem. Menubar y navigation-menu se quedan en solo `commit.subtle` porque no tienen evento de open/close declarado en el morfo (la apertura es de un dropdown adyacente).
- **Caveat (`tree-view` / `tree-grid`) — RESUELTO (verificado 2026-07-12, SEM-4)**: la
emisión aterriza vía `fallbackTarget` en el elemento real (`branchEl` / `rowEl`) y los
emisión aterriza vía `targetOverride` en el elemento real (`branchEl` / `rowEl`) y los
packs construyen sus selectores con `onBranch` / `onRow` (`semaSelector(morfo,
'branch'|'row')`) — emisión y cascada casan de punta a punta.
- **Caveat (emisión soma) — RESUELTO (verificado 2026-07-12, SEM-4)**: los 6 providers

@ -1418,9 +1418,10 @@ colocación del flyout—, a diferencia de la receta, que lee `:dir()`.
- **0** resolutores a pelo vivos — sólo quedan menciones a `getDir()` en
comentarios de documentación.
- Acepta `dir` sin correr la cadena: sólo `waveform` (delega en el Slider, §9.18).
- ⚠️ El union escrito a mano sobrevive en `palabras` (`types.ts:130` + su test),
que está **excluido de escritura** — la afirmación «0 inline unions» vale para
el catálogo, no para el árbol entero.
- ⚠️ El union escrito a mano sobrevive en `palabras` (`types.ts:130` + su test) —
la afirmación «0 inline unions» vale para el catálogo, no para el árbol entero.
(Estaba excluido de escritura; la exclusión se levantó el 2026-08-10 — ya es
corregible.)
**Verificación**: `check` sin errores nuevos (77 = la base con los ficheros de
`media-player`, de otra sesión). Tests de feed y switch en verde. En SSR los
@ -1564,8 +1565,10 @@ diciendo que el guard no ve esta forma, y dejarán de ser verdad):
`reference_dir_rule_double_flip`.
**Verificación**: `npm run rtl:check` debe seguir dando **1 error** — el de
`palabras-chrome.css:446`, preexistente y excluido de escritura. Cualquier otro
número hay que explicarlo. Más `npx vitest run src/uix/eidos/rtl-lint.test.ts`.
`palabras-chrome.css:446`, preexistente. (La exclusión de escritura de `palabras`
se levantó el 2026-08-10: si se corrige ese error, el número esperado pasa a **0**.)
Cualquier otro número hay que explicarlo. Más
`npx vitest run src/uix/eidos/rtl-lint.test.ts`.
### 10.1-bis · El refactor — handoff APARTE

@ -0,0 +1,214 @@
# CONTINUE — la superficie perceptual es UNA RANURA
> **Kickoff**: _«Lee `docs/process/CONTINUE-perceptual-surface.md` y sigue por §3,
> empezando por la campaña de emisión.»_
> **Fecha**: 2026-08-10 · Rama `alpha-0.1-dir-prefs` (COMPARTIDA — otra sesión
> trabaja en `blocks`; `git reset -q` + add sólo lo propio, siempre).
> **Doctrina viva**: [`architecture/sema.md` §The surface is ONE SLOT](../architecture/sema.md)
> y [`architecture/morfo.md` §`renderAttrs`](../architecture/morfo.md).
> Este documento sólo dice **qué se cerró, qué queda y cómo medirlo**.
---
## 1 · El hallazgo, y por qué costó tanto verlo
`data-event-*` es **una ranura por elemento**: dos ocurrencias sobre un nodo no
pueden expresarse las dos. El defecto lo encontraron **tres auditorías
independientes y ninguna lo cerró**:
| Auditoría | Fecha | Qué dijo |
| --- | --- | --- |
| fable **S1** | 2026-07-01 | «Race de señales solapadas sobre el mismo target», con el arreglo mínimo escrito. El README de `old-deprecated` afirmaba «findings executed». |
| sema **S-17** | 2026-08-05 | Re-descubierto solo. Midió el knob: el estampado de `commit-set` moría a los 24 ms de un hold de 240. |
| blocks **A-36 / A-65** | 2026-08-01/09 | Reproducido en NAVEGADOR: dos estampados a 1,1 ms, `press-squeeze` jamás. |
Tres veces el mismo defecto, tres veces sin arreglar. Lo que faltaba no era
diagnóstico: era que **nadie podía distinguir una redirección legítima de una
deriva**, porque la opción que redirigía se llamaba `fallbackTarget` y decía lo
contrario de lo que hacía.
---
## 2 · Lo cerrado (todos los guards vistos fallar antes de arreglar)
| # | Qué | Dónde |
| --- | --- | --- |
| 2.1 | **`fallbackTarget` → `targetOverride`** (202 apariciones / 64 ficheros). No cosmético: es la PRECONDICIÓN para auditar la redirección | `soma/runtime.svelte.ts` + todo el árbol |
| 2.2 | **El desestampado comprueba propiedad** (`data-event-id`). Rojo: `expected null to be 'sig-B'` | `sema/stamp.ts` · `sema/projection/dom.ts` |
| 2.3 | **`assertTargetOverride`** — el runtime lee el marcador de parte del elemento y avisa si no es `target` ni `allowedTargets`. Runtime porque el override es una expresión | `soma/runtime.svelte.ts` |
| 2.4 | **A-36 CERRADO, y sin `regime`** | los 4 overlays |
| 2.5 | **`regime` definido y cableado**, reducido a `replace \| queue` | `sema/types.ts` · `engine.ts` · `signal.ts` |
| 2.6 | **`renderAttrs`** — los attrs que no son partes | `morfo/types.ts` + `schema.ts` |
| 2.7 | `ACTIVE_DEV_TRACK` vaciado + deuda de chronos (scope · barrel · README) | `contracts.test.ts` |
| 2.8 | **S-36** barrel 68→71 + guard · **S-31** `vibrate(NaN)` · **S5** · **SO2** · **SO4** | varios |
### 2.4 A-36 — la lección: no era arbitrar, era dejar de redirigir
Los cuatro overlays cedían el estampado del `open` al trigger. La razón escrita
en el código («el content no ha montado») era cierta **cuando `open` era
`pre`**. Dialog / Drawer / Popover migraron a `post` hace tiempo y nadie retiró
el override; `float-panel` seguía en `pre` (último fuera de la doctrina que
`sema.md` escribe). Con `<Drawer.Trigger>` compuesto sobre `<Button>` —que es UN
solo elemento— el `open` caía encima del `contact-activate`.
Medido en `/blocks/site-header/preview` a 375 px:
```
ANTES contact-activate TRIGGER +329ms · emerge/open TRIGGER +331ms · press-squeeze: NUNCA
AHORA contact-activate TRIGGER +18,7ms · open CONTENT +20,6ms
getAnimations() → press-squeeze running (120ms) + slide-from-right-full
```
### 2.5 `regime` — dos valores, no cuatro
- **`replace`** (default) no había que construirlo: **2.2 lo terminó**. Antes era
el comportamiento *roto*.
- **`queue`** es el único nuevo. Aplicado a las 4 parejas irreducibles: los tres
toggles (el provider ES el botón) y el knob (`handle-drop` + `commit-set`).
- **`collapse` RETIRADO** — su caso (el parpadeo continuo) ya lo mató 2.2:
medido, **0 huecos** en 5 emisiones a 72 ms con hold de 240. Y reutilizar el
`data-event-id` habría minado la propiedad que lo curó.
- **`lock` RETIRADO** — bajo la semántica limpia significaría «un cierre que no
se anuncia si hay algo proyectando». Con él, sus 4 declaraciones muertas.
> ⚠️ **EL GUARD DE `queue` PASÓ EN VERDE SIENDO INCORRECTO.** Con timers falsos
> no hay animaciones, así que `awaitExpression` volvía al instante. El navegador
> midió que el commit encolado llegaba **1,6 s tarde**, porque esperaba la
> expresión entera (tope 1500 ms) en vez del hold. `queue` espera el **hold**;
> el desplazado está a salvo porque su limpieza ya es consciente del dueño. El
> test corregido inyecta un `getAnimations()` que nunca termina.
> **Medir la envolvente no es medir la salida** — otra vez.
### 2.6 `renderAttrs` + los fixes de palabras
Al vaciar `ACTIVE_DEV_TRACK` salieron 23 `data-palabras-*` que ningún morfo
declaraba y que `palabras.css` **sí estiliza**. No son partes ni pueden serlo:
aterrizan en el árbol que el usuario escribió. Campo nuevo, con la línea afilada
—*si el consumidor puede componerlo o direccionarlo, es una PARTE*— y ≥3
consumidores (palabras 23 · waveform 2 · aura 2).
Y tres más: **5 selectores muertos** en el predicado del focus-scope (barrido de
todo el repo: aparecían sólo ahí) · **RTL-1 real** en `palabras-chrome.css`
(`inset-inline-start` lógico + `translate` físico = el doble volteo; centrado con
margen derivado de un token, `rtl:check` 1→0) · el `dir?: 'ltr'|'rtl'` inline al
`Direction` canónico — lo que destapó que **el censo de dirección grepea el
NOMBRE del tipo**, así que un union inline lo escondía (exención firmada, misma
razón que `field-langs`).
---
## 3 · LA COLA — por dónde seguir, en orden
### 3.1 Campaña de emisión — **71 eventos / 33 componentes** ← EMPEZAR AQUÍ
Redirigen el estampado con `targetOverride` y su morfo no lo declara. El guard
del runtime los señala uno a uno al ejecutarse.
**No anotar a ciegas.** Por cada uno, la pregunta primero es *¿sobra la
redirección?* — como en A-36, donde el arreglo fue retirarla. Anotar una
redirección innecesaria la fosiliza. Sólo cuando es legítima (parte repetida: el
`day` pulsado, el `item` clicado) se declara `allowedTargets`.
Censo reproducible: cruzar cada `trigger(name, { targetOverride })` de
`soma/components` + `eidos/components` contra `target` + `allowedTargets` del
morfo. Hoy: 10 declarados / 71 sin declarar.
### 3.2 Restitución de persistentes — **eje NUEVO, ninguno de `regime` lo cubre**
Medido con el engine real:
```
afterWarn: "signal-warn-invalid" ← el aviso untilFix proyectado
duringCommit: "commit-submit" ← el commit lo pisa
afterCommit: null ← al limpiar se lleva el aviso
engineStillHoldsWarning: true ← el engine lo cree vivo
```
Una persistente **no compite por la ranura: es un estado al que la ranura debe
VOLVER**. Propuesta: tras el hold de una transitoria, el engine re-proyecta la
persistente viva de ese nodo. Afecta a `form`, `file-upload`, `textarea`,
`media-player`, `announce`, `aura`.
### 3.3 Eje B — ordenación y emisión
- **`sequence: 'coincident'` sigue sin implementar** (idéntico a `pre`); 21
eventos lo declaran. Implementarlo (emit sin await + handler inmediato) o
marcarlo reservado.
- **4 continuos sin throttle** contra la doctrina de `sema.md`:
`gradient-builder`, `color-picker` (área), `number-field`, `css-field`
emiten en CADA `pointermove`. Cinco compañeros sí throttlean
(`DRAG_SIGNAL_MS`). ⚠️ `rotate-align` NO es infractor: throttlea por detente.
- **Las 68 demos emiten sin `verb`** → desde el rediseño del 2026-08-06 el verbo
elige el sonido, así que un play de `emerge-close` **suena `open`** y
`commit-delete` suena `tick` en vez de `tick.loss`. El bloque está copiado 68
veces; extraer un helper a `$demo` es refactor aparte.
- **`knob.handle-drop` es `pre`** con handler → `data-dragging` sobrevive 240 ms
al soltar. **`button.contact-activate`** retrasa `onPress` 120 ms.
### 3.4 Eje C — cola de `AUDIT-sema-2026-08-05`, re-verificada abierta
`S-19` (`AnnounceFn` no compila contra `uix.announce`; ninguna raíz cablea el
canal) · `S-27/S-41` (`prepare()` abre AudioContext con `sound:'off'`) · `S-10`
(rama visual sin `??` → revienta el boot) · `S-33` (`'forbidden'` no prohíbe) ·
`S-34` (gemelo del schema copiado a mano) · `S-35` (la etiqueta esquiva la
política) · `S-30/S-38` (5 waivers, ya desbloqueados por S-31).
### 3.5 Eje D — fable, lo que sigue abierto
`M1 emission` (D.2, aceptada y nunca implementada — cerraría los waivers de
tooltip y gradient-picker) · `M4` (`enumPair` frágil con 3+ valores) · `M5`
(tipar el matcher `state`) · `M6` (`IntentExpectedFamily` deprecado, ×2) ·
`E2` (`globalThis.matchMedia` crudo en eidos) · `E3` (catch mudo en
`#renderSchemeCss`).
### 3.6 Eje E — canon del ledger de blocks
`A-47` (anillo de foco 1.43:1 sobre panel sólido) · `A-69` (`count-up`:
`duration` no significa segundos) · **`A-85` confirmado**: el morfo del drawer
declara el `aria-label` del trigger **sin condición** (a diferencia de `button`,
que usa `prop-truthy`), así que pisa el del consumidor → arreglar **y censar la
clase** en los 166 morfos.
---
## 4 · Decisiones del autor, pendientes
- **`mode` y `scope`**: 0 declaraciones, 0 consumidores, 0 JSDoc — la misma
situación que `collapse` y `lock`, ya retirados. ¿Se retiran también?
- **`regime` es por EVENTO, pero la colisión es de la SUPERFICIE.** Para la clase
intra-morfo el evento vale; en A-36 chocaban dos morfos distintos y ninguno
podía saber del otro. Si vuelve a aparecer una colisión cruzada que no se
resuelva quitando la redirección, hay que decidir quién manda.
---
## 5 · Cómo verificar en este hilo
**Línea base ANTES de tocar** — hay 6 fallos AJENOS y preexistentes en
`contracts.test.ts` (waveform barrel · provider filenames · MOR-4 · DOM writes de
menubar · data-attrs · namespaces de aura), y 1 en el proyecto de navegador de
soma (un timeout de 5 s en `migrated providers re-declare no morfo attrs`,
reproducido con los cambios en stash). Clasificar míos vs ajenos **por contenido
del diff**, nunca por confianza.
```
npx vitest run src/uix/sema src/uix/morfo src/uix/contracts.test.ts --project=server
npx vitest run src/uix/soma # proyecto navegador
npm run check # 74 errores = base
npm run docs:check # 0 / 618
npm run rtl:check # 0 / 177
```
**El navegador NO es opcional en este eje.** El A/B que aísla la causalidad:
`MutationObserver` ligado **al nodo** (no a un selector) sobre `data-event*`, más
`getAnimations()`. Dos trampas medidas hoy:
- **El panel oculto suspende rAF y no compone**: un bucle sobre
`requestAnimationFrame` se cuelga, `animationstart` no dispara y el
`screenshot` falla. Muestrear con temporizador y leer `getAnimations()` /
`getComputedStyle`, que sí son fiables ahí.
- **`awaitExpression` llega a su tope de 1500 ms** con el panel oculto, porque
las transiciones nunca terminan. No confundirlo con un defecto del código.
**Audio**: render offline replicando el grafo (`OfflineAudioContext`) y medir
pico / RMS. Contar osciladores responde «¿llegó algo?», nunca «¿se oye?».

@ -140,9 +140,13 @@ retirada por el mismo motivo — con el nombre debajo del intent, aplastar el
perfil evaluativo es inexpresable. Razonado en
[`book-deviations.md`](../decisions/book-deviations.md) §D.7.
⚠️ **Renombrar `fallbackTarget` → `targetOverride`** sigue PENDIENTE: 193
apariciones en 63 ficheros. El contrato ya es honesto (`allowedTargets` lo
declara); falta que el nombre lo sea. Es un commit mecánico y aparte.
✅ **Renombrar `fallbackTarget` → `targetOverride`: HECHO el 2026-08-10** (202
apariciones, 64 ficheros; crónicas conservan el nombre viejo por diseño). Y dejó
de ser cosmético: es la PRECONDICIÓN del censo de emisión. Mientras la opción se
leía como un fallback, un provider que apuntaba a otra PARTE era
indistinguible de uno que apuntaba a la instancia correcta de la parte
declarada. Con el nombre honesto, el runtime puede afirmar la diferencia
(`assertTargetOverride`) y el guard censarla.
### 3.1 Las 17 reglas — CERRADA
@ -159,7 +163,8 @@ bendice; borrarlo es lo que cierra cada una):
Censo de eventos huérfanos (medido): **14 de 249** en 104 morfos con soma. Tres
clases — verbo delegado (6), punto de extensión documentado en prosa
(virtual-list / virtual-grid `handle-scroll*`, que merecerían un `emission: 'app'`
declarado) y contrato muerto (tooltip). Más chronos ×2, de escritura excluida.
declarado) y contrato muerto (tooltip). Más chronos ×2 (la exclusión de escritura
se levantó el 2026-08-10 — ya accionables).
### 3.2 Los silenciosos

@ -366,7 +366,8 @@ sale en el diff. Es más pequeño, es estable, y no ancla en basura.
que lo garantiza — mientras quede una regla sin nombre, el árbol no compila.
- **Se cruza con la cola abierta.** Q2 (5 reglas hápticas inertes) y Q4 (14
eventos huérfanos) son **independientes** — no los bloquea este plan. Q3 sí
queda absorbido. S-07 queda absorbido. **chronos sigue excluido de escritura.**
queda absorbido. S-07 queda absorbido. **chronos: la exclusión de escritura se
levantó el 2026-08-10 (directiva del autor) — ya es accionable.**
- **`preload` y el fallback mudo** (S-04/S-24/S-25) siguen rotos. Si el catálogo
va a poblarse de WAVs, **S-24 debe arreglarse antes** o un 404 será invisible
para siempre.

@ -191,13 +191,15 @@ function collectMorfoComponentNamesByScope(scope: string): string[] {
.sort();
}
// Active-dev-track components, excluded from the audited/maintained catalogue
// (their morfo/eidos are still being authored). They are brought into
// compliance when their track lands; until then the catalogue guards skip them.
const ACTIVE_DEV_TRACK = new Set([
'palabras',
'chronos'
]);
// Active-dev-track components, excluded from the audited/maintained catalogue.
//
// EMPTY since 2026-08-10 (author directive): `palabras` and `chronos` were the
// two entries, exempted while their tracks were in flight. The exemption is
// lifted — they answer to the same catalogue guards as everybody else, and a
// violation of theirs is a violation like any other. The set stays (typed, and
// consulted by six guards) because a future in-flight track is a legitimate use;
// what is not legitimate is leaving an entry in it after its track lands.
const ACTIVE_DEV_TRACK = new Set<string>([]);
// MOR-4 enumeration guard (clean-room 2026-07-10). The acceptance machinery
// (`component-audit`, `eidos-lint-all`) enumerates BY MORFO FILE — an eidos
@ -750,10 +752,29 @@ describe('UIX layer contracts', () => {
});
it('guards Soma from direct mutable DOM writes', () => {
// The rule is about the LIVE document: a UIX-managed attr must travel
// through `dom.apply` so one owner writes it. A file that only ever
// touches a DETACHED buffer is outside that concern — there is nothing
// to coordinate, and no `ActiveDom` to route through.
//
// `palabras/engine/operations/parse-html.ts` is the paste pipeline: it
// works on an inert `DOMParser` document (its own header: "it's a parse
// buffer (inert document / detached container), never live"), marking
// Word's fake-list paragraphs before regrouping them. The marker never
// reaches the page; `normalizeDocument` consumes and drops it.
const detachedBufferOnly = join(
HERE,
'soma',
'components',
'palabras',
'engine',
'operations',
'parse-html.ts'
);
const violations = grepSources(
join(HERE, 'soma'),
/\.setAttribute\(|\.removeAttribute\(|\.toggleAttribute\(|\.classList\.|\.style\.setProperty\(|\.style\.[a-zA-Z]+\s*=/
);
).filter((file) => file !== detachedBufferOnly);
expect(violations).toEqual([]);
});

@ -163,7 +163,7 @@
if (open) return;
open = true;
onOpenChange?.(true);
runtime.trigger('open', { fallbackTarget: asTarget(listEl) });
runtime.trigger('open', { targetOverride: asTarget(listEl) });
pendingFocus = dialNavFirst(navState());
}
function closeMenu(restoreFocus = true) {
@ -172,7 +172,7 @@
onOpenChange?.(false);
focusedIndex = 0;
pendingFocus = null;
runtime.trigger('close', { fallbackTarget: asTarget(listEl) });
runtime.trigger('close', { targetOverride: asTarget(listEl) });
if (restoreFocus) triggerEl?.focus();
}
function toggle() {
@ -193,7 +193,7 @@
// Chosen by an action (click or native Enter/Space) — emit + close.
function select(el: HTMLElement | null, onSelect?: () => void) {
onSelect?.();
runtime.trigger('commit-select', { fallbackTarget: asTarget(el ?? listEl) });
runtime.trigger('commit-select', { targetOverride: asTarget(el ?? listEl) });
closeMenu();
}

@ -57,7 +57,7 @@
notifyUpdate(el, text) {
if (!live) return;
void runtime.trigger('signal-notify-update', {
fallbackTarget: el ?? undefined,
targetOverride: el ?? undefined,
message: text
});
}

@ -271,7 +271,7 @@
function openMenu() {
if (open) return;
open = true;
runtime.trigger('open', { fallbackTarget: asTarget(surfaceEl) });
runtime.trigger('open', { targetOverride: asTarget(surfaceEl) });
pendingFocus = onionNavFirst(currentRingNav());
onTriggerClick?.();
}
@ -282,7 +282,7 @@
selected = null;
focusedIndex = 0;
pendingFocus = null;
runtime.trigger('close', { fallbackTarget: asTarget(surfaceEl) });
runtime.trigger('close', { targetOverride: asTarget(surfaceEl) });
if (restoreFocus) triggerEl?.focus();
}
@ -299,7 +299,7 @@
selected = [...dp, index];
opt.onSelect?.();
runtime.trigger('commit-select', {
fallbackTarget: asTarget(sectorEl(ring, index) ?? surfaceEl)
targetOverride: asTarget(sectorEl(ring, index) ?? surfaceEl)
});
}
onItemClick?.(ring, index);

@ -441,15 +441,25 @@
content: '';
position: absolute;
inset-block: 0;
/* Centred on the 50% line, LOGICALLY (RTL-1). It used to pair
`inset-inline-start: 50%` with `translate: -50% 0`: the anchor flips in
RTL and the translate does not, so the line landed a full width off on
the wrong side — the double-flip trap. A negative inline margin of half
the width flips WITH the anchor, and the width is a token so the hover
state below stays centred too. */
--_palabras-col-resize-w: 2px;
inset-inline-start: 50%;
inline-size: 2px;
translate: -50% 0;
inline-size: var(--_palabras-col-resize-w);
margin-inline-start: calc(var(--_palabras-col-resize-w) / -2);
border-radius: var(--radius-full);
background: var(--color-border-default);
}
[data-palabras-col-resize]:hover::before,
[data-palabras-col-resize][data-dragging]::before {
inline-size: 3px;
/* Retune the token, not `inline-size`: the centring margin derives from
it, so the thicker line stays on the 50% mark instead of drifting half
a pixel. */
--_palabras-col-resize-w: 3px;
background: var(--color-primary-border);
}

@ -25,7 +25,7 @@ import { v } from '../types';
export const chronosMorfo = {
name: 'Chronos',
kebab: 'chronos',
scope: ['soma', 'sema'],
scope: ['soma', 'sema', 'eidos'],
expression: 'pack',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {

@ -45,6 +45,10 @@ export const comboboxMorfo = {
family: 'emerge',
verb: 'open',
target: v.partRef('content'),
// Trigger- or input-bound: a combobox may be opened from either
// surface, and the content is not mounted when `emerge-open`
// fires. Both declared (2026-08-10).
allowedTargets: [v.partRef('trigger'), v.partRef('input')],
sequence: 'post'
}
},
@ -63,6 +67,10 @@ export const comboboxMorfo = {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
// Trigger- or input-bound: a combobox may be opened from either
// surface, and the content is not mounted when `emerge-open`
// fires. Both declared (2026-08-10).
allowedTargets: [v.partRef('trigger'), v.partRef('input')],
sequence: 'pre'
}
}

@ -87,11 +87,14 @@ export const dialogMorfo = {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
// The close resolves `content ?? partRef(content) ?? trigger`: the
// tail lands on the TRIGGER when the content is already gone, so
// the redirection is declared rather than silent (2026-08-10).
allowedTargets: [v.partRef('trigger')],
sequence: 'pre',
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
commits: {
part: v.partRef('content'),
attr: 'data-state',

@ -58,11 +58,14 @@ export const drawerMorfo = {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
// The close resolves `content ?? partRef(content) ?? trigger`: the
// tail lands on the TRIGGER when the content is already gone, so
// the redirection is declared rather than silent (2026-08-10).
allowedTargets: [v.partRef('trigger')],
sequence: 'pre',
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
commits: {
part: v.partRef('content'),
attr: 'data-state',

@ -21,7 +21,16 @@ export const floatPanelMorfo = {
family: 'emerge',
verb: 'open',
target: v.partRef('content'),
sequence: 'pre'
// `post` (2026-08-10 — the last overlay to migrate; Dialog /
// Drawer / Popover made the move earlier, same doctrine as the
// checkbox-lag fix): the provider flips `open` in this event's
// HANDLER, so with `pre` the emit — and the family's ~240ms
// hold — would gate the content mount. With `post` the handler
// runs, `tick()` mounts the content, and the emit stamps the
// DECLARED target: the appearance is perceived on the thing
// appearing, and the trigger keeps its own surface free for its
// own events (A-36). Exit stays `pre` on `close`.
sequence: 'post'
},
commits: { part: v.partRef('content'), attr: 'data-state', value: 'open' }
},
@ -34,11 +43,14 @@ export const floatPanelMorfo = {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
// The close resolves `content ?? partRef(content) ?? trigger`: the
// tail lands on the TRIGGER when the content is already gone, so
// the redirection is declared rather than silent (2026-08-10).
allowedTargets: [v.partRef('trigger')],
sequence: 'pre',
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
commits: { part: v.partRef('content'), attr: 'data-state', value: 'closed' }
},
{

@ -235,7 +235,13 @@ export const knobMorfo = {
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
}
},
// Releasing the knob fires `handle-drop` and, if the value moved,
// `commit-set` — both on `control`, back to back, and the control is
// the only surface a knob has. `queue` lets the drop be perceived
// before the commit settles the value (audit S-17 measured the
// commit's projection dying 24ms into a 240ms hold here).
regime: 'queue'
},
// Double-click returns to defaultValue (audio-console convention).
// A-3.8 satisfied.

@ -13,6 +13,77 @@ export const palabrasMorfo = {
// `defineEngineSemantic({ overrides: { cascade } })` if they want more.
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/',
// The DOCUMENT contract. Palabras is an editor: its engine renders the
// user's own tree (`<blockquote>`, `<table>`, `<figure>`, `<span>`) and
// marks it. These are not parts — the DOM shape depends on what the user
// wrote, so the morfo could never enumerate the nodes — and they are not
// private (`palabras.css` styles 23 of them). Until 2026-08-10 the
// component's catalogue exemption hid the whole set; declared here, the
// contract soma writes and eidos reads is finally visible and guarded.
// See `Morfo.renderAttrs` for where the part / render line is drawn.
renderAttrs: [
// Node identity + structure
{ attr: 'data-palabras-node' },
{ attr: 'data-palabras-block' },
{ attr: 'data-palabras-block-selected' },
{ attr: 'data-palabras-id' },
{ attr: 'data-palabras-path' },
// Block formatting
{ attr: 'data-palabras-align' },
{ attr: 'data-palabras-indent' },
{ attr: 'data-palabras-heading-level' },
{ attr: 'data-palabras-list-kind' },
{ attr: 'data-palabras-marks' },
{ attr: 'data-palabras-checked' },
{ attr: 'data-palabras-check-toggle' },
{ attr: 'data-palabras-quote-marks' },
{ attr: 'data-palabras-empty-text' },
// Columns
{ attr: 'data-palabras-columns' },
{ attr: 'data-palabras-column' },
{ attr: 'data-palabras-column-count' },
// Table
{ attr: 'data-palabras-table-zebra' },
{ attr: 'data-palabras-table-header-row' },
{ attr: 'data-palabras-table-header-col' },
{ attr: 'data-palabras-cell-align' },
{ attr: 'data-palabras-cell-vertical' },
// Image block
{ attr: 'data-palabras-image-align' },
{ attr: 'data-palabras-image-fit' },
{ attr: 'data-palabras-image-full-width' },
{ attr: 'data-palabras-image-status' },
{ attr: 'data-palabras-image-tile' },
{ attr: 'data-palabras-image-placeholder' },
{ attr: 'data-palabras-image-placeholder-figure' },
{ attr: 'data-palabras-image-placeholder-label' },
{ attr: 'data-palabras-image-placeholder-hint' },
// Callout block
{ attr: 'data-palabras-callout-intent' },
{ attr: 'data-palabras-callout-title' },
{ attr: 'data-palabras-callout-icon' },
// Code block
{ attr: 'data-palabras-code-language' },
{ attr: 'data-palabras-code-token' },
// Embeds
{ attr: 'data-palabras-embed-provider' },
// Find & replace highlight
{ attr: 'data-palabras-find-match' },
{ attr: 'data-palabras-find-active' },
// i18n state of the CONTENT (not of the UI)
{ attr: 'data-palabras-i18n-fallback' },
{ attr: 'data-palabras-untranslated' },
// Paste cleanup — transient, lives only inside the parse buffer
{ attr: 'data-palabras-mso-marker' },
// The escape hatch of the focus scope. An editor built ON this engine
// stamps it on its own focus-stealing overlay (a portaled menu, a
// colour grid) so opening one is not read as an external blur — which
// would emit `commit-save-content` + `contact-focus` on every return.
// Twelve eidos chrome components carry it; it is a PUBLIC hook, which
// is exactly why it belongs in the declared contract.
{ attr: 'data-palabras-external-tool' }
],
texts: {
label: '#?components.palabras.label|Rich text editor',
content: '#?components.palabras.content|Editor content',

@ -60,11 +60,14 @@ export const popoverMorfo = {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
// The close resolves `content ?? partRef(content) ?? trigger`: the
// tail lands on the TRIGGER when the content is already gone, so
// the redirection is declared rather than silent (2026-08-10).
allowedTargets: [v.partRef('trigger')],
sequence: 'pre',
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
commits: {
part: v.partRef('content'),
attr: 'data-state',

@ -40,6 +40,10 @@ export const selectMorfo = {
family: 'emerge',
verb: 'open',
target: v.partRef('content'),
// Trigger-bound appearance: the listbox content is not mounted when
// `emerge-open` fires, and on close the trigger is the surface the
// user is looking at. Declared, not silent (2026-08-10).
allowedTargets: [v.partRef('trigger')],
sequence: 'post'
}
},
@ -56,6 +60,10 @@ export const selectMorfo = {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
// Trigger-bound appearance: the listbox content is not mounted when
// `emerge-open` fires, and on close the trigger is the surface the
// user is looking at. Declared, not silent (2026-08-10).
allowedTargets: [v.partRef('trigger')],
sequence: 'pre'
}
}

@ -63,7 +63,12 @@ export const switchMorfo = {
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
}
},
// One slot, and this pair cannot be separated: the provider IS the
// button. `queue` makes the commit wait for the contact's ~120ms
// glimpse instead of erasing it — the book's two moments become two
// beats (cap. 22 §8: "Contact inicia. Commit resuelve").
regime: 'queue'
}
],
direction: {},

@ -46,7 +46,12 @@ export const toggleGroupMorfo = {
supported: ['neutral', 'affirm', 'risk', 'threat']
},
sequence: 'post'
}
},
// One slot: `contact-press` and `commit-toggle` both stamp the pressed
// `item`, and the provider points both at the same element. `queue`
// makes the commit wait for the contact's ~120ms glimpse instead of
// erasing it (cap. 22 §8: "Contact inicia. Commit resuelve").
regime: 'queue'
},
{
// A pick blocked by the cardinality limit (at-max with `whenFull:

@ -61,7 +61,14 @@ export const toggleMorfo = {
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
}
},
// The surface is one slot and this pair cannot be separated: the
// provider IS the button, so contact and commit have nowhere else to
// go. `queue` makes the commit wait for the contact's ~120ms glimpse
// instead of erasing it — the book's two moments become two beats
// (cap. 22 §8: "Contact inicia. Commit resuelve"). The ORDER of sound
// and haptic is unchanged; only when the commit's turn starts moves.
regime: 'queue'
}
],
parts: [

@ -26,6 +26,12 @@ const EXCEPTIONS: Record<string, string> = {
// `dir` here is a field of `LangSpec` — the direction of each content
// LANGUAGE, not a prop of the component (axis handoff §9.19).
'field-langs': 'LangSpec.dir is per-language content metadata, not a component prop',
// Same shape as field-langs: its own `LangSpec` carries the direction of
// each CONTENT language for the multilingual document. Invisible to this
// census until 2026-08-10, when the hand-written `'ltr' | 'rtl'` union was
// replaced by the canonical `Direction` — the census greps the type name,
// so an inline union hid it (CONTINUE-direction §9.19 had it recorded).
palabras: 'LangSpec.dir is per-language content metadata, not a component prop',
// Accepts `dir` but deliberately does not run the chain: it has no
// directional maths of its own — the recipe mirrors via :dir() and the
// composed Slider runs the chain (axis handoff §9.18).

@ -375,6 +375,14 @@ const semaExpressionModeSchema = union(
literal('none')
);
// `renderAttrs` lives in BOTH the TypeScript union (`types.ts`) and here —
// same dual-list hazard as `MorfoElement`: extending only the type compiles
// clean and throws at runtime. Touch both in one edit.
const renderAttrSchema = object({
attr: string(),
values: optional(array(string()))
});
const morfoShallowSchema = object(
{
name: string(),
@ -385,6 +393,7 @@ const morfoShallowSchema = object(
events: optional(array(eventSchema)),
expression: optional(semaExpressionModeSchema),
translations: optional(object({}, { unknownKeys: 'passthrough' })),
renderAttrs: optional(array(renderAttrSchema)),
parts: array(object({}, { unknownKeys: 'passthrough' }))
// ^ parts are opaque here; walker recurses with `partShallowSchema`
},

@ -112,6 +112,91 @@ export const liveRegionFixtureMorfo = {
]
} as const satisfies Morfo;
/**
* The S5 conflict shape, isolated: an event that BOTH tunes its channels
* (`channels: ['sound']` — the author keeping haptic out of the way) AND
* declares `reducedMotionFallback: 'state'` (the morfo promising the user's
* reduced-motion preference is honoured by going silent). Until 2026-08-10 the
* first silently defeated the second.
*/
export const reducedMotionFixtureMorfo = {
name: 'ReducedMotionFixture',
kebab: 'reduced-motion-fixture',
scope: ['soma'],
events: [
{
name: 'commit-submit',
semantic: {
family: 'commit',
verb: 'submit',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post',
channels: ['sound']
},
a11ySemantic: { reducedMotionFallback: 'state' }
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;
/**
* SO4's shape: the prewrite lands on a DIFFERENT part than the emit target, so
* "the prewrite ran" and "the emit could run" are independently observable.
* `prewriteFixtureMorfo` cannot express it — there both share `content`, so a
* missing target hides the prewrite too and the test passes vacuously.
*/
export const prewriteSplitFixtureMorfo = {
name: 'PrewriteSplitFixture',
kebab: 'prewrite-split-fixture',
scope: ['soma'],
events: [
{
name: 'close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
prewrite: [{ part: v.partRef('header'), attr: 'data-last-action', value: 'cancelled' }]
}
],
parts: [
{
name: 'Header',
kebab: 'header',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [{ attr: 'data-last-action', values: ['cancelled'], severity: 'optional' }],
aria: []
},
{
name: 'Content',
kebab: 'content',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;
export const prewriteFixtureMorfo = {
name: 'PrewriteFixture',
kebab: 'prewrite-fixture',
@ -125,7 +210,6 @@ export const prewriteFixtureMorfo = {
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }
],

@ -455,7 +455,7 @@ export type MorfoEventSemantic = (
* the other axis: `allowedFamilies` declares the families a provider may
* concrete to, `allowedTargets` declares the parts it may land on.
*
* WHY IT EXISTS. `runtime.trigger(name, { fallbackTarget })` lets a
* WHY IT EXISTS. `runtime.trigger(name, { targetOverride })` lets a
* provider point at the exact interaction surface of a repeated part — the
* pressed day of a Calendar, the clicked page of a Pagination — and the
* option WINS over the registered ref of the declared target. That is
@ -879,6 +879,17 @@ export type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none
* Props live in the component's `types.ts` with JSDoc. Component-owned
* text slots live in `texts`.
*/
/**
* One `data-*` a component renders onto a non-part element. See
* {@link Morfo.renderAttrs} for the category and where its line is drawn.
*/
export interface MorfoRenderAttr {
/** The attribute, `data-{component}-…` like every other marker. */
attr: string;
/** Closed value set, when it has one (`['ltr','rtl']`, `['1'..'6']`). */
values?: readonly string[];
}
export interface Morfo {
/** Component display name, PascalCase. */
name: string;
@ -900,6 +911,35 @@ export interface Morfo {
* events exist, what family they belong to, and which intents are valid.
*/
events?: readonly MorfoEvent[];
/**
* `data-*` the component RENDERS onto elements that are not parts — and
* that eidos styles.
*
* **The line, and it is sharp: if a consumer can compose it or address it,
* it is a PART.** This is only for elements the component renders
* internally and the consumer never composes:
*
* - authored CONTENT a document engine renders (`palabras` marks the
* user's own `<blockquote>` / `<table>` / `<figure>` with block kind,
* heading level, syntax tokens, i18n state);
* - internal render nodes (`waveform`'s two `<path>`s, whose own source
* already calls them "eidos-only hooks").
*
* They cannot be `parts[]`: they land on nodes the morfo could never
* enumerate — the shape of the DOM depends on what the user wrote. And
* they cannot be `data-_*` (the private prefix, outside morfo by design)
* because eidos STYLES them: `palabras.css` alone has rules for 23 of
* them. A cross-layer contract that nothing declared, which is exactly
* what morfo exists to prevent.
*
* Declared here they become greppable, guarded (the catalogue guard reads
* this file) and reviewable. What the field does NOT buy is a runtime
* writer: the component emits them itself, as it always did.
*
* Added 2026-08-10, when lifting `palabras`' catalogue exemption exposed
* 23 undeclared ones.
*/
renderAttrs?: readonly MorfoRenderAttr[];
/**
* How the component projects its events onto the sema layer's perceptual
* channels. Per author decision 2026-05-26: `scope: ['sema']` declares

@ -43,6 +43,27 @@ describe('HapticChannel', () => {
expect(vibrateFn).not.toHaveBeenCalled();
});
it('never sends NaN to the device when the slice arrived PARTIAL (S-31)', async () => {
// `EffectiveSignature.haptic` is typed complete, but `applyOverride`
// installs a partial verbatim when the family base has no haptic slice
// (`emerge`, `shift`) — a pack rule saying `haptic: { kind: 'tick' }`
// reaches here with no intensity and no duration. `Math.min(1, undefined)`
// is NaN, and `vibrate(NaN)` is what the device would receive.
const vibrateFn = vi.fn().mockReturnValue(true);
const channel = new HapticChannel({
vibrateFn,
dom: { prefersReducedMotion: { matches: false } }
});
await channel.handle(
fakeSignal(),
effective({ haptic: { kind: 'tick' } as unknown as EffectiveSignature['haptic'] })
);
expect(vibrateFn).toHaveBeenCalledTimes(1);
const pattern = vibrateFn.mock.calls[0][0] as number | number[];
const values = Array.isArray(pattern) ? pattern : [pattern];
expect(values.every((v) => Number.isFinite(v))).toBe(true);
});
it('respects prefers-reduced-motion (no vibration when matches=true)', async () => {
const vibrateFn = vi.fn().mockReturnValue(true);
const channel = new HapticChannel({

@ -163,11 +163,23 @@ export class HapticChannel implements Channel {
// not its length.
if (profile.pattern) return [...profile.pattern];
// S-31 defence. `EffectiveSignature.haptic` is TYPED as a complete
// `HapticSignature`, but the resolver can install a PARTIAL one: when a
// cascade rule declares `haptic: { kind: 'tick' }` on a family whose base
// has no haptic slice (`emerge`, `shift`), `applyOverride` clones that
// partial as if it were whole. `intensity`/`duration` then arrive
// `undefined`, `Math.min(1, undefined)` is `NaN`, and the device gets
// `vibrate(NaN)`. Latent today only because those same rules are inert
// (S-30/S-38) — the moment one is widened with `channels`, it fires. The
// value that reaches the device is never allowed to be NaN.
const safeIntensity = Number.isFinite(intensity) ? intensity : 1;
const safeDuration = Number.isFinite(duration) ? duration : profile.floorMs;
// Vibration API does not expose intensity; modulate via duration.
// Clamp intensity so even at 0 a haptic still vibrates briefly —
// otherwise `navigator.vibrate(0)` is a silent cancel.
const clamped = Math.max(0.3, Math.min(1, intensity));
const dur = Math.max(profile.floorMs, Math.round(duration * clamped));
const clamped = Math.max(0.3, Math.min(1, safeIntensity));
const dur = Math.max(profile.floorMs, Math.round(safeDuration * clamped));
const scaled = Math.max(profile.floorMs, Math.round(dur * (profile.scale ?? 1)));
return profile.gapMs === undefined ? scaled : [scaled, profile.gapMs, scaled];

@ -81,6 +81,23 @@ export class VisualChannel implements Channel {
return this.projector.project(signal);
}
/**
* The hold this signal will observe — the REGISTRATION FLOOR, resolved by
* the one chain below. Public because the engine needs the same number to
* free the perceptual surface for a `regime: 'queue'` occurrence, and a
* second resolution path would be a second truth.
*
* Deliberately NOT the whole expression: `awaitExpression` waits for every
* running animation on the target (up to `MAX_EXPRESSION_WAIT_MS`), which
* is right for unstamping and wrong for queueing — measured on a real
* Toggle, the queued commit arrived **1.6 s** late because unrelated
* transitions kept the target busy. What the first occurrence is owed is
* its floor; what it does afterwards must not gate the next one.
*/
holdMsFor(signal: SemanticSignal, effective: EffectiveSignature): number {
return this.resolveHoldMs(signal, effective);
}
async handle(signal: SemanticSignal, effective: EffectiveSignature): Promise<void> {
const holdMs = this.resolveHoldMs(signal, effective);
if (holdMs <= 0) return;

@ -10,6 +10,7 @@
// `toast.ts`, ...) and export a `{name}Sema: Sema`.
export { accordionSema } from './accordion';
export { calendarSema } from './calendar';
export { chatComposerSema } from './chat-composer';
export { chatLogSema } from './chat-log';
export { chatMessageSema } from './chat-message';
@ -32,6 +33,8 @@ export { editableSema } from './editable';
export { fileUploadSema } from './file-upload';
export { floatPanelSema } from './float-panel';
export { formSema } from './form';
export { gradientBuilderSema } from './gradient-builder';
export { gradientPickerSema } from './gradient-picker';
export { imageAdjustmentsSema } from './image-adjustments';
export { imagePickerSema } from './image-picker';
export { knobSema } from './knob';

@ -130,4 +130,108 @@ describe('EngineSemantic.emit (integration with built-in VisualChannel)', () =>
await engine.emit({ target, name: 'announce', family: 'signal', intent: 'risk', hold: 9999 });
expect(target.hasAttribute('data-event')).toBe(false);
});
// ── regime: the surface is one slot (2026-08-10) ─────────────────────────
//
// `data-event-*` is a single slot per element. Where two events land on one
// node because a redirection put them there, the fix is to stop redirecting
// (A-36). Where the doctrine REQUIRES both on one node — a toggle's
// `contact-press` + `commit-toggle`, whose provider IS the button — there is
// nothing to re-target, and the second must wait its turn.
describe('regime', () => {
it("`replace` (the default) hands the surface over immediately", async () => {
const engine = new EngineSemantic({ dom: TEST_DOM });
const first = engine.emit({ target, name: 'contact-press', family: 'contact', hold: 120 });
const second = engine.emit({ target, name: 'commit-toggle', family: 'commit', intent: 'neutral', hold: 240 });
// No await in between: the newcomer took the slot at once.
expect(target.getAttribute('data-event')).toBe('commit-toggle');
await vi.advanceTimersByTimeAsync(400);
await Promise.all([first, second]);
});
it('`queue` waits for the live occurrence, so BOTH get their turn', async () => {
const engine = new EngineSemantic({ dom: TEST_DOM });
const seen: string[] = [];
const sample = () => seen.push(target.getAttribute('data-event') ?? '(clear)');
const first = engine.emit({ target, name: 'contact-press', family: 'contact', hold: 120 });
const second = engine.emit({
target,
name: 'commit-toggle',
family: 'commit',
intent: 'neutral',
hold: 240,
regime: 'queue'
});
sample(); // t≈0 — the contact holds the surface
await vi.advanceTimersByTimeAsync(60);
sample(); // t≈60 — still the contact
await vi.advanceTimersByTimeAsync(120);
sample(); // t≈180 — the contact finished; the commit took over
await vi.advanceTimersByTimeAsync(400);
await Promise.all([first, second]);
sample(); // both done
expect(seen).toEqual(['contact-press', 'contact-press', 'commit-toggle', '(clear)']);
});
it('the queue waits for the HOLD, not for the holder whole expression', async () => {
// Measured on a real Toggle before this fix: the queued `commit-toggle`
// arrived **1.6 s** after the click, because the holder's emit also
// awaits `awaitExpression` — every running animation on the target,
// capped at MAX_EXPRESSION_WAIT_MS (1500). Unrelated transitions on the
// button kept it busy to the cap. Fake timers hid it: with no
// animations `awaitExpression` returns at once.
//
// A target that reports a never-ending animation reproduces it.
const forever = { playState: 'running', finished: new Promise<void>(() => {}) };
(target as unknown as { getAnimations: () => unknown[] }).getAnimations = () => [forever];
const engine = new EngineSemantic({ dom: TEST_DOM });
void engine.emit({ target, name: 'contact-press', family: 'contact', hold: 120 });
const queued = engine.emit({
target,
name: 'commit-toggle',
family: 'commit',
intent: 'neutral',
hold: 240,
regime: 'queue'
});
// Just past the holder's hold — its expression is still pinned, but
// the surface is already free.
await vi.advanceTimersByTimeAsync(150);
expect(target.getAttribute('data-event')).toBe('commit-toggle');
await vi.advanceTimersByTimeAsync(2000);
await queued;
});
it('a queued emit still runs when the holder REJECTS (the surface always frees)', async () => {
// The holder's projection throwing must not strand the queue: the
// release lives in `finally`, not on the happy path. Only the FIRST
// emit fails — a channel that always threw would fail the queued one
// for its own reason and prove nothing about the surface.
let calls = 0;
const boom = new EngineSemantic({
dom: TEST_DOM,
visual: {
id: 'visual',
prepare: () => undefined,
handle: async () => {
if (++calls === 1) throw new Error('projection failed');
}
} as never
});
const holder = boom.emit({ target, name: 'contact-press', family: 'contact' });
await expect(holder).rejects.toThrow('projection failed');
// Queued behind a holder that already failed: must settle, not hang.
await expect(
boom.emit({ target, name: 'commit-toggle', family: 'commit', intent: 'neutral', regime: 'queue' })
).resolves.toBeTypeOf('string');
expect(calls).toBe(2);
});
});
});

@ -297,6 +297,17 @@ export class EngineSemantic {
/** Occurrences still "active" for dominance (C-3), keyed by occurrence id. */
private readonly activeOccurrences = new Map<string, { rank: number; expire: SemaTimerHandle }>();
/**
* The perceptual SURFACE registry: target → a promise that settles when the
* occurrence currently projecting on it is done.
*
* `data-event-*` is one slot per element, so two occurrences on a node
* cannot both express. `regime: 'queue'` reads this to wait its turn
* instead of displacing (`SemaRegime`). Keyed by element and cleared by the
* holder itself, so it never outlives the emit that created it.
*/
private readonly surfaces = new Map<HTMLElement, Promise<void>>();
constructor(opts: EngineSemanticOptions = {}) {
this.logger = opts.logger;
this.timers = opts.timers;
@ -603,6 +614,35 @@ export class EngineSemantic {
return id;
}
// `queue` — the surface is one slot, and this occurrence declared that it
// waits rather than displacing. Only the IRREDUCIBLE pairs use it: two
// events the doctrine requires on one node (a toggle's contact + commit;
// the knob's drop + set). Where the collision came from a redirection the
// answer was to stop redirecting (A-36), not to arbitrate.
//
// The WHOLE emit waits, sound included — not just the projection. The
// cascade resolves by matching selectors against the `data-event-*` this
// signal is about to write, so projecting late while resolving early
// would read the OTHER occurrence's stamp and pick its sound.
//
// Audio unlock is safe: you only queue behind something that just fired
// on the same gesture, and that one already primed the context.
if (signal.regime === 'queue' && signal.target) {
const live = this.surfaces.get(signal.target);
if (live) await live;
}
// Register this occurrence as the surface's holder, so a later `queue`
// waits for it. Resolved in the `finally` below — including the failure
// path, so a throw can never leave a surface permanently busy.
let releaseSurface: (() => void) | undefined;
let heldPromise: Promise<void> | undefined;
if (signal.target) {
heldPromise = new Promise<void>((resolve) => {
releaseSurface = resolve;
});
this.surfaces.set(signal.target, heldPromise);
}
const preparations: ChannelPreparation[] = [];
for (const channel of this.channels.values()) {
const handle = channel.prepare?.(enriched);
@ -633,6 +673,23 @@ export class EngineSemantic {
if (channel.id !== 'visual') otherChannels.push(channel);
}
// Free the surface when the HOLD elapses, not when the emit ends.
// The emit also waits for `awaitExpression` — every running animation
// on the target, capped at 1500ms — and gating the queue on that was
// measured on a real Toggle to delay the queued commit by **1.6s**,
// because unrelated transitions kept the node busy. What the holder is
// owed is its registration floor; what happens after must not block
// the next occurrence. The displaced projection is safe either way:
// the unstamp checks ownership (`data-event-id`), so the holder's late
// cleanup is a no-op once the queued one has taken the slot.
if (releaseSurface && visualChannel instanceof VisualChannel) {
const holdMs = visualChannel.holdMsFor(enriched, effective);
semaDelay(this.timers, holdMs, releaseSurface, {
channel: 'visual',
signal: enriched.id
});
}
for (const channel of otherChannels) {
channel.handle(enriched, effective).catch((err) => {
this.logger?.error('sema', `channel "${channel.id}" failed`, {
@ -658,6 +715,14 @@ export class EngineSemantic {
}
cleanedUp = true;
}
// Free the surface for anyone queued behind it. Two guards: only the
// CURRENT holder clears the entry (a later `replace` may already have
// taken it), and this runs on the throw path too — a rejected emit
// must not leave a surface busy forever.
if (signal.target && this.surfaces.get(signal.target) === heldPromise) {
this.surfaces.delete(signal.target);
}
releaseSurface?.();
}
// Persistent signals: keep projection alive past the hold. Caller

@ -504,6 +504,23 @@ describe('pack census — every cascade rule can fire, and fires where the morfo
).toEqual([]);
});
it('re-exports every pack from the barrel (the canon says "import from the barrel")', () => {
// S-36: 71 pack files, 68 re-exports. `calendar`, `gradient-builder` and
// `gradient-picker` were missing, so every consumer following the canon
// ("import from the barrel") silently got 68 of 71 — those three emitted
// with the raw family base instead of their authored signature, with no
// error anywhere. The blocks surface derives its pack list from the
// barrel, which is exactly how it lost them.
const barrel = readFileSync(join(__dirname, 'components', 'index.ts'), 'utf-8');
const missing = PACKS.filter(({ file }) => !barrel.includes(`from './${file}'`)).map(
({ file }) => file
);
expect(
missing,
'a pack file with no barrel export is invisible to anyone importing the canonical way'
).toEqual([]);
});
it('keeps the exception lists honest (every entry still names a live case)', () => {
const ruleIds = new Set(RULES.map((r) => r.id));
const stale = [

@ -85,6 +85,49 @@ describe('DomSignalProjector', () => {
expect(target.hasAttribute('data-event')).toBe(false);
});
// ── Ownership (2026-08-10) ───────────────────────────────────────────────
//
// The dangerous order is the one the test above never exercised and whose
// own comment admitted it: the OLDER signal cleaning up while the NEWER one
// is still mid-hold. The surface holds one occurrence, so an unconditional
// wipe let A erase B. Measured three times without ever being fixed —
// fable S1, sema S-17 (knob: 24 ms of a 240 ms projection), blocks A-36.
describe('ownership: an older cleanup never erases a live newer stamp', () => {
it('A.cleanup() with B still stamped leaves B intact', () => {
const handleA = projector.project(makeSignal({ id: 'sig-A', family: 'contact' }));
const handleB = projector.project(makeSignal({ id: 'sig-B', family: 'commit' }));
// A finishes its (shorter) hold first and cleans up.
handleA?.cleanup();
// B is still live: the whole surface must survive untouched.
expect(target.getAttribute('data-event-id')).toBe('sig-B');
expect(target.getAttribute('data-event-family')).toBe('commit');
expect(target.getAttribute('data-event-phase')).toBe('active');
// And B still owns its own retirement.
handleB?.cleanup();
expect(target.hasAttribute('data-event-id')).toBe(false);
expect(target.hasAttribute('data-event-phase')).toBe(false);
});
it('a persistent stamp survives a later transient occurrence cleaning up', () => {
// form.signal-warn-invalid is `untilFix`: it stays projected past its
// hold, and the provider owns `clear`. A transient commit on the same
// provider used to erase it while `engine.active` still held it — the
// warning vanished from the screen and `clear(id)` had nothing left
// to retire (S-17b).
const warning = projector.project(makeSignal({ id: 'sig-warn', family: 'signal' }));
const commit = projector.project(makeSignal({ id: 'sig-commit', family: 'commit' }));
commit?.cleanup();
// The transient owns the surface it overwrote, so it clears it…
expect(target.hasAttribute('data-event-id')).toBe(false);
// …and the persistent handle, now stale, must not resurrect or wipe.
warning?.cleanup();
expect(target.hasAttribute('data-event-id')).toBe(false);
});
});
it('returns undefined when signal has no target', () => {
// Signal type requires `target`, but the projector defends against
// pathological cases (e.g. a future projector accepting target-less

@ -16,9 +16,15 @@
* CustomEvent además/en lugar de los data-attrs) sin modificar
* el ciclo de vida del engine.
*
* El handle pattern es importante: retiene el `signal` por closure, así
* el cleanup retira EXACTAMENTE los attrs que escribió (incluso si la
* referencia al signal cambia entre emit y cleanup).
* El handle pattern es importante: retiene el `signal` por closure, así el
* cleanup puede comprobar que el estampado vivo SIGUE SIENDO el suyo
* (`data-event-id === signal.id`) antes de retirarlo.
*
* ⚠️ Hasta el 2026-08-10 este comentario decía que el cleanup «retira
* EXACTAMENTE los attrs que escribió», y era FALSO: `unstampEventAttrs`
* recibía el signal y lo descartaba, así que borraba lo que hubiera. La
* comprobación de propiedad vive ahora en `unstampEventAttrs`; aquí sólo se
* conserva el signal que la alimenta.
*/
import type { SemanticSignal } from '../signal'

@ -11,7 +11,7 @@
import type { ActorRef } from '$libs/actor';
import type { SemaChannelId, SemaSignatureOverride } from './channels';
import type { Intent } from '../intent';
import type { SemaFamily, SignalPersistence } from './types';
import type { SemaFamily, SemaRegime, SignalPersistence } from './types';
export interface SemanticSignal {
/** DOM target donde se proyecta la señal en el canal visual. */
@ -111,4 +111,14 @@ export interface SemanticSignal {
* See {@link SignalPersistence} for the per-value lifecycle.
*/
persistence?: SignalPersistence;
/**
* What happens when this occurrence arrives and the target surface is
* already carrying a live one. Copied by `SomaRuntime` from the morfo
* event's `regime`; default `'replace'`.
*
* The surface is ONE slot per element (`data-event*`), so two occurrences
* on one node cannot both express. See {@link SemaRegime}.
*/
regime?: SemaRegime;
}

@ -56,11 +56,31 @@ export function stampEventAttrs(
});
}
/**
* Retire the projection — but ONLY if it is still the one this signal wrote.
*
* The surface is one slot per element (`data-event`, `-id`, `-phase`,
* `-family`, `-intent`), so a second occurrence on the same node overwrites
* the first. Until 2026-08-10 the cleanup took the signal and DISCARDED it
* (`_signal`), wiping whatever it found: the older signal, finishing its hold
* first, erased the projection of the newer one mid-hold. Three independent
* audits measured it — fable S1 (2026-07-01), sema S-17 (2026-08-05, knob:
* `commit-set` erased 24 ms after birth instead of 240) and blocks A-36/A-65
* (2026-08-01/09, reproduced in the browser) — and it survived all three.
*
* The id is already in the DOM, so ownership costs one attribute read. When it
* does not match, the stamp belongs to another occurrence and the right thing
* to do is nothing: whoever owns it will retire it when ITS hold ends.
*/
export function unstampEventAttrs(
target: HTMLElement,
_signal: SemanticSignal,
signal: SemanticSignal,
dom: DomApplier
): void {
const owner = target.getAttribute('data-event-id');
// `owner === null` means nothing is stamped — cleaning is a harmless no-op
// and keeps the function idempotent, which the projection handle relies on.
if (owner !== null && signal.id !== undefined && owner !== signal.id) return;
dom.apply({
target,
attrs: clearEventAttrs()

@ -102,7 +102,47 @@ export type IntentExpectedFamily = IntentRequiredFamily;
export type IntentOptionalFamily = Exclude<SemaFamily, IntentRequiredFamily>;
export type SemaMode = 'blocking' | 'advisory';
export type SemaRegime = 'replace' | 'collapse' | 'lock' | 'queue';
/**
* Concurrency policy for ONE perceptual surface.
*
* The `data-event-*` projection is a single slot per element: two occurrences
* on the same node cannot both express. This says what happens when an
* occurrence ARRIVES and the surface is already carrying a live one.
*
* | Value | Behaviour |
* |---|---|
* | `replace` | Takes the surface immediately, displacing the live projection. **Default.** |
* | `queue` | Waits for the live occurrence to finish, then takes the surface. |
*
* **`replace` is not a feature, it is the floor** — and it only became correct
* on 2026-08-10, when the unstamp learnt to check ownership
* (`data-event-id`). Before that the displaced occurrence's cleanup wiped the
* displacing one mid-hold: three audits measured it (fable S1, sema S-17,
* blocks A-36/A-65) and none fixed it.
*
* **`queue` is for the irreducible pairs only** — two events the doctrine
* REQUIRES on one node, where no amount of re-targeting separates them: a
* toggle's `contact-press` + `commit-toggle` (its provider IS the button), the
* knob's `handle-drop` + `commit-set`. Where the collision comes from a
* redirection instead (the overlays' `open` stamping their trigger), the fix
* is to stop redirecting — that is how A-36 was closed, without this enum.
*
* Two values that were declared here and never meant anything:
*
* - `collapse` (extend the live occurrence instead of re-stamping) was
* proposed for continuous gestures. MEASURED after the ownership fix: five
* `handle-drag` emits 72 ms apart with a 240 ms hold leave **zero gaps** —
* the flicker it existed to cure is already gone, and reusing the
* `data-event-id` would undermine the ownership check that cured it.
* - `lock` (yield to whoever holds the surface) was declared by the four
* overlays' `close` and did nothing. Under this semantics it would mean a
* close that does not announce itself when something else is projecting —
* the opposite of what an exit signal is for.
*
* Both retired 2026-08-10 rather than left as a contract that lies.
*/
export type SemaRegime = 'replace' | 'queue';
export type SemaScope = 'part' | 'component' | 'scene';
export type SemaCause = 'keyboard' | 'pointer' | 'programmatic' | 'validation';

@ -70,7 +70,7 @@ The three terminals are **separate events with intrinsic intent** (the Dialog
`TriggerOptions` carries no intent, and the morfo's `fromProp` binds intent to
a PUBLIC prop — the outcome is machine state, not a consumer prop. They target
the **Ring** (the custody shape is where a close reads); the provider passes
the ring element as `fallbackTarget` when one is mounted, because an explicit
the ring element as `targetOverride` when one is mounted, because an explicit
fallback WINS over the declared part ref.
## State mapping (custody of control)

@ -171,14 +171,14 @@ export class AuraProvider {
#onTransition(prev: AuraState, next: AuraState): void {
this.#clearHeld(prev);
const fallbackTarget = this.opts.ref.current ?? undefined;
const targetOverride = this.opts.ref.current ?? undefined;
switch (next) {
case 'offered':
this.#announce(AURA_LANGS.ANNOUNCE_OFFER);
void this.runtime.trigger('delegate-offer', { fallbackTarget });
void this.runtime.trigger('delegate-offer', { targetOverride });
break;
case 'acting':
void this.runtime.trigger('sustain-processing', { fallbackTarget }).then((result) => {
void this.runtime.trigger('sustain-processing', { targetOverride }).then((result) => {
// State-bound: hold the id while acting; if the run already
// moved on before the emit resolved, clear immediately.
if (this.state === 'acting') this.#processingId = result.id;
@ -187,7 +187,7 @@ export class AuraProvider {
break;
case 'escalated':
this.#announce(AURA_LANGS.ANNOUNCE_ESCALATED);
void this.runtime.trigger('delegate-escalate', { fallbackTarget }).then((result) => {
void this.runtime.trigger('delegate-escalate', { targetOverride }).then((result) => {
if (this.state === 'escalated') this.#escalationId = result.id;
else if (result.id) this.runtime.clearSignal(result.id);
});
@ -211,12 +211,12 @@ export class AuraProvider {
: ([AURA_LANGS.ANNOUNCE_RETURNED, 'delegate-return'] as const);
this.#announce(text);
// The terminal firma belongs on the Ring (the custody shape is
// where a close reads). An explicit `fallbackTarget` WINS over
// where a close reads). An explicit `targetOverride` WINS over
// the morfo's declared part ref, so it must BE the ring when one
// is mounted — otherwise the provider's own ref would silently
// override the declared target.
void this.runtime.trigger(event, {
fallbackTarget: this.runtime.partRef('ring') ?? fallbackTarget
targetOverride: this.runtime.partRef('ring') ?? targetOverride
});
break;
}

@ -336,7 +336,7 @@ export class CalendarProvider implements CalendarNav {
if (this.opts.placeholder.current.compare(nextMonth) === 0) return;
this.opts.placeholder.current = nextMonth;
this.announceNavigation();
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('shift-navigate', target ? { targetOverride: target } : undefined);
}
prevPage(target?: HTMLElement): void {
@ -453,7 +453,7 @@ export class CalendarProvider implements CalendarNav {
this.opts.value.current = next;
eventName = next.some((d) => isSameDay(d, date)) ? 'commit-select' : 'commit-unselect';
}
void this.runtime.trigger(eventName, target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(eventName, target ? { targetOverride: target } : undefined);
}
isSelected(date: DateValue): boolean {

@ -297,7 +297,7 @@ export class CarouselProvider {
`[${attrs.item}][data-index="${target}"]`
);
if (slideEl) {
void this.runtime.trigger('shift-navigate-slide', { fallbackTarget: slideEl });
void this.runtime.trigger('shift-navigate-slide', { targetOverride: slideEl });
}
}

@ -145,7 +145,7 @@ export class CheckboxProvider {
const target = triggerEl ?? this.opts.ref?.current ?? null;
const eventName = next ? 'commit-toggle-check' : 'commit-toggle-uncheck';
void this.runtime.trigger(eventName, target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(eventName, target ? { targetOverride: target } : undefined);
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {

@ -0,0 +1,100 @@
# Chronos
Headless **event calendar** (scheduler). Not a date picker: cells host event
chips, chips are draggable / resizable, and the component owns an editor (a
reused Dialog) plus an undo/redo command stack.
The contract is declared in
[`morfo/components/chronos.ts`](../../../morfo/components/chronos.ts) — this
README documents the soma surface only. Design and competitive rationale live in
[`eidos/components/chronos/SPEC.md`](../../../eidos/components/chronos/SPEC.md).
## Anatomy
```svelte
<Chronos.Provider bind:events bind:view bind:focusedDate>
<!-- toolbar: navigation + undo/redo + the period heading -->
<!-- grid (month) | time-grid (week/day) | agenda — one per `view` -->
</Chronos.Provider>
```
The eidos root ([`<Chronos>`](../../../eidos/components/chronos/chronos.svelte))
composes the parts; consumers use that root rather than assembling the grid by
hand.
## Parts
Grouped by the view that renders them. `Provider` is the ARIA `application`
root; the three view bodies are mutually exclusive.
| Group | Parts |
| --- | --- |
| Root | `Provider` |
| Toolbar | `Toolbar` · `PrevButton` · `NextButton` · `TodayButton` · `UndoButton` · `RedoButton` · `Heading` |
| Month grid | `Grid` · `GridHead` · `HeadCell` · `GridBody` · `GridRow` · `DayCell` · `DayNumber` · `EventChip` · `EventResizeHandle` · `MoreLink` |
| Week / day | `TimeGrid` · `AllDayRow` · `DayColumn` · `TimeSlot` · `HourLabel` · `NowIndicator` |
| Agenda | `Agenda` · `AgendaGroup` · `AgendaItem` |
## ARIA
Chronos implements the WAI-ARIA
[Grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/).
| Part | `role` |
| --- | --- |
| Provider | `application` |
| Grid · TimeGrid | `grid` |
| GridHead · GridRow · DayColumn | `row` |
| HeadCell | `columnheader` |
| GridBody | `rowgroup` |
| DayCell · TimeSlot | `gridcell` |
| EventChip · MoreLink · AgendaItem · the five toolbar buttons | `button` |
| AgendaGroup | `group` |
## Keyboard
On the month grid (`Grid`):
| Key | Action |
| --- | --- |
| `ArrowLeft` / `ArrowRight` | previous / next day |
| `ArrowUp` / `ArrowDown` | previous / next week |
| `PageUp` / `PageDown` | previous / next month |
| `Home` / `End` | first / last day of week |
| `Enter` · `Space` | create an event, or open the focused one |
On the time grid (`TimeSlot`): `ArrowUp` / `ArrowDown` move by hour,
`ArrowLeft` / `ArrowRight` by day.
## Semantic events
Declared in the morfo and shipped with a sema pack
([`sema/components/chronos.ts`](../../../sema/components/chronos.ts)).
| Event | Family · verb | Target |
| --- | --- | --- |
| `shift-navigate` | `shift` · `navigate` | `provider` |
| `commit-save` | `commit` · `save` | `event-chip` |
| `commit-move` | `commit` · `move` | `event-chip` |
| `commit-delete` | `commit` · `delete` | `event-chip` |
| `commit-restore` | `commit` · `restore` | `provider` |
| `commit-apply` | `commit` · `apply` | `provider` |
| `handle-drag` | `handle` · `drag` | `event-chip` |
| `handle-resize` | `handle` · `resize` | `event-resize-handle` |
> **Known gap (audit S-37, 2026-08-05):** the pack ships rules for `handle-drag`
> and `handle-resize`, but no provider emits either — direct manipulation is
> currently perceptually silent. Either the provider emits them when it
> translates the reused drag-drop into commands, or the two rules retire.
The editor's open/close is **not** declared here: it is a reused Dialog, and the
`emerge` pair belongs to Dialog's own morfo. Chronos only orchestrates when the
Dialog opens.
## Notes
- Undo/redo are `commit-restore` / `commit-apply` (both canonical `commit`
verbs): restore re-applies the previous snapshot (neutral), apply re-applies
the undone one (affirm).
- The reusable brain (month-grid maths + the dual-axis overlap solver) is
exported from `exports.ts` as a pure, DOM-free surface.

@ -357,7 +357,7 @@ export class ChronosProvider {
private emitRange(target?: HTMLElement): void {
this.opts.onRangeChange?.(this.visibleRange);
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('shift-navigate', target ? { targetOverride: target } : undefined);
}
prev(target?: HTMLElement): void {
@ -449,7 +449,7 @@ export class ChronosProvider {
if (first && last && (date.compare(first) < 0 || date.compare(last) > 0)) {
this.calendar.goTo(startOfMonth(date));
this.announceNav();
void this.runtime.trigger('shift-navigate', from ? { fallbackTarget: from } : undefined);
void this.runtime.trigger('shift-navigate', from ? { targetOverride: from } : undefined);
}
queueMicrotask(() => {
const root = this.opts.ref.current;
@ -558,12 +558,12 @@ export class ChronosProvider {
}
undo(target?: HTMLElement): void {
if (this.calendar.undo()) {
void this.runtime.trigger('commit-restore', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-restore', target ? { targetOverride: target } : undefined);
}
}
redo(target?: HTMLElement): void {
if (this.calendar.redo()) {
void this.runtime.trigger('commit-apply', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-apply', target ? { targetOverride: target } : undefined);
}
}
@ -1181,14 +1181,14 @@ export class ChronosProvider {
this.calendar.dispatch({ type: 'update', id: draft.id, patch: draft });
this.opts.onEventUpdate?.(draft);
}
void this.runtime.trigger('commit-save', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-save', target ? { targetOverride: target } : undefined);
this.closeEditor();
}
deleteEvent(id: string, target?: HTMLElement): void {
if (this.calendar.dispatch({ type: 'delete', id })) {
this.opts.onEventDelete?.(id);
void this.runtime.trigger('commit-delete', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-delete', target ? { targetOverride: target } : undefined);
}
if (this.editing?.draft.id === id) this.closeEditor();
}
@ -1199,7 +1199,7 @@ export class ChronosProvider {
if (this.calendar.dispatch({ type: 'move', id, start, end })) {
const moved = this.calendar.events.find((e) => e.id === id);
if (moved) this.opts.onEventUpdate?.(moved);
void this.runtime.trigger('commit-move', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-move', target ? { targetOverride: target } : undefined);
}
}
@ -1245,7 +1245,7 @@ export class ChronosProvider {
if (this.calendar.dispatch({ type: 'resize', id, end })) {
const resized = this.calendar.events.find((e) => e.id === id);
if (resized) this.opts.onEventUpdate?.(resized);
void this.runtime.trigger('commit-save', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-save', target ? { targetOverride: target } : undefined);
}
}

@ -78,7 +78,7 @@ export class ClipboardProvider {
this.opts.onCopy.current?.(value);
void this.runtime.trigger(
'commit-save-copy',
triggerEl ? { fallbackTarget: triggerEl } : undefined
triggerEl ? { targetOverride: triggerEl } : undefined
);
this.clearResetTimer();
@ -95,7 +95,7 @@ export class ClipboardProvider {
this.opts.onError.current?.(err);
void this.runtime.trigger(
'commit-fail-copy',
triggerEl ? { fallbackTarget: triggerEl } : undefined
triggerEl ? { targetOverride: triggerEl } : undefined
);
throw err;
}

@ -340,7 +340,7 @@ export class ColorFieldProvider {
// the hidden form input; pass the visible field box explicitly so the
// kebab-keyed registry can't resolve the target to the hidden input.
if (this.opts.value.current?.hex !== prevHex) {
void this.runtime.trigger('commit-set', { fallbackTarget: this.fieldNode ?? undefined });
void this.runtime.trigger('commit-set', { targetOverride: this.fieldNode ?? undefined });
// Terminal callback: only on a completed value, never on clear (N2).
if (this.opts.value.current !== undefined) {
this.opts.onValueCommit.current?.(this.opts.value.current);

@ -384,7 +384,7 @@ provider DOM) throws:
```
soma::runtime.target: [soma-runtime] Trigger "commit-set" target part
"provider" has no DOM element registered (and no fallbackTarget supplied).
"provider" has no DOM element registered (and no targetOverride supplied).
```
The fix is to forward the ref. In a rich bag that is a `{ get, set }` entry;
@ -412,7 +412,7 @@ ColorPicker's. So `this.runtime.partRef('content')` from inside the picker
provider returns `null`.
`triggerClose` originally relied on that lookup to populate
`fallbackTarget` for `runtime.trigger('close', ...)`, which made every
`targetOverride` for `runtime.trigger('close', ...)`, which made every
`Cancel`/`Done`/`Clear` click throw `SomaRuntimeTargetError`. The fix is
to fall back to the picker provider's own DOM (now reliably registered
after fix #2):
@ -424,7 +424,7 @@ if (contentTarget) {
}
const fallback = contentTarget ?? this.opts.ref?.current ?? undefined;
void this.runtime.trigger('close', {
...(fallback ? { fallbackTarget: fallback } : {}),
...(fallback ? { targetOverride: fallback } : {}),
semantic: CAUSES[cause].semantic
});
```

@ -244,7 +244,7 @@ export class ComboboxProvider {
const eventTarget = target ?? this.resolveItemEl(value);
if (result.event && eventTarget) {
void this.runtime.trigger(result.event, { fallbackTarget: eventTarget });
void this.runtime.trigger(result.event, { targetOverride: eventTarget });
}
}
@ -254,7 +254,7 @@ export class ComboboxProvider {
// emerge.open — the popup appeared. The content isn't mounted yet, so
// fall back to the trigger / input for the perceptual stamp.
void this.runtime.trigger('emerge-open', {
fallbackTarget: this.triggerRef.current ?? this.inputRef.current ?? undefined
targetOverride: this.triggerRef.current ?? this.inputRef.current ?? undefined
});
}
@ -266,7 +266,7 @@ export class ComboboxProvider {
// Guard on wasOpen so a redundant close (already shut) stays silent.
if (wasOpen) {
void this.runtime.trigger('emerge-close', {
fallbackTarget: this.triggerRef.current ?? this.inputRef.current ?? undefined
targetOverride: this.triggerRef.current ?? this.inputRef.current ?? undefined
});
}
this.highlightedId = '';

@ -405,7 +405,7 @@ export class CommandProvider {
`[${attrs.item}][data-value="${CSS.escape(value)}"]`
);
if (itemEl) {
void this.runtime.trigger('commit-submit-invoke', { fallbackTarget: itemEl });
void this.runtime.trigger('commit-submit-invoke', { targetOverride: itemEl });
}
}
@ -732,7 +732,7 @@ export class CommandItemProvider {
// Morfo: commit-submit-invoke on `item` (affirm). Fire with the
// clicked element so the cascade matches this specific item.
void this.provider.runtime.trigger('commit-submit-invoke', {
fallbackTarget: e.currentTarget
targetOverride: e.currentTarget
});
};

@ -134,7 +134,7 @@ export class ContextMenuProvider {
// anchor for the "about to open" cue). The runtime throws if no
// target resolves, so undefined is not a silent skip.
const target = this.contentRef.current ?? this.runtime.partRef('trigger');
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('open', target ? { targetOverride: target } : undefined);
this.opts.open.current = true;
}
@ -143,7 +143,7 @@ export class ContextMenuProvider {
// Content still in DOM at this point; cascade matches before flip.
// Trigger fallback for parity with openAt (content unmounted in races).
const target = this.contentRef.current ?? this.runtime.partRef('trigger');
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('close', target ? { targetOverride: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
}
@ -464,7 +464,7 @@ export class ContextMenuItemProvider {
// Morfo: `commit-select` on `item` (affirm). Fire with the
// clicked element so the cascade matches this specific item.
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
targetOverride: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
@ -475,7 +475,7 @@ export class ContextMenuItemProvider {
e.preventDefault();
this.opts.onSelect.current();
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
targetOverride: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
}

@ -358,12 +358,12 @@ export class CssFieldProvider {
commit(
value: string | undefined,
opts: { clamp?: boolean; fallbackTarget?: HTMLElement } = {}
opts: { clamp?: boolean; targetOverride?: HTMLElement } = {}
): void {
this.pendingCommit = { value, clamp: opts.clamp ?? true };
void this.runtime.trigger(
'commit-set',
opts.fallbackTarget ? { fallbackTarget: opts.fallbackTarget } : undefined
opts.targetOverride ? { targetOverride: opts.targetOverride } : undefined
);
}
@ -378,7 +378,7 @@ export class CssFieldProvider {
? this.soma.langs.ts(message)
: (message ?? this.soma.langs.ts(CSS_FIELD_LANGS.INVALID));
void this.runtime.trigger('signal-warn-invalid', {
...(input ? { fallbackTarget: input } : {}),
...(input ? { targetOverride: input } : {}),
message: text
});
}
@ -765,7 +765,7 @@ export class CssFieldScrubberProvider {
this.startUnit = p && p.kind === 'dimension' && p.unit ? p.unit : this.provider.defaultUnit;
this.scrubStep = this.provider.step;
void this.provider.runtime.trigger('handle-pick', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
};
@ -784,7 +784,7 @@ export class CssFieldScrubberProvider {
e.preventDefault();
void this.provider.runtime.trigger('handle-drag-scrub', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
const sensitivity = this.opts.sensitivity.current;
@ -810,7 +810,7 @@ export class CssFieldScrubberProvider {
this.captured = false;
this.provider.scrubbing = false;
this.provider.commit(this.provider.opts.value.current, {
fallbackTarget: e?.currentTarget as HTMLElement | undefined
targetOverride: e?.currentTarget as HTMLElement | undefined
});
};
@ -820,7 +820,7 @@ export class CssFieldScrubberProvider {
if (this.provider.scrubbing) {
this.provider.scrubbing = false;
this.provider.commit(this.provider.opts.value.current, {
fallbackTarget: e?.currentTarget as HTMLElement | undefined
targetOverride: e?.currentTarget as HTMLElement | undefined
});
}
};

@ -84,15 +84,15 @@ describe('DialogProvider', () => {
it('opens and closes through the morfo runtime without a registered content part', async () => {
const { dom, eventEngine } = installSomaHarness();
const opts = createDialogOpts();
const fallbackTarget = document.createElement('div');
document.body.appendChild(fallbackTarget);
const targetOverride = document.createElement('div');
document.body.appendChild(targetOverride);
const { result: provider, cleanup } = withEffectRoot(() => {
const p = DialogProvider.create(opts);
p.contentId.current = 'dialog-content';
p.runtime.part('content', {
id: state('dialog-content'),
ref: state<HTMLElement | null>(fallbackTarget),
ref: state<HTMLElement | null>(targetOverride),
syncAttrs: true
});
return p;
@ -108,13 +108,13 @@ describe('DialogProvider', () => {
await provider.dismissWith('dismiss');
expect(opts.open.current).toBe(false);
expect(fallbackTarget.getAttribute('data-last-action')).toBe('dismissed');
expect(fallbackTarget.getAttribute('data-state')).toBe('closed');
expect(targetOverride.getAttribute('data-last-action')).toBe('dismissed');
expect(targetOverride.getAttribute('data-state')).toBe('closed');
expect(eventEngine.emit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'close',
family: 'emerge',
target: fallbackTarget
target: targetOverride
})
);

@ -264,7 +264,7 @@ export class DialogProvider {
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(target ? { targetOverride: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
@ -311,15 +311,15 @@ export class DialogTriggerProvider {
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
// `open`'s declared target (partRef('content')) is NOT in the DOM
// until handleOpen flips state. Pass the trigger as `fallbackTarget`
// so the runtime can still emit the perceptual signal — the visual
// channel writes its `data-event*` to the trigger briefly, sound /
// haptic channels don't care about the target.
void this.provider.runtime.trigger('open', {
fallbackTarget: e.currentTarget as HTMLElement
});
readonly onclick = () => {
// NO `targetOverride` (A-36, 2026-08-10). The override dated from the
// `pre` era, when the content genuinely wasn't mounted at emit time.
// `open` is `post`: the handler flips state, `tick()` mounts the
// content, and the runtime resolves the DECLARED target — the stamp
// lands on the surface that is appearing. Stamping the trigger instead
// OVERWROTE the composed Button's own `contact-activate` on the same
// node (one slot per element), killing its whole visual signature.
void this.provider.runtime.trigger('open');
};
readonly props = $derived.by(() => ({

@ -161,7 +161,7 @@ export class DragDropProvider {
this.announceTemplate(DRAG_DROP_LANGS.DRAG_STARTED, { item: params.label });
// Morfo: handle-pick on `draggable`. The source element is the
// draggable that just initiated the drag — pass it as fallback.
void this.runtime.trigger('handle-pick', { fallbackTarget: params.source });
void this.runtime.trigger('handle-pick', { targetOverride: params.source });
if (params.keyboard) {
this.setupKeyboardNav();
@ -218,7 +218,7 @@ export class DragDropProvider {
});
// Morfo: handle-drop on `droppable`. The target is the droppable
// that received the dropped item.
void this.runtime.trigger('handle-drop', { fallbackTarget: target });
void this.runtime.trigger('handle-drop', { targetOverride: target });
this.finish();
}
@ -233,7 +233,7 @@ export class DragDropProvider {
this.announce(DRAG_DROP_LANGS.DRAG_CANCELLED);
// Morfo: commit-cancel on `draggable`. The source still holds
// the dragged-from element; use it as fallback target.
void this.runtime.trigger('commit-cancel', { fallbackTarget: drag.source });
void this.runtime.trigger('commit-cancel', { targetOverride: drag.source });
// Return focus to the source for keyboard drags.
const source = drag.source;
this.finish();

@ -127,15 +127,15 @@ describe('DrawerProvider', () => {
const opts = createDrawerOpts();
opts.open.current = true;
opts.activeSnapPoint.current = 0.5;
const fallbackTarget = document.createElement('div');
document.body.appendChild(fallbackTarget);
const targetOverride = document.createElement('div');
document.body.appendChild(targetOverride);
const { result: provider, cleanup } = withEffectRoot(() => {
const p = DrawerProvider.create(opts);
p.contentId.current = 'drawer-content';
p.runtime.part('content', {
id: state('drawer-content'),
ref: state<HTMLElement | null>(fallbackTarget),
ref: state<HTMLElement | null>(targetOverride),
syncAttrs: true
});
return p;
@ -151,13 +151,13 @@ describe('DrawerProvider', () => {
// lands where the user left it. Resetting it here would also have been a
// silent write — the binding saw it, the callback did not.
expect(opts.activeSnapPoint.current).toBe(0.5);
expect(fallbackTarget.getAttribute('data-last-action')).toBe('dismissed');
expect(fallbackTarget.getAttribute('data-state')).toBe('closed');
expect(targetOverride.getAttribute('data-last-action')).toBe('dismissed');
expect(targetOverride.getAttribute('data-state')).toBe('closed');
expect(eventEngine.emit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'close',
family: 'emerge',
target: fallbackTarget
target: targetOverride
})
);

@ -323,7 +323,7 @@ export class DrawerProvider {
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(target ? { targetOverride: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
@ -420,18 +420,18 @@ export class DrawerTriggerProvider {
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
void this.provider.runtime.trigger('open', {
fallbackTarget: e.currentTarget as HTMLElement
});
// NO `targetOverride` on open (A-36, 2026-08-10): `open` is `post`, so the
// content mounts during the trigger and the runtime resolves the DECLARED
// target. Stamping the trigger instead overwrote the composed Button's
// `contact-activate` on the same node — measured live in site-header.
readonly onclick = () => {
void this.provider.runtime.trigger('open');
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
void this.provider.runtime.trigger('open', {
fallbackTarget: e.currentTarget as HTMLElement
});
void this.provider.runtime.trigger('open');
}
};

@ -143,7 +143,7 @@ export class MenuProvider {
this.openReason = reason;
const target =
this.contentRef.current ?? gestureTarget ?? this.runtime.partRef('trigger');
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('open', target ? { targetOverride: target } : undefined);
this.opts.open.current = true;
}
@ -154,7 +154,7 @@ export class MenuProvider {
// parity with handleOpen (content unmounted in rare races).
const target =
this.contentRef.current ?? gestureTarget ?? this.runtime.partRef('trigger');
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('close', target ? { targetOverride: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
}
@ -580,7 +580,7 @@ export class MenuItemProvider {
// `[data-dropdown-menu-item][data-event="commit-select"]` matches
// this specific item (not the most-recent registered one).
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
targetOverride: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
@ -591,7 +591,7 @@ export class MenuItemProvider {
e.preventDefault();
this.opts.onSelect.current();
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
targetOverride: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
}

@ -112,7 +112,7 @@ export class EditableProvider {
this.draft = this.opts.value.current;
this.opts.editing.current = true;
void this.runtime.trigger('shift-enter-mode', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
// Focus input on next tick
@ -137,7 +137,7 @@ export class EditableProvider {
this.opts.editing.current = false;
this.opts.onValueCommit.current(this.draft);
void this.runtime.trigger('commit-save', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}
@ -148,7 +148,7 @@ export class EditableProvider {
this.opts.editing.current = false;
this.opts.onValueRevert.current();
void this.runtime.trigger('commit-cancel', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}

@ -102,7 +102,7 @@ export class FeedProvider {
// keyboard navigation step that moves focus to a different
// article (Page Up/Down, Ctrl+Home/End).
if (article) {
void this.runtime.trigger('shift-navigate-focus-item', { fallbackTarget: article });
void this.runtime.trigger('shift-navigate-focus-item', { targetOverride: article });
}
}

@ -127,7 +127,7 @@ export class FileUploadProvider {
openPicker(target?: HTMLElement): void {
if (this.isDisabled) return;
this.hiddenInputRef.current?.click();
void this.runtime.trigger('trigger-picker', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('trigger-picker', target ? { targetOverride: target } : undefined);
}
addFiles(list: FileList | File[] | null, target?: HTMLElement): void {
@ -184,7 +184,7 @@ export class FileUploadProvider {
if (toAdd.length > 0) {
const next = this.opts.multiple.current ? [...current, ...toAdd] : toAdd;
this.opts.files.current = next;
void this.runtime.trigger('commit-set-add', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-set-add', target ? { targetOverride: target } : undefined);
}
// `signal-warn-reject` is `untilFix` (book §6.2): we clear any prior
// rejection warning on the provider before reading the new accept/
@ -206,7 +206,7 @@ export class FileUploadProvider {
count: String(rejected.length)
});
void this.runtime.trigger('signal-warn-reject', {
...(target ? { fallbackTarget: target } : {}),
...(target ? { targetOverride: target } : {}),
message
});
}
@ -220,7 +220,7 @@ export class FileUploadProvider {
// outstanding warn signal so the indicator doesn't lie about state.
const provider = this.runtime.partRef('provider');
if (provider) this.runtime.clearTarget(provider);
void this.runtime.trigger('commit-remove', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-remove', target ? { targetOverride: target } : undefined);
}
clearFiles(target?: HTMLElement): void {
@ -229,7 +229,7 @@ export class FileUploadProvider {
// Wiping the list clears any rejection state along with it.
const provider = this.runtime.partRef('provider');
if (provider) this.runtime.clearTarget(provider);
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-reset', target ? { targetOverride: target } : undefined);
}
readonly props = $derived.by(() =>

@ -266,8 +266,11 @@ export class FloatPanelProvider {
}
toggle() {
// NO `targetOverride` on open (A-36, 2026-08-10): `open` is `post` now,
// so the content mounts during the trigger and the runtime resolves the
// DECLARED target instead of stamping the trigger's node.
if (this.opts.open.current) void this.dismissWith('dismiss');
else void this.runtime.trigger('open', { fallbackTarget: this.triggerRef.current ?? undefined });
else void this.runtime.trigger('open');
}
dismissWith(
@ -283,7 +286,7 @@ export class FloatPanelProvider {
this.soma.dom.apply({ target, attrs: { 'data-last-action': cause.lastAction } });
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(target ? { targetOverride: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
@ -747,7 +750,7 @@ export class FloatPanelProvider {
}
this.bringToFront();
void this.runtime.trigger('drag-start', {
fallbackTarget: this.contentRef.current ?? this.dragEl ?? undefined
targetOverride: this.contentRef.current ?? this.dragEl ?? undefined
});
}
e.preventDefault();
@ -791,7 +794,7 @@ export class FloatPanelProvider {
// `dragging` so the effect's first non-active run reads the final value.
if (this.dragLivePos) this.setPosition(this.dragLivePos);
void this.runtime.trigger('drag-end', {
fallbackTarget: this.contentRef.current ?? this.dragEl ?? undefined
targetOverride: this.contentRef.current ?? this.dragEl ?? undefined
});
}
// Always settle the grabbed state (it was set on pointerdown, so a press
@ -880,7 +883,7 @@ export class FloatPanelProvider {
}
this.bringToFront();
void this.runtime.trigger('resize-start', {
fallbackTarget: this.contentRef.current ?? this.resizeEl ?? undefined
targetOverride: this.contentRef.current ?? this.resizeEl ?? undefined
});
}
e.preventDefault();
@ -942,7 +945,7 @@ export class FloatPanelProvider {
if (this.resizeLiveSize) this.setSize(this.resizeLiveSize);
if (this.resizeLivePos) this.setPosition(this.resizeLivePos);
void this.runtime.trigger('resize-end', {
fallbackTarget: this.contentRef.current ?? this.resizeEl ?? undefined
targetOverride: this.contentRef.current ?? this.resizeEl ?? undefined
});
}
// Always settle (set on pointerdown, so a no-move press must lower it too).

@ -139,13 +139,13 @@ export class FormProvider<TForm extends Form = Form> {
count: String(invalidFieldCount)
});
void this.runtime.trigger('signal-warn-invalid', {
...(target ? { fallbackTarget: target } : {}),
...(target ? { targetOverride: target } : {}),
message
});
this.focusFirstInvalid();
return;
}
void this.runtime.trigger('commit-submit', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-submit', target ? { targetOverride: target } : undefined);
};
readonly props = $derived.by(() =>
@ -252,7 +252,7 @@ export class FormResetProvider {
if (provider) this.provider.runtime.clearTarget(provider);
void this.provider.runtime.trigger(
'commit-reset',
target ? { fallbackTarget: target } : undefined
target ? { targetOverride: target } : undefined
);
};

@ -195,7 +195,7 @@ export class GridListProvider {
`[${attrs.row}][data-value="${CSS.escape(value)}"]`
);
if (rowEl) {
void this.runtime.trigger(eventName, { fallbackTarget: rowEl });
void this.runtime.trigger(eventName, { targetOverride: rowEl });
}
}
}

@ -13,6 +13,7 @@ export * as ChatLog from './chat-log';
export * as ChatMessage from './chat-message';
export * as ChatTyping from './chat-typing';
export * as Checkbox from './checkbox';
export * as Chronos from './chronos';
export * as Clipboard from './clipboard';
export * as Collapsible from './collapsible';
export * as ColorField from './color-field';

@ -199,7 +199,7 @@ export class ListboxProvider {
`[${attrs.item}][data-value="${CSS.escape(value)}"]`
);
if (itemEl) {
void this.runtime.trigger(eventName, { fallbackTarget: itemEl });
void this.runtime.trigger(eventName, { targetOverride: itemEl });
}
}
}

@ -7,7 +7,7 @@ import { menuDialMorfo } from '../../../morfo/components/menu-dial';
*
* - `keydown('trigger' | 'list', event)` → the morfo keyboard contract
* (open from the trigger; Arrow / Home / End / Enter / Escape on the list);
* - `trigger('open' | 'close' | 'commit-select', { fallbackTarget })` → the
* - `trigger('open' | 'close' | 'commit-select', { targetOverride })` → the
* perceptual signal (sound / haptic) + the a11y live region.
*
* Linear focus maths live in {@link './menu-dial-nav'} (testable apart).

@ -142,7 +142,7 @@ export class MenubarProvider {
// trigger element that just became active.
const triggerEl = this.triggerRegistry.get(value);
if (triggerEl) {
void this.runtime.trigger('commit-select', { fallbackTarget: triggerEl });
void this.runtime.trigger('commit-select', { targetOverride: triggerEl });
}
}

@ -30,10 +30,10 @@ describe('createMetricsRuntime — the live-update signal bridge', () => {
it('fires signal.notify with the intent read from the registered prop source', async () => {
const { runtime, emit } = harness(() => 'affirm');
// The eidos root passes the Value element as fallbackTarget (no runtime
// The eidos root passes the Value element as targetOverride (no runtime
// part registration for a pure-display tree) — mirror that call shape.
await runtime.trigger('signal-notify-update', {
fallbackTarget: document.createElement('span')
targetOverride: document.createElement('span')
});
expect(emit).toHaveBeenCalledTimes(1);
@ -50,7 +50,7 @@ describe('createMetricsRuntime — the live-update signal bridge', () => {
const { runtime, emit } = harness(() => 'neutral');
await runtime.trigger('signal-notify-update', {
fallbackTarget: document.createElement('span')
targetOverride: document.createElement('span')
});
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ intent: 'neutral' }));

@ -7,7 +7,7 @@ import { metricsMorfo } from '../../../morfo/components/metrics';
* class here: the eidos root owns the render and only needs to fire one
* perceptual signal when a live value changes.
*
* runtime.trigger('signal-notify-update', { fallbackTarget, message })
* runtime.trigger('signal-notify-update', { targetOverride, message })
*
* dispatches the `signal` family (sound / haptic) and — via the morfo's
* `a11ySemantic.requiresLiveRegion` — announces `message` to the live region.

@ -177,7 +177,7 @@ export class MonthGridProvider {
this.opts.placeholder.current = next;
void this.runtime.trigger(
'shift-navigate-step',
target ? { fallbackTarget: target } : undefined
target ? { targetOverride: target } : undefined
);
}
@ -198,7 +198,7 @@ export class MonthGridProvider {
const prev = this.opts.value.current;
if (sameMonth(prev, next)) return;
this.opts.value.current = next;
void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-set', target ? { targetOverride: target } : undefined);
}
// ── Keyboard ──────────────────────────────────────────────────────────────

@ -144,7 +144,7 @@ export class NavigationMenuProvider {
// announced a selection that had not happened, while the gestures that DO
// commit — pressing a link — stayed silent.
const triggerEl = this.triggerRefs.get(value);
void this.runtime.trigger('emerge-open', triggerEl ? { fallbackTarget: triggerEl } : undefined);
void this.runtime.trigger('emerge-open', triggerEl ? { targetOverride: triggerEl } : undefined);
}
/** Close whatever is open now. Arms the skip-delay window so the next
@ -158,7 +158,7 @@ export class NavigationMenuProvider {
// be announced against.
void this.runtime.trigger(
'emerge-close',
triggerEl ? { fallbackTarget: triggerEl } : undefined
triggerEl ? { targetOverride: triggerEl } : undefined
);
this.armSkipDelay();
}
@ -717,7 +717,7 @@ export class NavigationMenuLinkProvider {
*/
readonly onclick = (_e: SomaMouseEvent<HTMLAnchorElement>) => {
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: this.opts.ref?.current ?? undefined
targetOverride: this.opts.ref?.current ?? undefined
});
};

@ -422,7 +422,7 @@ export class NumberFieldProvider {
commit(
value: number | undefined,
opts: { clamp?: boolean; fallbackTarget?: HTMLElement } = {}
opts: { clamp?: boolean; targetOverride?: HTMLElement } = {}
): void {
this.pendingCommit = {
value,
@ -430,7 +430,7 @@ export class NumberFieldProvider {
};
void this.runtime.trigger(
'commit-set',
opts.fallbackTarget ? { fallbackTarget: opts.fallbackTarget } : undefined
opts.targetOverride ? { targetOverride: opts.targetOverride } : undefined
);
}
@ -830,7 +830,7 @@ export class NumberFieldScrubberProvider {
this.startY = e.clientY;
this.startValue = this.provider.startValue;
void this.provider.runtime.trigger('handle-pick', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
};
@ -850,7 +850,7 @@ export class NumberFieldScrubberProvider {
e.preventDefault();
void this.provider.runtime.trigger('handle-drag-scrub', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
const sensitivity = this.opts.sensitivity.current;
@ -883,7 +883,7 @@ export class NumberFieldScrubberProvider {
// Commit the final scrub value — fires onValueCommit once
const finalValue = this.provider.opts.value.current;
this.provider.commit(finalValue, {
fallbackTarget: e?.currentTarget as HTMLElement | undefined
targetOverride: e?.currentTarget as HTMLElement | undefined
});
};
@ -895,7 +895,7 @@ export class NumberFieldScrubberProvider {
// Commit the final scrub value on lost capture as well
const finalValue = this.provider.opts.value.current;
this.provider.commit(finalValue, {
fallbackTarget: e?.currentTarget as HTMLElement | undefined
targetOverride: e?.currentTarget as HTMLElement | undefined
});
}
};

@ -9,7 +9,7 @@ import { onionMenuMorfo } from '../../../morfo/components/onion-menu';
* - `keydown('trigger' | 'surface', event)` dispatches against the morfo's
* keyboard contract (Enter/Space/ArrowDown open; Arrow/Enter/Backspace/Escape
* navigate) → the eidos-supplied `actions` handlers;
* - `trigger('open' | 'close' | 'commit-select', { fallbackTarget })` emits the
* - `trigger('open' | 'close' | 'commit-select', { targetOverride })` emits the
* perceptual signal (sound / haptic) + drives the a11y live region.
*
* The eidos root creates this with `dom` + `eventEngine` (`uix.events`) +

@ -163,7 +163,7 @@ export class PaginationProvider {
if (page < 1 || page > this.totalPages) return;
if (page === this.opts.page.current) return;
this.pendingPage = page;
void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-set', target ? { targetOverride: target } : undefined);
}
goToPrevPage(target?: HTMLElement) {

@ -877,7 +877,7 @@ export class PalabrasProvider {
this.closeTypingBatch();
this.pendingCommitReason = reason;
void this.runtime.trigger('commit-save-content', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}
@ -886,7 +886,7 @@ export class PalabrasProvider {
this.closeTypingBatch();
this.publishHistory(undoPalabras(this.history));
void this.runtime.trigger('commit-restore-history', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}
@ -895,7 +895,7 @@ export class PalabrasProvider {
this.closeTypingBatch();
this.publishHistory(redoPalabras(this.history));
void this.runtime.trigger('commit-restore-history', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}
@ -934,7 +934,7 @@ export class PalabrasProvider {
if (!changed) return;
this.currentMatchIndex = -1;
void this.runtime.trigger('commit-set-replace', {
fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined
targetOverride: opts.target ?? this.opts.ref.current ?? undefined
});
return;
}
@ -953,7 +953,7 @@ export class PalabrasProvider {
? 'commit-reset-link'
: 'commit-set-format';
void this.runtime.trigger(event, {
fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined
targetOverride: opts.target ?? this.opts.ref.current ?? undefined
});
}
@ -1029,7 +1029,7 @@ export class PalabrasProvider {
return false;
}
void this.runtime.trigger('commit-set-link', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
return true;
}
@ -1038,7 +1038,7 @@ export class PalabrasProvider {
const changed = this.applyCommandWithOptions({ type: 'setCodeLanguage', language });
if (!changed) return false;
void this.runtime.trigger('commit-set-format', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
return true;
}
@ -1047,7 +1047,7 @@ export class PalabrasProvider {
const changed = this.applyCommandWithOptions({ type: 'unlink' });
if (!changed) return false;
void this.runtime.trigger('commit-reset-link', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
return true;
}
@ -1191,7 +1191,7 @@ export class PalabrasProvider {
const changed = this.applyCommandWithOptions({ type: 'toggleCheckItem' });
if (changed) {
void this.runtime.trigger('commit-set-check', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
}
return;
@ -1303,7 +1303,7 @@ export class PalabrasProvider {
const changed = this.applyCommandWithOptions({ type: 'toggleCheckItem', path });
if (!changed) return;
void this.runtime.trigger('commit-set-check', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
return;
}
@ -1631,7 +1631,7 @@ export class PalabrasProvider {
// best-effort telemetry trigger; intentionally swallowed if the
// runtime hasn't been started in the current scope (tests, SSR).
try {
void this.runtime.trigger('commit-save-content', { fallbackTarget: target });
void this.runtime.trigger('commit-save-content', { targetOverride: target });
} catch {
/* noop */
}
@ -1879,6 +1879,15 @@ export class PalabrasProvider {
// that downstream editors built ON the engine (e.g. `palabras`)
// stamp on THEIR focus-stealing overlays (a portaled menu / grid)
// so opening one isn't read as an external blur and looped.
//
// Five entries were removed on 2026-08-10 — `bubble-color`,
// `bubble-turn-into`, `column-inserter-menu`,
// `column-inserter-trigger`, `settings-gutter`. A whole-repo sweep
// found each in this list and NOWHERE else: the predicate was
// defending against markers nothing stamps. The real chrome they meant
// is already covered, by its own part marker or by
// `data-palabras-external-tool` (which twelve eidos components carry).
// They surfaced when palabras' catalogue exemption was lifted.
return !!element.closest(
[
'[data-palabras-block-gutter]',
@ -1886,21 +1895,16 @@ export class PalabrasProvider {
'[data-palabras-block-handle-menu]',
'[data-palabras-block-inserter]',
'[data-palabras-block-inserter-button]',
'[data-palabras-bubble-color]',
'[data-palabras-bubble-menu]',
'[data-palabras-bubble-turn-into]',
'[data-palabras-code-language-panel]',
'[data-palabras-code-language-picker]',
'[data-palabras-column-inserter]',
'[data-palabras-column-inserter-menu]',
'[data-palabras-column-inserter-trigger]',
'[data-palabras-drawer]',
'[data-palabras-external-tool]',
'[data-palabras-find-replace]',
'[data-palabras-heading-picker]',
'[data-palabras-image-float-bar]',
'[data-palabras-link-editor]',
'[data-palabras-settings-gutter]',
'[data-palabras-slash-menu]',
'[data-palabras-toolbar]',
'[data-palabras-toolbar-family]',
@ -2029,7 +2033,7 @@ export class PalabrasProvider {
else this.currentMatchIndex = -1;
void this.runtime.trigger('commit-set-replace', {
fallbackTarget: this.opts.ref.current ?? undefined
targetOverride: this.opts.ref.current ?? undefined
});
};
@ -2133,7 +2137,7 @@ export class PalabrasProvider {
const changed = this.applyCommand(command);
if (changed) {
void this.runtime.trigger('commit-set-format', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}
return changed;
@ -2163,7 +2167,7 @@ export class PalabrasProvider {
});
if (changed) {
void this.runtime.trigger('commit-set-format', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}
return changed;
@ -2219,7 +2223,7 @@ export class PalabrasProvider {
this.publishHistory(next);
void tick().then(() => this.restoreDomSelection());
void this.runtime.trigger('commit-set-slash', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
return true;
}
@ -2530,7 +2534,7 @@ export class PalabrasProvider {
this.soma.logger.warn('soma.words', `Ignored unsupported editor input: ${reason}`, {
context: { reason }
});
void this.runtime.trigger('signal-warn-invalid', { fallbackTarget: target });
void this.runtime.trigger('signal-warn-invalid', { targetOverride: target });
}
}
@ -3431,7 +3435,7 @@ export class PalabrasDrawerProvider {
this.opts.open.current = next;
try {
void this.provider.runtime.trigger('commit-toggle-drawer', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
} catch {
/* noop — runtime may not be wired (tests / SSR) */
@ -3444,7 +3448,7 @@ export class PalabrasDrawerProvider {
this.opts.open.current = open;
try {
void this.provider.runtime.trigger('commit-toggle-drawer', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
} catch {
/* noop */

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { OnChangeFn, WithChild, Without } from '../../types';
import type { Direction, OnChangeFn, WithChild, Without } from '../../types';
import type {
PrimitiveButtonAttributes,
PrimitiveDivAttributes,
@ -127,7 +127,7 @@ export type LangCode = string;
/** A supported content language: a bare code (its display name resolves via
* `Intl.DisplayNames`) or an explicit descriptor. Mirrors field-langs. */
export type LangSpec = LangCode | { code: LangCode; label?: string; dir?: 'ltr' | 'rtl' };
export type LangSpec = LangCode | { code: LangCode; label?: string; dir?: Direction };
/** The multilingual `value` shape — one `PalabrasDocument` per language. */
export type PalabrasLangValues = Record<LangCode, PalabrasDocument>;

@ -353,7 +353,7 @@ export class PathTraceProvider {
private firePick(): void {
if (this.pickFired) return;
this.pickFired = true;
void this.runtime.trigger('handle-pick', { fallbackTarget: this.tokenEl ?? undefined });
void this.runtime.trigger('handle-pick', { targetOverride: this.tokenEl ?? undefined });
}
private complete(): void {
@ -363,7 +363,7 @@ export class PathTraceProvider {
this.tracing = false;
this.offPath = false;
this.cancelQueuedHandleDrag();
void this.runtime.trigger('handle-drop', { fallbackTarget: this.trackEl ?? undefined });
void this.runtime.trigger('handle-drop', { targetOverride: this.trackEl ?? undefined });
this.announce(PATH_TRACE_LANGS.COMPLETE, 'polite');
this.host.solve(telemetry);
this.tel = null;
@ -393,7 +393,7 @@ export class PathTraceProvider {
)
return;
this.lastDragSignalAt = now;
void this.runtime.trigger('handle-drag', { fallbackTarget: this.tokenEl ?? undefined });
void this.runtime.trigger('handle-drag', { targetOverride: this.tokenEl ?? undefined });
}, this.opts.ref.current ?? undefined);
}

@ -104,7 +104,9 @@ describe('PopoverProvider', () => {
});
provider.setTriggerRef(target);
provider.scheduleHoverOpen(25, target);
// No override arg since A-36 (2026-08-10): the emit resolves the
// registered `content` ref — which in this harness IS `target`.
provider.scheduleHoverOpen(25);
await vi.advanceTimersByTimeAsync(25);
expect(opts.open.current).toBe(true);

@ -270,13 +270,13 @@ export class PopoverProvider {
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(target ? { targetOverride: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
}
scheduleHoverOpen(delay: number, fallbackTarget: HTMLElement) {
scheduleHoverOpen(delay: number) {
this.clearHoverTimer();
this.hoverTimer = this.soma.uix.timers.schedule(
this.hoverTimerKey,
@ -289,7 +289,7 @@ export class PopoverProvider {
// clearHoverTimer above already cancelled any pending hover-close, so
// the popover simply stays open — the hover is a no-op, as intended.
if (this.opts.open.current) return;
void this.runtime.trigger('open', { fallbackTarget });
void this.runtime.trigger('open');
},
{ replace: true, meta: { component: 'popover', action: 'hover-open' } }
);
@ -383,15 +383,14 @@ export class PopoverTriggerProvider {
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.opts.disabled?.current) return;
// Toggle through the runtime so both directions emit a perceptual signal.
// `open`'s declared target (content) isn't in the DOM when opening —
// pass the trigger as fallbackTarget so the visual channel still has
// somewhere to write `data-event*`.
// NO `targetOverride` on open (A-36, 2026-08-10): `open` is `post`, so
// the content mounts during the trigger and the runtime resolves the
// DECLARED target. Stamping the trigger overwrote a composed Button's
// `contact-activate` on the same node.
if (this.provider.opts.open.current) {
void this.provider.dismissWith('dismiss');
} else {
void this.provider.runtime.trigger('open', {
fallbackTarget: e.currentTarget as HTMLElement
});
void this.provider.runtime.trigger('open');
}
};
@ -402,9 +401,7 @@ export class PopoverTriggerProvider {
if (this.provider.opts.open.current) {
void this.provider.dismissWith('dismiss');
} else {
void this.provider.runtime.trigger('open', {
fallbackTarget: e.currentTarget as HTMLElement
});
void this.provider.runtime.trigger('open');
}
}
};
@ -418,12 +415,11 @@ export class PopoverTriggerProvider {
// this guard, modal+openOnHover loops (open → overlay → leave
// schedules close → close → re-enter → reopen).
if (this.provider.opts.modal.current && this.provider.opts.open.current) return;
const target = e.currentTarget as HTMLElement;
// Go through the runtime so Sema fires (prewrite + data-event*)
// the same as click/keyboard. Bypassing it (raw `handleOpen()`)
// skipped sound/haptic + perceptual signature on hover-opened
// popovers.
this.provider.scheduleHoverOpen(this.opts.openDelay.current, target);
this.provider.scheduleHoverOpen(this.opts.openDelay.current);
};
readonly onpointerleave = () => {

@ -108,7 +108,7 @@ export class ProofOfHumanProvider implements ProofOfHumanChallengeHost {
this.activeChallenge = false;
if (!wasActive) return;
void this.runtime.trigger('commit-cancel', {
fallbackTarget: this.opts.ref.current ?? undefined
targetOverride: this.opts.ref.current ?? undefined
});
this.announce(PROOF_OF_HUMAN_LANGS.CANCELLED, 'polite');
}
@ -135,12 +135,12 @@ export class ProofOfHumanProvider implements ProofOfHumanChallengeHost {
this.announce(PROOF_OF_HUMAN_LANGS.VERIFYING, 'polite');
} else if (s === 'verified') {
void this.runtime.trigger('commit-confirm', {
fallbackTarget: this.opts.ref.current ?? undefined
targetOverride: this.opts.ref.current ?? undefined
});
this.announce(PROOF_OF_HUMAN_LANGS.VERIFIED, 'assertive');
} else if (s === 'failed') {
void this.runtime.trigger('commit-fail', {
fallbackTarget: this.opts.ref.current ?? undefined
targetOverride: this.opts.ref.current ?? undefined
});
this.announce(PROOF_OF_HUMAN_LANGS.FAILED, 'assertive');
}

@ -141,7 +141,7 @@ export class RadioGroupProvider {
const target = itemEl ?? this.resolveItemEl(value);
if (target) {
void this.runtime.trigger('commit-select', { fallbackTarget: target });
void this.runtime.trigger('commit-select', { targetOverride: target });
}
}

@ -316,7 +316,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.opts.placeholder.current.subtract({ months: step })
);
this.announceNavigation();
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('shift-navigate', target ? { targetOverride: target } : undefined);
}
nextPage(target?: HTMLElement): void {
@ -327,7 +327,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.opts.placeholder.current.add({ months: step })
);
this.announceNavigation();
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('shift-navigate', target ? { targetOverride: target } : undefined);
}
setMonth(month: number): void {
@ -427,7 +427,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.opts.value.current = { start: date, end: undefined };
void this.runtime.trigger(
'commit-select-start',
target ? { fallbackTarget: target } : undefined
target ? { targetOverride: target } : undefined
);
}
@ -436,7 +436,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.anchor = undefined;
this.previewDate = undefined;
this.opts.value.current = { start: undefined, end: undefined };
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-reset', target ? { targetOverride: target } : undefined);
}
private clearEndSelection(target?: HTMLElement): void {
@ -449,7 +449,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.anchor = start;
this.previewDate = undefined;
this.opts.value.current = { start, end: undefined };
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-reset', target ? { targetOverride: target } : undefined);
}
private clearStartSelection(target?: HTMLElement): void {
@ -462,7 +462,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.anchor = end;
this.previewDate = undefined;
this.opts.value.current = { start: undefined, end };
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-reset', target ? { targetOverride: target } : undefined);
}
private commit(a: DateValue, b: DateValue, target?: HTMLElement): void {
@ -486,7 +486,7 @@ export class RangeCalendarProvider implements CalendarNav {
this.previewDate = undefined;
void this.runtime.trigger(
'commit-select-range',
target ? { fallbackTarget: target } : undefined
target ? { targetOverride: target } : undefined
);
}
@ -602,7 +602,7 @@ export class RangeCalendarProvider implements CalendarNav {
if (isOutsideMonth(target, this.visibleMonths[0])) {
this.opts.placeholder.current = startOfMonth(target);
void this.runtime.trigger('shift-navigate', {
fallbackTarget: e.currentTarget as HTMLElement
targetOverride: e.currentTarget as HTMLElement
});
}

@ -188,7 +188,7 @@ export class RatingGroupProvider {
);
if (clamped === this.opts.value.current) return;
this.pendingValue = clamped;
void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-set', target ? { targetOverride: target } : undefined);
}
setHover(v: number | null) {

@ -327,7 +327,7 @@ export class RotateAlignProvider {
const detent = Math.round(angle / DETENT_DEG);
if (detent !== this.lastDetent) {
this.lastDetent = detent;
void this.runtime.trigger('handle-drag', { fallbackTarget: this.needleEl ?? undefined });
void this.runtime.trigger('handle-drag', { targetOverride: this.needleEl ?? undefined });
}
const on = Math.abs(shortest(angle, this.target)) <= this.toleranceDeg;
if (on && !this.aligned) {
@ -367,7 +367,7 @@ export class RotateAlignProvider {
private firePick(): void {
if (this.pickFired) return;
this.pickFired = true;
void this.runtime.trigger('handle-pick', { fallbackTarget: this.needleEl ?? undefined });
void this.runtime.trigger('handle-pick', { targetOverride: this.needleEl ?? undefined });
}
private complete(): void {
@ -376,7 +376,7 @@ export class RotateAlignProvider {
const telemetry = this.buildTelemetry();
this.rotating = false;
this.settleHandle = null;
void this.runtime.trigger('handle-drop', { fallbackTarget: this.dialEl ?? undefined });
void this.runtime.trigger('handle-drop', { targetOverride: this.dialEl ?? undefined });
this.announce(ROTATE_ALIGN_LANGS.COMPLETE, 'polite');
this.host.solve(telemetry);
this.tel = null;

@ -225,7 +225,7 @@ export class SearchFieldProvider {
clear = (target?: HTMLElement) => {
if (this.isDisabled || this.isReadonly) return;
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-reset', target ? { targetOverride: target } : undefined);
};
private applySubmit() {
@ -238,7 +238,7 @@ export class SearchFieldProvider {
submit(target?: HTMLElement) {
if (this.isDisabled) return;
void this.runtime.trigger('commit-submit', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-submit', target ? { targetOverride: target } : undefined);
}
// ── Input events ────────────────────────────────────────────────────────

@ -200,7 +200,7 @@ export class SelectProvider {
this.opts.value.current = [...result.next];
const eventTarget = target ?? this.resolveItemEl(value);
if (result.event && eventTarget) {
void this.runtime.trigger(result.event, { fallbackTarget: eventTarget });
void this.runtime.trigger(result.event, { targetOverride: eventTarget });
}
}
@ -210,7 +210,7 @@ export class SelectProvider {
// emerge.open — the popup appeared. Content isn't mounted yet, so fall
// back to the trigger for the perceptual stamp.
void this.runtime.trigger('emerge-open', {
fallbackTarget: this.triggerRef.current ?? undefined
targetOverride: this.triggerRef.current ?? undefined
});
// Scroll selected item into view after content mounts
@ -228,7 +228,7 @@ export class SelectProvider {
// redundant close stays silent.
if (wasOpen) {
void this.runtime.trigger('emerge-close', {
fallbackTarget: this.triggerRef.current ?? undefined
targetOverride: this.triggerRef.current ?? undefined
});
}
this.typeahead.reset();

@ -119,7 +119,7 @@ export class StepperProvider {
if (clamped === this.opts.value.current) return;
this.opts.value.current = clamped;
void this.runtime.trigger(clamped >= this.count ? 'commit-complete' : 'shift-step', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
targetOverride: target ?? this.opts.ref.current ?? undefined
});
}

@ -230,7 +230,7 @@ export class TableSortTriggerProvider {
// the column-header; closest() resolves the ancestor for the
// cascade selector.
const target = e.currentTarget as HTMLElement;
void this.provider.runtime.trigger('commit-set-sort', { fallbackTarget: target });
void this.provider.runtime.trigger('commit-set-sort', { targetOverride: target });
};
readonly onkeydown = (e: SomaKeyboardEvent) => {
@ -240,7 +240,7 @@ export class TableSortTriggerProvider {
e.stopPropagation();
this.table.toggleSort(this.header.id, e.shiftKey);
const target = e.currentTarget as HTMLElement;
void this.provider.runtime.trigger('commit-set-sort', { fallbackTarget: target });
void this.provider.runtime.trigger('commit-set-sort', { targetOverride: target });
}
};
@ -314,7 +314,7 @@ export class TableRowProvider {
// cascade matches the specific row, not the most-recent one.
const rowEl = this.opts.ref?.current;
if (rowEl) {
void this.provider.runtime.trigger('commit-select', { fallbackTarget: rowEl });
void this.provider.runtime.trigger('commit-select', { targetOverride: rowEl });
}
}
};
@ -326,7 +326,7 @@ export class TableRowProvider {
this.table.toggleRowSelection(this.row.id);
const rowEl = this.opts.ref?.current;
if (rowEl) {
void this.provider.runtime.trigger('commit-select', { fallbackTarget: rowEl });
void this.provider.runtime.trigger('commit-select', { targetOverride: rowEl });
}
}
}
@ -592,7 +592,7 @@ export class TableRowDetailTriggerProvider {
// the row; closest() resolves the row ancestor for the cascade.
if (!wasOpen) {
const target = e.currentTarget as HTMLElement;
void this.provider.runtime.trigger('emerge-expand-row', { fallbackTarget: target });
void this.provider.runtime.trigger('emerge-expand-row', { targetOverride: target });
}
};
@ -604,7 +604,7 @@ export class TableRowDetailTriggerProvider {
this.provider.table.toggleRowDetail(this.row.id);
if (!wasOpen) {
const target = e.currentTarget as HTMLElement;
void this.provider.runtime.trigger('emerge-expand-row', { fallbackTarget: target });
void this.provider.runtime.trigger('emerge-expand-row', { targetOverride: target });
}
}
};

@ -124,7 +124,7 @@ export class TabsProvider {
}
if (target) {
void this.runtime.trigger('select', { fallbackTarget: target });
void this.runtime.trigger('select', { targetOverride: target });
}
}

@ -141,7 +141,7 @@ export class TagGroupProvider {
}
this.opts.value.current = next;
const eventName = wasSelected ? 'commit-unselect' : 'commit-select';
void this.runtime.trigger(eventName, target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger(eventName, target ? { targetOverride: target } : undefined);
}
remove(value: string, target?: HTMLElement) {
@ -153,14 +153,14 @@ export class TagGroupProvider {
this.opts.value.current = nextSel;
}
this.opts.onRemove.current?.(value);
void this.runtime.trigger('commit-remove', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-remove', target ? { targetOverride: target } : undefined);
}
clear(target?: HTMLElement) {
if (this.opts.disabled.current) return;
if (this.opts.value.current.length === 0) return;
this.opts.value.current = [];
void this.runtime.trigger('commit-reset', target ? { fallbackTarget: target } : undefined);
void this.runtime.trigger('commit-reset', target ? { targetOverride: target } : undefined);
}
// ── Keyboard ────────────────────────────────────────────────────────────

@ -97,7 +97,7 @@ export class TagsInputProvider {
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
void this.runtime.trigger('signal-warn-reject', {
fallbackTarget: target ?? this.inputRef.current ?? undefined,
targetOverride: target ?? this.inputRef.current ?? undefined,
message: this.soma.langs.ts(TAGS_INPUT_LANGS.REJECT_WARNING)
});
}
@ -109,7 +109,7 @@ export class TagsInputProvider {
// announcing stale state.
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
void this.runtime.trigger('commit-set-add', { fallbackTarget: target });
void this.runtime.trigger('commit-set-add', { targetOverride: target });
} else if (result === 'rejected') {
this.emitWarnReject(target);
}
@ -159,7 +159,7 @@ export class TagsInputProvider {
this.highlightedIndex = next.length - 1;
}
void this.runtime.trigger('commit-remove', {
fallbackTarget: target ?? this.getItemElement(index)
targetOverride: target ?? this.getItemElement(index)
});
}
@ -175,7 +175,7 @@ export class TagsInputProvider {
this.opts.value.current = [];
this.highlightedIndex = -1;
void this.runtime.trigger('commit-reset', {
fallbackTarget: target ?? this.opts.ref?.current ?? undefined
targetOverride: target ?? this.opts.ref?.current ?? undefined
});
}
@ -198,7 +198,7 @@ export class TagsInputProvider {
if (added) {
this.opts.inputValue.current = '';
void this.runtime.trigger('commit-set-add', {
fallbackTarget: target ?? this.inputRef.current ?? undefined
targetOverride: target ?? this.inputRef.current ?? undefined
});
}
if (rejected) {
@ -224,7 +224,7 @@ export class TagsInputProvider {
this.opts.inputValue.current = remaining;
if (added) {
void this.runtime.trigger('commit-set-add', {
fallbackTarget: target ?? this.inputRef.current ?? undefined
targetOverride: target ?? this.inputRef.current ?? undefined
});
}
if (rejected) {

@ -446,7 +446,7 @@ export class TimeFieldProvider {
// `commit-settle`) lands on it.
const nextValue = this.opts.value.current?.toString();
if (previousValue !== nextValue) {
void this.runtime.trigger('commit-set', { fallbackTarget: this.fieldNode ?? undefined });
void this.runtime.trigger('commit-set', { targetOverride: this.fieldNode ?? undefined });
// Terminal callback: only on a completed value, never on clear (N2).
if (nextValue !== undefined) {
this.opts.onValueCommit.current?.(this.opts.value.current);

@ -164,7 +164,7 @@ export class TimelineItemProvider {
const el = opts.ref.current;
if (!el) return;
fired = true;
untrack(() => void this.runtime.trigger('emerge-reveal', { fallbackTarget: el }));
untrack(() => void this.runtime.trigger('emerge-reveal', { targetOverride: el }));
});
}

@ -142,16 +142,16 @@ export class ToggleGroupProvider {
// The contact answers «te he sentido» and fires whatever the selection
// resolves to — a rejected press was still felt (book ch. 22 §1: it
// communicates that the action entered the system, not its result).
if (target) void this.runtime.trigger('contact-press', { fallbackTarget: target });
if (target) void this.runtime.trigger('contact-press', { targetOverride: target });
if (result.changed) {
this.opts.value.current = [...result.next];
if (target) void this.runtime.trigger('commit-toggle', { fallbackTarget: target });
if (target) void this.runtime.trigger('commit-toggle', { targetOverride: target });
return;
}
if (result.rejected && result.rejected !== 'disabled' && target) {
void this.runtime.trigger('commit-block', { fallbackTarget: target });
void this.runtime.trigger('commit-block', { targetOverride: target });
}
}

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

Loading…
Cancel
Save

Powered by TurnKey Linux.