diff --git a/src/lib/ext/app/app.svelte.ts b/src/lib/ext/app/app.svelte.ts index f9e38db9c..90c0f4df7 100644 --- a/src/lib/ext/app/app.svelte.ts +++ b/src/lib/ext/app/app.svelte.ts @@ -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'); */ const _fallback = new App({ dom: fallbackDom, + semantic: fallbackSemantic, langs: fallbackLangs, presentation: fallbackPresentation, logger: consoleLogger diff --git a/src/lib/ext/app/defaults.ts b/src/lib/ext/app/defaults.ts index fe5aaeef3..9a74f608a 100644 --- a/src/lib/ext/app/defaults.ts +++ b/src/lib/ext/app/defaults.ts @@ -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 }); diff --git a/src/lib/ext/app/index.ts b/src/lib/ext/app/index.ts index e99a78bea..b5b096328 100644 --- a/src/lib/ext/app/index.ts +++ b/src/lib/ext/app/index.ts @@ -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, diff --git a/src/lib/ext/app/types.ts b/src/lib/ext/app/types.ts index 9bbbab31c..b5277da7c 100644 --- a/src/lib/ext/app/types.ts +++ b/src/lib/ext/app/types.ts @@ -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; +} + // ── 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; diff --git a/src/uix/soma/core/soma.svelte.ts b/src/uix/soma/core/soma.svelte.ts index 21cb6f519..587e3049d 100644 --- a/src/uix/soma/core/soma.svelte.ts +++ b/src/uix/soma/core/soma.svelte.ts @@ -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; } diff --git a/src/uix/soma/morfo/runtime.svelte.test.ts b/src/uix/soma/morfo/runtime.svelte.test.ts index 0e21258d7..34ecbe60c 100644 --- a/src/uix/soma/morfo/runtime.svelte.test.ts +++ b/src/uix/soma/morfo/runtime.svelte.test.ts @@ -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((res) => { + pendingResolve = res + }) + } + } + + return { + engine, + calls, + resolve() { + pendingResolve?.() + pendingResolve = null + } + } +} + +describe('runtime.trigger', () => { + let dom: ReturnType + 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(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(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(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(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(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(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() + }) +}) diff --git a/src/uix/soma/morfo/runtime.svelte.ts b/src/uix/soma/morfo/runtime.svelte.ts index f7168b4d3..f1fbe4d22 100644 --- a/src/uix/soma/morfo/runtime.svelte.ts +++ b/src/uix/soma/morfo/runtime.svelte.ts @@ -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 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; /** 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; + /** + * 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; } 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 { + const props: Record = {}; + if (sources.props) { + for (const key in sources.props) props[key] = sources.props[key](); + } + return props; + } + + async function trigger(eventName: string): Promise { + 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 }; }