adom + sema: dom.apply + semantic.emit contract skeleton

The minimum primitives the closed architecture needs. No MorfoRuntime yet,
no provider migration — just the two operations that everything else will
compose on top of.

adom (only writer of mutable attrs):
- types StructuralChange + DomAttrValue
- applyChange(change) writes attrs (string/number → setAttribute, true →
  presence flag, false/null/undefined → removeAttribute)
- removeAttrs(target, names) targeted cleanup
- ActiveDom exposes apply() + remove() — same surface every consumer uses

sema (perceptual signal channel):
- new SemanticSignal type (runtime payload distinct from declaration-time
  SemaEvent): { target, name, intent?, family?, hold? }
- SemanticEngine accepts dom by construction (DI; not imported hard from
  $uix/adom inside sema)
- new emit(signal) async method with the agreed lifecycle:
  1. write data-event* via dom.apply
  2. await one rAF (CSS gets a frame to observe)
  3. resolve Promise (caller can dom.apply structural change)
  4. hold N frames (default 1, configurable per signal)
  5. clear data-event* via dom.remove
- legacy publish() flow untouched for backward compatibility

Tests: 12/12 green; full adom+sema+lib/dom suite 50/50 green;
svelte-check delta: -12 errors (no regressions in touched files).
morfo-runtime
dev 6 months ago
parent 13caaf215f
commit 02fa963cb8

@ -7,6 +7,7 @@ import {
type Breakpoints,
type ResponsiveProp
} from '$uix/lib/dom'
import { applyChange, removeAttrs, type StructuralChange } from './apply.js'
import { initViewportTracking, viewport } from './viewport.svelte.js'
export type ActiveDomProps = {
@ -20,6 +21,8 @@ export type ActiveDom = {
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined
isAtLeast(breakpoint: Breakpoint): boolean
matches(breakpoint: Breakpoint): boolean
apply(change: StructuralChange): void
remove(target: HTMLElement, names: readonly string[]): void
}
const viewportReadonly: { readonly width: number } = {
@ -52,6 +55,12 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
},
matches(breakpoint: Breakpoint): boolean {
return currentBreakpoint.current === breakpoint
},
apply(change: StructuralChange): void {
applyChange(change)
},
remove(target: HTMLElement, names: readonly string[]): void {
removeAttrs(target, names)
}
}
}

@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { applyChange, removeAttrs } from './apply'
describe('apply', () => {
let target: HTMLElement
beforeEach(() => {
document.body.innerHTML = ''
target = document.createElement('div')
document.body.appendChild(target)
})
it('writes string values via setAttribute', () => {
applyChange({ target, attrs: { 'data-state': 'open', role: 'dialog' } })
expect(target.getAttribute('data-state')).toBe('open')
expect(target.getAttribute('role')).toBe('dialog')
})
it('writes number values stringified', () => {
applyChange({ target, attrs: { 'aria-level': 2, tabindex: -1 } })
expect(target.getAttribute('aria-level')).toBe('2')
expect(target.getAttribute('tabindex')).toBe('-1')
})
it('writes true as empty string (presence flag)', () => {
applyChange({ target, attrs: { 'data-disabled': true } })
expect(target.hasAttribute('data-disabled')).toBe(true)
expect(target.getAttribute('data-disabled')).toBe('')
})
it('removes attribute on false / null / undefined', () => {
target.setAttribute('data-disabled', '')
target.setAttribute('aria-hidden', 'true')
target.setAttribute('data-loading', 'true')
applyChange({
target,
attrs: { 'data-disabled': false, 'aria-hidden': null, 'data-loading': undefined }
})
expect(target.hasAttribute('data-disabled')).toBe(false)
expect(target.hasAttribute('aria-hidden')).toBe(false)
expect(target.hasAttribute('data-loading')).toBe(false)
})
it('removes attrs by name', () => {
target.setAttribute('data-event', 'announce')
target.setAttribute('data-event-phase', 'active')
target.setAttribute('data-state', 'open')
removeAttrs(target, ['data-event', 'data-event-phase'])
expect(target.hasAttribute('data-event')).toBe(false)
expect(target.hasAttribute('data-event-phase')).toBe(false)
expect(target.getAttribute('data-state')).toBe('open')
})
it('mixes write and remove in a single change', () => {
target.setAttribute('data-loading', '')
applyChange({
target,
attrs: {
'data-state': 'open',
'data-loading': false
}
})
expect(target.getAttribute('data-state')).toBe('open')
expect(target.hasAttribute('data-loading')).toBe(false)
})
})

