soma: MorfoRuntime V1 + Toggle pilot

First piloted slice of the closed architecture: a runtime that interprets
the morfo declaration and writes mutable attrs to the DOM via dom.apply,
removing the need for the provider to hand-roll resolveMorfoProps bindings
on every part.

src/uix/soma/morfo/
- resolver.ts — pure morfo → attrs resolver, extracted from Provider so
  both legacy Provider (still in place) and the new runtime can share it
- runtime.svelte.ts — `createMorfoRuntime(morfo, sources)` with V1 surface:
  - registerPart(part, opts) — registers per-instance id/ref/sources and
    sets up a $effect that reapplies attrs whenever sources change
  - partProps(part) — returns ONLY identity (id + marker + ref attachment),
    never mutable attrs (those go through dom.apply)
- runtime.svelte.test.ts — 4 tests verifying initial application, reactive
  reapplication, and unknown-part rejection

ARIA stateRef refinement
The shared resolver now stringifies ARIA stateRef values to 'true'/'false'.
Boolean false on aria-* must mean "explicitly unpressed/unexpanded", not
"attribute absent" — the previous boolean return wired into dom.apply's
`false → removeAttribute` semantics, which is correct for data-* presence
flags but wrong for ARIA. Distinct contracts; resolver decides at the
boundary, not dom.apply.

Toggle morfo
Added explicit `value` sources to all data entries. The legacy provider
hard-coded data-state etc.; the runtime needs the morfo to declare where
each attr's value comes from. Now data-state references stateRef('on') and
data-disabled / data-readonly / data-required / data-invalid reference
their corresponding propRef with prop-truthy conditions.

Toggle provider migration
- ToggleProvider creates a MorfoRuntime in its constructor with Soma's dom.
- registerPart('provider', { id, ref }) wires the runtime to the button.
- props derivation now returns only:
  - runtime.partProps('provider') — id + data-toggle + ref attachment
  - disabled (HTML attr; not in morfo by design — primitive button concern)
  - onclick handler
- All morfo-declared attrs (data-state, aria-pressed, data-disabled,
  aria-disabled, aria-label, etc.) are written by the runtime's effect via
  dom.apply.

Verification
- Unit tests: 4/4 morfo runtime + full focused suite green.
- morfo:check: 66/66 — Toggle's emitted DOM matches the morfo declaration
  end-to-end now that data-* sources are pinned.
- smoke: 68/68 routes 200 OK.
- svelte-check: no new errors.

V2 (deferred — not part of this pilot):
- runtime.keydown(part, event) for morfo.keyboard dispatch.
- runtime.trigger(eventName) — prewrite + semantic.emit + handler chain
  (Toast will be the first component to exercise this).
- OptsFromProps + bindProps helper to absorb the props bridge boilerplate.
morfo-runtime
dev 6 months ago
parent 02fa963cb8
commit 8ede535305

