ADR
Prop Configurations and Bindings
Context
PropConfigurations lives on Element and sets a nested instance’s props — e.g. fixing a nested Button’s variant to "primary". Its current type accepts scalars only:
// types/PropConfigurations.ts (today)type PropConfigurations = Record<string, string | number | boolean>;Where PropBinding is already accepted
PropBinding ({ $binding: "#/props/<name>" }, from ADR-008) lets a field’s value be forwarded from a parent prop at emission time. It is already permitted on three element-level fields:
// types/Element.ts (today — unchanged by this ADR)type Element = { content?: string | PropBinding; // ✅ binding accepted instanceOf?: string | PropBinding | SubcomponentRef; // ✅ binding accepted styles?: Styles; // (Styles.visible: boolean | PropBinding) ✅ propConfigurations?: PropConfigurations; // ❌ scalar only — gap 1 // ...};Two gaps
Gap 1 — scalar prop forwarding. A Card that exposes a label prop and forwards it to a nested Button can already express the content case, but not the propConfigurations case:
props: label: { type: string }
elements: cardTitle: content: { $binding: "#/props/label" } # ✅ already works
nestedButton: instanceOf: Button propConfigurations: label: { $binding: "#/props/label" } # ❌ rejected by current typeGap 2 — slot prop fills. SlotContentRef ({ $slotContent: string }, from ADR-046) fills a nested instance’s slot prop with named content. Element.propConfigurations has no way to express this today:
elements: actionItem: instanceOf: ActionListItem propConfigurations: startVisual: $slotContent: "#/components/actionListItem/slotContentExamples/searchIcon" # ❌ rejectedWidening PropConfigurations to accept both PropBinding and SlotContentRef closes both gaps and makes the binding and fill model uniform across all element-level value fields.
Distinction from InstanceExample.propConfigurations
ADR-048 widened InstanceExample.propConfigurations to string | number | boolean | SlotContentRef. This ADR widens Element.propConfigurations to string | number | boolean | PropBinding | SlotContentRef. The difference is PropBinding: instance examples are documented configurations for human readers and tooling — live bindings do not belong there. Element.propConfigurations is the live element tree — both bindings and slot fills are valid.
Decision Drivers
- Consistent binding model —
PropBindingis the established pattern for pass-through bindings on element-level fields;PropConfigurationsis the only one that cannot participate - Consistent slot fill model —
SlotContentRefis the established pointer for slot fills (ADR-046);Element.propConfigurations.<slotName>is where a nested instance’s slot prop is filled from a parent element - Additive at the data level — the union is widened, not replaced: every previously-valid value stays valid and no field is removed or renamed. (This is still source-breaking for consumers that narrowed the value type — see Cons and Semver Decision.)
InstanceExample.propConfigurationsstays without PropBinding — that type represents a documented configuration; live bindings belong inElement.propConfigurationsonly- Type ↔ schema symmetry — Constitution §I
- No runtime logic — Constitution §II
Options Considered
Option A: Widen PropConfigurations value union to include PropBinding and SlotContentRef (Selected)
Add PropBinding and SlotContentRef as additional branches alongside string, number, and boolean.
// Beforetype PropConfigurations = Record<string, string | number | boolean>;
// Aftertype PropConfigurations = Record<string, string | number | boolean | PropBinding | SlotContentRef>;A single propConfigurations block can mix all value forms:
props: isDisabled: { type: boolean } startVisual: { type: slot }
elements: actionItem: instanceOf: ActionListItem propConfigurations: variant: default # static scalar isDisabled: { $binding: "#/props/isDisabled" } # PropBinding — forwarded from parent startVisual: $slotContent: "#/components/actionListItem/slotContentExamples/searchIcon" # SlotContentRef — slot fillPros:
- Closes both gaps in one widening — scalar, binding, and slot fill are all expressed in the same field
- Completes the binding pattern established on
Element.content,Element.instanceOf, andStyles.visible SlotContentRefis discriminated by$slotContent;PropBindingby$binding— no ambiguity between arms- Existing scalar values remain valid — already-emitted specs still validate against the widened schema (data-level backward compatibility)
Cons / Trade-offs:
- Source-breaking for typed consumers. Widening the value union breaks consumers that narrowed
propConfigurationsvalues to a single shape (e.g.value as number, or a non-exhaustiveswitch/if): they will mishandle — or fail to type-check against — the newPropBindingandSlotContentRefarms until updated to handle all four value shapes. Already-emitted data stays schema-valid, but consumer code is not automatically forward-compatible.
Option B: Separate propBindings field (Rejected)
Add a sibling field on Element for bindings; leave propConfigurations scalar-only.
nestedButton: propConfigurations: variant: primary propBindings: disabled: { $binding: "#/props/disabled" }Rejected because:
- Two fields keyed by the same prop name invites collisions (
propConfigurations.disabledandpropBindings.disabled) with no obvious precedence rule. - Inconsistent with
Element.content,Element.instanceOf, andStyles.visible, which each carry thescalar | PropBindingunion inline. - Consumers must read and merge two fields to know “what is this prop set to?” — Option A keeps the answer in one place.
Option C: String sentinel syntax (Rejected)
Encode bindings as magic strings, keeping the value union flat.
propConfigurations: variant: primary disabled: "$binding:#/props/disabled"Rejected because:
- Ambiguous with legitimate string values — a prop whose static value starts with
$binding:is indistinguishable from a binding. - Diverges from ADR-008. Every other binding site uses
{ $binding: string }; a second encoding only forpropConfigurationsfractures the model. - JSON Schema cannot validate the pointer payload of a sentinel string without a custom format; the object form gets
$ref: "#/definitions/PropBinding"validation for free.
Option D: Invert direction — declare forwarding on the parent prop (Rejected)
Express the pass-through relationship from the parent prop’s side.
interface StringProp { type: 'string'; forwardsTo?: string[];}Rejected because:
- Inverts natural locality. A reader inspecting
nestedButtonmust scan every parent prop’sforwardsTolist. - Inconsistent with the existing
PropBindingmodel oncontent,instanceOf, andstyles.visible— all declared at the consumption site. - A single parent prop forwarded to multiple targets becomes a list of pointers — harder to author and validate.
Option E: Defer — treat as a consumer concern (Rejected)
Leave PropConfigurations scalar-only; document that consumers should infer pass-through by matching prop names.
Rejected because:
- Name-matching is a heuristic, not a contract. Two props can share a name without a forwarding relationship; a forwarding relationship can exist between differently-named props.
- The schema already commits to explicit bindings for
content,instanceOf, andstyles.visible. LeavingpropConfigurationsas the exception forces consumers to support two reasoning modes.
Decision
Type changes (types/)
| File | Change | Bump |
|---|---|---|
PropConfigurations.ts | Widen value union to include PropBinding and SlotContentRef | MINOR |
Updated type (types/PropConfigurations.ts):
import type { PropBinding } from './PropBinding.js';import type { SlotContentRef } from './SlotContentRef.js';
type PropConfigurations = Record<string, string | number | boolean | PropBinding | SlotContentRef>;Schema changes (schema/)
| File | Change | Bump |
|---|---|---|
component.schema.json | Update #/definitions/PropConfigurations additionalProperties to add PropBinding and SlotContentRef branches | MINOR |
Updated definition (#/definitions/PropConfigurations):
# BeforePropConfigurations: type: object additionalProperties: oneOf: - type: string - type: number - type: boolean
# AfterPropConfigurations: type: object additionalProperties: oneOf: - type: string - type: number - type: boolean - $ref: "#/definitions/PropBinding" - $ref: "#/definitions/SlotContentRef"Out of scope for this ADR
- Slot prop pass-through via
PropBinding— forwarding a parent’s slot prop to a nested instance’s slot prop (startVisual: { $binding: "#/props/mySlot" }) is a distinct mechanism from filling a slot with content. The structural and semantic questions around slot prop forwarding are deferred to a follow-on ADR. InstanceExample.propConfigurations— acceptsstring | number | boolean | SlotContentRefper ADR-048;PropBindingis not accepted there by design.
Notes
- Discrimination.
PropBindingis discriminated by$binding;SlotContentRefby$slotContent; scalars by JSON primitive type. TheoneOfbranches are structurally unambiguous. PropBindingpath convention.{ $binding: "#/props/<name>" }— same JSON Pointer convention used onElement.contentandStyles.visible(ADR-008). Points at the parent component’s prop, not at a nested instance’s prop.SlotContentRefpath convention.{ $slotContent: "<JSON Pointer>" }— same pointer and resolution rules as ADR-046. Points at aSlotContentorCompositionentry. The path makes the registry scope explicit (#/components/<name>/slotContentExamples/<key>for component-scoped;#/compositions/<key>for system-scoped).InstanceExamplevsElement.InstanceExample.propConfigurations(ADR-048) is documented configuration —SlotContentRefaccepted,PropBindingnot.Element.propConfigurations(this ADR) is the live element tree — both accepted.
Type ↔ Schema Impact
- Symmetric: Yes
- Parity check:
PropConfigurationsvalue unionstring | number | boolean | PropBinding | SlotContentRef↔additionalProperties.oneOf(five branches: three scalar,$ref PropBinding,$ref SlotContentRef)
Downstream Impact
| Consumer | Impact | Action required |
|---|---|---|
specs-from-figma | Must emit PropBinding values where a nested prop is bound to a parent prop; must emit SlotContentRef values where a nested slot prop is filled | Handle PropBinding and SlotContentRef branches when processing element prop configurations |
specs-cli | Recompile; no output change until specs-from-figma emits the new value forms | Recompile |
specs-plugin-2 | Recompile | No change |
Semver Decision
Version bump: 0.20.0 → 0.21.0 (MINOR)
Justification: The PropConfigurations value union is widened — no field is removed, renamed, or narrowed, and every previously-valid value (and already-emitted spec) stays valid. Widening is nonetheless source-breaking for consumers that narrowed the value type (see Cons): a strict reading of Constitution §III (“MAJOR for any breaking change to a type signature”) points to MAJOR. The MINOR classification rests on the pre-1.0.0 convention (semver §4 — anything may change within 0.y.z), under which this breaking change ships as the 0.20.0 → 0.21.0 bump; consumers must still update their value handling. (Open for review: confirm MINOR under the pre-1.0 convention vs. MAJOR per a literal §III reading.)
Consequences
Element.propConfigurationscan express all three value forms in a single field: static scalar values, pass-through bindings to parent props, and slot fills via named content references- The binding pattern established by ADR-008 (
PropBinding) is now uniformly available across all element-level value fields:content,instanceOf,styles.visible, andpropConfigurations SlotContentReffrom ADR-046 is available at everypropConfigurations.<slotName>call site — slot props are first-class prop values, not a separate authoring channelInstanceExample.propConfigurations(ADR-048) is not affected — it acceptsSlotContentRefbut notPropBindingby design; live bindings belong in the element tree, not in documented examples- Slot prop pass-through (
PropBindingon a slot prop) remains deferred; this ADR covers scalar prop forwarding and slot content fills only