feat(soma)!: C+ cierre — actions POR REGISTRACIÓN (sidebar deja de rodear su propio mecanismo) y compile recoge las deps de teclado

Las dos piezas del saneo del dispatcher, el remate del wave C+:

PIEZA 1 — el mapa `actions` baja a la registración de la parte:
- part() gana `actions?` (mismo mapped type que el de runtime, la forma
  de states/props/parts). keydown(part, event, anchor?) resuelve
  PRIMERO en la registración del anclaje y CAE al mapa de runtime — y el
  ancla resuelve LAS DOS MITADES: bindings de la condición y handler,
  así que una parte repetida evalúa SU estado, no el del hermano más
  nuevo. SomaPartAnchor.keydown(event) despacha con la misma identidad
  de registro que trigger. Los 6 consumidores actuales intactos sin
  tocarlos.
- El caso FUNDACIONAL cierra: sidebar declaraba Escape→close-sub en
  menu-sub y lo ejecutaba en menu-item con firma-workaround
  (sidebar-provider:757-762 — «the shared runtime-level actions map
  cannot know which sub fired»). El handler vive ahora en la
  registración de menu-sub (guardas idénticas) y el item pasó de
  EJECUTAR a DESPACHAR (partInstance('menu-sub', el).keydown(event)).
  El morfo ya declaraba la verdad; ahora el mecanismo la ejecuta.
- AMBIGUO decidido: el keydown del handle NO se construye (sin
  consumidor hoy — nombrado). AMBIGUO nombrado sin corregir: con
  ActionNameOf=never el mapped type es {} y acepta cualquier clave — la
  trampa que DeclaredSourceMap mata; idéntica en el actions de runtime
  preexistente; cerrarla toca ambos niveles (bolsa del autor).

PIEZA 2 — compile recoge las deps de las condiciones de keyboard[]
(deuda nombrada del eje SourceMap): collectConditionDeps sobre los
KeyboardPlans en walkParts. MEDIDO antes→después: 496 entradas, 56
condicionadas, filas sin deps 6→0 — carousel.orientation,
float-panel.draggable/.resizable, search-field.clearOnEscape,
tabs.activationMode, textarea.submitOnEnter ganan su prop en deps.
Alcance recortado EN VOZ ALTA a nivel PARTE: requiredSources es la
adjudicación del censo de fuentes (fichero del otro carril, vetado) —
la asimetría queda CLAVADA en compile.test («leaves requiredSources to
the census»). Cero efecto runtime hoy: corrige el CONTRATO que leen los
instrumentos; la unión AST del censo queda redundante y su retirada
viaja con el otro carril.

Tests: 5 nuevos, CERO re-firmados (nada pineaba el workaround — la
suite de sidebar no tenía ni un Escape): per-registration gana ·
fallback por ACCIÓN no por registración · dos instancias repetidas
resuelven cada una la suya (con la aserción en voz alta de por qué el
ancla existe) · sidebar Escape flotante/en-flujo con providers REALES ·
las deps de condición recogidas + la asimetría clavada.

morfo+runtime 322/322 · sidebar 10/10 · server soma 739/739 · check 71
limpio de ficheros tocados. Constructor Opus 5, sesión coordinando y
verificando.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent 39f7dd1ed3
commit 3a394c1d4f

