soma + app: runtime.trigger() contract + Sema wired into App/Soma

Commit A of the Toast pilot: introduce the trigger() contract end-to-end
without migrating any provider yet. Toast's actual migration follows in a
separate commit so that bugs (if any) can be attributed to one layer at a
time.

App layer
- New `AppSemantic` contract in `$lib/ext/app/types`: a single-method
  interface (`emit(signal): Promise<void>`) implemented by SemanticEngine
  and accepted by the App constructor.
- App now owns `app.semantic`, defaulting to a fresh SemanticEngine wired
  to `app.dom` when the consumer doesn't pass one. Fallback App also gets
  `fallbackSemantic` so context-less calls don't throw.
- Soma re-exposes it as `soma.semantic` so providers reach it the same
  way they reach `soma.dom` / `soma.langs`.

MorfoRuntime
- New `runtime.trigger(eventName): Promise<void>` with the full sequence
  the architecture closed:
    1. apply `prewrite` attrs imperatively (transient markers)
    2. await `semantic.emit({ target, name, family, intent? })` — resolves
       the intent from `event.semantic.intent` (literal SemaIntent or
       SemaIntentBinding via `resolveSemaIntent`)
    3. invoke the provider's handler from `sources.events[name]`
    4. effects on the affected parts re-derive structural attrs and
       write them via `dom.apply` (automatic — not awaited here)
- New optional `sources.semantic` and `sources.events` on the runtime.
  Without `semantic`, step 2 is skipped. Without an events handler for
  the name, step 3 is skipped.
