feat(soma)!: fase C2a de P0 — la bolsa de render ES el contrato

El crítico-rebajado-a-mayor confirmado por el adversarial: 42 providers en
la tubería `syncAttrs` servían HTML sin role, sin aria-*, sin data-state y
sin type="button" (un Toggle dentro de un form era type="submit" — un clic
pre-hidratación lo enviaba). Dos tuberías de attrs coexistían sin regla:
el efecto cliente (`dom.apply`) y `renderProps()`.

`partPropsForRegistration` resuelve ahora el contrato completo: statics
(role, literales) + todo plan dinámico no-consumerWins, y los naming
(consumerWins, A-85) al final como siempre. Consecuencias:

- SSR lleva el contrato: los 42 providers lo heredan sin tocarlos (todos
  spreadean `.props`).
- La reactividad es de render: readBindings lee fuentes rune-backed, el
  effect queda como re-aserción idempotente hasta C2c.
- `renderProps()` pasa a devolver la misma bolsa — queda como alias hasta
  que sus ~70 call sites migren a `.props` (C2b) y se borre, sin shim.
- El pin viejo («partProps sin attrs mutables») afirmaba el contrato
  contrario y se re-firma; nace ssr-contract.test.ts (server project, sin
  ventana) con harness de composición real — rojo antes del pliegue por
  los 5 attrs, verde después.

Verificación: ssr-contract 6/6 · runtime.svelte.test 50/50 ·
soma-attr-audit verde · smoke 323/323 (hidratación limpia) · morfo:check
y perm-check re-corridos (los rojos restantes son fase A adjudicada, no
regresión de esto).

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