@ -12,6 +12,7 @@ import { toastMorfo } from './components/toast'
import { accordionMorfo } from './components/accordion'
import { buttonMorfo } from './components/button'
import { fieldMorfo } from './components/field'
import { tabsMorfo } from './components/tabs'
import { prewriteFixtureMorfo } from './test-fixtures'
describe('compileMorfo — parts', () => {
@ -423,6 +424,57 @@ describe('compileMorfo — keyboard plans', () => {
expect(provider.keyboard.find((k) => k.raw === 'Escape')!.kind).toBe('spec')
expect(provider.keyboard.find((k) => k.raw === '0-9')!.kind).toBe('display')
})
it('collects the deps of a keyboard CONDITION into the part (2026-08-27)', () => {
// `runtime.keydown` evaluates the condition with `shouldEmitMorfoEntry`
// against the part's bindings, exactly as an attr plan does — but the walk
// used to keep the condition and collect none of its deps, so a prop read
// ONLY by a key was invisible to the compiled contract.
const morfo = {
name: 'Keys',
kebab: 'keys-cond',
scope: ['soma'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['on'],
data: [],
aria: [],
keyboard: [
{
key: 'Enter',
action: 'commit',
condition: { when: 'prop-equals', prop: 'activationMode', value: 'manual' }
},
{ key: 'Escape', action: 'close', condition: { when: 'state-equals', state: 'on' } },
{ key: 'Tab', action: 'next', condition: { when: 'part-present', part: 'provider' } },
// Unconditional entries contribute nothing.
{ key: 'Home', action: 'first' }
]
}
]
} as never
const compiled = compileMorfo(morfo)
const provider = compiled.parts.byKebab.get('provider')!
expect(provider.deps.props).toEqual(['activationMode'])
expect(provider.deps.states).toEqual(['on'])
expect(provider.deps.parts).toEqual(['provider'])
})
it('leaves requiredSources to the census — the keyboard half is part-level only', () => {
// The deliberate asymmetry of the 2026-08-27 wave: the component-level
// contract still does NOT list what only a key reads (tabs
// `activationMode` is the live case), because `source-census.test.ts`
// subtracts that channel from its calibration and owns the adjudication.
const trigger = compileMorfo(tabsMorfo).parts.byKebab.get('trigger')!
expect(trigger.deps.props).toContain('activationMode')
expect(compileMorfo(tabsMorfo).contracts.requiredSources.props).not.toContain('activationMode')
})
})
describe('compileMorfo — actions', () => {

@ -520,7 +520,24 @@ function walkParts(
const keyboardPlans: KeyboardPlan[] = []
if (part.keyboard) {
for (const kb of part.keyboard) {
keyboardPlans.push(compileKeyboard(kb))
const plan = compileKeyboard(kb)
// A keyboard condition is READ at dispatch: `runtime.keydown`
// evaluates it with `shouldEmitMorfoEntry` against the very bindings
// the attr plans use. Until 2026-08-27 the compiler kept the
// condition and collected none of its deps, so a prop consumed ONLY
// by a key (tabs `activationMode`, float-panel `draggable` /
// `resizable`, search-field `clearOnEscape`, textarea
// `submitOnEnter`) was invisible to the compiled contract and read
// as an orphan registration.
//
// PART deps only. The component-level `contracts.requiredSources` is
// the OTHER half: `morfo/source-census.test.ts` subtracts the
// keyboard channel from its calibration precisely because the
// compiled contract does not model it, and moving those five props
// into `requiredSources` is that census' adjudication, not this
// walk's. Named, not dropped.
collectConditionDeps(plan.condition, partStates, partProps, partRefs)
keyboardPlans.push(plan)
}
}

@ -7,7 +7,7 @@ import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { FloatingProvider } from '$soma/layers/floating';
import { FloatingContent, FloatingProvider } from '$soma/layers/floating';
import {
createSomaRuntime,
type EventEngineEmitter,
@ -18,6 +18,7 @@ import {
SidebarMenuButtonProvider,
SidebarMenuItemProvider,
SidebarMenuSubButtonProvider,
SidebarMenuSubProvider,
SidebarProvider,
SidebarRailProvider,
SidebarTriggerProvider
@ -90,8 +91,18 @@ function installSomaHarness() {
});
vi.spyOn(SidebarMenuItemProvider.ctx, 'getOr').mockImplementation((fallback) => item ?? fallback);
// The item runs a floating shell for its flyout; its layer context is a
// component context too.
vi.spyOn(FloatingProvider.ctx, 'set').mockImplementation((value) => value);
// component context too — and the sub reads it back to build its
// `FloatingContent`, so `get` must answer with what `set` stored.
let floatingProvider: FloatingProvider | undefined;
vi.spyOn(FloatingProvider.ctx, 'set').mockImplementation((value) => {
floatingProvider = value;
return value;
});
vi.spyOn(FloatingProvider.ctx, 'get').mockImplementation(() => {
if (!floatingProvider) throw new Error('no FloatingProvider in scope');
return floatingProvider;
});
vi.spyOn(FloatingContent.ctx, 'set').mockImplementation((value) => value);
return { dom, eventEngine };
}
@ -357,3 +368,88 @@ describe('SidebarMenuItemProvider — the icon-mode flyout', () => {
dom.dispose();
});
});
describe('SidebarMenuSubProvider — Escape, declared on menu-sub and executed there', () => {
/**
* The founding case of «actions BY REGISTRATION» (2026-08-27). The morfo
* declares `Escape → close-sub` on `menu-sub`, a REPEATED part; before the
* runtime could resolve a handler per registration the component had to run
* the key from the item, because the shared runtime-level `actions` map
* «cannot know which sub fired». The item still LISTENS (focus sits on the
* row, not on the `<ul>`) and now dispatches into the instance it owns.
*
* Note the sidebar's runtime registers NO runtime-level `actions` at all
* (see `SidebarProvider`'s `soma.runtime(...)` call): the only path that can
* close this flyout is the per-registration handler.
*/
function mountItemWithRealSub(shell: SidebarProvider, itemEl: HTMLElement, subEl: HTMLElement) {
const item = SidebarMenuItemProvider.create(partOpts(itemEl, 'item'));
const sub = SidebarMenuSubProvider.create(partOpts(subEl, 'sub'));
// `onRefChange` fires from a Svelte ATTACHMENT, which a provider unit test
// has no template to run — the refs are handed over by hand, as the
// flyout suite above already does.
item.subRef.current = subEl;
return { item, sub, shell };
}
it('while floating: Escape closes the flyout and returns focus to the row', async () => {
const { dom, eventEngine } = installSomaHarness();
const opts = shellOpts({ open: false, collapsible: 'icon' });
const itemEl = el('li', 'item');
const subEl = el('ul', 'sub');
const rowEl = el('button', 'row');
const { result, cleanup } = withEffectRoot(() => {
const shell = SidebarProvider.create(opts);
return mountItemWithRealSub(shell, itemEl, subEl);
});
await tick();
result.item.buttonRef.current = rowEl;
result.item.openSub();
await flushTriggers();
expect(result.item.subOpen).toBe(true);
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
const stopPropagation = vi.spyOn(escape, 'stopPropagation');
(result.item.props.onkeydown as (e: KeyboardEvent) => void)(escape);
await flushTriggers();
expect(result.item.subOpen).toBe(false);
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(rowEl);
expect(emitted(eventEngine)).toEqual([
{ name: 'emerge-open-sub', family: 'emerge', target: subEl },
{ name: 'emerge-close-sub', family: 'emerge', target: subEl }
]);
cleanup();
dom.dispose();
});
it('in flow (panel expanded) there is nothing to dismiss, so Escape passes through', async () => {
const { dom, eventEngine } = installSomaHarness();
const opts = shellOpts({ open: true, collapsible: 'icon' });
const itemEl = el('li', 'item');
const subEl = el('ul', 'sub');
const { result, cleanup } = withEffectRoot(() => {
const shell = SidebarProvider.create(opts);
return mountItemWithRealSub(shell, itemEl, subEl);
});
await tick();
expect(result.item.floating).toBe(false);
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
const stopPropagation = vi.spyOn(escape, 'stopPropagation');
(result.item.props.onkeydown as (e: KeyboardEvent) => void)(escape);
await flushTriggers();
expect(result.item.subOpen).toBe(true);
expect(stopPropagation).not.toHaveBeenCalled();
expect(eventEngine.emit).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
});

@ -549,11 +549,12 @@ export class SidebarMenuItemProvider {
this.closeSub();
},
// Escape has to work from wherever focus IS — normally the row button,
// not the sub — so it is handled at the item, which contains both.
// not the sub — so the item LISTENS, and the sub the item owns DECIDES:
// the morfo declares the key on `menu-sub` and the handler is registered
// there, resolved by the same registry identity as `emitSub`.
onkeydown: (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !this.floating || !this.subOpenState.current) return;
event.stopPropagation();
this.closeSub({ returnFocus: true });
const el = this.subRef.current;
if (el) this.provider.runtime.partInstance('menu-sub', el)?.keydown(event);
}
}));
}
@ -703,6 +704,18 @@ export class SidebarMenuSubProvider {
ref: opts.ref,
states: { open: () => this.open },
props: { floating: () => this.isFloating },
// The morfo declares `Escape → close-sub` HERE, and here the handler
// lives: per REGISTRATION, so the sub that dispatched is the sub that
// closes. Focus normally sits on the ROW, so the keydown arrives from
// the item — anchored on this instance, never on «the newest menu-sub».
// Only meaningful while floating: in flow there is nothing to dismiss.
actions: {
'close-sub': (event) => {
if (!this.isFloating || !this.open) return;
event.stopPropagation();
this.item?.closeSub({ returnFocus: true });
}
},
onRefChange: (el) => {
if (this.item) this.item.subRef.current = el;
}
@ -754,11 +767,10 @@ export class SidebarMenuSubProvider {
readonly props = $derived.by(() => ({
...this.runtimePart.props,
...(this.isFloating && this.floating ? this.floating.props : {})
// The morfo declares `Escape → close-sub` on this part, and the item
// handles it for the whole subtree (focus normally sits on the ROW, not
// here): runtime keyboard PLANS resolve their handler from the shared
// runtime-level `actions` map, which cannot know which sub fired, and a
// handler bound only to this `<ul>` would never see the row's keydown.
// No `onkeydown` here: focus normally sits on the ROW, not on this
// `<ul>`, so a handler bound to this element would never see the press.
// The item LISTENS and dispatches into this instance; the `close-sub`
// handler itself is registered on this part (see the constructor).
// Leaving / entering is likewise owned by the item, which contains both.
}));
}

@ -25,6 +25,7 @@ import { progressMorfo } from '@/uix/morfo/components/progress';
import { drawerMorfo } from '@/uix/morfo/components/drawer';
import { popoverMorfo } from '@/uix/morfo/components/popover';
import { floatPanelMorfo } from '@/uix/morfo/components/float-panel';
import { sidebarMorfo } from '@/uix/morfo/components/sidebar';
import { createSomaRuntime, type EventEngineEmitter } from './runtime.svelte';
import { mergeProps } from './props';
@ -1435,6 +1436,97 @@ describe('runtime.keydown', () => {
cleanup();
});
// ── actions BY REGISTRATION (2026-08-27) ──────────────────────────────────
describe('per-registration actions', () => {
it('a handler registered on the part WINS over the runtime-level map', () => {
const shared = vi.fn();
const mine = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(switchMorfo, {
dom,
dir: state<Direction | undefined>(undefined),
actions: { toggle: shared }
});
r.part('provider', { id: state('sw-1'), actions: { toggle: mine } });
return r;
});
expect(result.keydown('provider', keyEvent('Enter'))).toBe(true);
expect(mine).toHaveBeenCalledTimes(1);
expect(shared).not.toHaveBeenCalled();
cleanup();
});
it('the fallback is per ACTION, not per registration — both tiers answer at once', () => {
// dialog content declares Tab → focus-next and Shift+Tab → focus-prev.
// Registering ONE of them per-part must not orphan the other.
const own = vi.fn();
const shared = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(dialogMorfo, {
dom,
actions: { 'focus-prev': shared, close: vi.fn() }
});
r.part('content', { id: state('d-1'), actions: { 'focus-next': own } });
return r;
});
expect(result.keydown('content', keyEvent('Tab'))).toBe(true);
expect(own).toHaveBeenCalledTimes(1);
expect(result.keydown('content', keyEvent('Tab', { shift: true }))).toBe(true);
expect(shared).toHaveBeenCalledTimes(1);
cleanup();
});
it('two instances of a REPEATED part each resolve their OWN handler', () => {
// The founding case, in its own morfo: sidebar declares `Escape →
// close-sub` on `menu-sub`, one instance per row. Under the shared
// runtime-level map every row's Escape ran the same handler, which
// «cannot know which sub fired» — the workaround this replaces.
const subA = document.createElement('ul');
const subB = document.createElement('ul');
document.body.append(subA, subB);
const closeA = vi.fn();
const closeB = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(sidebarMorfo, {
dom,
dir: state<Direction | undefined>(undefined)
});
r.part('menu-sub', {
id: state('sub-a'),
ref: state<HTMLElement | null>(subA),
actions: { 'close-sub': closeA }
});
r.part('menu-sub', {
id: state('sub-b'),
ref: state<HTMLElement | null>(subB),
actions: { 'close-sub': closeB }
});
return r;
});
flushSync();
expect(result.partInstance('menu-sub', subA)?.keydown(keyEvent('Escape'))).toBe(true);
expect(closeA).toHaveBeenCalledTimes(1);
expect(closeB).not.toHaveBeenCalled();
expect(result.partInstance('menu-sub', subB)?.keydown(keyEvent('Escape'))).toBe(true);
expect(closeB).toHaveBeenCalledTimes(1);
expect(closeA).toHaveBeenCalledTimes(1);
// And the un-anchored dispatch keeps answering «the newest live
// instance» — said out loud, because it is exactly why the anchor
// exists: a caller that knows which instance fired must name it.
result.keydown('menu-sub', keyEvent('Escape'));
expect(closeB).toHaveBeenCalledTimes(2);
expect(closeA).toHaveBeenCalledTimes(1);
cleanup();
});
});
// ── fable audit residue, closed 2026-08-10 ───────────────────────────────
describe('trigger contract fixes (fable S5 · SO2 · SO4)', () => {
function harness(morfo: Parameters<typeof createSomaRuntime>[0], reducedMotion = false) {

@ -325,12 +325,14 @@ export type SomaRuntimeSources<M extends Morfo = Morfo> = SomaRuntimeBaseSources
* Per-part sources, keyed by the same declaration as the component level
* (2026-08-27). Deliberately keyed by the COMPONENT's union and NOT by
* `CompiledPart.deps` of this part: the component union is enough to kill a
* typo, which is what the crack was about, and `deps` is still INCOMPLETE —
* `compile.ts` never runs `collectConditionDeps` over a `keyboard[]` plan, so
* five live registrations (tabs `activationMode`, float-panel
* `draggable`/`resizable`, search-field `clearOnEscape`, textarea
* `submitOnEnter`) are legitimate and invisible to the compiled contract. A
* per-part type built on `deps` today would reject them.
* typo, which is what the crack was about. Tightening the key to this part's
* own `deps` is a SHARPER gate (it would also catch a key no plan of this part
* reads) and a separate decision — the census owns that adjudication, 79 rows
* of it. What is no longer a reason is the one this note used to give: the
* keyboard hole — `compile.ts` never running `collectConditionDeps` over a
* `keyboard[]` plan, which made five live registrations invisible — is closed
* at PART level since 2026-08-27. `contracts.requiredSources` still omits
* those five props; that half is the census' too.
*/
export interface SomaRuntimePartBaseOpts<M extends Morfo = Morfo> {
/** Per-instance id of this part (from the part-provider's opts). */
@ -345,6 +347,25 @@ export interface SomaRuntimePartBaseOpts<M extends Morfo = Morfo> {
props?: SourceMap;
/** Per-part part-id sources (override component-level for this part only). */
parts?: PartSourceMap<M>;
/**
* Per-REGISTRATION keyboard action handlers, dispatched by `keydown` before
* the runtime-level `sources.actions` map (2026-08-27).
*
* WHY the runtime-level map alone was not enough, in the words of the
* workaround it retires (sidebar-provider, retired with this): «runtime
* keyboard PLANS resolve their handler from the shared runtime-level
* `actions` map, which cannot know which sub fired». A morfo declares
* `Escape → close-sub` on `menu-sub`; `menu-sub` is a REPEATED part, so
* every row's sub-menu shared one handler and the component had to move the
* behaviour to a part the morfo never declared it on. The declaration was
* right and the mechanism could not execute it.
*
* Keyed by the COMPONENT's action union for the same reason the source
* buckets above are (`ActionNameOf` spans every part); WHICH part may fire
* an action is already decided by the morfo's `keyboard[]`, since `keydown`
* only ever scans the dispatching part's own plans.
*/
actions?: { [K in ActionNameOf<M>]?: KeyboardActionHandler };
}
export interface SomaRuntimePartContext<T = unknown> {
@ -404,6 +425,17 @@ export interface SomaRuntimePart<M extends Morfo, K extends PartKebabOf<M>> {
export interface SomaPartAnchor<M extends Morfo, K extends PartKebabOf<M>> {
readonly el: HTMLElement;
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
/**
* Dispatch a keyboard event against THIS instance's `morfo.keyboard` — its
* own `actions` registration, its own bindings for the plan conditions.
* The un-anchored `runtime.keydown(part, event)` resolves the part through
* `liveReg`, which for a repeated part answers «the newest attached», not
* «the one that fired»: the sidebar's Escape lands on a row whose sub-menu
* is a sibling of the pressed row's, and no map keyed by action name can
* tell them apart. The identity is the registry's, exactly as for
* `trigger`.
*/
keydown(event: KeyboardEvent): boolean;
}
/**
@ -446,8 +478,14 @@ export interface SomaRuntime<M extends Morfo> {
*
* Matches `event.key` plus modifier flags against each entry's `key` spec
* (supports `"Shift+Tab"`, `"Ctrl+A"`, etc.). On the first match, looks up
* the corresponding handler in `sources.actions[entry.action]` and calls
* it with the original event.
* the corresponding handler — FIRST on the registration that answers for
* the part (`runtime.part(part, { actions })`), then on the runtime-level
* `sources.actions[entry.action]` — and calls it with the original event.
*
* The registration this resolves through is `liveReg(part)`: correct for a
* singleton, «the newest attached» for a repeated part. When the caller
* knows WHICH instance fired, dispatch through its anchor instead
* (`partInstance(part, el).keydown(event)`).
*
* Returns `true` if a handler ran, `false` otherwise — the consumer can
* use the result to decide whether to fall through to native behavior.
@ -619,6 +657,7 @@ interface PartRegistration {
states: SourceMap | undefined;
props: SourceMap | undefined;
parts: SourceMap | undefined;
actions: Partial<Record<string, KeyboardActionHandler>> | undefined;
bindings: RuntimeBindingsScratch;
/** Compiled part metadata so `partProps` doesn't re-walk the morfo tree. */
meta: CompiledPart;
@ -785,6 +824,7 @@ export function createSomaRuntime<M extends Morfo>(
states: opts.states,
props: opts.props,
parts: opts.parts,
actions: opts.actions,
bindings: createBindingsScratch(opts, boundSources, compiledPart.deps.parts.length > 0),
meta: compiledPart
};
@ -874,6 +914,9 @@ export function createSomaRuntime<M extends Morfo>(
el,
trigger(eventName: string, opts: TriggerOptions = {}) {
return trigger(eventName, opts, reg);
},
keydown(event: KeyboardEvent) {
return keydown(partName, event, reg);
}
};
}
@ -955,18 +998,25 @@ export function createSomaRuntime<M extends Morfo>(
return true;
}
function keydown(part: string, event: KeyboardEvent): boolean {
function keydown(part: string, event: KeyboardEvent, anchor?: PartRegistration): boolean {
const compiledPart = compiled.parts.byKebab.get(part);
if (!compiledPart || compiledPart.keyboard.length === 0) return false;
for (const plan of compiledPart.keyboard) {
if (!matchesParsedKey(plan.key, event)) continue;
// Skip entries whose condition is currently falsy.
const reg = liveReg(part);
// Skip entries whose condition is currently falsy. The ANCHORED
// registration answers for both halves of the dispatch — the condition's
// bindings and the handler — so a repeated part evaluates ITS state, not
// the newest sibling's.
const reg = anchor ?? liveReg(part);
const bindings = reg ? readBindings(reg, boundSources, resolveLivePartId) : EMPTY_BINDINGS;
if (!shouldEmitMorfoEntry(plan.condition, bindings)) continue;
const handler = actionHandlers?.[plan.action];
// Per-REGISTRATION first, runtime-level second: the instance that
// dispatches is the one that knows which occurrence this is, and a
// provider that registered no per-part handler keeps the shared map it
// always had.
const handler = reg?.actions?.[plan.action] ?? actionHandlers?.[plan.action];
// No handler registered for THIS plan — keep scanning: another plan
// on the same key (different condition / action) may still handle it
// (SOM-2, clean-room 2026-07-10: `return false` here aborted the

Loading…
Cancel
Save

Powered by TurnKey Linux.