@ -15,11 +15,31 @@ export const toggleMorfo = {
optional: false,
states: ['on', 'off'],
data: [
{ attr: 'data-state', values: ['on', 'off'] },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' },
{ attr: 'data-required', severity: 'optional' }
{ attr: 'data-state', values: ['on', 'off'], value: v.stateRef('on') },
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
},
{
attr: 'data-readonly',
value: v.propRef('readonly'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'readonly' }
},
{
attr: 'data-invalid',
value: v.propRef('invalid'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'invalid' }
},
{
attr: 'data-required',
value: v.propRef('required'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'required' }
}
],
aria: [
{ attr: 'type', value: v.literal('button') },

@ -1,7 +1,9 @@
import { Provider, type WithRefOpts } from '../../provider';
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
import { createAttrs, registerContract } from '../../attrs';
import type { ActiveProps, StateProps } from '../../reactive';
import type { OnChangeFn, SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { FieldProvider } from '../field/field-provider.svelte';
import { toggleMorfo } from '../../../morfo/components/toggle';
@ -32,12 +34,36 @@ export class ToggleProvider extends Provider<ToggleOpts> {
}
readonly field = FieldProvider.get();
readonly runtime: MorfoRuntime;
private constructor(opts: ToggleOpts) {
super(opts, 'Toggle', 'provider', attrs.provider);
super(opts, { morfo: toggleMorfo, part: 'provider' });
// Register as the Field's primary input so `Field.Label`'s `for`
// attribute lands on this button. Direct assign — NOT $effect (A30).
if (this.field) this.field.inputId.current = opts.id.current;
// MorfoRuntime — V1 pilot. Owns the morfo-derived attrs (data-state,
// aria-pressed, aria-disabled, etc.); this provider keeps only handler
// wiring + the HTML `disabled` attribute (not a morfo concern).
const soma = Soma.require();
this.runtime = createMorfoRuntime(toggleMorfo, {
dom: soma.dom,
states: {
on: () => opts.pressed.current
},
props: {
disabled: () => this.isDisabled,
readonly: () => this.isReadonly,
required: () => this.isRequired,
invalid: () => this.isInvalid,
ariaLabel: () => opts.ariaLabel.current
}
});
this.runtime.registerPart('provider', {
id: opts.id,
ref: opts.ref
});
}
readonly isDisabled = $derived.by(
@ -64,23 +90,24 @@ export class ToggleProvider extends Provider<ToggleOpts> {
this.opts.onPressedChange.current?.(next);
};
/**
* Render-time props. Only includes:
* - identity from the runtime (`id`, `data-toggle`, ref attachment)
* - the HTML `disabled` attribute (not in morfo — primitive button concern)
* - the `onclick` handler
*
* All morfo-declared attrs (`data-state`, `aria-pressed`, `aria-disabled`,
* etc.) are applied to the DOM by the runtime's effect via `dom.apply`.
*/
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-pressed': this.opts.pressed.current,
'aria-label': this.opts.ariaLabel.current,
'aria-disabled': this.isDisabled || undefined,
'aria-readonly': this.isReadonly || undefined,
'aria-required': this.isRequired || undefined,
'aria-invalid': this.isInvalid || undefined,
'data-state': this.opts.pressed.current ? 'on' : 'off',
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
'data-required': boolToEmptyStrOrUndef(this.isRequired),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
...this.runtime.partProps('provider'),
disabled: this.isDisabled || undefined,
onclick: this.onclick
} as const)
);
}
// `attrs` is exported for any consumer needing the static marker name (e.g.
// querySelector in tests). The runtime owns the runtime emission.
export { attrs as toggleAttrs };

@ -0,0 +1,17 @@
export {
createMorfoRuntime,
type MorfoRuntime,
type MorfoRuntimeSources,
type RegisterPartOpts,
type SourceMap
} from './runtime.svelte';
export {
findMorfoPart,
resolvePartAttrs,
resolveMorfoSource,
resolveMorfoDataValue,
resolveMorfoAriaValue,
shouldEmitMorfoEntry,
type MorfoBindings
} from './resolver';

