feat(soma): morfo-sourced part props — renderProps() + prop-defined (pilot)

Foundation for removing the morfo↔soma attribute duplication: providers that
compose attrs in their `props` getter were re-declaring role/aria/data that the
morfo already declares (a second source that drifts — the spin-field bug).

- runtime: new `SomaRuntimePart.renderProps()` — static identity + every
  morfo-declared static/dynamic attr, resolved against THIS part's registered
  sources. A part's getter spreads it and adds ONLY soma-specific extras
  (handlers, formatted values, native attrs), instead of hardcoding role/aria.
- morfo: new `prop-defined` condition (`emitted iff prop !== undefined`) so an
  optional numeric aria (aria-valuemin at min=0) emits correctly — `prop-truthy`
  wrongly dropped 0. Wired through types, resolver, schema, compile dep-collect.
- NumberField Input migrated as the reference: registers value/min/max as part
  sources, spreads `renderProps()`, keeps only soma extras. Removes the
  hardcoded role + aria-valuenow/min/max + data-spin-field-input duplication.
  ARIA booleans (aria-required/disabled/…) stay soma overrides for now — a
  propRef-valued aria compiles to raw mode, so soma still stringifies them; a
  future `v.ariaBool()` helper would let those resolve from the morfo too.
- COMPONENT_GUIDE: "Part props: read the morfo, don't re-declare it" doctrine.

Verified bit-for-bit in browser (role/aria/data identical incl. min=0 →
aria-valuemin="0") + provider tests 7/7. The survey found ~⅔ of components carry
this duplication (form controls 70%); this lands the pattern + the NumberField
Input reference. Family rollout is the documented backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 3c059ff087
commit 59a851850c

@ -670,7 +670,11 @@ function collectConditionDeps(
): void {
if (condition === 'always') return
if (condition.when === 'state-equals') states.add(condition.state)
else if (condition.when === 'prop-truthy' || condition.when === 'prop-falsy')
else if (
condition.when === 'prop-truthy' ||
condition.when === 'prop-falsy' ||
condition.when === 'prop-defined'
)
props.add(condition.prop)
else if (condition.when === 'part-present') partRefs.add(condition.part)
}

@ -83,13 +83,13 @@ export const numberFieldMorfo = {
attr: 'aria-valuemin',
value: v.propRef('min'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'min' }
condition: { when: 'prop-defined', prop: 'min' }
},
{
attr: 'aria-valuemax',
value: v.propRef('max'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'max' }
condition: { when: 'prop-defined', prop: 'max' }
},
{ attr: 'aria-valuetext', value: v.propRef('value'), severity: 'recommended' },
{ attr: 'aria-required', value: v.propRef('required'), severity: 'optional' },

@ -45,6 +45,7 @@ export function shouldEmitMorfoEntry(
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];
if (condition.when === 'prop-defined') return bindings.props?.[condition.prop] !== undefined;
return true;
}

@ -139,7 +139,8 @@ const conditionObjectSchema = discriminated('when', [
value: string()
}),
object({ when: literal('prop-truthy'), prop: string() }),
object({ when: literal('prop-falsy'), prop: string() })
object({ when: literal('prop-falsy'), prop: string() }),
object({ when: literal('prop-defined'), prop: string() })
]);
const conditionSchema = union(literal('always'), conditionObjectSchema) as Schema<

@ -154,13 +154,16 @@ export type MorfoDataValue = MorfoValueSource;
* `{ when: 'state-equals', state, value }` — emitted iff state === value
* `{ when: 'prop-truthy', prop }` — emitted iff consumer prop is truthy
* `{ when: 'prop-falsy', prop }` — emitted iff consumer prop is falsy
* `{ when: 'prop-defined', prop }` — emitted iff consumer prop !== undefined
* (e.g. aria-valuemin when min is 0)
*/
export type MorfoCondition =
| 'always'
| { when: 'part-present'; part: string }
| { when: 'state-equals'; state: string; value: string }
| { when: 'prop-truthy'; prop: string }
| { when: 'prop-falsy'; prop: string };
| { when: 'prop-falsy'; prop: string }
| { when: 'prop-defined'; prop: string };
// ── Severity ──────────────────────────────────────────────────────────────