@ -0,0 +1,48 @@
/**
* `apply` / `remove` — the only mutation surface of `ActiveDom`.
*
* `ActiveDom` doesn't know Morfo, Sema, Soma, or Eidos. It receives instructions
* already resolved by upper layers and applies them to the DOM. Nothing else.
*
* Contract:
* - `string | number` → `setAttribute(name, String(value))`
* - `true` → `setAttribute(name, '')` (presence flag, e.g. `data-disabled`)
* - `false | null | undefined` → `removeAttribute(name)`
*
* No event dispatch, no batching, no observation. Pure mutations.
*/
import { isBrowser } from '$uix/lib/dom'
export type DomAttrValue = string | number | boolean | null | undefined
export type StructuralChange = {
target: HTMLElement
attrs: Record<string, DomAttrValue>
}
function writeAttr(target: HTMLElement, name: string, value: DomAttrValue): void {
if (value === false || value === null || value === undefined) {
target.removeAttribute(name)
return
}
if (value === true) {
target.setAttribute(name, '')
return
}
target.setAttribute(name, String(value))
}
export function applyChange(change: StructuralChange): void {
if (!isBrowser) return
for (const name in change.attrs) {
writeAttr(change.target, name, change.attrs[name])
}
}
export function removeAttrs(target: HTMLElement, names: readonly string[]): void {
if (!isBrowser) return
for (const name of names) {
target.removeAttribute(name)
}
}

@ -1,4 +1,5 @@
export * from './active-dom.svelte.js'
export * from './apply.js'
export * from './body-scroll-lock.svelte.js'
export * from './dom-context.svelte.js'
export * from './roving-focus-group.svelte.js'

@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createActiveDom } from '$uix/adom'
import { SemanticEngine } from './engine'
type Frame = () => void
function setupRafQueue() {
const queue: Frame[] = []
const raf = vi.fn((cb: Frame) => {
queue.push(cb)
return queue.length
}) as unknown as typeof window.requestAnimationFrame
const flush = () => {
const pending = queue.splice(0, queue.length)
for (const cb of pending) cb()
}
return { queue, raf, flush }
}
describe('SemanticEngine.emit', () => {
let target: HTMLElement
let originalRaf: typeof window.requestAnimationFrame
let rafQueue: ReturnType<typeof setupRafQueue>
beforeEach(() => {
document.body.innerHTML = ''
target = document.createElement('div')
document.body.appendChild(target)
originalRaf = window.requestAnimationFrame
rafQueue = setupRafQueue()
window.requestAnimationFrame = rafQueue.raf
;(globalThis as { requestAnimationFrame?: typeof window.requestAnimationFrame }).requestAnimationFrame =
rafQueue.raf
})
afterEach(() => {
window.requestAnimationFrame = originalRaf
;(globalThis as { requestAnimationFrame?: typeof window.requestAnimationFrame }).requestAnimationFrame =
originalRaf
})
it('throws when constructed without a dom', async () => {
const engine = new SemanticEngine()
await expect(
engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' })
).rejects.toThrow(/requires `dom`/)
})
it('writes signal attrs synchronously, before the frame elapses', () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
void engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' })
expect(target.getAttribute('data-event')).toBe('announce')
expect(target.getAttribute('data-event-phase')).toBe('active')
expect(target.getAttribute('data-event-family')).toBe('alert')
expect(target.getAttribute('data-intent')).toBe('risk')
expect(target.getAttribute('data-event-id')).toMatch(/^sig-\d+$/)
})
it('resolves only after one rAF', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
let resolved = false
const promise = engine.emit({ target, name: 'announce' }).then(() => {
resolved = true
})
await Promise.resolve()
expect(resolved).toBe(false)
rafQueue.flush()
await promise
expect(resolved).toBe(true)
})
it('keeps signal in DOM during the hold window after resolving', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
await Promise.all([engine.emit({ target, name: 'announce', hold: 2 }), rafQueue.flush()])
expect(target.getAttribute('data-event')).toBe('announce')
// hold=2: cleanup scheduled with 2 frames; first tick decrements to 1
rafQueue.flush()
expect(target.getAttribute('data-event')).toBe('announce')
// second tick decrements to 0 and cleans up
rafQueue.flush()
expect(target.hasAttribute('data-event')).toBe(false)
expect(target.hasAttribute('data-event-phase')).toBe(false)
expect(target.hasAttribute('data-event-id')).toBe(false)
})
it('default hold is 1 frame', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
const promise = engine.emit({ target, name: 'announce' })
rafQueue.flush()
await promise
expect(target.getAttribute('data-event')).toBe('announce')
rafQueue.flush()
expect(target.hasAttribute('data-event')).toBe(false)
})
it('omits family/intent attrs when not provided', async () => {
const dom = createActiveDom()
const engine = new SemanticEngine({ dom })
const promise = engine.emit({ target, name: 'open' })
expect(target.hasAttribute('data-event-family')).toBe(false)
expect(target.hasAttribute('data-intent')).toBe(false)
rafQueue.flush()
await promise
})
})