@ -56,7 +56,13 @@ describe('createSomaRuntime', () => {
document.body.innerHTML = '';
});
it('partProps returns identity (id + marker) without mutable attrs', () => {
it('partProps carries the full morfo contract, statics and resolved dynamics included', () => {
// Re-signed for the fase-C unification (P0, audit 2026-08-26). The old
// pin asserted the OPPOSITE — identity only, mutable attrs excluded —
// which is what left 42 providers server-rendering with no role /
// aria-* / data-state / type="button". The render bag is now the single
// attr pipeline: it must resolve the contract, so SSR carries it and
// Svelte's reactivity re-renders it.
const { result: runtime, cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const ref = state<HTMLElement | null>(null);
@ -71,9 +77,8 @@ describe('createSomaRuntime', () => {
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();
expect(props['data-state']).toBe('off');
expect(props['aria-pressed']).toBe('false');
cleanup();
});
@ -851,7 +856,9 @@ describe('runtime.trigger', () => {
it('propagates a numeric hold from a morfo event to the signal', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) => (e.name === 'signal-announce' ? { ...e, hold: 1500 } : e))
events: toastMorfo.events.map((e) =>
e.name === 'signal-announce' ? { ...e, hold: 1500 } : e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
@ -1335,7 +1342,14 @@ describe('runtime.keydown', () => {
// ── fable audit residue, closed 2026-08-10 ───────────────────────────────
describe('trigger contract fixes (fable S5 · SO2 · SO4)', () => {
function harness(morfo: Parameters<typeof createSomaRuntime>[0], reducedMotion = false) {
const logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() };
const logger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn()
};
// Typed parameter: an untyped `vi.fn(async () => …)` makes
// `mock.calls[0]` a zero-length tuple, so reading the signal back is a
// type error even though the call is real.

@ -13,18 +13,24 @@
*
* Surface:
* - `part(part, opts)` — high-level part handle for concrete provider
* classes: registration, context publication, id/marker/ref props and
* contract helpers. When `syncAttrs` is true, the runtime also owns the
* part's morfo-derived DOM attrs through `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.
* classes: registration, context publication, the render bag and contract
* helpers. When `syncAttrs` is true, the runtime additionally re-asserts
* the part's morfo-derived DOM attrs through `dom.apply`.
* - `partProps(part)` — the FULL render bag (P0 fase C, audit 2026-08-26):
* static identity (id, marker, ref attachment, dir) plus the resolved morfo
* contract — `staticAttrs` (role, literals) and every dynamic plan. It is
* the single attr pipeline: the same values server-render and re-derive
* through Svelte's reactivity. Before the unification the bag carried
* identity only and the contract lived in a client-only effect, so 42
* providers server-rendered with no role / aria-* / data-state.
* - `keydown(part, event)` — dispatch from `morfo.keyboard`.
* - `trigger(eventName)` — orchestrates prewrite + eventEngine.emit + handler.
*
* Operational rules:
* - `partProps` must not include any state-derived attr — that would race
* with `dom.apply`. The boundary is identity vs. derivation.
* - The render bag and the `syncAttrs` effect resolve the SAME plans from the
* same bindings, so a double write is idempotent by construction. The
* effect survives for imperative re-assertion (prewrites, non-render
* consumers) until fase C2c retires it.
* - `part(..., { syncAttrs: true })` 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`.
@ -252,10 +258,7 @@ export interface SomaRuntimePart<M extends Morfo, K extends PartKebabOf<M>> {
* element ever travels through an untyped option. Mount-state fallback
* (`targetFallback`) applies only to the UN-anchored `runtime.trigger`.
*/
trigger(
eventName: EventNameTargeting<M, K>,
opts?: TriggerOptions
): Promise<TriggerResult>;
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
}
/**
@ -265,10 +268,7 @@ 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>;
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
}
/**
@ -689,28 +689,13 @@ export function createSomaRuntime<M extends Morfo>(
return props;
},
renderProps() {
// The full render bag for a part that composes its attrs in Svelte
// props (rather than `syncAttrs: true`): static identity + every
// morfo-declared static/dynamic attr, resolved against THIS part's
// registered sources (`opts.props/states/parts` merged with the
// component-level sources). The caller spreads this and then adds
// ONLY what the morfo can't express — event handlers, formatted
// values (override the raw morfo value), native form attrs. This is
// how "morfo declares, soma executes" holds without re-declaring
// role/aria/data in the provider.
const bindings = readBindings(reg, sources);
const props: Record<string, unknown> = {
...partPropsForRegistration(partName, reg),
...compiledPart.staticAttrs
};
for (const plan of compiledPart.dynamicAttrs) {
// Naming defaults already arrive via partPropsForRegistration
// (spread above) — the one bag both paths share.
if (plan.consumerWins) continue;
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
return props;
// Since the fase-C unification (P0, audit 2026-08-26) the render
// bag IS the full contract — `partPropsForRegistration` resolves
// static identity + every morfo-declared static/dynamic attr
// against THIS part's registered sources. `renderProps()` and
// `props` are now the same computation; this name survives until
// the ~70 call sites migrate to `props` (fase C2b), then it goes.
return partPropsForRegistration(partName, reg);
},
assert<P extends Record<string, unknown>>(props: P): P {
assertContract(compiled.kebab, partName, props, sources.logger);
@ -768,19 +753,34 @@ export function createSomaRuntime<M extends Morfo>(
if (value !== undefined) props.dir = value;
}
// Naming defaults (`consumerWins` plans — `aria-label`). Same vehicle as
// `dir` above: this is the one bag BOTH `props` and `renderProps()`
// include, and the only place the consumer's restProps can meet the
// morfo's default (`mergeProps` resolves naming attrs consumer-first).
// Writing them from the syncAttrs effect instead would overwrite the
// consumer's attr after every render — the A-85 class. Reads are
// reactive: `translate` is `langs.ts()` (rune-backed locale), so any
// $derived composing this bag re-renders the default on locale change.
let namingBindings: MorfoBindings | undefined;
// The morfo contract itself (P0 fase C, audit 2026-08-26): `staticAttrs`
// (role + unconditional literals) and every non-`consumerWins` dynamic
// plan resolve into the render bag, so the contract reaches
// server-rendered HTML and re-derives through Svelte's own reactivity —
// `readBindings` reads rune-backed sources, so any template or $derived
// spreading this bag re-renders when a state/prop source changes. Before
// this, the whole slice lived only in the client `syncAttrs` effect and
// 42 providers server-rendered with no role / aria-* / data-state /
// type="button" (a Toggle in a form defaulted to type="submit").
Object.assign(props, reg.meta.staticAttrs);
let bindings: MorfoBindings | undefined;
for (const plan of reg.meta.dynamicAttrs) {
if (plan.consumerWins) continue;
bindings ??= readBindings(reg, sources);
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
// Naming defaults (`consumerWins` plans — `aria-label`) go LAST and are
// the only plans `mergeProps` resolves consumer-first. Writing them from
// the syncAttrs effect instead would overwrite the consumer's attr after
// every render — the A-85 class. Reads are reactive: `translate` is
// `langs.ts()` (rune-backed locale), so the default re-renders on locale
// change.
for (const plan of reg.meta.dynamicAttrs) {
if (!plan.consumerWins) continue;
namingBindings ??= readBindings(reg, sources);
const value = evalAttrPlan(plan, namingBindings);
bindings ??= readBindings(reg, sources);
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}

@ -0,0 +1,54 @@
// Deliberately NO environment directive: the server project is `node`, where
// `window` does not exist — the same shape as SSR. (Never name the directive
// literally in a comment here: vitest matches the token anywhere in the file
// and would silently switch this suite to jsdom — the trap the eidos SSR
// suite documents.)
import { describe, expect, it } from 'vitest';
import { render } from 'svelte/server';
import Harness from './test/ssr-harness.svelte';
/**
* P0 fase C (audit 2026-08-26): the morfo contract must reach the
* server-rendered HTML. Before the render-bag unification, every part on the
* `syncAttrs` pipeline server-rendered with no `role`, no `aria-*`, no
* `data-state` and no `type="button"` — a Toggle inside a form defaulted to
* `type="submit"` and a pre-hydration click submitted it.
*
* Two representatives pin the shared mechanism (`partPropsForRegistration`):
* Toggle (literal attr + aria-boolean + data-state) and RadioGroup
* (role on provider and items + aria-checked). The fix lives in the runtime,
* so every provider inherits it; these assertions are the canary, not the
* census.
*/
describe('morfo contract attrs reach server-rendered HTML', () => {
it('the environment really has no window (guards the directive trap)', () => {
expect(typeof window).toBe('undefined');
});
const html = () => render(Harness, { props: {} }).body;
it('Toggle server-renders its literal type="button"', () => {
expect(html()).toContain('type="button"');
});
it('Toggle server-renders aria-pressed from its state', () => {
expect(html()).toContain('aria-pressed="true"');
});
it('Toggle server-renders data-state', () => {
expect(html()).toMatch(/data-toggle[^>]*data-state="on"/);
});
it('RadioGroup server-renders role="radiogroup"', () => {
expect(html()).toContain('role="radiogroup"');
});
it('RadioGroup items server-render role="radio" with aria-checked', () => {
const body = html();
expect(body).toContain('role="radio"');
expect(body).toContain('aria-checked="true"');
expect(body).toContain('aria-checked="false"');
});
});

@ -0,0 +1,20 @@
<script lang="ts">
// Minimal real composition (no mocks — CLAUDE.md): the same boot the demo
// layout performs, without events/prefs/eidos. On the server createActiveUix
// degrades (no dom), which is exactly the environment under test.
import { createActiveUix, setActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import * as Toggle from '$soma/components/toggle';
import * as RadioGroup from '$soma/components/radio-group';
const uix = createActiveUix({ langs: { schema: {}, defaultLocale: 'es' } });
setActiveUix(uix);
Soma.create();
</script>
<Toggle.Provider pressed={true}>Bold</Toggle.Provider>
<RadioGroup.Provider value="a">
<RadioGroup.Item value="a">A</RadioGroup.Item>
<RadioGroup.Item value="b">B</RadioGroup.Item>
</RadioGroup.Provider>
Loading…
Cancel
Save

Powered by TurnKey Linux.