@ -158,6 +158,43 @@ export class {Name}TriggerProvider {
}
```
### Part props: read the morfo, don't re-declare it
"Morfo declares, soma executes" — a part's `role` / `aria-*` / `data-*` live in
the morfo. A provider must NEVER re-declare them as literals in its `props`
getter (that is duplication: the same attr in two sources, which drift). Two
sanctioned ways to apply them:
- **No soma-specific extras** → `syncAttrs: true` (the runtime writes the morfo
attrs via `dom.apply`). The `props` getter is identity-only
(`...this.runtimePart.props`) plus event handlers.
- **Needs soma-specific extras** (event handlers, a locale-formatted value, a
native form attr the morfo doesn't model) → spread
**`...this.runtimePart.renderProps()`** (static identity + every morfo attr,
resolved against this part's registered `props`/`states` sources), then add
ONLY the extras. Register the value sources at the `runtime.part(...)` call:
```ts
this.runtimePart = provider.runtime.part('input', {
id, ref, owner: this,
props: { value: () => provider.value, min: () => provider.min }
});
readonly props = $derived.by(() => this.runtimePart.assert({
...this.runtimePart.renderProps(), // role, aria-valuenow/min, data-*
oninput: this.oninput, // handler (morfo can't model)
'aria-valuetext': this.formatValue(...) // formatted (overrides raw morfo)
}));
```
Override a morfo attr only when soma genuinely owns the *value* (formatting,
stringifying an ARIA boolean). Never override it just to repeat it.
> **Anti-pattern**: `{ ...this.runtimePart.props, role: 'spinbutton', 'aria-disabled': ... }`
> — `role`/`aria-disabled` are morfo-declared; spreading `renderProps()` supplies
> them. Most existing providers still do this (a documented migration backlog);
> NumberField's Input is the reference for the corrected shape.
### Static Method Convention
All classes that use Svelte context follow the same 3-method pattern:

@ -570,7 +570,16 @@ export class NumberFieldInputProvider {
this.runtimePart = this.provider.runtime.part('input', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Per-part sources for the morfo's value-bound aria (the component-level
// sources only carry disabled/readonly/required/invalid). With these,
// `renderProps()` resolves aria-valuenow/min/max from the morfo instead
// of the provider re-declaring them.
props: {
value: () => this.provider.opts.value.current,
min: () => this.provider.opts.min.current,
max: () => this.provider.opts.max.current
}
});
// When embedded inside Field, register this input id so Field.Label's
// `for=` attribute targets it.
@ -591,22 +600,28 @@ export class NumberFieldInputProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'spinbutton' as const,
// Morfo-declared attrs (role, data-spin-field-input, aria-valuenow/min/
// max/required/disabled/readonly/invalid), resolved against this part's
// registered sources — no longer re-declared in the provider.
...this.runtimePart.renderProps(),
// Soma-only: what the morfo can't express.
inputmode: 'decimal' as const,
'data-spin-field-input': '',
autocomplete: 'off' as const,
autocorrect: 'off' as const,
spellcheck: false,
dir: this.provider.dir,
value: this.provider.inputValue,
'aria-valuenow': this.provider.opts.value.current,
'aria-valuemin': this.provider.opts.min.current,
'aria-valuemax': this.provider.opts.max.current,
// Morfo declares the raw value for aria-valuetext; soma supplies the
// locale-formatted text.
'aria-valuetext':
this.provider.opts.value.current !== undefined
? this.provider.formatValue(this.provider.opts.value.current)
: undefined,
// ARIA booleans: the morfo declares them (propRef), but a propRef-valued
// aria attr compiles to raw mode → renderProps emits the boolean. ARIA
// wants the "true"/"false" string, so soma stringifies (same role as the
// formatted aria-valuetext above). A future `v.ariaBool()` morfo helper
// would let these resolve from the morfo too.
'aria-required': boolToStr(this.provider.isRequired),
'aria-disabled': boolToStr(this.provider.isDisabled),
'aria-readonly': boolToStr(this.provider.isReadonly),

@ -181,6 +181,13 @@ export interface SomaRuntimePart {
readonly props: Record<string, unknown>;
/** Resolve morfo static/dynamic attrs for render-time legacy/manual props. */
resolveProps(bindings?: MorfoBindings): Record<string, unknown>;
/**
* Static identity + every morfo-declared attr resolved against THIS part's
* registered sources. Spread this in a part's `props` getter, then add only
* the soma-specific extras (handlers, formatted overrides, native attrs).
* The canonical alternative to hardcoding role/aria/data in the provider.
*/
renderProps(): Record<string, unknown>;
/** Validate an authored prop bag against the morfo data contract. */
assert<P extends Record<string, unknown>>(props: P): P;
}
@ -482,6 +489,27 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
}
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) {
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
return props;
},
assert<P extends Record<string, unknown>>(props: P): P {
assertContract(compiled.kebab, partName, props, sources.logger);
return props;

Loading…
Cancel
Save

Powered by TurnKey Linux.