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 `
`, `
`, ``) 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 `