@ -0,0 +1,142 @@
/**
* Pure morfo → attrs resolver.
*
* Walks a `MorfoPart`'s `data` and `aria` declarations, evaluates conditions,
* resolves value sources against the supplied bindings, and returns a flat
* attribute map ready to render or to pass to `dom.apply`.
*
* No reactivity, no side-effects, no DOM. The runtime layer wraps these in
* effects; the Provider base class wraps them in a `$derived.by`.
*/
import type {
MorfoAriaEntry,
MorfoCondition,
MorfoData,
MorfoPart,
MorfoPrimitiveValueSource,
MorfoValueSource
} from '../../morfo/types';
export interface MorfoBindings {
props?: Record<string, unknown>;
states?: Record<string, unknown>;
parts?: Record<string, unknown>;
translations?: (key: string) => string | undefined;
}
export function findMorfoPart(parts: readonly MorfoPart[], target: string): MorfoPart | undefined {
for (const part of parts) {
if (part.kebab === target) return part;
if (part.parts) {
const nested = findMorfoPart(part.parts, target);
if (nested) return nested;
}
}
return undefined;
}
export function shouldEmitMorfoEntry(
condition: MorfoCondition | undefined,
bindings: MorfoBindings
): boolean {
if (!condition || condition === 'always') return true;
if (condition.when === 'part-present') return Boolean(bindings.parts?.[condition.part]);
if (condition.when === 'state-equals') return bindings.states?.[condition.state] === condition.value;
if (condition.when === 'prop-truthy') return Boolean(bindings.props?.[condition.prop]);
if (condition.when === 'prop-falsy') return !bindings.props?.[condition.prop];
return true;
}
function resolveMorfoPrimitiveSource(
source: MorfoPrimitiveValueSource,
bindings: MorfoBindings
): unknown {
if (source.kind === 'literal') return source.value;
if (source.kind === 'stateRef') return bindings.states?.[source.state];
if (source.kind === 'partRef') return bindings.parts?.[source.target];
if (source.kind === 'propRef') return bindings.props?.[source.prop];
if (source.kind === 'translationRef') return bindings.translations?.(source.key);
return undefined;
}
export function resolveMorfoSource(source: MorfoValueSource, bindings: MorfoBindings): unknown {
if (source.kind !== 'mapRef') {
return resolveMorfoPrimitiveSource(source, bindings);
}
const raw = resolveMorfoPrimitiveSource(source.source, bindings);
if (raw === undefined || raw === null) {
return source.fallback;
}
const mapped = source.map[String(raw)];
return mapped ?? source.fallback;
}
export function resolveMorfoDataValue(data: MorfoData, bindings: MorfoBindings): unknown {
const source = data.value;
if (!source) return undefined;
const raw = resolveMorfoSource(source, bindings);
if (!data.values || data.values.length === 0) {
return raw ? '' : undefined;
}
if (source.kind === 'stateRef' && typeof raw === 'boolean') {
if (raw) return source.state;
return data.values.find((value) => value !== source.state);
}
return raw;
}
export function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: MorfoBindings): unknown {
const raw = resolveMorfoSource(entry.value, bindings);
if (entry.value.kind === 'stateRef') {
// ARIA boolean attrs require literal 'true'/'false' strings, not the
// HTML boolean attribute semantics. `aria-pressed="false"` is a valid
// state distinct from the attribute being absent — never remove it
// based on falsy values.
return Boolean(raw) ? 'true' : 'false';
}
return raw;
}
/**
* Resolves a part's full attribute map (role + data-* + aria-*) against the
* given bindings. Returns `undefined`-valued entries for declared attrs that
* shouldn't be emitted in the current state — the caller decides whether to
* skip them (Svelte does it automatically; `dom.apply` removes them).
*/
export function resolvePartAttrs(
partMeta: MorfoPart,
bindings: MorfoBindings
): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (partMeta.role) {
result.role = partMeta.role;
}
for (const data of partMeta.data) {
if (!data.value) continue;
if (!shouldEmitMorfoEntry(data.condition, bindings)) continue;
const value = resolveMorfoDataValue(data, bindings);
if (value !== undefined) {
result[data.attr] = value;
}
}
for (const aria of partMeta.aria) {
if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue;
const value = resolveMorfoAriaValue(aria, bindings);
if (value !== undefined) {
result[aria.attr] = value;
}
}
return result;
}

