diff --git a/docs/architecture/morfo.md b/docs/architecture/morfo.md
index 594a69acc..9b745bc61 100644
--- a/docs/architecture/morfo.md
+++ b/docs/architecture/morfo.md
@@ -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 `
` / `` /
+ `` nodes whose shape depends on what the user wrote;
+- a component marks its internal render nodes — `waveform`'s two ``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.
diff --git a/docs/architecture/sema.md b/docs/architecture/sema.md
index b8dc16427..3f527db12 100644
--- a/docs/architecture/sema.md
+++ b/docs/architecture/sema.md
@@ -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
diff --git a/docs/audit/components/calendar.md b/docs/audit/components/calendar.md
index efc636464..88b72c49d 100644
--- a/docs/audit/components/calendar.md
+++ b/docs/audit/components/calendar.md
@@ -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 |
diff --git a/docs/audit/components/color-field.md b/docs/audit/components/color-field.md
index 4c6ed6d96..1b3e7066c 100644
--- a/docs/audit/components/color-field.md
+++ b/docs/audit/components/color-field.md
@@ -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 |
diff --git a/docs/audit/components/time-field.md b/docs/audit/components/time-field.md
index 93b256575..80efa61a5 100644
--- a/docs/audit/components/time-field.md
+++ b/docs/audit/components/time-field.md
@@ -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` ✓ |
diff --git a/docs/decisions/book-deviations.md b/docs/decisions/book-deviations.md
index 853ff4dbd..8e9951a29 100644
--- a/docs/decisions/book-deviations.md
+++ b/docs/decisions/book-deviations.md
@@ -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
diff --git a/docs/process/CONTINUE-direction.md b/docs/process/CONTINUE-direction.md
index ba6817594..c552b2c87 100644
--- a/docs/process/CONTINUE-direction.md
+++ b/docs/process/CONTINUE-direction.md
@@ -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
diff --git a/docs/process/CONTINUE-perceptual-surface.md b/docs/process/CONTINUE-perceptual-surface.md
new file mode 100644
index 000000000..5bc004afc
--- /dev/null
+++ b/docs/process/CONTINUE-perceptual-surface.md
@@ -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 `` compuesto sobre `` —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?».
diff --git a/docs/process/CONTINUE-sema-audit.md b/docs/process/CONTINUE-sema-audit.md
index 565512254..a2268996a 100644
--- a/docs/process/CONTINUE-sema-audit.md
+++ b/docs/process/CONTINUE-sema-audit.md
@@ -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
diff --git a/docs/process/PLAN-sound-names.md b/docs/process/PLAN-sound-names.md
index 4a961d97e..2c0ee8399 100644
--- a/docs/process/PLAN-sound-names.md
+++ b/docs/process/PLAN-sound-names.md
@@ -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.
diff --git a/src/uix/contracts.test.ts b/src/uix/contracts.test.ts
index 25a151477..80880b8bb 100644
--- a/src/uix/contracts.test.ts
+++ b/src/uix/contracts.test.ts
@@ -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([]);
// 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([]);
});
diff --git a/src/uix/eidos/components/menu-dial/menu-dial.svelte b/src/uix/eidos/components/menu-dial/menu-dial.svelte
index a1da8b930..dc92fa98b 100644
--- a/src/uix/eidos/components/menu-dial/menu-dial.svelte
+++ b/src/uix/eidos/components/menu-dial/menu-dial.svelte
@@ -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();
}
diff --git a/src/uix/eidos/components/metrics/metrics.svelte b/src/uix/eidos/components/metrics/metrics.svelte
index 532669767..f0aa79d90 100644
--- a/src/uix/eidos/components/metrics/metrics.svelte
+++ b/src/uix/eidos/components/metrics/metrics.svelte
@@ -57,7 +57,7 @@
notifyUpdate(el, text) {
if (!live) return;
void runtime.trigger('signal-notify-update', {
- fallbackTarget: el ?? undefined,
+ targetOverride: el ?? undefined,
message: text
});
}
diff --git a/src/uix/eidos/components/onion-menu/onion-menu.svelte b/src/uix/eidos/components/onion-menu/onion-menu.svelte
index 3d3b9a42e..0a43b618f 100644
--- a/src/uix/eidos/components/onion-menu/onion-menu.svelte
+++ b/src/uix/eidos/components/onion-menu/onion-menu.svelte
@@ -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);
diff --git a/src/uix/eidos/components/palabras/palabras-chrome.css b/src/uix/eidos/components/palabras/palabras-chrome.css
index 768982283..f0ae1cdf0 100644
--- a/src/uix/eidos/components/palabras/palabras-chrome.css
+++ b/src/uix/eidos/components/palabras/palabras-chrome.css
@@ -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);
}
diff --git a/src/uix/morfo/components/chronos.ts b/src/uix/morfo/components/chronos.ts
index 764c06fa8..4b046323b 100644
--- a/src/uix/morfo/components/chronos.ts
+++ b/src/uix/morfo/components/chronos.ts
@@ -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: {
diff --git a/src/uix/morfo/components/combobox.ts b/src/uix/morfo/components/combobox.ts
index 7e3cb082f..f78f03507 100644
--- a/src/uix/morfo/components/combobox.ts
+++ b/src/uix/morfo/components/combobox.ts
@@ -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'
}
}
diff --git a/src/uix/morfo/components/dialog.ts b/src/uix/morfo/components/dialog.ts
index cadccbe98..a08057588 100644
--- a/src/uix/morfo/components/dialog.ts
+++ b/src/uix/morfo/components/dialog.ts
@@ -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',
diff --git a/src/uix/morfo/components/drawer.ts b/src/uix/morfo/components/drawer.ts
index 01b921945..f023660a1 100644
--- a/src/uix/morfo/components/drawer.ts
+++ b/src/uix/morfo/components/drawer.ts
@@ -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',
diff --git a/src/uix/morfo/components/float-panel.ts b/src/uix/morfo/components/float-panel.ts
index e89ab925f..ad65257af 100644
--- a/src/uix/morfo/components/float-panel.ts
+++ b/src/uix/morfo/components/float-panel.ts
@@ -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' }
},
{
diff --git a/src/uix/morfo/components/knob.ts b/src/uix/morfo/components/knob.ts
index 9b37fc80c..0effbbc5c 100644
--- a/src/uix/morfo/components/knob.ts
+++ b/src/uix/morfo/components/knob.ts
@@ -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.
diff --git a/src/uix/morfo/components/palabras.ts b/src/uix/morfo/components/palabras.ts
index eebc771e0..a7a1ff9dc 100644
--- a/src/uix/morfo/components/palabras.ts
+++ b/src/uix/morfo/components/palabras.ts
@@ -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 (``, ``, ``, ``) 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',
diff --git a/src/uix/morfo/components/popover.ts b/src/uix/morfo/components/popover.ts
index 66f825a8c..7920d252f 100644
--- a/src/uix/morfo/components/popover.ts
+++ b/src/uix/morfo/components/popover.ts
@@ -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',
diff --git a/src/uix/morfo/components/select.ts b/src/uix/morfo/components/select.ts
index 4d6862b0c..63b74969f 100644
--- a/src/uix/morfo/components/select.ts
+++ b/src/uix/morfo/components/select.ts
@@ -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'
}
}
diff --git a/src/uix/morfo/components/switch.ts b/src/uix/morfo/components/switch.ts
index 07786e836..0cb227881 100644
--- a/src/uix/morfo/components/switch.ts
+++ b/src/uix/morfo/components/switch.ts
@@ -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: {},
diff --git a/src/uix/morfo/components/toggle-group.ts b/src/uix/morfo/components/toggle-group.ts
index 2a2177466..4d11b4270 100644
--- a/src/uix/morfo/components/toggle-group.ts
+++ b/src/uix/morfo/components/toggle-group.ts
@@ -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:
diff --git a/src/uix/morfo/components/toggle.ts b/src/uix/morfo/components/toggle.ts
index fb4e1044f..e107eba10 100644
--- a/src/uix/morfo/components/toggle.ts
+++ b/src/uix/morfo/components/toggle.ts
@@ -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: [
diff --git a/src/uix/morfo/direction-census.test.ts b/src/uix/morfo/direction-census.test.ts
index ead1de175..b3f06818e 100644
--- a/src/uix/morfo/direction-census.test.ts
+++ b/src/uix/morfo/direction-census.test.ts
@@ -26,6 +26,12 @@ const EXCEPTIONS: Record = {
// `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).
diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts
index 8b3c8191e..f5ec11914 100644
--- a/src/uix/morfo/schema.ts
+++ b/src/uix/morfo/schema.ts
@@ -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`
},
diff --git a/src/uix/morfo/test-fixtures.ts b/src/uix/morfo/test-fixtures.ts
index 7aa88be6e..53a5849f1 100644
--- a/src/uix/morfo/test-fixtures.ts
+++ b/src/uix/morfo/test-fixtures.ts
@@ -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' }
],
diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts
index da41b96fd..12062fa0f 100644
--- a/src/uix/morfo/types.ts
+++ b/src/uix/morfo/types.ts
@@ -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 `` / `` / `` with block kind,
+ * heading level, syntax tokens, i18n state);
+ * - internal render nodes (`waveform`'s two ``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
diff --git a/src/uix/sema/chans/haptic.test.ts b/src/uix/sema/chans/haptic.test.ts
index 244266253..5c4a09a96 100644
--- a/src/uix/sema/chans/haptic.test.ts
+++ b/src/uix/sema/chans/haptic.test.ts
@@ -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({
diff --git a/src/uix/sema/chans/haptic.ts b/src/uix/sema/chans/haptic.ts
index dcd7618a0..a37de5c72 100644
--- a/src/uix/sema/chans/haptic.ts
+++ b/src/uix/sema/chans/haptic.ts
@@ -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];
diff --git a/src/uix/sema/chans/visual.ts b/src/uix/sema/chans/visual.ts
index cd95699ab..b64abb968 100644
--- a/src/uix/sema/chans/visual.ts
+++ b/src/uix/sema/chans/visual.ts
@@ -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 {
const holdMs = this.resolveHoldMs(signal, effective);
if (holdMs <= 0) return;
diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts
index e20b6a7a9..888d58b86 100644
--- a/src/uix/sema/components/index.ts
+++ b/src/uix/sema/components/index.ts
@@ -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';
diff --git a/src/uix/sema/emit.test.ts b/src/uix/sema/emit.test.ts
index 78adc64bf..4a11e9594 100644
--- a/src/uix/sema/emit.test.ts
+++ b/src/uix/sema/emit.test.ts
@@ -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(() => {}) };
+ (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);
+ });
+ });
});
diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts
index 18376c5fc..192d2e1da 100644
--- a/src/uix/sema/engine.ts
+++ b/src/uix/sema/engine.ts
@@ -297,6 +297,17 @@ export class EngineSemantic {
/** Occurrences still "active" for dominance (C-3), keyed by occurrence id. */
private readonly activeOccurrences = new Map();
+ /**
+ * 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>();
+
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 | undefined;
+ if (signal.target) {
+ heldPromise = new Promise((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
diff --git a/src/uix/sema/pack-census.test.ts b/src/uix/sema/pack-census.test.ts
index 7a3025eeb..65e399eaa 100644
--- a/src/uix/sema/pack-census.test.ts
+++ b/src/uix/sema/pack-census.test.ts
@@ -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 = [
diff --git a/src/uix/sema/projection/dom.test.ts b/src/uix/sema/projection/dom.test.ts
index e35c3fa5c..71c6d6336 100644
--- a/src/uix/sema/projection/dom.test.ts
+++ b/src/uix/sema/projection/dom.test.ts
@@ -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
diff --git a/src/uix/sema/projection/dom.ts b/src/uix/sema/projection/dom.ts
index b148a0676..7480ec80d 100644
--- a/src/uix/sema/projection/dom.ts
+++ b/src/uix/sema/projection/dom.ts
@@ -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'
diff --git a/src/uix/sema/signal.ts b/src/uix/sema/signal.ts
index 8b16b85b6..9c74afd9e 100644
--- a/src/uix/sema/signal.ts
+++ b/src/uix/sema/signal.ts
@@ -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;
}
diff --git a/src/uix/sema/stamp.ts b/src/uix/sema/stamp.ts
index dcb241760..c064ba515 100644
--- a/src/uix/sema/stamp.ts
+++ b/src/uix/sema/stamp.ts
@@ -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()
diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts
index 7d0d4e816..298abdd63 100644
--- a/src/uix/sema/types.ts
+++ b/src/uix/sema/types.ts
@@ -102,7 +102,47 @@ export type IntentExpectedFamily = IntentRequiredFamily;
export type IntentOptionalFamily = Exclude;
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';
diff --git a/src/uix/soma/components/aura/README.md b/src/uix/soma/components/aura/README.md
index 399cb2da1..174f19b8b 100644
--- a/src/uix/soma/components/aura/README.md
+++ b/src/uix/soma/components/aura/README.md
@@ -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)
diff --git a/src/uix/soma/components/aura/aura-provider.svelte.ts b/src/uix/soma/components/aura/aura-provider.svelte.ts
index 845e987f4..2522eccc1 100644
--- a/src/uix/soma/components/aura/aura-provider.svelte.ts
+++ b/src/uix/soma/components/aura/aura-provider.svelte.ts
@@ -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;
}
diff --git a/src/uix/soma/components/calendar/calendar-provider.svelte.ts b/src/uix/soma/components/calendar/calendar-provider.svelte.ts
index 31c9bedd7..6043aa9b2 100644
--- a/src/uix/soma/components/calendar/calendar-provider.svelte.ts
+++ b/src/uix/soma/components/calendar/calendar-provider.svelte.ts
@@ -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 {
diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.ts
index d770740fd..6d96ab8ed 100644
--- a/src/uix/soma/components/carousel/carousel-provider.svelte.ts
+++ b/src/uix/soma/components/carousel/carousel-provider.svelte.ts
@@ -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 });
}
}
diff --git a/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts b/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts
index d9f996237..ae098233b 100644
--- a/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts
+++ b/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts
@@ -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) => {
diff --git a/src/uix/soma/components/chronos/README.md b/src/uix/soma/components/chronos/README.md
new file mode 100644
index 000000000..daeac0402
--- /dev/null
+++ b/src/uix/soma/components/chronos/README.md
@@ -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
+
+
+
+
+```
+
+The eidos root ([``](../../../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.
diff --git a/src/uix/soma/components/chronos/chronos-provider.svelte.ts b/src/uix/soma/components/chronos/chronos-provider.svelte.ts
index b7625f6a2..85b175e6f 100644
--- a/src/uix/soma/components/chronos/chronos-provider.svelte.ts
+++ b/src/uix/soma/components/chronos/chronos-provider.svelte.ts
@@ -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);
}
}
diff --git a/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts b/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts
index 47284b88d..dd7daa52b 100644
--- a/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts
+++ b/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts
@@ -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;
}
diff --git a/src/uix/soma/components/color-field/color-field-provider.svelte.ts b/src/uix/soma/components/color-field/color-field-provider.svelte.ts
index c0426358c..21e71e3ce 100644
--- a/src/uix/soma/components/color-field/color-field-provider.svelte.ts
+++ b/src/uix/soma/components/color-field/color-field-provider.svelte.ts
@@ -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);
diff --git a/src/uix/soma/components/color-picker/README.md b/src/uix/soma/components/color-picker/README.md
index af32ef944..be135922c 100644
--- a/src/uix/soma/components/color-picker/README.md
+++ b/src/uix/soma/components/color-picker/README.md
@@ -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
});
```
diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts
index 5bd937651..43e97f8ae 100644
--- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts
+++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts
@@ -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 = '';
diff --git a/src/uix/soma/components/command/command-provider.svelte.ts b/src/uix/soma/components/command/command-provider.svelte.ts
index b560986ed..f421825ec 100644
--- a/src/uix/soma/components/command/command-provider.svelte.ts
+++ b/src/uix/soma/components/command/command-provider.svelte.ts
@@ -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
});
};
diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts
index b41143041..e011a48cf 100644
--- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts
+++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts
@@ -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();
}
diff --git a/src/uix/soma/components/css-field/css-field-provider.svelte.ts b/src/uix/soma/components/css-field/css-field-provider.svelte.ts
index da37a0092..fa67c6103 100644
--- a/src/uix/soma/components/css-field/css-field-provider.svelte.ts
+++ b/src/uix/soma/components/css-field/css-field-provider.svelte.ts
@@ -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
});
}
};
diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts
index 1d3d0cffb..1ea28fe3c 100644
--- a/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts
+++ b/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts
@@ -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(fallbackTarget),
+ ref: state(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
})
);
diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts
index 94b1d3ede..0b8ecf2df 100644
--- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts
+++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts
@@ -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) => {
- // `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(() => ({
diff --git a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts
index d6522288f..65e0418cb 100644
--- a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts
+++ b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts
@@ -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();
diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts
index e640b84a3..2e67f79c0 100644
--- a/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts
+++ b/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts
@@ -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(fallbackTarget),
+ ref: state(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
})
);
diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts
index 5b857fab3..94eddb3e7 100644
--- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts
+++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts
@@ -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) => {
- 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) => {
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');
}
};
diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts
index 6f59baedf..eabe3bca5 100644
--- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts
+++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts
@@ -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();
}
diff --git a/src/uix/soma/components/editable/editable-provider.svelte.ts b/src/uix/soma/components/editable/editable-provider.svelte.ts
index 67b546af4..0abb7c190 100644
--- a/src/uix/soma/components/editable/editable-provider.svelte.ts
+++ b/src/uix/soma/components/editable/editable-provider.svelte.ts
@@ -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
});
}
diff --git a/src/uix/soma/components/feed/feed-provider.svelte.ts b/src/uix/soma/components/feed/feed-provider.svelte.ts
index 3a3c96826..a4fe30949 100644
--- a/src/uix/soma/components/feed/feed-provider.svelte.ts
+++ b/src/uix/soma/components/feed/feed-provider.svelte.ts
@@ -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 });
}
}
diff --git a/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts b/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts
index dafffc806..2dc1663a4 100644
--- a/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts
+++ b/src/uix/soma/components/file-upload/file-upload-provider.svelte.ts
@@ -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(() =>
diff --git a/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts b/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts
index 58952a97b..d23f9a172 100644
--- a/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts
+++ b/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts
@@ -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).
diff --git a/src/uix/soma/components/form/form-provider.svelte.ts b/src/uix/soma/components/form/form-provider.svelte.ts
index d7e0e1579..2e3b86dbd 100644
--- a/src/uix/soma/components/form/form-provider.svelte.ts
+++ b/src/uix/soma/components/form/form-provider.svelte.ts
@@ -139,13 +139,13 @@ export class FormProvider {
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
);
};
diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts
index a4bf7789e..b53035d18 100644
--- a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts
+++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts
@@ -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 });
}
}
}
diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts
index 2e037c721..ccc741374 100644
--- a/src/uix/soma/components/index.ts
+++ b/src/uix/soma/components/index.ts
@@ -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';
diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.ts
index a05e20846..463b1c126 100644
--- a/src/uix/soma/components/listbox/listbox-provider.svelte.ts
+++ b/src/uix/soma/components/listbox/listbox-provider.svelte.ts
@@ -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 });
}
}
}
diff --git a/src/uix/soma/components/menu-dial/menu-dial.svelte.ts b/src/uix/soma/components/menu-dial/menu-dial.svelte.ts
index 8146d93b6..676677686 100644
--- a/src/uix/soma/components/menu-dial/menu-dial.svelte.ts
+++ b/src/uix/soma/components/menu-dial/menu-dial.svelte.ts
@@ -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).
diff --git a/src/uix/soma/components/menubar/menubar-provider.svelte.ts b/src/uix/soma/components/menubar/menubar-provider.svelte.ts
index 39fd1bfe6..ee3523f55 100644
--- a/src/uix/soma/components/menubar/menubar-provider.svelte.ts
+++ b/src/uix/soma/components/menubar/menubar-provider.svelte.ts
@@ -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 });
}
}
diff --git a/src/uix/soma/components/metrics/metrics.svelte.test.ts b/src/uix/soma/components/metrics/metrics.svelte.test.ts
index c3e3f1a72..09d24626a 100644
--- a/src/uix/soma/components/metrics/metrics.svelte.test.ts
+++ b/src/uix/soma/components/metrics/metrics.svelte.test.ts
@@ -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' }));
diff --git a/src/uix/soma/components/metrics/metrics.svelte.ts b/src/uix/soma/components/metrics/metrics.svelte.ts
index 1e8233a38..79d38df8f 100644
--- a/src/uix/soma/components/metrics/metrics.svelte.ts
+++ b/src/uix/soma/components/metrics/metrics.svelte.ts
@@ -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.
diff --git a/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts b/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts
index 4733591d9..ceeec8d1a 100644
--- a/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts
+++ b/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts
@@ -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 ──────────────────────────────────────────────────────────────
diff --git a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts
index 2849d6b1d..3d7bfc4e1 100644
--- a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts
+++ b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts
@@ -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) => {
void this.provider.runtime.trigger('commit-select', {
- fallbackTarget: this.opts.ref?.current ?? undefined
+ targetOverride: this.opts.ref?.current ?? undefined
});
};
diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts
index 1cd2cce0d..2b62f7299 100644
--- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts
+++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts
@@ -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
});
}
};
diff --git a/src/uix/soma/components/onion-menu/onion-menu.svelte.ts b/src/uix/soma/components/onion-menu/onion-menu.svelte.ts
index 566388927..0dcb3c9b0 100644
--- a/src/uix/soma/components/onion-menu/onion-menu.svelte.ts
+++ b/src/uix/soma/components/onion-menu/onion-menu.svelte.ts
@@ -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`) +
diff --git a/src/uix/soma/components/pagination/pagination-provider.svelte.ts b/src/uix/soma/components/pagination/pagination-provider.svelte.ts
index 3cef5db39..fdc732667 100644
--- a/src/uix/soma/components/pagination/pagination-provider.svelte.ts
+++ b/src/uix/soma/components/pagination/pagination-provider.svelte.ts
@@ -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) {
diff --git a/src/uix/soma/components/palabras/palabras-provider.svelte.ts b/src/uix/soma/components/palabras/palabras-provider.svelte.ts
index af7cafaf6..6114f354b 100644
--- a/src/uix/soma/components/palabras/palabras-provider.svelte.ts
+++ b/src/uix/soma/components/palabras/palabras-provider.svelte.ts
@@ -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 */
diff --git a/src/uix/soma/components/palabras/types.ts b/src/uix/soma/components/palabras/types.ts
index c0686e853..0f5acb99f 100644
--- a/src/uix/soma/components/palabras/types.ts
+++ b/src/uix/soma/components/palabras/types.ts
@@ -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;
diff --git a/src/uix/soma/components/path-trace/path-trace-provider.svelte.ts b/src/uix/soma/components/path-trace/path-trace-provider.svelte.ts
index 29ae27925..0566bdc96 100644
--- a/src/uix/soma/components/path-trace/path-trace-provider.svelte.ts
+++ b/src/uix/soma/components/path-trace/path-trace-provider.svelte.ts
@@ -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);
}
diff --git a/src/uix/soma/components/popover/popover-provider.svelte.test.ts b/src/uix/soma/components/popover/popover-provider.svelte.test.ts
index 694b8749b..b50267014 100644
--- a/src/uix/soma/components/popover/popover-provider.svelte.test.ts
+++ b/src/uix/soma/components/popover/popover-provider.svelte.test.ts
@@ -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);
diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts
index 8e7ec9c0d..19cd1f6fd 100644
--- a/src/uix/soma/components/popover/popover-provider.svelte.ts
+++ b/src/uix/soma/components/popover/popover-provider.svelte.ts
@@ -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) => {
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 = () => {
diff --git a/src/uix/soma/components/proof-of-human/proof-of-human-provider.svelte.ts b/src/uix/soma/components/proof-of-human/proof-of-human-provider.svelte.ts
index 31f9cbd9b..867781408 100644
--- a/src/uix/soma/components/proof-of-human/proof-of-human-provider.svelte.ts
+++ b/src/uix/soma/components/proof-of-human/proof-of-human-provider.svelte.ts
@@ -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');
}
diff --git a/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts b/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts
index ded7aafab..642a23243 100644
--- a/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts
+++ b/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts
@@ -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 });
}
}
diff --git a/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts b/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts
index 636c0182e..0fb9ee301 100644
--- a/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts
+++ b/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts
@@ -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
});
}
diff --git a/src/uix/soma/components/rating-group/rating-group-provider.svelte.ts b/src/uix/soma/components/rating-group/rating-group-provider.svelte.ts
index 4bc5f38f8..dc95a789e 100644
--- a/src/uix/soma/components/rating-group/rating-group-provider.svelte.ts
+++ b/src/uix/soma/components/rating-group/rating-group-provider.svelte.ts
@@ -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) {
diff --git a/src/uix/soma/components/rotate-align/rotate-align-provider.svelte.ts b/src/uix/soma/components/rotate-align/rotate-align-provider.svelte.ts
index 5de4cd729..975a40066 100644
--- a/src/uix/soma/components/rotate-align/rotate-align-provider.svelte.ts
+++ b/src/uix/soma/components/rotate-align/rotate-align-provider.svelte.ts
@@ -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;
diff --git a/src/uix/soma/components/search-field/search-field-provider.svelte.ts b/src/uix/soma/components/search-field/search-field-provider.svelte.ts
index 390edcfeb..be92ec11f 100644
--- a/src/uix/soma/components/search-field/search-field-provider.svelte.ts
+++ b/src/uix/soma/components/search-field/search-field-provider.svelte.ts
@@ -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 ────────────────────────────────────────────────────────
diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts
index 45714397f..156f6e896 100644
--- a/src/uix/soma/components/select/select-provider.svelte.ts
+++ b/src/uix/soma/components/select/select-provider.svelte.ts
@@ -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();
diff --git a/src/uix/soma/components/stepper/stepper-provider.svelte.ts b/src/uix/soma/components/stepper/stepper-provider.svelte.ts
index afae30cf6..148472352 100644
--- a/src/uix/soma/components/stepper/stepper-provider.svelte.ts
+++ b/src/uix/soma/components/stepper/stepper-provider.svelte.ts
@@ -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
});
}
diff --git a/src/uix/soma/components/table/table-provider.svelte.ts b/src/uix/soma/components/table/table-provider.svelte.ts
index bcfe64243..fe389dcb8 100644
--- a/src/uix/soma/components/table/table-provider.svelte.ts
+++ b/src/uix/soma/components/table/table-provider.svelte.ts
@@ -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 });
}
}
};
diff --git a/src/uix/soma/components/tabs/tabs-provider.svelte.ts b/src/uix/soma/components/tabs/tabs-provider.svelte.ts
index 4089c1984..c911bcf15 100644
--- a/src/uix/soma/components/tabs/tabs-provider.svelte.ts
+++ b/src/uix/soma/components/tabs/tabs-provider.svelte.ts
@@ -124,7 +124,7 @@ export class TabsProvider {
}
if (target) {
- void this.runtime.trigger('select', { fallbackTarget: target });
+ void this.runtime.trigger('select', { targetOverride: target });
}
}
diff --git a/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts
index bb9832dac..908428845 100644
--- a/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts
+++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts
@@ -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 ────────────────────────────────────────────────────────────
diff --git a/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts b/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts
index 57b1367af..9b4be8bfe 100644
--- a/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts
+++ b/src/uix/soma/components/tags-input/tags-input-provider.svelte.ts
@@ -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) {
diff --git a/src/uix/soma/components/time-field/time-field-provider.svelte.ts b/src/uix/soma/components/time-field/time-field-provider.svelte.ts
index 765d7432e..b5441d46f 100644
--- a/src/uix/soma/components/time-field/time-field-provider.svelte.ts
+++ b/src/uix/soma/components/time-field/time-field-provider.svelte.ts
@@ -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);
diff --git a/src/uix/soma/components/timeline/timeline-provider.svelte.ts b/src/uix/soma/components/timeline/timeline-provider.svelte.ts
index 2229d7ab3..f624aeee6 100644
--- a/src/uix/soma/components/timeline/timeline-provider.svelte.ts
+++ b/src/uix/soma/components/timeline/timeline-provider.svelte.ts
@@ -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 }));
});
}
diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
index 6c53ecd99..3c4127ae0 100644
--- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
+++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
@@ -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 });
}
}
diff --git a/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts b/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts
index 9dde8e6d3..c00f7800c 100644
--- a/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts
+++ b/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts
@@ -352,7 +352,7 @@ export class ToolbarGroupItemProvider {
if (this.isDisabled) return;
this.group.toggleItem(this.opts.value.current);
void this.provider.runtime.trigger('commit-toggle', {
- fallbackTarget: e.currentTarget as HTMLElement
+ targetOverride: e.currentTarget as HTMLElement
});
};
diff --git a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts
index 3905f36c2..498c90a69 100644
--- a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts
+++ b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts
@@ -171,7 +171,7 @@ export class TreeGridProvider {
);
if (rowEl) {
const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand';
- void this.runtime.trigger(eventName, { fallbackTarget: rowEl });
+ void this.runtime.trigger(eventName, { targetOverride: rowEl });
}
}
@@ -222,7 +222,7 @@ export class TreeGridProvider {
`[${attrs.row}][data-value="${CSS.escape(value)}"]`
);
if (rowEl) {
- void this.runtime.trigger('commit-select', { fallbackTarget: rowEl });
+ void this.runtime.trigger('commit-select', { targetOverride: rowEl });
}
}
if (mode !== 'range') this.anchor = value;
diff --git a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts
index 4568329c6..36b8b1550 100644
--- a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts
+++ b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts
@@ -125,7 +125,7 @@ export class TreeViewProvider {
);
if (branchEl) {
const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand';
- void this.runtime.trigger(eventName, { fallbackTarget: branchEl });
+ void this.runtime.trigger(eventName, { targetOverride: branchEl });
}
}
@@ -177,7 +177,7 @@ export class TreeViewProvider {
// clicked element (item or branch) as `fromEl`; the cascade pack
// matches both via separate rules.
if (fromEl) {
- void this.runtime.trigger('commit-select', { fallbackTarget: fromEl });
+ void this.runtime.trigger('commit-select', { targetOverride: fromEl });
}
}
diff --git a/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts b/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts
index a7f77ae4b..916cbcaf5 100644
--- a/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts
+++ b/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts
@@ -199,7 +199,7 @@ export class VirtualGridProvider {
// `handle-scroll-column` are intentionally NOT emitted here for
// the same fatigue reason as virtual-list (haptic-only family,
// pixel-frequency events would buzz nonstop).
- void this.runtime.trigger('shift-navigate-to-cell', { fallbackTarget: vp });
+ void this.runtime.trigger('shift-navigate-to-cell', { targetOverride: vp });
};
readonly snippetProps = $derived.by(() => ({
diff --git a/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts
index be6d73767..fc82870c8 100644
--- a/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts
+++ b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts
@@ -448,7 +448,7 @@ export class VirtualListProvider {
const vpForEmit = this.viewportRef;
void this.runtime.trigger(
'shift-navigate-to-index',
- vpForEmit ? { fallbackTarget: vpForEmit } : undefined
+ vpForEmit ? { targetOverride: vpForEmit } : undefined
);
this.pinToDomEnd(behavior);
@@ -489,7 +489,7 @@ export class VirtualListProvider {
const vpForEmit = this.viewportRef;
void this.runtime.trigger(
'shift-navigate-to-index',
- vpForEmit ? { fallbackTarget: vpForEmit } : undefined
+ vpForEmit ? { targetOverride: vpForEmit } : undefined
);
this.applyScrollOffset(target, opts.behavior ?? 'auto');
diff --git a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts
index f6dacf712..0710e76bc 100644
--- a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts
+++ b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts
@@ -167,7 +167,7 @@ export class YearGridProvider {
this.opts.placeholder.current = next;
void this.runtime.trigger(
'shift-navigate-step',
- target ? { fallbackTarget: target } : undefined
+ target ? { targetOverride: target } : undefined
);
}
@@ -187,7 +187,7 @@ export class YearGridProvider {
const prev = this.opts.value.current;
if (prev?.year === year) return;
this.opts.value.current = { year };
- void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
+ void this.runtime.trigger('commit-set', target ? { targetOverride: target } : undefined);
}
// ── Keyboard ──────────────────────────────────────────────────────────────
diff --git a/src/uix/soma/errors.ts b/src/uix/soma/errors.ts
index c44b37b51..9b7f9d3e6 100644
--- a/src/uix/soma/errors.ts
+++ b/src/uix/soma/errors.ts
@@ -64,7 +64,7 @@ export class SomaRuntimeTargetError extends CodeError {
super(SOMA_ERR_RUNTIME_TARGET, {
message:
`[soma-runtime] Trigger "${eventName}" target part "${part}" has no DOM element ` +
- 'registered (and no fallbackTarget supplied).'
+ 'registered (and no targetOverride supplied).'
});
this.eventName = eventName;
this.part = part;
diff --git a/src/uix/soma/runtime.svelte.test.ts b/src/uix/soma/runtime.svelte.test.ts
index 79a20eead..cc7b3fce3 100644
--- a/src/uix/soma/runtime.svelte.test.ts
+++ b/src/uix/soma/runtime.svelte.test.ts
@@ -8,16 +8,23 @@ import type { SemanticSignal } from '$uix/sema';
import { state } from '$libs/reactive';
import type { Direction } from './types';
+import type { Morfo } from '$uix/morfo';
import { toggleMorfo } from '@/uix/morfo/components/toggle';
import { toastMorfo } from '@/uix/morfo/components/toast';
import { dialogMorfo } from '@/uix/morfo/components/dialog';
import {
keyboardFixtureMorfo,
liveRegionFixtureMorfo,
- prewriteFixtureMorfo
+ prewriteFixtureMorfo,
+ prewriteSplitFixtureMorfo,
+ reducedMotionFixtureMorfo
} from '@/uix/morfo/test-fixtures';
import { switchMorfo } from '@/uix/morfo/components/switch';
import { progressMorfo } from '@/uix/morfo/components/progress';
+import { calendarMorfo } from '@/uix/morfo/components/calendar';
+import { drawerMorfo } from '@/uix/morfo/components/drawer';
+import { popoverMorfo } from '@/uix/morfo/components/popover';
+import { floatPanelMorfo } from '@/uix/morfo/components/float-panel';
import { createSomaRuntime, type EventEngineEmitter } from './runtime.svelte';
/**
@@ -525,7 +532,7 @@ describe('runtime.trigger', () => {
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: registeredItem, syncAttrs: true });
- void r.trigger('present', { fallbackTarget: concreteItem });
+ void r.trigger('present', { targetOverride: concreteItem });
});
expect(sem.calls[0]).toMatchObject({ target: concreteItem, name: 'present' });
@@ -1146,4 +1153,188 @@ describe('runtime.keydown', () => {
expect(preventDefaultSpy).not.toHaveBeenCalled();
cleanup();
});
+
+ // ── fable audit residue, closed 2026-08-10 ───────────────────────────────
+ describe('trigger contract fixes (fable S5 · SO2 · SO4)', () => {
+ function harness(morfo: Parameters[0], reducedMotion = false) {
+ const logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() };
+ // Typed parameter: an untyped `vi.fn(async () => …)` makes
+ // `mock.calls[0]` a zero-length tuple, so reading the signal back is a
+ // type error even though the call is real.
+ const emit = vi.fn(async (_signal: SemanticSignal) => 'sig-1');
+ const engine: EventEngineEmitter = { emit };
+ const reducedDom = {
+ ...dom,
+ prefersReducedMotion: { matches: reducedMotion }
+ } as unknown as typeof dom;
+ const { result: runtime, cleanup } = withEffectRoot(() =>
+ createSomaRuntime(morfo, { dom: reducedDom, eventEngine: engine, logger })
+ );
+ return { runtime, logger, emit, cleanup };
+ }
+
+ it('S5 · the user reduced-motion silence WINS over the morfo channels', async () => {
+ // `reducedMotionFallback: 'state'` forces `channels: []`. It used to be
+ // the LAST term of a `??` chain, so any morfo declaring `channels`
+ // silently defeated the user preference. Choosing which channels
+ // express an event is the author's call; whether motion reaches THIS
+ // user is not.
+ const el = document.createElement('div');
+ el.setAttribute('data-reduced-motion-fixture', '');
+ document.body.appendChild(el);
+ const { runtime, emit, cleanup } = harness(reducedMotionFixtureMorfo, true);
+ runtime.part('provider', { id: state('rm-1'), ref: state(el) });
+ await runtime.trigger('commit-submit', { targetOverride: el });
+ expect(emit).toHaveBeenCalledTimes(1);
+ expect(emit.mock.calls[0][0]).toMatchObject({ channels: [] });
+ cleanup();
+ });
+
+ it('S5 · with no reduced-motion preference the morfo channels still rule', async () => {
+ const el = document.createElement('div');
+ el.setAttribute('data-reduced-motion-fixture', '');
+ document.body.appendChild(el);
+ const { runtime, emit, cleanup } = harness(reducedMotionFixtureMorfo, false);
+ runtime.part('provider', { id: state('rm-2'), ref: state(el) });
+ await runtime.trigger('commit-submit', { targetOverride: el });
+ expect(emit.mock.calls[0][0]).toMatchObject({ channels: ['sound'] });
+ cleanup();
+ });
+
+ it('SO2 · warns when `semantic` is passed to a non-polymorphic event', async () => {
+ // The option's own JSDoc promised this warn and there was none, so a
+ // provider believed it had overridden the family and had not.
+ const el = document.createElement('div');
+ el.setAttribute('data-live-region-fixture', '');
+ document.body.appendChild(el);
+ const { runtime, logger, cleanup } = harness(liveRegionFixtureMorfo);
+ runtime.part('provider', { id: state('lr-1'), ref: state(el) });
+ await runtime.trigger('signal-announce', {
+ targetOverride: el,
+ semantic: { family: 'commit', verb: 'save', intent: 'affirm' }
+ });
+ const call = logger.warn.mock.calls.find((c) => String(c[1]).includes('not polymorphic'));
+ expect(call).toBeDefined();
+ expect(call?.[2]?.context).toMatchObject({
+ declaredFamily: 'signal',
+ requestedFamily: 'commit'
+ });
+ cleanup();
+ });
+
+ it('SO4 · a rejected target leaves NO prewrite behind', async () => {
+ // The prewrite used to run before the target was validated, so an
+ // emit target that had unmounted mid-flight left `data-last-action`
+ // written and THEN threw — half-applied DOM whose only witness is an
+ // attribute nobody will clear.
+ //
+ // The fixture splits the two parts on purpose: `header` (mounted,
+ // receives the prewrite) vs `content` (the emit target, never
+ // mounted). With both on one part the assertion is vacuous.
+ const header = document.createElement('div');
+ document.body.appendChild(header);
+ const { runtime, cleanup } = harness(prewriteSplitFixtureMorfo);
+ runtime.part('header', { id: state('ps-h'), ref: state(header) });
+ runtime.part('content', { id: state('ps-c'), ref: state(null) });
+ await expect(runtime.trigger('close-cancel')).rejects.toThrow();
+ expect(header.hasAttribute('data-last-action')).toBe(false);
+ cleanup();
+ });
+ });
+
+ // ── targetOverride is auditable (2026-08-10) ─────────────────────────────
+ //
+ // The option was called `fallbackTarget` and always WON over the declared
+ // ref, so redirecting the stamp to a different PART looked identical to
+ // aiming at the right instance of the right part. Measured cost of that
+ // blindness: S-12 (textarea, 6×/13× louder than written), S-08 / S-15
+ // (navigation-menu, the same defect twice in one file), A-36 (a Button's
+ // `contact-activate` and a Drawer's `open` on ONE node, 2 ms apart).
+ //
+ // The morfo is the only thing that can tell them apart, so the runtime asks
+ // it — at emit time, because the override is an expression and only the
+ // live element knows which part it is.
+ describe('targetOverride contract', () => {
+ function emitWith(morfo: Parameters[0], event: string, el: HTMLElement) {
+ const logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() };
+ const engine: EventEngineEmitter = { emit: vi.fn(async () => 'sig-1') };
+ const { result: runtime, cleanup } = withEffectRoot(() =>
+ createSomaRuntime(morfo, { dom, eventEngine: engine, logger })
+ );
+ return { runtime, logger, cleanup };
+ }
+
+ it('stays silent when the override carries the declared target part', async () => {
+ // calendar.shift-navigate declares target `provider`.
+ const el = document.createElement('div');
+ el.setAttribute('data-calendar', '');
+ document.body.appendChild(el);
+ const { runtime, logger, cleanup } = emitWith(calendarMorfo, 'shift-navigate', el);
+ await runtime.trigger('shift-navigate', { targetOverride: el });
+ expect(logger.warn).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('stays silent when the override carries a declared allowedTargets part', async () => {
+ // calendar.shift-navigate lists the arrows / selects / day it may
+ // travel to — the repeated-part redirection, declared.
+ const el = document.createElement('button');
+ el.setAttribute('data-calendar-next-button', '');
+ document.body.appendChild(el);
+ const { runtime, logger, cleanup } = emitWith(calendarMorfo, 'shift-navigate', el);
+ await runtime.trigger('shift-navigate', { targetOverride: el });
+ expect(logger.warn).not.toHaveBeenCalled();
+ cleanup();
+ });
+
+ it('warns when the override lands on a part the morfo never declared', async () => {
+ // A FIXTURE, not a catalogue component: this assertion is about the
+ // mechanism, and pointing it at a real morfo makes it fail the day
+ // that component is brought into compliance — which is exactly what
+ // happened here on 2026-08-10, when `drawer.open` gained its
+ // `allowedTargets` and this test went red for the right reason.
+ const el = document.createElement('div');
+ el.setAttribute('data-prewrite-split-fixture-header', '');
+ document.body.appendChild(el);
+ const { runtime, logger, cleanup } = emitWith(prewriteSplitFixtureMorfo, 'close-cancel', el);
+ runtime.part('content', { id: state('ps-c2'), ref: state(el) });
+ await runtime.trigger('close-cancel', { targetOverride: el });
+ const call = logger.warn.mock.calls.find((c) => String(c[1]).includes('off-contract'));
+ expect(call).toBeDefined();
+ expect(call?.[0]).toBe('soma');
+ expect(call?.[1]).toContain('prewrite-split-fixture.close-cancel');
+ expect(call?.[2]?.context).toMatchObject({
+ declared: ['content'],
+ landedOn: ['data-prewrite-split-fixture-header']
+ });
+ cleanup();
+ });
+
+ it('A-36 · the overlay appearance stamps its OWN content, never the trigger', () => {
+ // A-36, measured in the browser on site-header: ``
+ // composed over a `` is ONE element, so the Button's
+ // `contact-activate` and the Drawer's `open` landed on the same node
+ // 2 ms apart — one slot per element, so the contact's whole visual
+ // signature (`press-squeeze`) never painted.
+ //
+ // The fix is not arbitration, it is the doctrine: each event on its
+ // own surface. The trigger-bound stamp was a workaround from the
+ // `pre` era, when the content really wasn't mounted at emit time.
+ // With `post` it is — so `open` resolves its declared `content`, and
+ // the trigger's surface is free for the trigger's own events.
+ //
+ // Pinned two ways, because either alone can drift: the morfo must
+ // declare no trigger redirection, and the sequence must stay `post`
+ // (going back to `pre` would resurrect the workaround's premise).
+ for (const morfo of [dialogMorfo, drawerMorfo, popoverMorfo, floatPanelMorfo]) {
+ const open = (morfo as Morfo).events?.find((e) => e.name === 'open');
+ expect(open, `${morfo.kebab} declares an 'open' event`).toBeDefined();
+ expect(
+ open?.semantic.allowedTargets,
+ `${morfo.kebab}.open must not redirect the stamp off its content`
+ ).toBeUndefined();
+ expect(open?.semantic.sequence, `${morfo.kebab}.open stays 'post'`).toBe('post');
+ }
+ });
+ });
});
diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts
index b1a4a666e..088ecb874 100644
--- a/src/uix/soma/runtime.svelte.ts
+++ b/src/uix/soma/runtime.svelte.ts
@@ -303,13 +303,34 @@ export interface TriggerResult {
*/
export interface TriggerOptions {
/**
- * Element to use as the visual signal target when the morfo's declared
- * target part isn't (yet) in the DOM, or when the event belongs to one
- * concrete instance of a repeated part such as `Item`. An explicit
- * fallback wins over the registered part ref because the caller is
- * pointing at the exact interaction surface for this emit.
+ * The element this emit stamps on, REPLACING the registered ref of the
+ * event's declared `target` part.
+ *
+ * It WINS — always, not only when the declared ref is missing. That is the
+ * behaviour this option has always had; until 2026-08-10 it was called
+ * `fallbackTarget`, and the name said the opposite of what the code did
+ * (`opts.fallbackTarget ?? targetReg?.ref?.current`). The rename is the
+ * precondition for auditing the redirection: while the option read as a
+ * fallback, a provider aiming the stamp at a different PART looked
+ * identical to one aiming at the right instance of the right part.
+ *
+ * Two legitimate uses, and the morfo tells them apart:
+ *
+ * - **Repeated part.** `registrations` is keyed by part NAME, so a
+ * repeated part (`day`, `item`, `row`) registers whichever instance
+ * mounted last. Pointing at the pressed one is the only way to stamp the
+ * surface the user actually touched. The part is the declared `target`,
+ * so nothing extra is needed.
+ * - **A different part.** Redirecting to another part (an overlay stamping
+ * on its trigger because the content hasn't mounted) MUST be declared in
+ * the morfo event's `allowedTargets`. Without that declaration a sema
+ * pack written from the morfo aims at a node the stamp never visits —
+ * the class measured in audits S-08 / S-15 (dead pack rules) and A-36
+ * (two morfos stamping one node 2 ms apart).
+ *
+ * The runtime asserts this in dev: see `assertTargetOverride`.
*/
- fallbackTarget?: HTMLElement;
+ targetOverride?: HTMLElement;
/**
* Per-call signal overrides. Each runtime channel slice (`sound`,
* `haptic`, future channels, …) merges with whatever the morfo event
@@ -648,6 +669,50 @@ export function createSomaRuntime(
return rootPropsScratch;
}
+ /**
+ * A `targetOverride` may land on the event's declared `target` part (the
+ * repeated-part case: the pressed `day`, the clicked `item`) or on a part
+ * the morfo lists in `allowedTargets`. Anything else is undeclared
+ * redirection: the stamp goes somewhere the contract never mentions, so a
+ * sema pack or an eidos recipe written FROM the morfo aims at a node the
+ * signal never visits, and nothing can tell that mistake from a legitimate
+ * redirection.
+ *
+ * Measured instances of the mistake: S-12 (textarea, 6×/13× louder than
+ * written), S-08 / S-15 (navigation-menu, twice in one file), A-36 (the
+ * Button's `contact-activate` and the Drawer's `open` on ONE node, 2 ms
+ * apart — the contact's whole visual signature never painted).
+ *
+ * Runtime rather than static: the override is an expression
+ * (`e.currentTarget`), so only the live element can answer which part it
+ * is. It reads the markers the element already carries — no extra state.
+ * A warning, never a throw: sema is ornamental and a mis-aimed stamp must
+ * not break the component.
+ */
+ function assertTargetOverride(action: { name: string; target: string; semantic: unknown }, el: HTMLElement): void {
+ if (!sources.logger) return;
+ const semantic = action.semantic as { allowedTargets?: readonly { target: string }[] };
+ const allowedParts = [
+ action.target,
+ ...(semantic.allowedTargets ?? []).map((ref) => ref.target)
+ ];
+ const allowedMarkers = allowedParts
+ .map((kebab) => compiled.parts.attrs[kebab])
+ .filter((marker): marker is string => marker !== undefined);
+ if (allowedMarkers.some((marker) => el.hasAttribute(marker))) return;
+ // Name what the element IS, so the report says where the stamp landed
+ // instead of only where it should have.
+ const own = Object.values(compiled.parts.attrs).filter((marker) => el.hasAttribute(marker));
+ sources.logger.warn('soma', `targetOverride on "${compiled.kebab}.${action.name}" lands off-contract`, {
+ context: {
+ event: action.name,
+ declared: allowedParts,
+ landedOn: own.length > 0 ? own : '(no part marker of this component)',
+ fix: 'aim at the declared target, or declare the redirection in the morfo event `allowedTargets`'
+ }
+ });
+ }
+
function trigger(eventName: string, opts: TriggerOptions = {}): Promise {
const result = runTrigger(eventName, opts);
// Pre-attach a logging catch so fire-and-forget dispatch (`void
@@ -668,6 +733,28 @@ export function createSomaRuntime(
throw new SomaRuntimeEventError(morfo.kebab, eventName);
}
+ // 0. Validate the emit target BEFORE any DOM write (SO4, fable audit
+ // 2026-07-01). The prewrite used to run first, so a target that had
+ // unmounted mid-flight left `data-last-action` written on the DOM and
+ // then threw — a half-applied state whose only witness was an attribute
+ // nobody would clear. Nothing observable happens until we know the
+ // occurrence can complete.
+ //
+ // ONLY for `pre` / `coincident`, where the emit precedes the handler and
+ // the target must therefore already exist. A `post` event's target may
+ // legitimately MOUNT during the trigger (the overlay `open`s: the
+ // handler flips state, `tick()` mounts the content, the emit stamps it),
+ // so validating it here would reject the canonical open flow. `post`
+ // keeps its emit-time check inside `runEmit` — and if that one fails,
+ // the handler has already done the component's real work; only the
+ // ornament is lost, which is sema's contract.
+ if (sources.eventEngine && (action.semantic.sequence ?? 'pre') !== 'post') {
+ const emitReg = registrations.get(action.target);
+ if (!(opts.targetOverride ?? emitReg?.ref?.current ?? null)) {
+ throw new SomaRuntimeTargetError(eventName, action.target);
+ }
+ }
+
// 1. prewrite — transient markers applied before the perceptual signal.
// Each prewrite entry already carries a literal value; no source resolution.
for (const write of action.prewrite) {
@@ -720,6 +807,24 @@ export function createSomaRuntime(
resolvedFamily = provided.family;
resolvedIntent = provided.intent;
resolvedVerb = provided.verb;
+ } else if (opts.semantic) {
+ // SO2 (fable audit 2026-07-01): `TriggerOptions.semantic`'s own JSDoc
+ // promised a warn here and there was none, so a provider that passed a
+ // concretion to a NON-polymorphic event believed it had overridden the
+ // family and had not. Silence made the two cases — honoured and
+ // ignored — indistinguishable at the call site.
+ sources.logger?.warn(
+ 'soma',
+ `trigger("${eventName}", { semantic }) ignored — the event is not polymorphic`,
+ {
+ context: {
+ event: eventName,
+ declaredFamily: action.semantic.family,
+ requestedFamily: opts.semantic.family,
+ fix: 'declare `allowedFamilies` on the morfo event, or drop the `semantic` option'
+ }
+ }
+ );
}
// The verb TRAVELS (fixed 2026-08-06, audit S-40 — it used to be computed
// and discarded here while the type promised it was emitted). It selects
@@ -749,6 +854,15 @@ export function createSomaRuntime(
// signal entirely (no motion, no haptic, no sound) and rely on the
// state attrs the runtime writes naturally. We do that by forcing
// `channels: []` which the engine short-circuits without dispatching.
+ //
+ // S5 (fable audit 2026-07-01) — this WINS over the morfo's and the
+ // call's `channels`. It used to be the last term of a `??` chain, so a
+ // morfo tuning its channels (`channels: ['sound']`, to keep a live
+ // region from competing with haptic) silently defeated a user
+ // ACCESSIBILITY PREFERENCE. The old comment defended it as deliberate
+ // ("authors can still force motion"), but it conflated two different
+ // intents: choosing which channels express an event is the author's;
+ // deciding whether motion reaches this user is not.
const a11yChannelsOverride =
prefersReducedMotion && reducedFallback === 'state' ? ([] as const) : undefined;
@@ -757,10 +871,11 @@ export function createSomaRuntime(
const runEmit = async () => {
if (!sources.eventEngine) return;
const targetReg = registrations.get(action.target);
- const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
+ const target = opts.targetOverride ?? targetReg?.ref?.current ?? null;
if (!target) {
throw new SomaRuntimeTargetError(eventName, action.target);
}
+ if (opts.targetOverride) assertTargetOverride(action, opts.targetOverride);
const props = snapshotRootProps();
const family = resolvedFamily;
// Intent comes from the resolved morfo declaration (concrete
@@ -776,10 +891,11 @@ export function createSomaRuntime(
const hold = resolveSemaDuration(action.hold);
// Per-call overrides win over morfo-declared. Channels: per-call
// fully replaces morfo's. Overrides: shallow per-channel merge
- // (per-call channel slices win wholesale on conflict). a11y
- // reduced-motion override has the LOWEST precedence so authors
- // can still force motion when they know the context warrants it.
- const channels = opts.channels ?? action.semantic.channels ?? a11yChannelsOverride;
+ // (per-call channel slices win wholesale on conflict).
+ //
+ // The a11y reduced-motion silence is FIRST, not last (S5): a user
+ // preference is not a default for an author to fall back on.
+ const channels = a11yChannelsOverride ?? opts.channels ?? action.semantic.channels;
const morfoOverrides = action.semantic.overrides;
const overrides = opts.overrides
? ({ ...(morfoOverrides ?? {}), ...opts.overrides } as SemaSignatureOverride)
@@ -794,6 +910,10 @@ export function createSomaRuntime(
...(channels !== undefined ? { channels } : {}),
...(overrides ? { overrides } : {}),
...(opts.actor !== undefined ? { actor: opts.actor } : {}),
+ // Surface concurrency policy, declared by the morfo. Only the
+ // irreducible pairs set it (`SemaRegime`); everything else takes
+ // the default `replace`.
+ ...(action.regime !== undefined ? { regime: action.regime } : {}),
persistence
});
};
@@ -829,7 +949,7 @@ export function createSomaRuntime(
a11y.requiresFocusMove || (prefersReducedMotion && reducedFallback === 'focus');
if (wantsFocus) {
const targetReg = registrations.get(action.target);
- const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
+ const target = opts.targetOverride ?? targetReg?.ref?.current ?? null;
if (target) sources.dom.focus(target);
}
};