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
parent
13caaf215f
commit
02fa963cb8
@ -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
|
||||
})
|
||||
})
|
||||
@ -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…
Reference in new issue