@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushSync } from 'svelte'
import { createActiveDom } from '$uix/adom'
import { state } from '../reactive'
import { toggleMorfo } from '../../morfo/components/toggle'
import { createMorfoRuntime } from './runtime.svelte'
/**
* Effect roots are normally provided by Svelte components. For unit tests we
* wrap the runtime construction in `$effect.root` so `$effect` calls inside
* `registerPart` have a parent to attach to.
*/
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T
const cleanup = $effect.root(() => {
result = fn()
})
return { result, cleanup }
}
describe('createMorfoRuntime', () => {
let target: HTMLElement
let dom: ReturnType<typeof createActiveDom>
beforeEach(() => {
document.body.innerHTML = ''
target = document.createElement('button')
target.id = 'tog-1'
document.body.appendChild(target)
dom = createActiveDom()
})
afterEach(() => {
document.body.innerHTML = ''
})
it('partProps returns identity (id + marker) without mutable attrs', () => {
const { result: runtime, cleanup } = withEffectRoot(() => {
const id = state('tog-1')
const ref = state<HTMLElement | null>(null)
const r = createMorfoRuntime(toggleMorfo, {
dom,
states: { on: () => false }
})
r.registerPart('provider', { id, ref })
return r
})
const props = runtime.partProps('provider')
expect(props.id).toBe('tog-1')
expect(props['data-toggle']).toBe('')
// Mutable attrs are NOT in partProps; they're written via dom.apply by the effect.
expect(props['data-state']).toBeUndefined()
expect(props['aria-pressed']).toBeUndefined()
cleanup()
})
it('writes morfo-derived attrs to DOM when ref attaches', () => {
const ref = state<HTMLElement | null>(null)
const { cleanup } = withEffectRoot(() => {
const id = state('tog-1')
const r = createMorfoRuntime(toggleMorfo, {
dom,
states: { on: () => false },
props: {
disabled: () => false,
readonly: () => false,
required: () => false,
invalid: () => false,
ariaLabel: () => undefined
}
})
r.registerPart('provider', { id, ref })
})
// Simulate Svelte's attachment firing — the runtime registered a setter
// on the ref via attachRef, but in tests we mutate the ref directly.
ref.current = target
flushSync()
expect(target.getAttribute('data-state')).toBe('off')
expect(target.getAttribute('aria-pressed')).toBe('false')
expect(target.getAttribute('type')).toBe('button')
expect(target.hasAttribute('data-disabled')).toBe(false)
expect(target.hasAttribute('aria-disabled')).toBe(false)
cleanup()
})
it('reapplies attrs when sources change', () => {
const ref = state<HTMLElement | null>(null)
const pressed = state(false)
const disabled = state(false)
const { cleanup } = withEffectRoot(() => {
const id = state('tog-1')
const r = createMorfoRuntime(toggleMorfo, {
dom,
states: { on: () => pressed.current },
props: {
disabled: () => disabled.current,
readonly: () => false,
required: () => false,
invalid: () => false,
ariaLabel: () => undefined
}
})
r.registerPart('provider', { id, ref })
})
ref.current = target
flushSync()
expect(target.getAttribute('data-state')).toBe('off')
pressed.current = true
flushSync()
expect(target.getAttribute('data-state')).toBe('on')
expect(target.getAttribute('aria-pressed')).toBe('true')
disabled.current = true
flushSync()
expect(target.getAttribute('aria-disabled')).toBe('true')
cleanup()
})
it('throws when registering an unknown part', () => {
const { cleanup } = withEffectRoot(() => {
const r = createMorfoRuntime(toggleMorfo, { dom })
expect(() => r.registerPart('nonexistent', { id: state('x') })).toThrow(/not declared/)
})
cleanup()
})
})

