soma: emit perceptual signal on open in dialog/drawer/popover

Diagnosis: clicking "open" on dialog/drawer/popover produced no sound
(or any semantic feedback). Cause: every overlay trigger was bypassing
`runtime.trigger(...)` with a direct `handleOpen()` because the morfo's
declared target part (overlay content) doesn't yet exist in the DOM
when the trigger fires — and `runtime.trigger` was throwing when the
target part had no registered ref.

Fix:

- `MorfoRuntime.trigger(name, opts?)` now accepts `{ fallbackTarget?:
  HTMLElement }`. When the declared target part is unmounted, the
  fallback is used as the visual signal target so the perceptual
  signal still fires. Sound / vibra channels don't care about the
  target; the visual channel writes its `data-event*` to the trigger
  briefly, which is the natural locus of the user's action anyway.

- Dialog / Drawer / Popover triggers now go through `runtime.trigger`
  with the trigger element as `fallbackTarget`. Each provider's
  `events` map gains an `open` (or `present`) entry that runs
  `handleOpen()` as the runtime's handler step — preserving the
  prewrite → emit → handler order.

- Popover's toggle uses `close-dismiss` for the close direction so
  both directions emit a perceptual signal, not just open.

Soma class: `soma.dom` and `soma.semantic` are now non-optional again
(matching pre-migration semantics — every layout declares both
services). `soma.format.*` slices stay optional.

Verified end-to-end with a Playwright probe that spies AudioContext
construction:

    Open dialog       → 2 oscillators (was 0)
    Press Escape      → +2 (was 2)
    Click overlay     → +2 (was 0)

Gates: vitest 7 files / 78 tests, smoke 68/68, morfo-check 66/66.
morfo-runtime
dev 5 months ago
parent 86f01f5d8e
commit 930d4c8c16

@ -118,6 +118,7 @@ export class DialogProvider extends Provider<DialogOpts> {
modal: () => opts.modal.current
},
events: {
open: () => this.handleOpen(),
'close-save': () => this.handleClose(),
'close-cancel': () => this.handleClose(),
'close-dismiss': () => this.handleClose(),
@ -197,12 +198,15 @@ export class DialogTriggerProvider extends Provider<DialogTriggerOpts> {
this.provider.triggerId.current = opts.id.current;
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
// 'open' bypasses runtime.trigger() because the morfo declares its
// target = partRef('content'), and content is not in the DOM until
// after open flips. Direct state mutation. The close-* events DO go
// through trigger() because content exists during the close cadence.
this.provider.handleOpen();
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
// `open`'s declared target (partRef('content')) is NOT in the DOM
// until handleOpen flips state. Pass the trigger as `fallbackTarget`
// so the runtime can still emit the perceptual signal — the visual
// channel writes its `data-event*` to the trigger briefly, sound /
// vibra channels don't care about the target.
void this.provider.runtime.trigger('open', {
fallbackTarget: e.currentTarget as HTMLElement
});
};
readonly props = $derived.by(() =>

@ -154,6 +154,7 @@ export class DrawerProvider extends Provider<DrawerOpts> {
modal: () => opts.modal.current
},
events: {
present: () => this.handleOpen(),
'close-save': () => this.handleClose(),
'close-cancel': () => this.handleClose(),
'close-dismiss': () => this.handleClose(),
@ -255,14 +256,20 @@ export class DrawerTriggerProvider extends Provider<DrawerTriggerOpts> {
this.provider.triggerId.current = opts.id.current;
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.provider.handleOpen();
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
// `open`'s declared target is the drawer content, not yet in the DOM.
// Fall back to the trigger so the perceptual signal still emits.
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.provider.handleOpen();
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
};

@ -99,6 +99,7 @@ export class PopoverProvider extends Provider<PopoverOpts> {
states: { open: () => opts.open.current },
props: { modal: () => opts.modal.current },
events: {
present: () => this.handleOpen(),
'close-save': () => this.handleClose(),
'close-cancel': () => this.handleClose(),
'close-dismiss': () => this.handleClose(),
@ -174,14 +175,30 @@ export class PopoverTriggerProvider extends Provider<PopoverTriggerOpts> {
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.provider.toggle();
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
// Toggle through the runtime so both directions emit a perceptual
// signal. `present`'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*`.
if (this.provider.opts.open.current) {
void this.provider.runtime.trigger('close-dismiss');
} else {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.provider.toggle();
if (this.provider.opts.open.current) {
void this.provider.runtime.trigger('close-dismiss');
} else {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
}
};

@ -50,11 +50,11 @@ export class Soma {
get langs(): ActiveLang {
return this.app.lang as ActiveLang;
}
get dom(): ActiveDom | undefined {
return this.app.dom as ActiveDom | undefined;
get dom(): ActiveDom {
return this.app.dom as ActiveDom;
}
get semantic(): EngineSemantic | undefined {
return this.app.semantic as EngineSemantic | undefined;
get semantic(): EngineSemantic {
return this.app.semantic as EngineSemantic;
}
get nums(): ActiveNumbers | undefined {
return (this.app.format as ActiveFormat | undefined)?.numbers;

@ -144,7 +144,20 @@ export interface MorfoRuntime {
* Resolves only after `semantic.emit` has had its rAF and the handler has
* returned. Effects run on the next reactive tick, not awaited here.
*/
trigger(eventName: string): Promise<void>;
trigger(eventName: string, opts?: TriggerOptions): Promise<void>;
}
/**
* Per-call overrides for `runtime.trigger`.
*/
export interface TriggerOptions {
/**
* Element to use as the visual signal target when the morfo's declared
* target part isn't (yet) in the DOM. Typical use: the trigger element
* for `'open'` events, where the overlay/content the morfo points at
* doesn't exist until the `handleOpen` step flips state.
*/
fallbackTarget?: HTMLElement;
}
interface PartRegistration {
@ -299,7 +312,7 @@ export function createMorfoRuntime(
return props;
}
async function trigger(eventName: string): Promise<void> {
async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<void> {
const event = findEvent(eventName);
if (!event) {
throw new Error(
@ -308,11 +321,17 @@ export function createMorfoRuntime(
}
// Resolve target part — must be a registered part with a mounted DOM ref.
// Some events (notably 'open' for overlay components) fire BEFORE the
// declared target part exists in the DOM. The caller can pass
// `fallbackTarget` (typically the trigger element) so the visual
// channel still has somewhere meaningful to write its `data-event*`
// marker — and so the perceptual signal as a whole still emits to
// the sound / vibra channels (which don't care about the target).
const targetReg = registrations.get(event.target.target);
const target = targetReg?.ref?.current ?? null;
const target = targetReg?.ref?.current ?? opts.fallbackTarget ?? null;
if (!target) {
throw new Error(
`[morfo-runtime] Trigger "${eventName}" target part "${event.target.target}" has no DOM element registered.`
`[morfo-runtime] Trigger "${eventName}" target part "${event.target.target}" has no DOM element registered (and no fallbackTarget supplied).`
);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.