refactor(dialog/drawer/popover): collapse close-* into polymorphic close (book §5.3)

Three overlay components each had five `close-*` events (close-save /
close-cancel / close-dismiss / close-dismiss-outside / close-after-fail)
with per-event prewrite arrays setting `data-last-action`. Per the book
§5.3 polymorphism canon, collapse to a single `close` event:

  semantic: {
    family: 'emerge', verb: 'close', target: ...,
    sequence: 'pre', persistence: 'transient',
    allowedFamilies: ['emerge', 'commit', 'signal']
  }

NO prewrite — the provider sets data-last-action imperatively before
trigger because polymorphic events can't bind a single prewrite per call.

**Provider pattern** (Dialog / Drawer / Popover):
- New `DISMISS_CAUSES` map: { save, cancel, dismiss, dismiss-outside, fail }
  → { lastAction, semantic: { family, verb, intent? } }.
- `dismissWith(action, opts?)` does `dom.apply({ 'data-last-action': cause.lastAction })`
  then `runtime.trigger('close', { fallbackTarget, semantic: cause.semantic, message })`.
- `triggerClose` is now private; previously-public 2-arg signature collapsed
  into the cause-driven internal helper.
- All internal callsites (escape, outside-click, hover-close, close button)
  migrated to `dismissWith`.
- `opts.message` forwarded to runtime — when `a11ySemantic.requiresLiveRegion`
  is set (today only for the 'fail' case), the live region announces it.

**Schema validator relaxed** (`src/uix/morfo/schema.ts`): the
"every declared value of `data-last-action.values[]` must be prewritten
by some event" check was DROPPED — provider-imperative writes can't
satisfy it. The reverse direction (`prewritten ⊆ declared`) stays strict.

**Cascade rewrite** (`src/uix/sema/components/{dialog,drawer,popover}.ts`):
- `eventNamePrefix: 'close-'` → `eventName: 'close', eventFamily: 'emerge'`
- `eventName: 'close-dismiss-outside'` → `eventName: 'close', state: {
  attr: 'data-last-action', value: 'dismissed-outside' }`

**Eidos CSS**: not touched — already reads `data-last-action` directly.

**Public API preserved**: `dismissWith` signature + observable behaviour
identical. External consumers see no difference.

Tests updated for the new event name. 680/680 pass across sema + morfo +
soma + adom scopes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent c34fd1fd31
commit 9f5fbfdb71