- `morfo.events.commits` is intentionally NOT executed by the runtime —
  it remains descriptive metadata documenting what an external observer
  will see (per the architecture's "state is the source of truth" rule).

Tests
- 8 new trigger unit tests using the toast and dialog morfos:
  - throws on undeclared event
  - throws when target part has no DOM
  - emits with intent resolved from prop (toastMorfo.announce)
  - falls back to declared default intent when prop missing
  - omits intent for transitional families (toastMorfo.present)
  - prewrite applied before semantic.emit (dialogMorfo.close-cancel)
  - handler invoked only after semantic.emit resolves
  - skips semantic.emit when no semantic source provided

Verification
- 84/84 focused unit tests green (full adom/sema/lib/dom/morfo/soma).
- 66/66 morfo-check.
- 68/68 smoke routes.
- svelte-check unchanged at 155 (no new errors introduced).

Next (commit B): migrate Toast to use the contract — wire close.onclick,
swipe gesture, auto-timer, and Escape keydown to runtime.trigger().
morfo-runtime
dev 6 months ago
parent 53d6539abe
commit 2b39a2423d

@ -1,8 +1,10 @@
import { Context } from 'runed';
import { createActiveDom } from '$uix/adom';
import { SemanticEngine } from '$uix/sema';
import type {
AppOptions,
AppDom,
AppSemantic,
AppLangs,
AppNums,
AppMoney,
@ -14,7 +16,13 @@ import type {
DateOrder,
HourCycle
} from './types';
import { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
import {
fallbackDom,
fallbackLangs,
fallbackPresentation,
fallbackSemantic,
consoleLogger
} from './defaults';
/**
* App — root service compositor.
@ -30,6 +38,7 @@ import { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from
export class App {
// ── Services ────────────────────────────────────────────────────────
readonly dom: AppDom;
readonly semantic: AppSemantic;
readonly langs: AppLangs;
readonly nums: AppNums | undefined;
readonly money: AppMoney | undefined;
@ -40,6 +49,7 @@ export class App {
constructor(opts: AppOptions) {
this.dom = opts.dom ?? createActiveDom();
this.semantic = opts.semantic ?? new SemanticEngine({ dom: this.dom });
this.langs = opts.langs;
this.nums = opts.nums;
this.money = opts.money;
@ -127,6 +137,7 @@ const _ctx = new Context<App>('App');
*/
const _fallback = new App({
dom: fallbackDom,
semantic: fallbackSemantic,
langs: fallbackLangs,
presentation: fallbackPresentation,
logger: consoleLogger

@ -1,5 +1,6 @@
import { createActiveDom } from '$uix/adom';
import type { AppLangs, AppPresentation, AppLogger } from './types';
import { SemanticEngine } from '$uix/sema';
import type { AppLangs, AppPresentation, AppLogger, AppSemantic } from './types';
/** No-op langs: returns path as-is, no locale switching */
export const fallbackLangs: AppLangs = {
@ -39,3 +40,6 @@ export const fallbackPresentation: AppPresentation = {
/** Minimal dom runtime: default breakpoints + responsive helpers. */
export const fallbackDom = createActiveDom({});
/** Default semantic engine wired to the fallback dom. */
export const fallbackSemantic: AppSemantic = new SemanticEngine({ dom: fallbackDom });

@ -1,10 +1,17 @@
export { App } from './app.svelte';
export { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
export {
fallbackDom,
fallbackLangs,
fallbackPresentation,
fallbackSemantic,
consoleLogger
} from './defaults';
export type {
AppServices,
AppOptions,
AppDom,
AppSemantic,
AppLangs,
AppNums,
AppMoney,

@ -1,4 +1,5 @@
import type { ActiveDom } from '$uix/adom';
import type { SemanticSignal } from '$uix/sema';
// ── Direction ────────────────────────────────────────────────────────────────
@ -166,6 +167,23 @@ export interface AppPresentation {
/** DOM runtime contract. Implements viewport + breakpoints + responsive helpers. */
export type AppDom = ActiveDom;
// ── AppSemantic ──────────────────────────────────────────────────────────────
/**
* Semantic engine contract. Responsible for orchestrating perceptual signals
* (`data-event*`) on a target with the agreed lifecycle: write the signal,
* await one rAF so CSS / Eidos can react, resolve the Promise, hold N frames,
* then clean up. The provider/Soma decides when to emit; the engine never
* decides what occurred.
*
* Implementations may also publish to other channels (sound, vibra) by
* subscribing to their own internal bus — that's an engine detail, not the
* contract.
*/
export interface AppSemantic {
emit(signal: SemanticSignal): Promise<void>;
}
// ── AppLogger ────────────────────────────────────────────────────────────────
/** Logging contract. logr implements this directly. */
@ -185,6 +203,8 @@ export interface AppLogger {
export interface AppServices {
/** DOM runtime — viewport, breakpoints, responsive helpers */
readonly dom: AppDom;
/** Semantic engine — emits perceptual signals as `data-event*` on a target */
readonly semantic: AppSemantic;
/** i18n — locale, translation, interpolation, module extension */
readonly langs: AppLangs;
/** Numbers — formatting, parsing, separator preferences */
@ -207,6 +227,7 @@ export interface AppServices {
export interface AppOptions {
dom?: AppDom;
semantic?: AppSemantic;
langs: AppLangs;
nums?: AppNums;
money?: AppMoney;

@ -2,6 +2,7 @@ import { Context } from 'runed';
import { App } from '$lib/ext/app';
import type {
AppDom,
AppSemantic,
AppLangs,
AppNums,
AppMoney,
@ -45,6 +46,9 @@ export class Soma {
get dom(): AppDom {
return this.app.dom;
}
get semantic(): AppSemantic {
return this.app.semantic;
}
get nums(): AppNums | undefined {
return this.app.nums;
}

@ -1,12 +1,16 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushSync } from 'svelte'
import { createActiveDom } from '$uix/adom'
import type { AppSemantic } from '$lib/ext/app'
import type { SemanticSignal } from '$uix/sema'
import { state } from '../reactive'
import { toggleMorfo } from '../../morfo/components/toggle'
import { toastMorfo } from '../../morfo/components/toast'
import { dialogMorfo } from '../../morfo/components/dialog'
import { createMorfoRuntime } from './runtime.svelte'
/**
@ -169,3 +173,202 @@ describe('createMorfoRuntime', () => {
cleanup()
})
})
// ── trigger(eventName) ──────────────────────────────────────────────────────
function fakeSemantic(): { engine: AppSemantic; calls: SemanticSignal[]; resolve: () => void } {
const calls: SemanticSignal[] = []
let pendingResolve: (() => void) | null = null
const engine: AppSemantic = {
emit(signal: SemanticSignal) {
calls.push(signal)
return new Promise<void>((res) => {
pendingResolve = res
})
}
}
return {
engine,
calls,
resolve() {
pendingResolve?.()
pendingResolve = null
}
}
}
describe('runtime.trigger', () => {
let dom: ReturnType<typeof createActiveDom>
let item: HTMLElement
let content: HTMLElement
beforeEach(() => {
document.body.innerHTML = ''
item = document.createElement('div')
document.body.appendChild(item)
content = document.createElement('div')
document.body.appendChild(content)
dom = createActiveDom()
})
afterEach(() => {
document.body.innerHTML = ''
})
it('throws when the event is not declared in the morfo', async () => {
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, { dom })
expect(r.trigger('nonexistent')).rejects.toThrow(/not declared/)
})
cleanup()
})
it('throws when the target part has no DOM element registered', async () => {
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, { dom })
// item part declared in toastMorfo as the target of `present`, but not registered here
expect(r.trigger('present')).rejects.toThrow(/has no DOM element/)
})
cleanup()
})
it('emits the perceptual signal to the resolved target with intent from prop', async () => {
const sem = fakeSemantic()
const itemRef = state<HTMLElement | null>(null)
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, {
dom,
semantic: sem.engine,
props: {
intent: () => 'risk'
}
})
r.registerPart('item', { id: state('toast-1'), ref: itemRef })
itemRef.current = item
void r.trigger('announce')
})
expect(sem.calls).toHaveLength(1)
expect(sem.calls[0]).toMatchObject({
target: item,
name: 'announce',
family: 'alert',
intent: 'risk'
})
sem.resolve()
await Promise.resolve()
cleanup()
})
it('falls back to the declared default intent when the prop is missing', async () => {
const sem = fakeSemantic()
const itemRef = state<HTMLElement | null>(null)
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, {
dom,
semantic: sem.engine,
props: {}
})
r.registerPart('item', { id: state('toast-1'), ref: itemRef })
itemRef.current = item
void r.trigger('announce')
})
expect(sem.calls[0]).toMatchObject({ family: 'alert', intent: 'neutral' })
sem.resolve()
await Promise.resolve()
cleanup()
})
it('omits intent for transitional families', async () => {
const sem = fakeSemantic()
const itemRef = state<HTMLElement | null>(null)
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, {
dom,
semantic: sem.engine
})
r.registerPart('item', { id: state('toast-1'), ref: itemRef })
itemRef.current = item
void r.trigger('present')
})
expect(sem.calls[0]).toMatchObject({ name: 'present', family: 'emerge' })
expect(sem.calls[0].intent).toBeUndefined()
sem.resolve()
await Promise.resolve()
cleanup()
})
it('applies prewrite imperatively before semantic.emit', async () => {
const sem = fakeSemantic()
const contentRef = state<HTMLElement | null>(null)
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(dialogMorfo, {
dom,
semantic: sem.engine,
states: { open: () => true },
props: { disabled: () => false, modal: () => false }
})
r.registerPart('content', { id: state('dlg-1'), ref: contentRef })
contentRef.current = content
void r.trigger('close-cancel')
})
// prewrite ran before semantic.emit (which is still pending — we never resolved it)
expect(content.getAttribute('data-last-action')).toBe('cancelled')
expect(sem.calls).toHaveLength(1)
sem.resolve()
await Promise.resolve()
cleanup()
})
it('invokes the provider handler after semantic.emit resolves', async () => {
const sem = fakeSemantic()
const itemRef = state<HTMLElement | null>(null)
const handler = vi.fn()
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, {
dom,
semantic: sem.engine,
events: { dismiss: handler }
})
r.registerPart('item', { id: state('toast-1'), ref: itemRef })
itemRef.current = item
void r.trigger('dismiss')
})
// semantic still pending → handler not called yet
expect(handler).not.toHaveBeenCalled()
sem.resolve()
await Promise.resolve()
await Promise.resolve()
expect(handler).toHaveBeenCalledTimes(1)
cleanup()
})
it('skips semantic.emit when no semantic source is provided', async () => {
const itemRef = state<HTMLElement | null>(null)
const handler = vi.fn()
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toastMorfo, {
dom,
events: { dismiss: handler }
})
r.registerPart('item', { id: state('toast-1'), ref: itemRef })
itemRef.current = item
void r.trigger('dismiss')
})
await Promise.resolve()
await Promise.resolve()
// Without semantic, the runtime jumps straight to the handler.
expect(handler).toHaveBeenCalledTimes(1)
cleanup()
})
})

@ -28,8 +28,10 @@
import { untrack } from 'svelte';
import type { ActiveDom, DomAttrValue } from '$uix/adom';
import type { AppSemantic } from '$lib/ext/app';
import { resolveSemaIntent } from '$uix/sema';
import type { Morfo, MorfoPart } from '../../morfo/types';
import type { Morfo, MorfoEvent, MorfoPart } from '../../morfo/types';
import { createAttrs } from '../attrs';
import { attachRef, type RefAttachment } from '../provider/provider.svelte';
import type { Active, State } from '../reactive';
@ -42,15 +44,34 @@ import {
export type SourceMap = Record<string, () => unknown>;
/**
* Provider-supplied event handler. Synchronous by V1 contract — async work
* happens in the call-site before invoking `runtime.trigger(eventName)`.
* Returning a value is allowed; it is ignored by the runtime.
*/
export type EventHandler = () => unknown;
export interface MorfoRuntimeSources {
/** DOM service. Required — the runtime applies attrs through it. */
dom: ActiveDom;
/**
* Semantic engine. Required to use `runtime.trigger()` against any morfo
* event whose declaration carries `semantic`. Without it, `trigger` works
* for events that have only `prewrite` + handler but skips the perceptual
* signal step.
*/
semantic?: AppSemantic;
/** Component-wide state sources. Read by `stateRef` declarations. */
states?: SourceMap;
/** Component-wide prop sources. Read by `propRef` declarations. */
props?: SourceMap;
/** Component-wide part-id sources. Read by `partRef` declarations. */
parts?: SourceMap;
/**
* Provider's per-event handlers. Each is called by `runtime.trigger` after
* `semantic.emit` resolves and is responsible for mutating internal state.
*/
events?: Record<string, EventHandler>;
/** Translation lookup. Read by `translationRef` declarations. */
translate?: (key: string) => string | undefined;
}
@ -71,6 +92,24 @@ export interface RegisterPartOpts {
export interface MorfoRuntime {
registerPart(part: string, opts: RegisterPartOpts): void;
partProps(part: string): Record<string, unknown>;
/**
* Trigger a morfo-declared event by name.
*
* Sequence:
* 1. apply `prewrite` attrs imperatively (transient markers like
* `data-last-action`)
* 2. await `semantic.emit({ target, name, family, intent? })`
* 3. invoke the provider's handler from `sources.events[name]`
* 4. effects on the affected parts re-derive structural attrs from the
* new state and write them via `dom.apply` (automatic)
*
* Step 2 is skipped when `sources.semantic` is absent or when the morfo
* event has no semantic. Step 3 is skipped when no handler is registered.
*
* 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>;
}
interface PartRegistration {
@ -167,5 +206,76 @@ export function createMorfoRuntime(
return props;
}
return { registerPart, partProps };
function findEvent(name: string): MorfoEvent | undefined {
return morfo.events?.find((event) => event.name === name);
}
function snapshotRootProps(): Record<string, unknown> {
const props: Record<string, unknown> = {};
if (sources.props) {
for (const key in sources.props) props[key] = sources.props[key]();
}
return props;
}
async function trigger(eventName: string): Promise<void> {
const event = findEvent(eventName);
if (!event) {
throw new Error(
`[morfo-runtime] Event "${eventName}" not declared in "${morfo.kebab}".`
);
}
// Resolve target part — must be a registered part with a mounted DOM ref.
const targetReg = registrations.get(event.target.target);
const target = targetReg?.ref?.current ?? null;
if (!target) {
throw new Error(
`[morfo-runtime] Trigger "${eventName}" target part "${event.target.target}" has no DOM element registered.`
);
}
// 1. prewrite — transient markers applied before the perceptual signal.
// Each prewrite entry already carries a literal value; no source resolution.
if (event.prewrite) {
for (const write of event.prewrite) {
const writeReg = registrations.get(write.part.target);
const writeTarget = writeReg?.ref?.current ?? null;
if (writeTarget) {
sources.dom.apply({ target: writeTarget, attrs: { [write.attr]: write.value } });
}
}
}
// 2. semantic.emit — perceptual signal. Resolves after one rAF; CSS gets
// a frame to observe the data-event* before the structural change lands.
if (sources.semantic) {
const props = snapshotRootProps();
const family = event.semantic.family;
const intent =
'intent' in event.semantic
? resolveSemaIntent(event.semantic.intent, props)
: undefined;
await sources.semantic.emit({
target,
name: event.name,
family,
...(intent ? { intent } : {})
});
}
// 3. handler — provider mutates internal state. Synchronous by V1
// contract; any returned value is ignored.
const handler = sources.events?.[eventName];
if (handler) {
await handler();
}
// 4. Effects on the affected parts re-derive structural attrs from the
// new state and write them via `dom.apply` automatically — no explicit
// step here. State is the source of truth; the DOM is its derivation.
}
return { registerPart, partProps, trigger };
}

Loading…
Cancel
Save

Powered by TurnKey Linux.