From 14aa218658f0753910a3a8cbe5feffcc79f2df6d Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 26 Apr 2026 04:33:40 +0200 Subject: [PATCH] =?UTF-8?q?soma:=20Toast=20pilot=20=E2=80=94=20multi-insta?= =?UTF-8?q?nce=20runtime=20+=20trigger()=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit B of the Toast pilot. The runtime.trigger() contract from commit A now has its first real consumer: all three dismiss paths route through `runtime.trigger('dismiss')`, which emits the perceptual signal one frame before the toaster mutates state. Multi-instance architecture validated Toast is the first component with multiple per-instance subtrees: each toast in the queue is its own scope with its own children. Two runtime instances coexist per Toast tree: ToastProvider (root) └── runtime instance A — registers `viewport` only ToastItemProvider (one per active toast in queue) └── runtime instance B — registers `item` + its children (`title`, `description`, `action`, `close`) `partRef('item')` resolves to THIS toast's element because the lookup happens on THIS Item's runtime, not a shared one. `runtime.trigger('dismiss')` on Item-B targets Item-B's DOM element. Per-toast isolation by construction. dismiss wired through runtime.trigger Three call sites now go through the same path instead of calling `toaster.dismiss(id)` directly: - ToastCloseProvider.onclick (user clicks the × button) - ToastItemProvider startTimer setTimeout callback (auto-dismiss) - ToastItemProvider onpointerup (swipe gesture meets threshold) The runtime fires `data-event="dismiss"` on the item, awaits one rAF for CSS to observe it, then calls the dismiss handler that mutates `toast.dismissing = true`. The item's effect re-derives `data-state="closed"` from `isOpen = !dismissing` and writes it via dom.apply on the next tick. intent-driven role / aria-live still works The morfo's `mapRef(propRef('intent'), { neutral: 'status', risk: 'alert', … })` for `role` and `aria-live` is resolved by the runtime per-Item: each toast reads its own `intent` from `opts.toast.current.intent` via the per-Item runtime sources. Risk/threat toasts get `role=alert` + `aria-live=assertive`; the others get `role=status` + `aria-live=polite`. No code in the provider selects this — the morfo declares it, the resolver computes it. Per-part props Action's `altText` is per-instance per-action — passed to the runtime via `registerPart('action', { props: { altText: () => opts.altText.current } })`. Validates that the runtime's per-part source override (introduced in commit 8ede5353 but never exercised) works as designed. Translation lookup Close's `aria-label` resolves through `v.translationRef` against the Item runtime's `translate` source (`soma.langs.ts`). Confirms translationRef ergonomics through the runtime path. What this commit does NOT do (deferred) - `present` and `announce` events: declared in the morfo but not wired. Would fire on Item mount; needs a small effect at the right phase. - `runtime.keydown` for Item's `Escape` keyboard declaration: requires the V2 runtime API. - presence layer (data-starting-style/data-ending-style) still flows through Svelte render — not migrated to dom.apply. Mixed model is OK while the boundary is clearly between morfo-declared attrs (runtime) and layer-declared transitions (Svelte). Verification - 84/84 focused unit tests green. - 66/66 morfo-check (toast included). - 68/68 smoke routes 200 OK. - svelte-check unchanged at 155. --- .../components/toast/toast-provider.svelte.ts | 177 +++++++++++++----- 1 file changed, 129 insertions(+), 48 deletions(-) diff --git a/src/uix/soma/components/toast/toast-provider.svelte.ts b/src/uix/soma/components/toast/toast-provider.svelte.ts index c17601b6d..c690b45c8 100644 --- a/src/uix/soma/components/toast/toast-provider.svelte.ts +++ b/src/uix/soma/components/toast/toast-provider.svelte.ts @@ -1,14 +1,19 @@ import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider'; -import { readableActive, state, type Active, type ActiveProps } from '../../reactive'; +import { createAttrs, registerContract } from '../../attrs'; +import { readableActive, state, type ActiveProps } from '../../reactive'; import type { SomaMouseEvent } from '../../types'; import { Soma } from '../../core/soma.svelte'; import { isBrowser } from '../../dom'; +import { createMorfoRuntime, type MorfoRuntime } from '../../morfo'; import { Presence } from '../../layers/presence.svelte'; import type { Toaster, ToastData, SwipeDirection } from './toaster.svelte'; import { toastMorfo } from '../../../morfo/components/toast'; -// ── Provider (root, no DOM) ───────────────────────────────────────────────── +createAttrs(toastMorfo); +registerContract(toastMorfo); + +// ── Provider (root, virtual) ──────────────────────────────────────────────── interface ToastProviderOpts extends @@ -20,17 +25,33 @@ interface ToastProviderOpts export class ToastProvider extends Provider { static readonly ctx = context('Toast'); - static get(): ToastProvider | undefined { return this.ctx.getOr(undefined) as ToastProvider | undefined; } - static require(): ToastProvider { return this.ctx.get(); } + static get(): ToastProvider | undefined { + return this.ctx.getOr(undefined) as ToastProvider | undefined; + } + static require(): ToastProvider { + return this.ctx.get(); + } static create(opts: ToastProviderOpts) { return new ToastProvider(opts); } readonly soma = Soma.get(); + readonly runtime: MorfoRuntime; private constructor(opts: ToastProviderOpts) { super(opts, { morfo: toastMorfo, part: 'provider' }, ToastProvider.ctx); + + // Root-scope runtime — only the Viewport part registers here. Item and + // its children get their own runtime per-instance (see ToastItemProvider). + const soma = Soma.require(); + this.runtime = createMorfoRuntime(toastMorfo, { + dom: soma.dom, + semantic: soma.semantic, + props: { + label: () => opts.label.current + } + }); } get toaster(): Toaster { @@ -53,6 +74,10 @@ export class ToastViewportProvider extends Provider { private constructor(opts: ToastViewportOpts) { super(opts, { morfo: toastMorfo, part: 'viewport' }); this.provider = ToastProvider.require(); + this.provider.runtime.registerPart('viewport', { + id: opts.id, + ref: opts.ref + }); // Register global hotkey to focus viewport, with cleanup. Supports // composite hotkeys like `['AltLeft', 'F8']` by tracking currently @@ -67,7 +92,6 @@ export class ToastViewportProvider extends Provider { return hotkey.every((k) => { if (e.code === k || e.key === k) return true; if (pressed.has(k)) return true; - // Treat modifier aliases as currently active. if ((k === 'Alt' || k === 'AltLeft' || k === 'AltRight') && e.altKey) return true; if ((k === 'Control' || k === 'ControlLeft' || k === 'ControlRight') && e.ctrlKey) return true; @@ -117,12 +141,7 @@ export class ToastViewportProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps, - ...this.resolveMorfoProps({ - props: { - label: this.provider.opts.label.current - } - }), + ...this.provider.runtime.partProps('viewport'), tabindex: -1 } as const) ); @@ -139,8 +158,12 @@ interface ToastItemOpts export class ToastItemProvider extends Provider { static readonly ctx = context('ToastItem'); - static get(): ToastItemProvider | undefined { return this.ctx.getOr(undefined) as ToastItemProvider | undefined; } - static require(): ToastItemProvider { return this.ctx.get(); } + static get(): ToastItemProvider | undefined { + return this.ctx.getOr(undefined) as ToastItemProvider | undefined; + } + static require(): ToastItemProvider { + return this.ctx.get(); + } static create(opts: ToastItemOpts) { return new ToastItemProvider(opts); @@ -149,6 +172,13 @@ export class ToastItemProvider extends Provider { readonly provider: ToastProvider; readonly presence: Presence; + /** + * Per-instance runtime. Every toast in the queue is its own scope: + * `partRef('item')` must resolve to THIS toast's element, not any other. + * Title / Description / Action / Close inside this Item register here. + */ + readonly runtime: MorfoRuntime; + // tracked IDs for ARIA titleId = state(''); descriptionId = state(''); @@ -174,6 +204,39 @@ export class ToastItemProvider extends Provider { super(opts, { morfo: toastMorfo, part: 'item' }, ToastItemProvider.ctx); this.provider = ToastProvider.require(); + // Per-instance runtime for { item, title, description, action, close }. + // `events.dismiss` is the WHEN — runtime.trigger('dismiss') runs the + // perceptual signal first, then this callback mutates state, then the + // item's effect re-derives data-state etc. via dom.apply. + const soma = Soma.require(); + this.runtime = createMorfoRuntime(toastMorfo, { + dom: soma.dom, + semantic: soma.semantic, + states: { + open: () => this.isOpen + }, + props: { + intent: () => opts.toast.current.intent, + loading: () => opts.toast.current.loading, + swipeState: () => (this.swipeState === 'idle' ? undefined : this.swipeState), + swipeDirection: () => this.provider.toaster.swipeDirection + }, + parts: { + title: () => this.titleId.current || undefined, + description: () => this.descriptionId.current || undefined + }, + translate: (key) => soma.langs.ts(key), + events: { + dismiss: () => { + this.provider.toaster.dismiss(opts.toast.current.id); + } + } + }); + this.runtime.registerPart('item', { + id: opts.id, + ref: opts.ref + }); + // Presence for exit animation this.presence = new Presence({ open: readableActive(() => this.isOpen), @@ -216,7 +279,7 @@ export class ToastItemProvider extends Provider { this.clearTimer(); this.startTime = Date.now(); this.timerId = setTimeout(() => { - this.provider.toaster.dismiss(this.opts.toast.current.id); + void this.runtime.trigger('dismiss'); }, this.remaining); } @@ -337,7 +400,7 @@ export class ToastItemProvider extends Provider { if (isCorrectDirection && Math.abs(delta) >= this.swipeThreshold) { this.swipeState = 'end'; - this.provider.toaster.dismiss(this.opts.toast.current.id); + void this.runtime.trigger('dismiss'); } else { this.swipeState = 'cancel'; this.swipeDeltaX = 0; @@ -351,22 +414,7 @@ export class ToastItemProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps, - ...this.resolveMorfoProps({ - states: { - open: this.isOpen - }, - props: { - intent: this.opts.toast.current.intent, - loading: this.opts.toast.current.loading, - swipeState: this.swipeState === 'idle' ? undefined : this.swipeState, - swipeDirection: this.provider.toaster.swipeDirection - }, - parts: { - title: this.titleId.current || undefined, - description: this.descriptionId.current || undefined - } - }), + ...this.runtime.partProps('item'), tabindex: 0, style: { '--soma-toast-swipe-move-x': `${this.swipeDeltaX}px`, @@ -403,15 +451,21 @@ export class ToastTitleProvider extends Provider { return new ToastTitleProvider(opts); } + readonly toastItem: ToastItemProvider; + private constructor(opts: ToastTitleOpts) { super(opts, { morfo: toastMorfo, part: 'title' }); - const toastItem = ToastItemProvider.require(); - toastItem.titleId.current = opts.id.current; + this.toastItem = ToastItemProvider.require(); + this.toastItem.titleId.current = opts.id.current; + this.toastItem.runtime.registerPart('title', { + id: opts.id, + ref: opts.ref + }); } readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps + ...this.toastItem.runtime.partProps('title') } as const) ); } @@ -425,15 +479,21 @@ export class ToastDescriptionProvider extends Provider { return new ToastDescriptionProvider(opts); } + readonly toastItem: ToastItemProvider; + private constructor(opts: ToastDescriptionOpts) { super(opts, { morfo: toastMorfo, part: 'description' }); - const toastItem = ToastItemProvider.require(); - toastItem.descriptionId.current = opts.id.current; + this.toastItem = ToastItemProvider.require(); + this.toastItem.descriptionId.current = opts.id.current; + this.toastItem.runtime.registerPart('description', { + id: opts.id, + ref: opts.ref + }); } readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps + ...this.toastItem.runtime.partProps('description') } as const) ); } @@ -452,18 +512,26 @@ export class ToastActionProvider extends Provider { return new ToastActionProvider(opts); } + readonly toastItem: ToastItemProvider; + private constructor(opts: ToastActionOpts) { super(opts, { morfo: toastMorfo, part: 'action' }); + this.toastItem = ToastItemProvider.require(); + + // Per-part props: `altText` lives on Action's own opts, not on the Item. + // Pass it via registerPart so the Action's effect resolves it locally. + this.toastItem.runtime.registerPart('action', { + id: opts.id, + ref: opts.ref, + props: { + altText: () => opts.altText.current + } + }); } readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps, - ...this.resolveMorfoProps({ - props: { - altText: this.opts.altText.current - } - }) + ...this.toastItem.runtime.partProps('action') } as const) ); } @@ -482,19 +550,32 @@ export class ToastCloseProvider extends Provider { private constructor(opts: ToastCloseOpts) { super(opts, { morfo: toastMorfo, part: 'close' }); this.toastItem = ToastItemProvider.require(); + this.toastItem.runtime.registerPart('close', { + id: opts.id, + ref: opts.ref + }); } + /** + * Routes through `runtime.trigger('dismiss')` rather than calling + * `toaster.dismiss(...)` directly. The runtime emits the perceptual signal + * (`data-event="dismiss"` on the item) one frame before the structural + * change, then invokes the dismiss handler that mutates the toaster state. + * + * `void` is intentional — DOM event handlers must be sync; the trigger's + * Promise chain runs in the background. + */ readonly onclick = (_e: SomaMouseEvent) => { - this.toastItem.provider.toaster.dismiss(this.toastItem.opts.toast.current.id); + void this.toastItem.runtime.trigger('dismiss'); }; readonly props = $derived.by(() => this.assertProps({ - ...this.baseProps, - ...this.resolveMorfoProps({ - translations: (key) => this.toastItem.provider.soma?.langs.ts(key) - }), + ...this.toastItem.runtime.partProps('close'), onclick: this.onclick } as const) ); } + +// `SwipeDirection` is re-exported by toaster.svelte.ts; nothing else here uses it. +export type { SwipeDirection };