@ -1,6 +1,7 @@
import { DEV } from 'esm-env'
import { normalizeSemaEvent } from './event'
import type { SemanticSignal } from './signal'
import type {
SemaAttrWrite,
SemaCause,
@ -14,8 +15,39 @@ import type {
SemaScope
} from './types'
import type { ActiveDom } from '$uix/adom'
import type { PartRef } from '../lib/types'
export interface SemanticEngineOpts {
/**
* DOM service injected by construction. Required for `emit()`. The legacy
* `publish()` path still works without it for backward compatibility while
* MorfoRuntime is being rolled out.
*/
dom?: ActiveDom
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve())
} else {
setTimeout(resolve, 0)
}
})
}
function buildSignalAttrs(signal: SemanticSignal, id: string): Record<string, string> {
const attrs: Record<string, string> = {
'data-event': signal.name,
'data-event-id': id,
'data-event-phase': 'active'
}
if (signal.family) attrs['data-event-family'] = signal.family
if (signal.intent) attrs['data-intent'] = signal.intent
return attrs
}
export interface SemanticEventDecl {
name: string
target: PartRef
@ -98,7 +130,73 @@ function resolvePartElement(
export class SemanticEngine {
private readonly subscribers = new Set<SemanticSubscriber>()
private readonly dom: ActiveDom | undefined
private nextId = 0
private nextSignalId = 0
constructor(opts: SemanticEngineOpts = {}) {
this.dom = opts.dom
}
/**
* Emit a perceptual signal. Resolves after the signal is written to the DOM
* **and** has had one animation frame to be observed by Eidos / CSS. The
* signal is then held for `signal.hold` extra frames (default 1) before
* being cleared.
*
* Sequence:
* 1. write `data-event*` via `dom.apply`
* 2. await 1 rAF
* 3. resolve the Promise (caller can now `dom.apply(structuralChange)`)
* 4. hold N frames
* 5. clear `data-event*`
*
* The cleanup is fire-and-forget: it doesn't block the resolution, and an
* error in the caller's structural change after `await` does not affect it.
*/
async emit(signal: SemanticSignal): Promise<void> {
if (!this.dom) {
throw new Error(
'[semantic] SemanticEngine.emit requires `dom` to be passed in the constructor.'
)
}
const id = `sig-${this.nextSignalId++}`
const attrs = buildSignalAttrs(signal, id)
const dom = this.dom
dom.apply({ target: signal.target, attrs })
await nextFrame()
const hold = Math.max(1, signal.hold ?? 1)
const names = Object.keys(attrs)
this.scheduleCleanup(signal.target, names, hold)
}
private scheduleCleanup(target: HTMLElement, names: readonly string[], holdFrames: number): void {
const dom = this.dom
if (!dom) return
let remaining = holdFrames
const tick = () => {
remaining -= 1
if (remaining <= 0) {
dom.remove(target, names)
return
}
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(tick)
} else {
setTimeout(tick, 16)
}
}
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(tick)
} else {
setTimeout(tick, 16)
}
}
publish(
contract: SemanticComponentContract,

@ -38,9 +38,12 @@ export type {
SemanticComponentContract,
SemanticPublishContext,
PublishedSemanticEvent,
SemanticEventFilter
SemanticEventFilter,
SemanticEngineOpts
} from './engine'
export { SemanticEngine } from './engine'
export type { SemanticSignal } from './signal'
export { SemaInvariantError, validateSemaEvent, validateSemaIntentBinding } from './validation'

@ -0,0 +1,31 @@
/**
* SemanticSignal — runtime payload that the provider (or MorfoRuntime) hands
* to `EngineSemantic.emit(...)`. Distinct from `SemaEvent`, which describes
* the *declaration* of an event in a morfo file (`{ family, intent }`).
*
* `SemanticSignal` describes the actual occurrence: a target, a name, the
* resolved family/intent, and an optional hold duration in animation frames.
*/
import type { SemaFamily, SemaIntent } from './types'
export interface SemanticSignal {
/** DOM target where the perceptual signal is reflected. */
target: HTMLElement
/** Event name as declared in `morfo.events[].name` (e.g. `'close-cancel'`). */
name: string
/** Resolved intent for valenced families. Omit for transitional families. */
intent?: SemaIntent
/** Resolved family. When omitted, only `data-event` and `data-event-id` are written. */
family?: SemaFamily
/**
* Frames the signal stays in the DOM after the Promise resolves.
* Defaults to 1. Components with longer perceptual windows can extend it
* (e.g. announce events that need to "linger" for AT or Eidos animations).
*/
hold?: number
}
Loading…
Cancel
Save

Powered by TurnKey Linux.