@ -0,0 +1,171 @@
/**
* MorfoRuntime — interpreter of a component's morfo within Soma.
*
* V1 surface:
* - `registerPart(part, opts)` — the part-provider declares its instance to
* the runtime (id source, optional ref, optional per-part sources). Sets up
* an internal `$effect` that recomputes the part's mutable attrs whenever
* any of its sources change and applies them via `dom.apply`.
* - `partProps(part)` — returns ONLY the part's static identity (id, marker,
* ref attachment). Mutable attrs (data-state, aria-*, etc.) are written to
* the DOM by the runtime's effect, never via Svelte render.
*
* Operational rules:
* - `partProps` must not include any state-derived attr — that would race
* with `dom.apply`. The boundary is identity vs. derivation.
* - `registerPart` must be called inside an effect root (a Svelte component
* scope or a class constructor invoked during component init), because the
* per-part effect uses `$effect`.
* - Effects are best-effort idempotent: each tick reapplies the full attr
* map for that part. Missing attrs are removed via `dom.apply`'s
* `false/null/undefined` handling.
*
* Not in V1 (planned for V2):
* - `keydown(part, event)` — dispatch from `morfo.keyboard`.
* - `trigger(eventName)` — orchestrates prewrite + semantic.emit + handler.
* - `OptsFromProps` + `bindProps` helper to absorb the props bridge.
*/
import { untrack } from 'svelte';
import type { ActiveDom, DomAttrValue } from '$uix/adom';
import type { Morfo, MorfoPart } from '../../morfo/types';
import { createAttrs } from '../attrs';
import { attachRef, type RefAttachment } from '../provider/provider.svelte';
import type { Active, State } from '../reactive';
import {
findMorfoPart,
resolvePartAttrs,
type MorfoBindings
} from './resolver';
export type SourceMap = Record<string, () => unknown>;
export interface MorfoRuntimeSources {
/** DOM service. Required — the runtime applies attrs through it. */
dom: ActiveDom;
/** 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;
/** Translation lookup. Read by `translationRef` declarations. */
translate?: (key: string) => string | undefined;
}
export interface RegisterPartOpts {
/** Per-instance id of this part (from the part-provider's opts). */
id: Active<string>;
/** Per-instance ref. Required for parts with DOM. */
ref?: State<HTMLElement | null>;
/** Optional callback when the ref attaches/detaches. */
onRefChange?: (el: HTMLElement | null) => void;
/** Per-part state sources (override component-level for this part only). */
states?: SourceMap;
/** Per-part prop sources (override component-level for this part only). */
props?: SourceMap;
}
export interface MorfoRuntime {
registerPart(part: string, opts: RegisterPartOpts): void;
partProps(part: string): Record<string, unknown>;
}
interface PartRegistration {
id: Active<string>;
ref: State<HTMLElement | null> | undefined;
attachment: RefAttachment | undefined;
states: SourceMap | undefined;
props: SourceMap | undefined;
}
function readBindings(
reg: PartRegistration,
sources: MorfoRuntimeSources
): MorfoBindings {
const states: Record<string, unknown> = {};
const props: Record<string, unknown> = {};
const parts: Record<string, unknown> = {};
if (sources.states) {
for (const key in sources.states) states[key] = sources.states[key]();
}
if (sources.props) {
for (const key in sources.props) props[key] = sources.props[key]();
}
if (sources.parts) {
for (const key in sources.parts) parts[key] = sources.parts[key]();
}
if (reg.states) {
for (const key in reg.states) states[key] = reg.states[key]();
}
if (reg.props) {
for (const key in reg.props) props[key] = reg.props[key]();
}
return { states, props, parts, translations: sources.translate };
}
export function createMorfoRuntime(
morfo: Morfo,
sources: MorfoRuntimeSources
): MorfoRuntime {
const attrs = createAttrs(morfo);
const registrations = new Map<string, PartRegistration>();
function registerPart(part: string, opts: RegisterPartOpts): void {
const partMeta = findMorfoPart(morfo.parts as readonly MorfoPart[], part);
if (!partMeta) {
throw new Error(
`[morfo-runtime] Part "${part}" not declared in morfo "${morfo.kebab}".`
);
}
const reg: PartRegistration = {
id: opts.id,
ref: opts.ref,
attachment: undefined,
states: opts.states,
props: opts.props
};
if (opts.ref) {
reg.attachment = attachRef(opts.ref, (el) => {
untrack(() => opts.onRefChange?.(el));
});
}
registrations.set(part, reg);
// Effect: whenever the part's ref attaches OR any source changes,
// recompute the morfo-derived attrs and write them via dom.apply.
// Reading `reg.ref.current` is the reactive subscription point — the
// `attachRef` setter mutates it when Svelte's attachment callback fires.
$effect(() => {
const target = reg.ref ? reg.ref.current : null;
if (!target) return;
const bindings = readBindings(reg, sources);
const resolved = resolvePartAttrs(partMeta, bindings);
sources.dom.apply({ target, attrs: resolved as Record<string, DomAttrValue> });
});
}
function partProps(part: string): Record<string, unknown> {
const reg = registrations.get(part);
if (!reg) return {};
const marker = attrs[part];
const props: Record<string, unknown> = {
id: reg.id.current
};
if (marker) props[marker] = '';
if (reg.attachment) Object.assign(props, reg.attachment);
return props;
}
return { registerPart, partProps };
}
Loading…
Cancel
Save

Powered by TurnKey Linux.