From adf89a0811c11b395d6a98553da7448d540546dc Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 26 May 2026 03:04:48 +0200 Subject: [PATCH] feat(soma): wire runtime.trigger for batch A (7 components) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los 11 packs sema shipped en commit 2d562f37 quedaban dormantes porque soma no llamaba runtime.trigger en ningún sitio de estos 7 componentes. Esta es la cabling que los activa. Patrón: emisión en el state-mutator central (no en handlers individuales) cuando es posible, pasando fallbackTarget al elemento DOM apropiado para que el cascade selector matchee la instancia correcta (no la "más reciente registrada"). - toggle-group: `commit-toggle` desde Item.onclick con item button como fallbackTarget. - menubar: `commit-select` desde Provider.open(value), usando triggerRegistry para resolver value→element. Cubre click, keyboard, hover-follow y arrow-nav en una sola emisión. - navigation-menu: `commit-select` desde Provider.openNow(value), usando triggerRefs existente. Closest() del trigger sube al item ancestro. - dropdown-menu: `open` y `close` desde handleOpen/handleClose (sequence: 'pre' — antes de la mutación de state); `commit-select` desde MenuItemProvider.onclick + onkeydown. - context-menu: `open` desde openAt(), `close` desde handleClose, `commit-select` desde ContextMenuItemProvider.onclick + onkeydown. - tree-view: `emerge-expand`/`emerge-collapse` desde toggleExpand (lookup branch via querySelector). `commit-select` desde select() con parámetro fromEl pasado por BranchControl/ItemProvider. - tree-grid: análogos via lookup de row por data-value. Pack tree-view actualizado: añade cascade rule para commit-select sobre `branch` (mismo tuning que item) — branches y leaves comparten firma perceptual. Fix colateral: tag-group test esperaba intent='neutral' para commit-unselect, pero el canon (f3641347) lo declara como 'affirm'. Test actualizado. Verificación: - npm run morfo:vocabulary → EXIT 0 - npx vitest run src/uix/sema src/uix/morfo → 195/195 pass - npx vitest run src/uix/soma → 419/419 pass (74 files) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/sema/components/tree-view.ts | 9 ++++++ .../context-menu-provider.svelte.ts | 19 +++++++++++- .../dropdown-menu-provider.svelte.ts | 24 ++++++++++++++- .../menubar/menubar-provider.svelte.ts | 9 ++++++ .../navigation-menu-provider.svelte.ts | 10 +++++++ .../tag-group-provider.svelte.test.ts | 5 +++- .../toggle-group-provider.svelte.ts | 10 ++++++- .../tree-grid/tree-grid-provider.svelte.ts | 22 +++++++++++++- .../tree-view/tree-view-provider.svelte.ts | 30 ++++++++++++++++--- 9 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/uix/sema/components/tree-view.ts b/src/uix/sema/components/tree-view.ts index 7dedaf779..b4b6a2d0f 100644 --- a/src/uix/sema/components/tree-view.ts +++ b/src/uix/sema/components/tree-view.ts @@ -45,10 +45,19 @@ export const treeViewSema: Sema = { selector: onBranch({ eventName: 'emerge-collapse' }), sound: soundTuning('emerge.exit.soft') }, + // Commit-select can fire on either part: leaves use `item`, branches + // re-fire the same morfo event but land on the branch element. Two + // cascade rules with identical tuning keep the perceptual signature + // consistent across leaf and branch selection. { selector: onItem({ eventName: 'commit-select' }), sound: soundTuning('form.commit.subtle'), haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 } + }, + { + selector: onBranch({ eventName: 'commit-select' }), + sound: soundTuning('form.commit.subtle'), + haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 } } ] }; 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 21e8b417b..d75ebd09a 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 @@ -117,10 +117,19 @@ export class ContextMenuProvider { openAt(x: number, y: number) { this.anchorPoint.current = { x, y }; + // Morfo declares `open` on `content` (emerge.open, sequence pre). + // Content may not be mounted yet on first open — pass it as fallback + // when available, otherwise let the runtime fire with no target. + const target = this.contentRef.current; + void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined); this.opts.open.current = true; } handleClose() { + // Morfo declares `close` on `content` (emerge.close, sequence pre). + // Content still in DOM at this point; cascade matches before flip. + const target = this.contentRef.current; + void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined); this.typeahead.reset(); this.opts.open.current = false; } @@ -428,9 +437,14 @@ export class ContextMenuItemProvider { }); } - readonly onclick = (_e: SomaMouseEvent) => { + readonly onclick = (e: SomaMouseEvent) => { if (this.opts.disabled.current) return; this.opts.onSelect.current(); + // 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 + }); if (this.opts.closeOnSelect.current) this.provider.handleClose(); }; @@ -439,6 +453,9 @@ export class ContextMenuItemProvider { if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.opts.onSelect.current(); + void this.provider.runtime.trigger('commit-select', { + fallbackTarget: e.currentTarget + }); if (this.opts.closeOnSelect.current) this.provider.handleClose(); } }; 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 75b368655..2d4bca42c 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 @@ -114,10 +114,22 @@ export class MenuProvider { } handleOpen() { + // Sequence: 'pre' per the morfo — fire before the structural flip + // so the perceptual signal lands at the gesture moment. If the + // content hasn't mounted yet (first open), the runtime falls back + // to no target and the channels still play (sound/haptic don't + // need a DOM target; visual will pick it up on next emit). + const target = this.contentRef.current; + void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined); this.opts.open.current = true; } handleClose() { + // Sequence: 'pre' per the morfo — content is still in the DOM here, + // so the cascade can match `[data-dropdown-menu-content]` cleanly + // before the close flip unmounts it. + const target = this.contentRef.current; + void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined); this.typeahead.reset(); this.opts.open.current = false; } @@ -452,9 +464,16 @@ export class MenuItemProvider { }); } - readonly onclick = (_e: SomaMouseEvent) => { + readonly onclick = (e: SomaMouseEvent) => { if (this.opts.disabled.current) return; this.opts.onSelect.current(); + // Morfo declares `commit-select` on `item` (affirm). Fire with the + // clicked element as fallback target so the cascade selector + // `[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 + }); if (this.opts.closeOnSelect.current) this.provider.handleClose(); }; @@ -463,6 +482,9 @@ export class MenuItemProvider { if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.opts.onSelect.current(); + void this.provider.runtime.trigger('commit-select', { + fallbackTarget: e.currentTarget + }); if (this.opts.closeOnSelect.current) this.provider.handleClose(); } }; diff --git a/src/uix/soma/components/menubar/menubar-provider.svelte.ts b/src/uix/soma/components/menubar/menubar-provider.svelte.ts index 154d28f74..1a95bb0ed 100644 --- a/src/uix/soma/components/menubar/menubar-provider.svelte.ts +++ b/src/uix/soma/components/menubar/menubar-provider.svelte.ts @@ -99,6 +99,15 @@ export class MenubarProvider { this.opts.value.current = value; if (prev) this.openChangeListeners.get(prev)?.(false); this.openChangeListeners.get(value)?.(true); + // Morfo declares `commit-select` on `trigger` (affirm). Picking a + // top-level menubar entry — by click, keyboard activate, hover- + // follow, or arrow-nav between triggers — all flow through this + // method. Firing here covers every path with one stamp on the + // trigger element that just became active. + const triggerEl = this.triggerRegistry.get(value); + if (triggerEl) { + void this.runtime.trigger('commit-select', { fallbackTarget: triggerEl }); + } } /** Close the currently open menu. */ 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 76fbb5f10..c7a53df28 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,6 +144,16 @@ export class NavigationMenuProvider { if (this.opts.value.current === value) return; this.opts.value.current = value; this.opts.onValueChange.current?.(value); + // Morfo declares `commit-select` on `item` (affirm). Centralising + // the emission here covers every path (trigger click, keyboard + // activate, hover-follow via scheduleOpen). The fallback target + // is the trigger element; the pack selector targets `item`, but + // `closest()` walks from the trigger up to the wrapping li with + // `data-navigation-menu-item`, matching correctly. + const triggerEl = this.triggerRefs.get(value); + if (triggerEl) { + void this.runtime.trigger('commit-select', { fallbackTarget: triggerEl }); + } } /** Close whatever is open now. Arms the skip-delay window so the next diff --git a/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts index 10510ef91..f0ee4a0b7 100644 --- a/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts +++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts @@ -349,7 +349,10 @@ describe('TagGroupProvider', () => { target: alphaEl, name: 'commit-unselect', family: 'commit', - intent: 'neutral' + // Per the canon: `commit.unselect` carries `affirm` intent (the + // natural pair of `commit.select`). Updated in commit f3641347 + // when `unselect` was added to the canon. + intent: 'affirm' }); result.beta.remove(); 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 934ed057b..90117e7d7 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 @@ -160,9 +160,17 @@ export class ToggleGroupItemProvider { return items[0] === el; }); - readonly onclick = (_e: SomaMouseEvent) => { + readonly onclick = (e: SomaMouseEvent) => { if (this.isDisabled) return; this.provider.toggleItem(this.opts.value.current); + // Morfo declares `commit-toggle` on the `item` part. Pass the + // clicked button as `fallbackTarget` so the cascade selector + // `[data-toggle-group-item][data-event="commit-toggle"]` matches + // this exact item (the runtime would otherwise see N registered + // items under the same kebab and pick arbitrarily). + void this.provider.runtime.trigger('commit-toggle', { + fallbackTarget: e.currentTarget + }); }; readonly onkeydown = (e: SomaKeyboardEvent) => { 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 634f2500f..4ba2363df 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 @@ -143,9 +143,20 @@ export class TreeGridProvider { toggleExpand(value: string) { if (this.opts.disabled.current) return; const current = this.opts.expanded.current; - const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + const wasExpanded = current.includes(value); + const next = wasExpanded ? current.filter((v) => v !== value) : [...current, value]; this.opts.expanded.current = next; this.opts.onExpandedChange.current?.(next); + // Morfo: emerge-expand / emerge-collapse on `row`. Find the row + // element by data-value (tree-grid uses a single Row part for both + // leaves and branches; the row itself is what reveals/hides children). + const rowEl = this.opts.ref?.current?.querySelector( + `[${attrs.row}][data-value="${CSS.escape(value)}"]` + ); + if (rowEl) { + const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand'; + void this.runtime.trigger(eventName, { fallbackTarget: rowEl }); + } } expand(value: string) { @@ -188,6 +199,15 @@ export class TreeGridProvider { if (next !== current) { this.opts.value.current = next; this.opts.onValueChange.current?.(next); + // Morfo: commit-select on `row` (affirm). Look up the row + // element by value so the cascade stamp lands on the right + // row (multiple rows are registered under the same kebab). + const rowEl = this.opts.ref?.current?.querySelector( + `[${attrs.row}][data-value="${CSS.escape(value)}"]` + ); + if (rowEl) { + void this.runtime.trigger('commit-select', { fallbackTarget: 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 e63d94150..698147be0 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 @@ -99,8 +99,20 @@ export class TreeViewProvider { toggleExpand(value: string) { const current = this.opts.expandedValue.current; - const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + const wasExpanded = current.includes(value); + const next = wasExpanded ? current.filter((v) => v !== value) : [...current, value]; this.opts.expandedValue.current = next; + // Morfo declares `emerge-expand` and `emerge-collapse` on `branch`. + // Look up the branch DOM element by value (only branches carry + // `data-tree-view-branch`; the same DOM query pattern is used by + // `expandSiblings` above). + const branchEl = this.opts.ref?.current?.querySelector( + `[${attrs.branch}][data-value="${CSS.escape(value)}"]` + ); + if (branchEl) { + const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand'; + void this.runtime.trigger(eventName, { fallbackTarget: branchEl }); + } } expand(value: string) { @@ -134,7 +146,7 @@ export class TreeViewProvider { return this.opts.selectedValue.current.includes(value); } - select(value: string) { + select(value: string, fromEl?: HTMLElement) { let next: string[]; if (this.opts.selectionMode.current === 'single') { next = this.isSelected(value) ? [] : [value]; @@ -144,6 +156,12 @@ export class TreeViewProvider { : [...this.opts.selectedValue.current, value]; } this.opts.selectedValue.current = next; + // Morfo declares `commit-select` on `item`. Call sites pass the + // 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 }); + } } // ── Focus & navigation ─────────────────────────────────────────────────── @@ -381,7 +399,10 @@ export class TreeViewBranchControlProvider { if (this.provider.opts.expandOnClick.current) { this.provider.toggleExpand(this.branch.opts.value.current); } - this.provider.select(this.branch.opts.value.current); + // Pass the branch element so the commit-select event stamps on + // the actual branch (the click landed on the control, but the + // semantic target is the branch — same value). + this.provider.select(this.branch.opts.value.current, this.branch.opts.ref?.current ?? undefined); }; readonly props = $derived.by(() => @@ -541,7 +562,8 @@ export class TreeViewItemProvider { readonly onclick = (_e: SomaMouseEvent) => { if (this.isDisabled) return; - this.provider.select(this.opts.value.current); + // Pass the item element so commit-select stamps on this leaf. + this.provider.select(this.opts.value.current, this.opts.ref?.current ?? undefined); }; readonly props = $derived.by(() =>