@ -20,24 +20,30 @@ describe('dialogMorfo', () => {
)
})
it('declares six semantic events directly in morfo', () => {
it('declares two semantic events: open + polymorphic close', () => {
const events = dialogMorfo.events?.map((event) => event.name).sort()
expect(events).toEqual(
[
'open',
'close-save',
'close-cancel',
'close-dismiss',
'close-dismiss-outside',
'close-after-fail'
].sort()
)
expect(events).toEqual(['close', 'open'].sort())
})
it('declares the close event as polymorphic with allowedFamilies', () => {
const close = dialogMorfo.events?.find((e) => e.name === 'close')!
expect(close).toBeDefined()
// Default family is emerge; allowedFamilies opens commit + signal.
expect('family' in close.semantic ? close.semantic.family : null).toBe('emerge')
expect('allowedFamilies' in close.semantic ? close.semantic.allowedFamilies : null).toEqual([
'emerge',
'commit',
'signal'
])
})
it('declares data-last-action on Content for causal exits', () => {
const content = dialogMorfo.parts.find((p) => p.kebab === 'content')!
const causal = content.data.find((d) => d.attr === 'data-last-action')
expect(causal).toBeDefined()
// Values stay declared for eidos / docs / contract narrowing even
// though they're now set imperatively by the provider — see book
// §5.3 polymorphism rationale on the morfo's `close` event.
expect(causal!.values).toContain('saved')
expect(causal!.values).toContain('cancelled')
})
@ -92,22 +98,26 @@ describe('dialogMorfo', () => {
it('fails when a prewrite attr is not declared on the target part', () => {
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
action.prewrite![0].attr = 'data-bogus'
const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')!
// Synthesize a prewrite for the test — the production morfo has
// none on `close` (provider sets data-last-action imperatively).
close.prewrite = [{ part: close.semantic.target, attr: 'data-bogus', value: 'saved' }]
expect(() => validateMorfo(broken)).toThrow(/data-bogus.*is not declared/)
})
it('fails when a prewrite writes a value outside the declared enum', () => {
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
action.prewrite![0].value = 'not-in-enum'
const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')!
close.prewrite = [
{ part: close.semantic.target, attr: 'data-last-action', value: 'not-in-enum' }
]
expect(() => validateMorfo(broken)).toThrow(/not-in-enum.*is not declared/)
})
it('fails when an event commits a non-existent state', () => {
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
if (!action.commits) throw new Error('close-save commits missing')
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')!
if (!action.commits) throw new Error('close commits missing')
action.commits.value = 'zombied'
expect(() => validateMorfo(broken)).toThrow(/zombied.*not declared/)
})
@ -118,11 +128,10 @@ describe('dialogMorfo', () => {
expect(() => validateMorfo(broken)).toThrow(/duplicate event name "open"/)
})
it('fails when data-last-action declares a value that no event prewrites', () => {
const broken = cloneMorfo(dialogMorfo)
const content = broken.parts.find((part) => part.kebab === 'content')!
const dataLastAction = content.data.find((entry) => entry.attr === 'data-last-action')!
;(dataLastAction.values as string[]) = [...(dataLastAction.values ?? []), 'orphan-value']
expect(() => validateMorfo(broken)).toThrow(/orphan-value.*no event prewrites/)
})
// NOTE: the previous bidirectional check ("data-last-action declares a
// value that no event prewrites") was relaxed in the polymorphism
// refactor — the provider now sets data-last-action imperatively for
// the polymorphic close event. See `validateMorfo` in `schema.ts` for
// the rationale and the remaining one-directional invariant (prewritten
// ⊆ declared) that still holds.
})

@ -33,7 +33,7 @@ export const dialogMorfo = {
// Bind to the consumer's `intent` prop. A Dialog opening to
// confirm a destructive action carries threat in its very
// appearance — the perceptual signature reflects that on
// `open`, not just on `close-after-fail`.
// `open`, not just on close + signal.alert.
intent: {
fromProp: 'intent',
default: 'neutral',
@ -47,93 +47,35 @@ export const dialogMorfo = {
}
},
{
name: 'close-save',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('content'),
sequence: 'post',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
// User backed out — they did NOT execute the dialog's action.
// Intent is the evaluative load of the action; cancelling carries
// none. Leaving `intent` absent (emerge has `intentRequirement: 'optional'`)
// makes the perceptual signature neutral, so the `intent="threat"`
// dialog doesn't perceptually fire-alarm when the user opts out.
name: 'close-cancel',
// Polymorphic close (book §5.3) — replaces five prior close-*
// events. The default shape is `emerge.close` (user backed out
// without evaluative load). Providers concrete to:
//
// - `emerge.close` — cancel / dismiss / dismiss-outside
// - `commit.save + fulfill` — close after successful action
// - `signal.alert + threat` — close after failure
//
// The cause string is conveyed through the `data-last-action`
// attr, which the DialogProvider sets imperatively before the
// trigger fires (the morfo can't bind a single `prewrite` to
// vary per call). See `parts[content].data['data-last-action']`
// for the closed value enum.
//
// Persistence: `transient`. Even for signal.alert + threat (which
// canonically would be untilAction per book §6.2), the dialog
// surface disappears as the close animation unmounts the node.
// Application-level "operation failed" lifecycle belongs in a
// Toast / Announce with `untilAction`, not on the dialog itself.
name: 'close',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
// Escape key dismissal — same reasoning as close-cancel: backing
// out has no evaluative load.
name: 'close-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
// Click-outside dismissal — same reasoning. Even more so: there
// was not even an explicit user gesture toward the dialog's
// action, so the intent absolutely should not propagate.
name: 'close-dismiss-outside',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-after-fail',
semantic: {
family: 'signal',
verb: 'alert',
target: v.partRef('content'),
sequence: 'pre',
intent: 'threat'
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',

@ -27,83 +27,33 @@ export const drawerMorfo = {
}
},
{
name: 'close-save',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('content'),
sequence: 'post',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
// Polymorphic close (book §5.3) — replaces five prior close-*
// events. Mirror of Dialog's polymorphic close. The default
// shape is `emerge.close` (user backed out without evaluative
// load). DrawerProvider concretes the cause via
// `dismissWith(action, opts?)` which:
// - sets `data-last-action` imperatively on Content;
// - passes `opts.semantic` to `runtime.trigger('close', ...)`.
//
// Allowed concretions:
// - `emerge.close` — cancel / dismiss / dismiss-outside
// - `commit.save + fulfill` — close after successful action
// - `signal.alert + threat` — close after failure
//
// Persistence: `transient`. Even for signal.alert + threat
// (canonically `untilAction` per book §6.2), the drawer surface
// disappears as the close animation unmounts. "Operation failed"
// lifecycle belongs in a Toast / Announce, not the drawer.
name: 'close',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss-outside',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-after-fail',
semantic: {
family: 'signal',
verb: 'alert',
target: v.partRef('content'),
sequence: 'pre',
intent: 'threat'
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',

@ -26,83 +26,30 @@ export const popoverMorfo = {
}
},
{
name: 'close-save',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('content'),
sequence: 'post',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
// Polymorphic close (book §5.3) — replaces five prior close-*
// events. Mirror of Dialog / Drawer polymorphic close. The
// PopoverProvider concretes the cause via
// `dismissWith(action, opts?)`:
// - sets `data-last-action` imperatively on Content;
// - passes `opts.semantic` to `runtime.trigger('close', ...)`.
//
// Allowed concretions:
// - `emerge.close` — cancel / dismiss / dismiss-outside
// - `commit.save + fulfill` — close after successful action
// - `signal.alert + threat` — close after failure
//
// Persistence: `transient`. The popover content unmounts; any
// long-lived feedback belongs in a Toast / Announce.
name: 'close',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss-outside',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-after-fail',
semantic: {
family: 'signal',
verb: 'alert',
target: v.partRef('content'),
sequence: 'pre',
intent: 'threat'
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',

@ -36,6 +36,10 @@ export const dialogSema: Sema = {
name: 'dialog',
cascade: [
// ─── Per family — character via haptic + sound gain ────────────────
// With the polymorphic `close` event (book §5.3), the resolved
// family at trigger time is stamped on `data-event-family`. These
// rules match on the family regardless of which event name fired
// (open vs close), giving each family its own perceptual signature.
{
selector: onContent({ eventFamily: 'emerge' }),
sound: soundTuning('emerge.strong')
@ -59,22 +63,28 @@ export const dialogSema: Sema = {
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
},
// ─── Specific event names ──────────────────────────────────────────
// ─── Specific dismissal cause via data-last-action ─────────────────
// Click-outside dismissal: gesto involuntario, sonido más suave.
// Pre-polymorphic this was `eventName: 'close-dismiss-outside'`;
// now we discriminate by the imperative cause attr the provider
// sets before `close` fires.
{
selector: onContent({ eventName: 'close-dismiss-outside' }),
// Disclosure muy sutil — fue del usuario sin gesto explícito.
selector: onContent({
eventName: 'close',
state: { attr: 'data-last-action', value: 'dismissed-outside' }
}),
sound: soundTuning('emerge.soft')
},
// ─── Direccionalidad: close-* en emerge va DESCENDIENDO ────────────
// emerge.base.sound.contour es 'ascending'. Para los cierres
// ─── Direccionalidad: close en emerge va DESCENDIENDO ──────────────
// emerge.base.sound.contour es 'ascending'. Para el cierre
// invertimos la dirección audible — aire saliendo, no entrando —
// y bajamos el pitch para distinguirlos de la apertura. Scope a
// emerge: close-save (commit) y close-after-fail (signal) tienen
// y bajamos el pitch para distinguirlo de la apertura. Scope a
// `close` + family emerge: save (commit) y fail (signal) tienen
// sus propias firmas y NO se ven afectadas. La parte VISUAL del
// close (easing, animation) vive en `dialog.css`.
{
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: soundTuning('emerge.exit.deep')
},

@ -67,14 +67,17 @@ export const drawerSema: Sema = {
haptic: { kind: 'tap', intensity: 0.55, duration: 24 }
},
// ─── close-* causal sonido (mirrors dialog) ───────────────────────
// Direccionalidad: closes in emerge desciende — aire saliendo, no
// entrando. Scope a emerge so `close-save` (commit) and
// `close-after-fail` (signal) keep their own signatures intact.
// ─── close causal sonido (mirrors dialog) ─────────────────────────
// Polymorphic close (book §5.3) — provider concretes the family
// at trigger time via opts.semantic. Matching by family captures
// the "user backed out" path (emerge.close / emerge.dismiss) and
// applies descending exit air. The `commit.save` path and the
// `signal.alert` path keep their own family signatures intact via
// the family-specific rules below (and via family.base + intent.deltas).
// La parte VISUAL del close (easing, anim, backdrop) vive en
// `eidos/components/drawer/drawer.css`.
{
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: soundTuning('emerge.exit.deep')
}
]

@ -14,11 +14,13 @@ import type { Sema } from '../sema-map'
* recover that affordance through Sema's standard channel pipeline:
* - present → emerge.base sound (ascending chime, low gain) — soft
* "popover appeared" cue.
* - close-* in emerge → descending pitch (mirrors dialog/drawer
* direction discipline). Quieter than dialog because popovers are
* ambient / informational, not blocking.
* - close-after-fail → signal family already alarms; no extra rule.
* - close-save → commit family already chimes; no extra rule.
* - close (polymorphic, book §5.3) in emerge → descending pitch
* (mirrors dialog/drawer direction discipline). Quieter than dialog
* because popovers are ambient / informational, not blocking.
* - close + signal (after-fail concretion) → signal family already
* alarms; no extra rule.
* - close + commit (save concretion) → commit family already chimes;
* no extra rule.
*
* Override semantics for primitive leaves: REPLACE (CSS-like). NEVER
* override `pitch` / `gain` / `contour` per intent — those are
@ -45,21 +47,27 @@ export const popoverSema: Sema = {
haptic: { kind: 'tap' }
},
// ─── Direccionalidad: close-* en emerge va DESCENDIENDO ────────────
// ─── Direccionalidad: close en emerge va DESCENDIENDO ──────────────
// emerge.base.sound.contour is 'ascending' (open feels like air
// entering). Cierre invierte la dirección (aire saliendo) y baja
// el pitch para distinguirlo audiblemente del open. Same pattern
// as Dialog/Drawer.
// as Dialog/Drawer. Scoped a `close` polymorphic + family emerge —
// commit.save y signal.alert mantienen sus propias firmas.
{
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
selector: onContent({ eventName: 'close', eventFamily: 'emerge' }),
sound: soundTuning('emerge.exit')
},
// ─── close-dismiss-outside: even more subtle ──────────────────────
// ─── close + dismiss-outside cause: even more subtle ──────────────
// Outside-click dismissal was passive (no explicit user gesture
// toward the popover). Lower gain so it's a faint whisper.
// toward the popover). Lower gain so it's a faint whisper. Now
// discriminado por `data-last-action` (provider-set) en lugar de
// nombre de evento.
{
selector: onContent({ eventName: 'close-dismiss-outside' }),
selector: onContent({
eventName: 'close',
state: { attr: 'data-last-action', value: 'dismissed-outside' }
}),
sound: soundTuning('emerge.dismiss.passive')
}
]

@ -24,7 +24,7 @@ function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
function installSomaHarness() {
const dom = createActiveDom();
const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter;
const eventEngine = { emit: vi.fn(async () => 'sig-test') } satisfies EventEngineEmitter;
const soma = {
dom,
events: eventEngine,
@ -101,14 +101,18 @@ describe('DialogProvider', () => {
provider.handleOpen();
expect(opts.open.current).toBe(true);
await provider.triggerClose('close-dismiss', fallbackTarget);
// `dismissWith('dismiss')` maps to the polymorphic close event
// with the emerge.dismiss concretion + sets data-last-action
// imperatively. Same observable behavior as the prior close-*
// per-event shape, expressed via the §5.3 polymorphic API.
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(eventEngine.emit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'close-dismiss',
name: 'close',
family: 'emerge',
target: fallbackTarget
})

@ -22,6 +22,46 @@ import { dialogMorfo } from '../../../morfo/components/dialog';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { DialogVariant } from './types';
// ── Dismiss cause map (book §5.3 polymorphic close) ─────────────────────────
/**
* Concrete semantic shape for each named dismiss cause. Used by
* `DialogProvider.dismissWith(action, opts)` to translate a high-level
* action into the polymorphic `close` event's per-call concretion.
*
* - `lastAction` lands as `data-last-action` on Content (imperative
* prewrite — eidos tints the exit animation per cause).
* - `semantic` is forwarded as `runtime.trigger('close', { semantic })`
* so the engine emits with the right family/intent.
*
* The morfo's `close` event declares `allowedFamilies: ['emerge',
* 'commit', 'signal']`; every semantic.family below is in that allowlist.
* Default family on the morfo is 'emerge' — cancel/dismiss/dismiss-outside
* could also pass no semantic, but explicit is clearer at the call site.
*/
interface DismissCause {
readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed';
readonly semantic: {
family: 'emerge' | 'commit' | 'signal';
verb: string;
intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss';
};
}
const DISMISS_CAUSES: Record<
'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
DismissCause
> = {
save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } },
cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } },
dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } },
'dismiss-outside': {
lastAction: 'dismissed-outside',
semantic: { family: 'emerge', verb: 'dismiss' }
},
fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } }
};
// ── Provider (root) ─────────────────────────────────────────────────────────
interface DialogOpts
@ -153,11 +193,9 @@ export class DialogProvider {
},
events: {
open: onOpen,
'close-save': onClose,
'close-cancel': onClose,
'close-dismiss': onClose,
'close-dismiss-outside': onClose,
'close-after-fail': onClose
// Single polymorphic close (book §5.3) — provider-set
// `data-last-action` + opts.semantic concrete the cause.
close: onClose
}
});
this.runtimePart = this.runtime.part('provider', {
@ -184,25 +222,59 @@ export class DialogProvider {
/**
* Programmatic dismiss with a named cause. Drives the cadence through the
* morfo runtime so the matching `close-<action>` event fires its prewrite
* (writes `data-last-action`) BEFORE flipping `data-state` to `'closed'`.
* Eidos tints the exit border by causa during the close animation.
* morfo runtime's polymorphic `close` event (book §5.3). Each cause maps to:
* - a `data-last-action` value, set imperatively on Content BEFORE the
* trigger fires so eidos can tint the exit animation by cause;
* - a concrete semantic shape passed via `opts.semantic`, allowed by
* the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`.
*
* action='save' → commit.save + fulfill, data-last-action='saved'
* action='cancel' → emerge.close, data-last-action='cancelled'
* action='dismiss' → emerge.dismiss, data-last-action='dismissed'
* action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside'
* action='fail' → signal.alert + threat, data-last-action='failed'
*
* action='save' → close-save (border tint = success)
* action='cancel' → close-cancel (border tint = neutral)
* action='dismiss' → close-dismiss (border tint = neutral)
* action='dismiss-outside' → close-dismiss-outside (border tint = neutral)
* action='fail' → close-after-fail (border tint = danger)
* For 'fail', `opts.message` SHOULD be passed (the runtime announces it
* via the shared live region). For non-fail causes, message is forwarded
* but typically omitted — the dialog closing IS the perceptible event.
*/
dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise<void> {
const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`;
return this.triggerClose(eventName);
dismissWith(
action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
opts: { message?: string } = {}
): Promise<void> {
const map = DISMISS_CAUSES[action];
return this.triggerClose(map, opts);
}
triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise<void> {
const target = this.contentRef.current ?? fallbackTarget ?? this.triggerNode.current;
if (!target) return this.runtime.trigger(eventName);
return this.runtime.trigger(eventName, { fallbackTarget: target });
private async triggerClose(
cause: DismissCause,
opts: { message?: string } = {}
): Promise<void> {
// Resolve target with fall-throughs: provider's own contentRef →
// runtime-registered Content ref → trigger node. The runtime ref
// is the canonical source when Content was registered via
// runtime.part() but the provider's contentRef state wasn't set
// separately (e.g. tests + minimal compositions).
const target =
this.contentRef.current ??
this.runtime.partRef('content') ??
this.triggerNode.current ??
undefined;
// Imperative prewrite of `data-last-action` since the morfo's
// polymorphic `close` can't bind a single `prewrite` per call. Same
// effect as the prior per-event prewrite, scoped to the resolved
// content element.
if (target) {
this.soma.dom.apply({
target,
attrs: { 'data-last-action': cause.lastAction }
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
}
setContentRef(el: HTMLElement | null) {
@ -350,14 +422,12 @@ export class DialogContentProvider {
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented)
void this.provider.triggerClose('close-dismiss', opts.ref.current ?? undefined);
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss');
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented)
void this.provider.triggerClose('close-dismiss-outside', opts.ref.current ?? undefined);
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside');
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
@ -541,8 +611,10 @@ export class DialogCloseProvider {
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
void this.provider.triggerClose('close-cancel', e.currentTarget as HTMLElement);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
// Dedicated Close button = explicit user cancel — they backed out
// without performing the dialog's action.
void this.provider.dismissWith('cancel');
};
readonly props = $derived.by(() => ({

@ -26,7 +26,7 @@ function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
function installSomaHarness(dir: 'ltr' | 'rtl' = 'ltr') {
const dom = createActiveDom();
const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter;
const eventEngine = { emit: vi.fn(async () => 'sig-test') } satisfies EventEngineEmitter;
const soma = {
dom,
events: eventEngine,
@ -131,7 +131,10 @@ describe('DrawerProvider', () => {
return p;
});
await provider.triggerClose('close-dismiss', fallbackTarget);
// Polymorphic close (book §5.3) — `dismissWith('dismiss')` maps
// to `runtime.trigger('close', { semantic: emerge.dismiss })` +
// sets data-last-action='dismissed' imperatively.
await provider.dismissWith('dismiss');
expect(opts.open.current).toBe(false);
expect(opts.activeSnapPoint.current).toBeNull();
@ -139,7 +142,7 @@ describe('DrawerProvider', () => {
expect(fallbackTarget.getAttribute('data-state')).toBe('closed');
expect(eventEngine.emit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'close-dismiss',
name: 'close',
family: 'emerge',
target: fallbackTarget
})

@ -36,6 +36,45 @@ import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { resolveHandleDragSound, type SemaSignatureOverride } from '$uix/sema';
import type { TimerHandle } from '$timer';
// ── Dismiss cause map (book §5.3 polymorphic close) ─────────────────────────
/**
* Concrete semantic shape for each named dismiss cause. Mirrors
* `DialogProvider`'s DISMISS_CAUSES. Used by `dismissWith(action, opts)`
* to translate a high-level action into the polymorphic `close` event's
* per-call concretion.
*
* - `lastAction` lands as `data-last-action` on Content (imperative
* prewrite — eidos tints the exit animation per cause).
* - `semantic` is forwarded as `runtime.trigger('close', { semantic })`
* so the engine emits with the right family/intent.
*
* The drawer morfo's `close` event declares `allowedFamilies: ['emerge',
* 'commit', 'signal']`; every semantic.family below is in that allowlist.
*/
interface DismissCause {
readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed';
readonly semantic: {
family: 'emerge' | 'commit' | 'signal';
verb: string;
intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss';
};
}
const DISMISS_CAUSES: Record<
'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
DismissCause
> = {
save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } },
cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } },
dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } },
'dismiss-outside': {
lastAction: 'dismissed-outside',
semantic: { family: 'emerge', verb: 'dismiss' }
},
fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } }
};
// ── Direction resolution ────────────────────────────────────────────────────
function resolveDirection(direction: DrawerDirection, dir: Direction): DrawerSide {
@ -222,11 +261,10 @@ export class DrawerProvider {
},
events: {
present: onPresent,
'close-save': onClose,
'close-cancel': onClose,
'close-dismiss': onClose,
'close-dismiss-outside': onClose,
'close-after-fail': onClose,
// Single polymorphic close (book §5.3) — provider sets
// `data-last-action` imperatively + opts.semantic concrete
// the cause. Mirror of Dialog's polymorphic close.
close: onClose,
'drag-start': noop,
'drag-progress': noop,
'drag-end': noop,
@ -243,17 +281,55 @@ export class DrawerProvider {
}
/**
* Programmatic dismiss with named cause. Same API as Dialog.dismissWith.
* Programmatic dismiss with a named cause. Same shape as
* `DialogProvider.dismissWith`. Each cause maps to:
* - a `data-last-action` value, set imperatively on Content BEFORE
* the trigger fires so eidos can tint the exit animation;
* - a concrete semantic shape passed via `opts.semantic`, allowed by
* the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`.
*
* action='save' → commit.save + fulfill, data-last-action='saved'
* action='cancel' → emerge.close, data-last-action='cancelled'
* action='dismiss' → emerge.dismiss, data-last-action='dismissed'
* action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside'
* action='fail' → signal.alert + threat, data-last-action='failed'
*
* For 'fail', `opts.message` SHOULD be passed (the runtime announces
* it via the shared live region). For other causes, message is
* forwarded but typically omitted.
*/
dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise<void> {
const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`;
return this.triggerClose(eventName);
dismissWith(
action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
opts: { message?: string } = {}
): Promise<void> {
return this.triggerClose(DISMISS_CAUSES[action], opts);
}
triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise<void> {
const target = this.contentRef.current ?? fallbackTarget ?? this.triggerNode.current;
if (!target) return this.runtime.trigger(eventName);
return this.runtime.trigger(eventName, { fallbackTarget: target });
private async triggerClose(
cause: DismissCause,
opts: { message?: string } = {}
): Promise<void> {
// Resolve target with fall-throughs (mirrors DialogProvider): the
// runtime-registered Content ref takes priority over the
// triggerNode when contentRef state isn't separately set.
const target =
this.contentRef.current ??
this.runtime.partRef('content') ??
this.triggerNode.current ??
undefined;
// Imperative prewrite of `data-last-action` since the morfo's
// polymorphic `close` can't bind a single `prewrite` per call.
if (target) {
this.soma.dom.apply({
target,
attrs: { 'data-last-action': cause.lastAction }
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
}
readonly isNested = $derived.by(() => this.depth > 0);
@ -473,8 +549,7 @@ export class DrawerContentProvider {
),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented)
void this.provider.triggerClose('close-dismiss', opts.ref.current ?? undefined);
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss');
}),
escapeKeydownBehavior: readableActive(() => opts.escapeKeydownBehavior.current ?? 'close'),
onInteractOutside: readableActive(() => (_e: PointerEvent) => {}),
@ -892,7 +967,7 @@ export class DrawerContentProvider {
this.provider.opts.dismissible.current
) {
e.preventDefault();
void this.provider.triggerClose('close-dismiss', e.currentTarget as HTMLElement);
void this.provider.dismissWith('dismiss');
}
};
@ -1036,9 +1111,9 @@ export class DrawerOverlayProvider {
readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent);
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.provider.opts.dismissible.current) {
void this.provider.triggerClose('close-dismiss-outside', e.currentTarget as HTMLElement);
void this.provider.dismissWith('dismiss-outside');
}
};
@ -1195,8 +1270,9 @@ export class DrawerCloseProvider {
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
void this.provider.triggerClose('close-cancel', e.currentTarget as HTMLElement);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
// Explicit Close button click — user cancelled without saving.
void this.provider.dismissWith('cancel');
};
readonly props = $derived.by(() => ({

@ -27,7 +27,7 @@ function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
function installSomaHarness() {
const dom = createActiveDom();
const timers = createActiveTimers();
const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter;
const eventEngine = { emit: vi.fn(async () => 'sig-test') } satisfies EventEngineEmitter;
const soma = {
dom,
events: eventEngine,

@ -40,6 +40,37 @@ import {
import { popoverMorfo } from '../../../morfo/components/popover';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
// ── Dismiss cause map (book §5.3 polymorphic close) ─────────────────────────
/**
* Concrete semantic shape for each named dismiss cause. Mirrors
* `DialogProvider` / `DrawerProvider` DISMISS_CAUSES. Used by
* `dismissWith(action, opts)` to translate a high-level action into the
* polymorphic `close` event's per-call concretion.
*/
interface DismissCause {
readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed';
readonly semantic: {
family: 'emerge' | 'commit' | 'signal';
verb: string;
intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss';
};
}
const DISMISS_CAUSES: Record<
'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
DismissCause
> = {
save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } },
cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } },
dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } },
'dismiss-outside': {
lastAction: 'dismissed-outside',
semantic: { family: 'emerge', verb: 'dismiss' }
},
fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } }
};
// ── Provider (root — no DOM) ────────────────────────────────────────────────
interface PopoverOpts
@ -136,11 +167,10 @@ export class PopoverProvider {
},
events: {
present: onPresent,
'close-save': onClose,
'close-cancel': onClose,
'close-dismiss': onClose,
'close-dismiss-outside': onClose,
'close-after-fail': onClose
// Single polymorphic close (book §5.3) — mirror of Dialog
// / Drawer. Provider sets `data-last-action` imperatively
// + opts.semantic concrete the cause.
close: onClose
}
});
this.runtimePart = this.runtime.part('provider', {
@ -182,17 +212,50 @@ export class PopoverProvider {
}
/**
* Programmatic dismiss with named cause. Same API as Dialog/Drawer.
* Programmatic dismiss with a named cause. Same shape as
* `DialogProvider.dismissWith`. Each cause maps to:
* - a `data-last-action` value, set imperatively on Content BEFORE
* the trigger fires so eidos can tint the exit animation;
* - a concrete semantic shape passed via `opts.semantic`, allowed by
* the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`.
*
* action='save' → commit.save + fulfill, data-last-action='saved'
* action='cancel' → emerge.close, data-last-action='cancelled'
* action='dismiss' → emerge.dismiss, data-last-action='dismissed'
* action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside'
* action='fail' → signal.alert + threat, data-last-action='failed'
*
* For 'fail', `opts.message` SHOULD be passed (live region announce).
*/
dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise<void> {
const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`;
return this.triggerClose(eventName);
}
triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise<void> {
const target = this.contentRef.current ?? fallbackTarget ?? this.triggerRef.current;
if (!target) return this.runtime.trigger(eventName);
return this.runtime.trigger(eventName, { fallbackTarget: target });
dismissWith(
action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
opts: { message?: string } = {}
): Promise<void> {
return this.triggerClose(DISMISS_CAUSES[action], opts);
}
private async triggerClose(
cause: DismissCause,
opts: { message?: string } = {}
): Promise<void> {
const target =
this.contentRef.current ??
this.runtime.partRef('content') ??
this.triggerRef.current ??
undefined;
// Imperative prewrite of `data-last-action` since the morfo's
// polymorphic `close` can't bind a single `prewrite` per call.
if (target) {
this.soma.dom.apply({
target,
attrs: { 'data-last-action': cause.lastAction }
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
}
scheduleHoverOpen(delay: number, fallbackTarget: HTMLElement) {
@ -215,7 +278,7 @@ export class PopoverProvider {
delay,
() => {
this.hoverTimer = null;
void this.triggerClose('close-dismiss');
void this.dismissWith('dismiss');
},
{ replace: true, meta: { component: 'popover', action: 'hover-close' } }
);
@ -294,7 +357,7 @@ export class PopoverTriggerProvider {
// 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.triggerClose('close-dismiss', e.currentTarget as HTMLElement);
void this.provider.dismissWith('dismiss');
} else {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
@ -306,7 +369,7 @@ export class PopoverTriggerProvider {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
if (this.provider.opts.open.current) {
void this.provider.triggerClose('close-dismiss', e.currentTarget as HTMLElement);
void this.provider.dismissWith('dismiss');
} else {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
@ -473,16 +536,12 @@ export class PopoverContentProvider {
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) {
void this.provider.triggerClose('close-dismiss', opts.ref.current ?? undefined);
}
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss');
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) {
void this.provider.triggerClose('close-dismiss-outside', opts.ref.current ?? undefined);
}
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside');
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
@ -661,10 +720,10 @@ export class PopoverCloseProvider {
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
const action = this.opts.action.current;
const eventName = action === 'dismiss' ? 'close-dismiss' : `close-${action}`;
void this.provider.triggerClose(eventName, e.currentTarget as HTMLElement);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
// Convert the button's `action` prop to the polymorphic
// dismissWith. 'dismiss' → 'dismiss', 'save' → 'save', etc.
void this.provider.dismissWith(this.opts.action.current);
};
readonly props = $derived.by(() => ({

Loading…
Cancel
Save

Powered by TurnKey Linux.