From 65f95f62d5974c4d7e9d13dd0e11c9956df848ca Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:35:19 -0400 Subject: [PATCH 01/20] =?UTF-8?q?feat(adr):=20draft=20ADR=20042=20?= =?UTF-8?q?=E2=80=94=20Composition=20as=20a=20First-Class=20Type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces Composition as a peer type to Component, covering slot defaults, component examples, layout patterns, and page views. Supersedes draft ADR-025 (nested slot API). Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 380 ++++++++++++++++++++++++++++++++++++ adr/INDEX.md | 1 + 2 files changed, 381 insertions(+) create mode 100644 adr/042-composition-type.md diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md new file mode 100644 index 0000000..3865726 --- /dev/null +++ b/adr/042-composition-type.md @@ -0,0 +1,380 @@ +# ADR: Composition as a First-Class Type + +**Branch**: `042-composition-type` +**Created**: 2026-04-29 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(extends and absorbs its `Composition` type proposal)* + +--- + +## Context + +The schema represents individual components with rich fidelity — anatomy, props, styling variants, layout. But the schema has no concept of *composition*: a pre-arranged grouping of component instances that expresses how components are combined in context. + +Compositions appear at multiple scales in Figma-sourced design systems: + +- **Slot-default content** — The specific elements placed inside a component's slot layer in Figma, representing the canonical default for that slot. For example, a `Card` component's `content` slot may have a `Body` text element and an `Action` button as its default composition. +- **Component examples** — Standalone instances of a component shown in context with all slots filled and prop values set. These demonstrate typical, ready-made usage (e.g., a `ProductCard` example with a featured image, title, and CTA button). +- **Layout compositions** — Multi-component arrangements that form a portion of a UI: a filter grid with a data table, a sidebar with accordion and checkboxes. Not a single component — a named assembly of collaborating components. +- **Page compositions** — Full-page canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. + +Today, the schema cannot represent any of these. `SlotProp.default` holds only a string description. `PropConfigurations` accepts only scalar values — it cannot express structured slot content. There is no catalogue of named, reusable compositions that consumers can discover, render, or validate against. + +DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") began addressing the narrowest case: expressing inline slot content within a parent component's `propConfigurations`. That proposal introduced a `Composition` type with `anatomy`, `layout`, and `elements`. This ADR supersedes ADR-025 by adopting its `Composition` shape, retaining the `propConfigurations` widening it proposed, and extending the model to cover the full compositional range — from slot defaults to page views — with a shared type, named catalogue, and cross-referencing mechanism. + +The recursive challenge is real but tractable. A `Card` slot holds a `ProductCard` composition; `ProductCard`'s anatomy may itself include a `Button` instance with its own slot. Rather than inlining the full recursive tree, this ADR adopts a **flat catalogue** approach: all named compositions are declared at the root level, composition anatomy elements reference component names (as `instanceOf`, already supported), and recursive nesting is resolved by consumers against the catalogue — not embedded inline in the schema. + +--- + +## Decision Drivers + +- **Composition is a first-class concept** — components and compositions are peers in the schema's conceptual model; the type hierarchy must reflect this +- **Additive-only changes** — all new fields are optional; no existing field is removed or narrowed → MINOR semver +- **Type ↔ schema symmetry** — every new type field has a corresponding schema definition (Constitution §I) +- **No runtime logic** — only type declarations and schema; no validation functions or algorithms (Constitution §II) +- **Flat catalogue over deep inline nesting** — recursive composition trees must not require unbounded schema depth; a named flat catalogue with `instanceOf` references provides tractable resolution +- **Scale independence** — the same `Composition` type must work for a slot fragment (2 elements) and a page view (dozens of component instances); `kind` classifies without requiring separate types +- **Consistent reference patterns** — referencing a named composition follows the same `string` key convention already used in `AnatomyElement.instanceOf` and `SlotProp.anyOf` — no new `$ref` protocol +- **PropConfigurations completeness** — slot content flowing from a parent into a child's slot must be expressible (ADR-025 requirement, retained here) + +--- + +## Options Considered + +### Option A: Single `Composition` type with `kind`, flat root-level catalogue, `string` key references *(Selected)* + +Introduce one `Composition` type covering all scales. A `kind` field classifies each composition without requiring separate types. All named compositions are catalogued under a root-level `compositions` key. Component slots reference named compositions by their string key. The `propConfigurations` value union is widened to allow inline `Composition` for slot content authored by a parent component. + +```yaml +# Root-level spec output — new shape +components: + Card: { ... } +compositions: + cardDefault: + kind: slot-default + title: Card – default content + anatomy: + body: + type: text + action: + type: instance + instanceOf: Button + layout: + - body + - action + elements: + body: + content: "Card body text" + action: + instanceOf: Button + propConfigurations: + label: "Learn more" + type: secondary +``` + +```yaml +# SlotProp — new optional field +content: + type: slot + defaultComposition: cardDefault # references compositions.cardDefault +``` + +**Pros**: +- One type, one catalogue key — no proliferation of type variants +- `kind` enables tooling to filter/render at appropriate detail level +- Flat catalogue avoids schema recursion depth problems +- String key references are consistent with `instanceOf`, `anyOf` +- Inline `Composition` in `propConfigurations` (ADR-025 pattern) is preserved for parent-authored slot content +- All changes are additive optional fields → MINOR + +**Cons / Trade-offs**: +- `kind` is advisory — the schema cannot enforce that a `slot-default` composition has exactly one slot's worth of elements; that validation is a consumer concern +- Cross-composition references (one composition using another as a sub-composition) are not explicitly modelled in this ADR — left for a follow-on + +--- + +### Option B: Separate types per composition scale *(Rejected)* + +Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types with different required/optional fields per scale. + +**Rejected because**: +- The structural shape is identical across scales (`anatomy`, `elements`, `layout`); scale differences are semantic, not structural +- Four separate types multiplies the schema surface area and the downstream type import burden without adding constraint expressiveness +- Diverging shapes would require separate schema definitions, separate JSON schema refs, and separate consumer handling — adding complexity without benefit + +--- + +### Option C: Compositions as sub-records on `Component` only *(Rejected)* + +Attach compositions to the component that owns them (`Component.compositions?: Record`) and skip a root-level catalogue. + +**Rejected because**: +- Layout and page compositions are not owned by a single component — they span multiple components +- A slot-default composition may be shared or referenced by multiple consumers; component-scoped storage prevents cross-component reuse +- Tooling that needs to enumerate all compositions (for rendering, documentation, or validation) would have to traverse every component — a flat root-level catalogue is O(1) to access + +--- + +### Option D: Absorb compositions into `Variant` (reuse existing structure) *(Rejected)* + +A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse `Variant` with a new discriminating field. + +**Rejected because**: +- `Variant` is bound to a single component's prop configuration state (`configuration?: PropConfigurations`); it has no anatomy of its own +- `Composition` is a multi-element, multi-component fragment with its own `anatomy` — this is structurally distinct from a variant +- Conflating the two would make `Variant` ambiguous and would require downstream consumers to distinguish "is this a variant or a composition?" through heuristics rather than type structure + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| New: `Composition.ts` | Add `CompositionKind`, `Composition`, `Compositions` | MINOR | +| `Props.ts` | Add optional `defaultComposition?: string` to `SlotProp` | MINOR | +| `PropConfigurations.ts` | Widen value union to include `PropBinding \| Composition` | MINOR | +| `Component.ts` | Add optional `examples?: string[]` | MINOR | +| `index.ts` | Export `Composition`, `CompositionKind`, `Compositions` | MINOR | + +**New file** (`types/Composition.ts`): + +```yaml +# CompositionKind — classification of a composition's scale and intent +CompositionKind: + 'slot-default' # default content for a component's named slot + 'example' # a complete, ready-made usage of a component with slots filled + 'layout' # a multi-component partial-page arrangement + 'page' # a full canonical page view + +# Composition — a pre-arranged grouping of component instances +Composition: + title?: string # human-readable label + kind?: CompositionKind # optional — omit for inline propConfigurations use + anatomy: Anatomy # required — element type map for all instances in this fragment + elements?: Elements # optional — style/content bindings per element + layout?: Layout # optional — top-level ordering of fragment children +``` + +```yaml +# Compositions — root-level catalogue type +Compositions: Record +``` + +**Extended `SlotProp`** (`types/Props.ts`): + +```yaml +# Before +SlotProp: + type: 'slot' + default?: string | null + nullable?: boolean + minItems?: number + maxItems?: number + anyOf?: string[] + $extensions?: PropExtensions + +# After +SlotProp: + type: 'slot' + default?: string | null + nullable?: boolean + minItems?: number + maxItems?: number + anyOf?: string[] + defaultComposition?: string # key into root-level compositions catalogue + $extensions?: PropExtensions +``` + +**Widened `PropConfigurations`** (`types/PropConfigurations.ts`): + +```yaml +# Before +PropConfigurations: Record + +# After +PropConfigurations: Record +``` + +**Extended `Component`** (`types/Component.ts`): + +```yaml +# Before +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + +# After +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + examples?: string[] # keys into root-level compositions catalogue +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Add `Composition` definition; add `CompositionKind` enum definition | MINOR | +| `component.schema.json` | Add `defaultComposition` property to `SlotProp` definition | MINOR | +| `component.schema.json` | Widen `PropConfigurations` `additionalProperties` to include `PropBinding` and `Composition` refs | MINOR | +| `component.schema.json` | Add `examples` property to `Component` definition | MINOR | +| `components.schema.json` | Add optional `compositions` property: `patternProperties` referencing `Composition` definition | MINOR | + +**New definition** (`#/definitions/Composition` in `component.schema.json`): + +```yaml +Composition: + type: object + description: "A pre-arranged grouping of component instances representing slot content, a usage example, a layout pattern, or a canonical page view." + required: [anatomy] + properties: + title: + type: string + description: "Human-readable label for this composition." + kind: + $ref: "#/definitions/CompositionKind" + anatomy: + $ref: "#/definitions/Anatomy" + elements: + $ref: "#/definitions/Elements" + layout: + $ref: "#/definitions/Layout" + additionalProperties: false +``` + +**New definition** (`#/definitions/CompositionKind`): + +```yaml +CompositionKind: + type: string + enum: + - slot-default + - example + - layout + - page + description: "Classifies a composition by its scale and intent." +``` + +**New property** (`SlotProp.defaultComposition` in `#/definitions/SlotProp/properties`): + +```yaml +defaultComposition: + type: string + description: "Key of a named composition in the root-level compositions catalogue that represents this slot's default content." +``` + +**Updated `PropConfigurations`** (`#/definitions/PropConfigurations`): + +```yaml +# Before +PropConfigurations: + type: object + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + +# After +PropConfigurations: + type: object + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + - $ref: "#/definitions/PropBinding" + - $ref: "#/definitions/Composition" +``` + +**New property** (`Component.examples` in `#/definitions/Component/properties`): + +```yaml +examples: + type: array + items: + type: string + description: "Keys of root-level compositions demonstrating this component in canonical usage contexts." +``` + +**New property** (`components.schema.json`, under `properties`): + +```yaml +compositions: + type: object + description: "Named compositions catalogued at the spec root — slot defaults, examples, layouts, and page views." + patternProperties: + "^[a-zA-Z0-9_-]+$": + $ref: "component.schema.json#/definitions/Composition" + additionalProperties: false +``` + +### Notes + +- `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because simple slot fragments may not need full styling bindings. +- `Composition.kind` is optional to preserve the inline `Composition` use case (within `propConfigurations`), where kind classification is unnecessary. +- `SlotProp.defaultComposition` is a `string` key reference, not a `$ref` object — consistent with `AnatomyElement.instanceOf` and `SlotProp.anyOf[]` which also reference component names as plain strings. Structured `$ref`-style references would require a registry concept that does not yet exist. +- `Component.examples` references root-level compositions, not inline definitions — this keeps component definitions lean and enables the same composition to be referenced from multiple contexts. +- Recursive composition is supported implicitly: `Composition.anatomy` elements can have `instanceOf: string` (referencing component names); if that component has a slot, a composition can fill it via `Composition.elements[element].propConfigurations` carrying an inline `Composition`. Consumers resolve the tree by walking the catalogue. The schema does not need to model the recursion depth explicitly. +- The `components.schema.json` change requires removing `additionalProperties: false` at the root level and replacing it with an explicit `properties` block that includes both `components` and `compositions`. Only one of the two keys needs to be present — `required: [components]` remains unchanged if component-first output is the norm; `compositions` is a standalone addition. +- `PropConfigurations` widening to include `PropBinding` completes a pattern already established: `PropBinding` (`{ $binding: "..." }`) is already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. Allowing it in `propConfigurations` values enables a parent component to bind a nested instance's scalar prop to the parent's own prop, alongside static values and `Composition` values. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes — every type field maps to a schema property +- **Parity check**: + - `Composition { title?, kind?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` with same required/optional pattern + - `CompositionKind` string literal union ↔ `#/definitions/CompositionKind` enum + - `Compositions = Record` ↔ `components.schema.json#/properties/compositions` patternProperties + - `SlotProp.defaultComposition?: string` ↔ `#/definitions/SlotProp/properties/defaultComposition` type string + - `PropConfigurations` value union `string | number | boolean | PropBinding | Composition` ↔ `additionalProperties.oneOf` with five branches + - `Component.examples?: string[]` ↔ `#/definitions/Component/properties/examples` array of string + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | New optional types appear in output shape; transformer must detect and emit composition anatomy, elements, and layout from slot layers and example frames | Read `Composition`, `CompositionKind`, `Compositions` from schema; emit `compositions` at root level and `SlotProp.defaultComposition` keys | +| `specs-cli` | Recompile against updated types; CLI output must include `compositions` key when present; `--config` may need `kind` filter options in future | Recompile; no breaking consumer change — new optional keys appear in output | +| `specs-plugin-2` | Recompile; plugin must be able to read and display composition entries in the panel when present | Recompile; composition rendering is out of scope for this ADR — panel can ignore unknown keys initially | + +--- + +## Semver Decision + +**Version bump**: `0.19.0 → 0.20.0` (`MINOR`) + +**Justification**: All changes are additive optional fields on existing types (`SlotProp.defaultComposition`, `PropConfigurations` value union widening, `Component.examples`) or entirely new types (`Composition`, `CompositionKind`, `Compositions`) and new optional root-level schema keys (`compositions`). No existing field is removed, renamed, narrowed, or made required. Per Constitution §III: additive types and new optional fields → MINOR. + +--- + +## Consequences + +- `Composition` is a first-class type in the schema, co-equal with `Component` as a structural concept +- All four composition scales — slot default, example, layout, page — are expressible with a single type using `kind` as a classifier; no separate type proliferation +- Named compositions are catalogued at the root level of the spec output under `compositions`; tooling can enumerate, filter, and render them without traversing individual component records +- Slot default content is declared as `SlotProp.defaultComposition: string` pointing to a root-level named composition — the string `default` field retains backward compatibility as a free-form description or is deprecated in a future ADR +- Component examples are declared as `Component.examples: string[]` referencing root-level compositions — components remain lean; their usage demonstrations are decoupled from their structural definition +- Inline `Composition` in `propConfigurations` (the ADR-025 pattern) is preserved — parent components can express slot content they author without naming or cataloguing the fragment +- Recursive composition depth is tractable: `Composition.anatomy` elements carry `instanceOf` references, and `propConfigurations` can carry inline compositions for nested slot content; consumers resolve the tree by walking the flat catalogue rather than parsing unbounded schema nesting +- ADR-025 ("Flowing Content into a Nested Instance's Slot") is superseded — its `Composition` type and `PropConfigurations` widening are absorbed into this ADR with the extended shape +- Future ADRs can add cross-composition reference (`Composition.uses?: string[]`), composition-level `$extensions` for Figma provenance, or `kind`-specific required fields once the base mechanism is established diff --git a/adr/INDEX.md b/adr/INDEX.md index c922fb6..c055f21 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,7 @@ | # | Title | Highlights | |---|-------|------------| +| 042 | Composition as a First-Class Type | | | 041 | Layout Positioning — Constraint-Based Naming | | | 035 | Make Config Properties with Defaults Optional | | | 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | From 796264c7fdc2bfb572b11e136074c1685e9d983d Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:00:06 -0400 Subject: [PATCH 02/20] feat(adr): revise ADR 042 with clarified composition model - Component.examples: Record (inline, kind-tagged) - CompositionRef { $composition: string } in Children union for slot bindings - Slot defaults live in variant/element layer, vary per variant - PropConfigurations widening retained (consumer inline pattern) - System-scoped compositions.yaml schema deferred to follow-on ADR Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 325 +++++++++++++++++++++--------------- 1 file changed, 191 insertions(+), 134 deletions(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 3865726..51e1ff7 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -4,7 +4,7 @@ **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) -**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(extends and absorbs its `Composition` type proposal)* +**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(extends and absorbs its `Composition` type and `PropConfigurations` widening)* --- @@ -16,14 +16,27 @@ Compositions appear at multiple scales in Figma-sourced design systems: - **Slot-default content** — The specific elements placed inside a component's slot layer in Figma, representing the canonical default for that slot. For example, a `Card` component's `content` slot may have a `Body` text element and an `Action` button as its default composition. - **Component examples** — Standalone instances of a component shown in context with all slots filled and prop values set. These demonstrate typical, ready-made usage (e.g., a `ProductCard` example with a featured image, title, and CTA button). -- **Layout compositions** — Multi-component arrangements that form a portion of a UI: a filter grid with a data table, a sidebar with accordion and checkboxes. Not a single component — a named assembly of collaborating components. -- **Page compositions** — Full-page canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. +- **Layout compositions** — Multi-component arrangements forming a portion of a UI: a filter grid with a data table, a sidebar with an accordion and checkboxes. Not a single component — a named assembly of collaborating components. +- **Page compositions** — Full canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. -Today, the schema cannot represent any of these. `SlotProp.default` holds only a string description. `PropConfigurations` accepts only scalar values — it cannot express structured slot content. There is no catalogue of named, reusable compositions that consumers can discover, render, or validate against. +Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. `PropConfigurations` accepts only scalar values — it cannot express structured slot content. There is no `Composition` type for tooling to discover, render, or validate against. -DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") began addressing the narrowest case: expressing inline slot content within a parent component's `propConfigurations`. That proposal introduced a `Composition` type with `anatomy`, `layout`, and `elements`. This ADR supersedes ADR-025 by adopting its `Composition` shape, retaining the `propConfigurations` widening it proposed, and extending the model to cover the full compositional range — from slot defaults to page views — with a shared type, named catalogue, and cross-referencing mechanism. +DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") began addressing the narrowest case: expressing inline slot content within a parent component's `propConfigurations` when a parent component flows defined content into a child instance's slot. That proposal introduced a `Composition` type with `anatomy`, `layout`, and `elements`. This ADR supersedes ADR-025 by adopting its shape, retaining the `PropConfigurations` widening, and extending the model to cover the full compositional range. -The recursive challenge is real but tractable. A `Card` slot holds a `ProductCard` composition; `ProductCard`'s anatomy may itself include a `Button` instance with its own slot. Rather than inlining the full recursive tree, this ADR adopts a **flat catalogue** approach: all named compositions are declared at the root level, composition anatomy elements reference component names (as `instanceOf`, already supported), and recursive nesting is resolved by consumers against the catalogue — not embedded inline in the schema. +### Composition scoping + +The four composition kinds split naturally into two scopes: + +- **Component-scoped** (`slot-default`, `example`): Authored by the component designer. They live inside the component definition itself under `Component.examples`. Slot defaults express what Figma places in a slot layer by default; examples show the component used in ready-made contexts. These are variant-sensitive — a slot's composition reference can differ per variant, expressed through the variant's element bindings. +- **System-scoped** (`layout`, `page`): Independent of any single component. They live in a separate file (`compositions.yaml`, parallel to `components.yaml`). The schema for that file is a follow-on ADR; this ADR defines the type that powers it. + +### Slot default content and the variant layer + +A slot element in the anatomy (type: `slot`) receives its content through code — callers pass children to the slot prop. In the spec, the component author declares the default slot content by setting a `CompositionRef` (`{ $composition: "key" }`) on the slot element's `children` field within `default.elements` or a specific variant's `elements`. This follows the existing pattern where `children` may be a `PropBinding` (consumer-bound) — the new `CompositionRef` branch expresses author-defined default content. Since this lives in the variant layer, different variants can reference different compositions for the same slot. + +### Consumer slot content and the parent component pattern + +When a *consumer* of a component authors a parent that flows content into a child's slot — e.g., a `ProductCard` that places a `Title` and `Button` inside `Card`'s `content` slot — that content is expressed as an inline `Composition` value in `PropConfigurations`. This is the ADR-025 pattern, retained here. The two patterns are complementary: the component author defines named compositions (`Component.examples`); the component consumer authors inline compositions when filling a child's slot. --- @@ -33,27 +46,36 @@ The recursive challenge is real but tractable. A `Card` slot holds a `ProductCar - **Additive-only changes** — all new fields are optional; no existing field is removed or narrowed → MINOR semver - **Type ↔ schema symmetry** — every new type field has a corresponding schema definition (Constitution §I) - **No runtime logic** — only type declarations and schema; no validation functions or algorithms (Constitution §II) -- **Flat catalogue over deep inline nesting** — recursive composition trees must not require unbounded schema depth; a named flat catalogue with `instanceOf` references provides tractable resolution +- **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant, not only at the component level; the existing variant/element layer is the right location +- **Consistent reference patterns** — `$composition` follows the established `$`-prefix convention (`$binding`, `$ref`, `$token`) for structured references in the schema - **Scale independence** — the same `Composition` type must work for a slot fragment (2 elements) and a page view (dozens of component instances); `kind` classifies without requiring separate types -- **Consistent reference patterns** — referencing a named composition follows the same `string` key convention already used in `AnatomyElement.instanceOf` and `SlotProp.anyOf` — no new `$ref` protocol -- **PropConfigurations completeness** — slot content flowing from a parent into a child's slot must be expressible (ADR-025 requirement, retained here) +- **PropConfigurations completeness** — structured slot content flowing from a parent into a child component's slot must be expressible alongside scalar prop values +- **Scope separation** — component-scoped compositions belong inside the component definition; system-scoped compositions (layouts, pages) belong in a separate file with a follow-on schema --- ## Options Considered -### Option A: Single `Composition` type with `kind`, flat root-level catalogue, `string` key references *(Selected)* +### Option A: `CompositionRef` in `Children`, inline `Component.examples`, `PropConfigurations` widening *(Selected)* -Introduce one `Composition` type covering all scales. A `kind` field classifies each composition without requiring separate types. All named compositions are catalogued under a root-level `compositions` key. Component slots reference named compositions by their string key. The `propConfigurations` value union is widened to allow inline `Composition` for slot content authored by a parent component. +Introduce `Composition` and `CompositionRef` types. Component-scoped compositions live inline in `Component.examples: Record`. The variant/element layer references named compositions via `CompositionRef` on the slot element's `children`. Consumer-side slot content uses an inline `Composition` in `PropConfigurations`. System-scoped compositions use `Compositions` (the same type) in a separate file defined by a follow-on ADR. ```yaml -# Root-level spec output — new shape -components: - Card: { ... } -compositions: - cardDefault: +# Component with inline examples and variant-layer slot binding +title: Card +anatomy: + root: + type: container + contentSlot: + type: slot +props: + content: + type: slot + +examples: + cardBodyDefault: kind: slot-default - title: Card – default content + title: Card – default body anatomy: body: type: text @@ -70,60 +92,80 @@ compositions: instanceOf: Button propConfigurations: label: "Learn more" - type: secondary + +default: + elements: + contentSlot: + children: { $composition: cardBodyDefault } # references examples.cardBodyDefault ``` ```yaml -# SlotProp — new optional field -content: - type: slot - defaultComposition: cardDefault # references compositions.cardDefault +# Parent component (ProductCard) flowing inline content into Card's slot +default: + elements: + card: + instanceOf: Card + propConfigurations: + content: # Card's slot prop — inline Composition value + anatomy: + title: + type: text + cta: + type: instance + instanceOf: Button + layout: + - title + - cta + elements: + title: + content: { $binding: "#/props/title" } + cta: + instanceOf: Button + propConfigurations: + label: { $binding: "#/props/ctaLabel" } ``` **Pros**: -- One type, one catalogue key — no proliferation of type variants -- `kind` enables tooling to filter/render at appropriate detail level -- Flat catalogue avoids schema recursion depth problems -- String key references are consistent with `instanceOf`, `anyOf` -- Inline `Composition` in `propConfigurations` (ADR-025 pattern) is preserved for parent-authored slot content -- All changes are additive optional fields → MINOR +- `CompositionRef` follows the `$`-prefix convention — unambiguous in the `Children` union alongside `string[]` and `PropBinding` +- Slot default content lives in the variant layer — naturally varies per variant, no SlotProp change needed +- `Component.examples` is inline — tools can read the full composition without a separate lookup +- Author-defined (named) and consumer-defined (inline) composition patterns are distinct and complementary +- All changes additive → MINOR **Cons / Trade-offs**: -- `kind` is advisory — the schema cannot enforce that a `slot-default` composition has exactly one slot's worth of elements; that validation is a consumer concern -- Cross-composition references (one composition using another as a sub-composition) are not explicitly modelled in this ADR — left for a follow-on +- `Component.examples` can grow large for heavily-slotted components; schema has no length constraint (advisory concern, not a structural problem) +- Inline `Composition` in `PropConfigurations` requires consumers to handle two patterns: named (in `examples`) and inline (in `propConfigurations`) --- -### Option B: Separate types per composition scale *(Rejected)* +### Option B: All compositions at root level, referenced by string key everywhere *(Rejected)* -Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types with different required/optional fields per scale. +All compositions (including slot defaults and examples) live in a root-level `compositions` catalogue, keyed by name. Components and slot elements reference them by string key. No inline compositions. **Rejected because**: -- The structural shape is identical across scales (`anatomy`, `elements`, `layout`); scale differences are semantic, not structural -- Four separate types multiplies the schema surface area and the downstream type import burden without adding constraint expressiveness -- Diverging shapes would require separate schema definitions, separate JSON schema refs, and separate consumer handling — adding complexity without benefit +- Component-scoped compositions (slot defaults, examples) are owned by one component and have no cross-component reuse value; forcing them to a shared root catalogue adds indirection without benefit +- Tooling that reads a component spec would need a separate catalogue lookup to resolve composition data +- String keys alone (without a `$`-prefix) cannot be distinguished from other string-valued fields; structured `CompositionRef` is required for unambiguous discrimination in the `Children` union --- -### Option C: Compositions as sub-records on `Component` only *(Rejected)* +### Option C: Separate types per composition scale *(Rejected)* -Attach compositions to the component that owns them (`Component.compositions?: Record`) and skip a root-level catalogue. +Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types. **Rejected because**: -- Layout and page compositions are not owned by a single component — they span multiple components -- A slot-default composition may be shared or referenced by multiple consumers; component-scoped storage prevents cross-component reuse -- Tooling that needs to enumerate all compositions (for rendering, documentation, or validation) would have to traverse every component — a flat root-level catalogue is O(1) to access +- The structural shape is identical across scales (`anatomy`, `elements`, `layout`); scale differences are semantic, not structural +- Four types multiply the schema surface area and downstream import burden without adding constraint expressiveness --- -### Option D: Absorb compositions into `Variant` (reuse existing structure) *(Rejected)* +### Option D: Absorb compositions into `Variant` *(Rejected)* -A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse `Variant` with a new discriminating field. +A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse `Variant` with a discriminating field. **Rejected because**: -- `Variant` is bound to a single component's prop configuration state (`configuration?: PropConfigurations`); it has no anatomy of its own -- `Composition` is a multi-element, multi-component fragment with its own `anatomy` — this is structurally distinct from a variant -- Conflating the two would make `Variant` ambiguous and would require downstream consumers to distinguish "is this a variant or a composition?" through heuristics rather than type structure +- `Variant` is bound to a single component's prop configuration state; it has no anatomy of its own +- `Composition` is a multi-element, multi-component fragment — structurally distinct from a variant --- @@ -133,11 +175,11 @@ A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse | File | Change | Bump | |------|--------|------| -| New: `Composition.ts` | Add `CompositionKind`, `Composition`, `Compositions` | MINOR | -| `Props.ts` | Add optional `defaultComposition?: string` to `SlotProp` | MINOR | -| `PropConfigurations.ts` | Widen value union to include `PropBinding \| Composition` | MINOR | -| `Component.ts` | Add optional `examples?: string[]` | MINOR | -| `index.ts` | Export `Composition`, `CompositionKind`, `Compositions` | MINOR | +| New: `Composition.ts` | Add `CompositionKind`, `CompositionRef`, `Composition`, `Compositions` | MINOR | +| `Children.ts` | Widen `Children` to `string[] \| PropBinding \| CompositionRef` | MINOR | +| `Component.ts` | Add optional `examples?: Record` | MINOR | +| `PropConfigurations.ts` | Widen value union to `string \| number \| boolean \| PropBinding \| Composition` | MINOR | +| `index.ts` | Export `Composition`, `CompositionKind`, `CompositionRef`, `Compositions` | MINOR | **New file** (`types/Composition.ts`): @@ -146,56 +188,39 @@ A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse CompositionKind: 'slot-default' # default content for a component's named slot 'example' # a complete, ready-made usage of a component with slots filled - 'layout' # a multi-component partial-page arrangement - 'page' # a full canonical page view + 'layout' # a multi-component partial-page arrangement (system-scoped) + 'page' # a full canonical page view (system-scoped) + +# CompositionRef — structured reference to a named composition +CompositionRef: + $composition: string # key in Component.examples (component-scoped) # Composition — a pre-arranged grouping of component instances Composition: title?: string # human-readable label - kind?: CompositionKind # optional — omit for inline propConfigurations use + kind?: CompositionKind # optional for inline propConfigurations use; expected for named compositions anatomy: Anatomy # required — element type map for all instances in this fragment - elements?: Elements # optional — style/content bindings per element - layout?: Layout # optional — top-level ordering of fragment children -``` + elements?: Elements # optional — style/content/propConfigurations bindings per element + layout?: Layout # optional — ordering of fragment children -```yaml -# Compositions — root-level catalogue type +# Compositions — convenience type for a named record of compositions Compositions: Record ``` -**Extended `SlotProp`** (`types/Props.ts`): +**Widened `Children`** (`types/Children.ts`): ```yaml # Before -SlotProp: - type: 'slot' - default?: string | null - nullable?: boolean - minItems?: number - maxItems?: number - anyOf?: string[] - $extensions?: PropExtensions +Children: string[] | PropBinding # After -SlotProp: - type: 'slot' - default?: string | null - nullable?: boolean - minItems?: number - maxItems?: number - anyOf?: string[] - defaultComposition?: string # key into root-level compositions catalogue - $extensions?: PropExtensions +Children: string[] | PropBinding | CompositionRef ``` -**Widened `PropConfigurations`** (`types/PropConfigurations.ts`): - ```yaml -# Before -PropConfigurations: Record - -# After -PropConfigurations: Record +# Usage — slot element in default.elements +contentSlot: + children: { $composition: cardBodyDefault } # CompositionRef ``` **Extended `Component`** (`types/Component.ts`): @@ -222,25 +247,36 @@ Component: variants?: Variants invalidVariantCombinations?: PropConfigurations[] metadata?: Metadata - examples?: string[] # keys into root-level compositions catalogue + examples?: Record # component-scoped named compositions +``` + +**Widened `PropConfigurations`** (`types/PropConfigurations.ts`): + +```yaml +# Before +PropConfigurations: Record + +# After +PropConfigurations: Record ``` ### Schema changes (`schema/`) | File | Change | Bump | |------|--------|------| -| `component.schema.json` | Add `Composition` definition; add `CompositionKind` enum definition | MINOR | -| `component.schema.json` | Add `defaultComposition` property to `SlotProp` definition | MINOR | -| `component.schema.json` | Widen `PropConfigurations` `additionalProperties` to include `PropBinding` and `Composition` refs | MINOR | +| `component.schema.json` | Add `Composition` definition | MINOR | +| `component.schema.json` | Add `CompositionKind` definition | MINOR | +| `component.schema.json` | Add `CompositionRef` definition | MINOR | +| `component.schema.json` | Update `Children` definition to include `CompositionRef` branch | MINOR | +| `component.schema.json` | Update `PropConfigurations` `additionalProperties` to include `PropBinding` and `Composition` | MINOR | | `component.schema.json` | Add `examples` property to `Component` definition | MINOR | -| `components.schema.json` | Add optional `compositions` property: `patternProperties` referencing `Composition` definition | MINOR | -**New definition** (`#/definitions/Composition` in `component.schema.json`): +**New definition** (`#/definitions/Composition`): ```yaml Composition: type: object - description: "A pre-arranged grouping of component instances representing slot content, a usage example, a layout pattern, or a canonical page view." + description: "A pre-arranged grouping of component instances: slot default content, a usage example, a layout pattern, or a canonical page view." required: [anatomy] properties: title: @@ -262,20 +298,41 @@ Composition: ```yaml CompositionKind: type: string - enum: - - slot-default - - example - - layout - - page + enum: [slot-default, example, layout, page] description: "Classifies a composition by its scale and intent." ``` -**New property** (`SlotProp.defaultComposition` in `#/definitions/SlotProp/properties`): +**New definition** (`#/definitions/CompositionRef`): ```yaml -defaultComposition: - type: string - description: "Key of a named composition in the root-level compositions catalogue that represents this slot's default content." +CompositionRef: + type: object + description: "Structured reference to a named composition in Component.examples." + required: [$composition] + properties: + $composition: + type: string + description: "Key of the named composition in Component.examples." + additionalProperties: false +``` + +**Updated `Children`** (`#/definitions/Children`): + +```yaml +# Before +Children: + oneOf: + - type: array + items: { type: string } + - $ref: "#/definitions/PropBinding" + +# After +Children: + oneOf: + - type: array + items: { type: string } + - $ref: "#/definitions/PropBinding" + - $ref: "#/definitions/CompositionRef" ``` **Updated `PropConfigurations`** (`#/definitions/PropConfigurations`): @@ -306,33 +363,27 @@ PropConfigurations: ```yaml examples: - type: array - items: - type: string - description: "Keys of root-level compositions demonstrating this component in canonical usage contexts." -``` - -**New property** (`components.schema.json`, under `properties`): - -```yaml -compositions: type: object - description: "Named compositions catalogued at the spec root — slot defaults, examples, layouts, and page views." + description: "Component-scoped named compositions: slot defaults and ready-made usage examples." patternProperties: "^[a-zA-Z0-9_-]+$": - $ref: "component.schema.json#/definitions/Composition" + $ref: "#/definitions/Composition" additionalProperties: false ``` +### Out of scope for this ADR + +- **`compositions.yaml` file schema** — A separate schema file for system-scoped (`layout`, `page`) compositions living outside any component spec. Deferred to a follow-on ADR. The `Compositions = Record` type is defined here and ready for that ADR to reference. +- **Cross-composition references** — One composition referencing another named composition via `CompositionRef` within its own `elements.*.children`. The type permits it structurally; a dedicated ADR can formalize the resolution semantics. + ### Notes -- `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because simple slot fragments may not need full styling bindings. -- `Composition.kind` is optional to preserve the inline `Composition` use case (within `propConfigurations`), where kind classification is unnecessary. -- `SlotProp.defaultComposition` is a `string` key reference, not a `$ref` object — consistent with `AnatomyElement.instanceOf` and `SlotProp.anyOf[]` which also reference component names as plain strings. Structured `$ref`-style references would require a registry concept that does not yet exist. -- `Component.examples` references root-level compositions, not inline definitions — this keeps component definitions lean and enables the same composition to be referenced from multiple contexts. -- Recursive composition is supported implicitly: `Composition.anatomy` elements can have `instanceOf: string` (referencing component names); if that component has a slot, a composition can fill it via `Composition.elements[element].propConfigurations` carrying an inline `Composition`. Consumers resolve the tree by walking the catalogue. The schema does not need to model the recursion depth explicitly. -- The `components.schema.json` change requires removing `additionalProperties: false` at the root level and replacing it with an explicit `properties` block that includes both `components` and `compositions`. Only one of the two keys needs to be present — `required: [components]` remains unchanged if component-first output is the norm; `compositions` is a standalone addition. -- `PropConfigurations` widening to include `PropBinding` completes a pattern already established: `PropBinding` (`{ $binding: "..." }`) is already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. Allowing it in `propConfigurations` values enables a parent component to bind a nested instance's scalar prop to the parent's own prop, alongside static values and `Composition` values. +- `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because minimal slot fragments may not need full styling or explicit ordering. +- `Composition.kind` is optional to preserve the inline `Composition` use case in `propConfigurations`, where kind classification is unnecessary noise. For named compositions in `Component.examples`, authors are expected to set `kind`; tooling may warn when it is absent. +- `CompositionRef.$composition` is a plain string key (not a JSON Pointer like `SubcomponentRef.$ref`) because it resolves locally within `Component.examples` — no pointer traversal is needed. +- `PropBinding` in `PropConfigurations` is a natural complement to inline `Composition`: it allows a parent component to bind a nested instance's scalar prop to the parent's own prop, alongside static values and composition-structured slot content. It follows the same `{ $binding: "..." }` shape already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. +- The slot default `CompositionRef` in `Children` is the author-side declaration; the inline `Composition` in `PropConfigurations` is the consumer-side declaration. These are not in conflict — they represent two distinct roles in the component hierarchy. +- Recursive composition is supported implicitly: `Composition.anatomy` elements can carry `instanceOf: string` (already supported); if that component has a slot, the composition's `elements` can carry an inline `Composition` in `propConfigurations`. Consumers resolve the tree by walking the named catalogue. No schema changes are needed for recursive support. --- @@ -342,10 +393,11 @@ compositions: - **Parity check**: - `Composition { title?, kind?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` with same required/optional pattern - `CompositionKind` string literal union ↔ `#/definitions/CompositionKind` enum - - `Compositions = Record` ↔ `components.schema.json#/properties/compositions` patternProperties - - `SlotProp.defaultComposition?: string` ↔ `#/definitions/SlotProp/properties/defaultComposition` type string + - `CompositionRef { $composition: string }` ↔ `#/definitions/CompositionRef` with required `$composition` + - `Children = string[] | PropBinding | CompositionRef` ↔ `#/definitions/Children` updated `oneOf` (three branches) - `PropConfigurations` value union `string | number | boolean | PropBinding | Composition` ↔ `additionalProperties.oneOf` with five branches - - `Component.examples?: string[]` ↔ `#/definitions/Component/properties/examples` array of string + - `Component.examples?: Record` ↔ `#/definitions/Component/properties/examples` patternProperties referencing `Composition` + - `Compositions = Record` — TypeScript type defined; corresponding schema file deferred to follow-on ADR --- @@ -353,9 +405,9 @@ compositions: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | New optional types appear in output shape; transformer must detect and emit composition anatomy, elements, and layout from slot layers and example frames | Read `Composition`, `CompositionKind`, `Compositions` from schema; emit `compositions` at root level and `SlotProp.defaultComposition` keys | -| `specs-cli` | Recompile against updated types; CLI output must include `compositions` key when present; `--config` may need `kind` filter options in future | Recompile; no breaking consumer change — new optional keys appear in output | -| `specs-plugin-2` | Recompile; plugin must be able to read and display composition entries in the panel when present | Recompile; composition rendering is out of scope for this ADR — panel can ignore unknown keys initially | +| `specs-from-figma` | Must detect and emit `Component.examples` (slot-default and example compositions) from Figma slot layers and example frames; must emit `CompositionRef` in `default.elements` and variant elements; must widen `PropConfigurations` emission to include inline `Composition` | Read new types from schema; implement composition detection; update output emitters | +| `specs-cli` | Recompile against updated types; CLI output includes `examples` key when present in component spec; `Children` and `PropConfigurations` output shapes expand | Recompile; no breaking consumer change — new optional keys appear in output | +| `specs-plugin-2` | Recompile; panel may display composition entries when present | Recompile; composition rendering is a follow-on capability — panel can pass through unknown composition data initially | --- @@ -363,18 +415,23 @@ compositions: **Version bump**: `0.19.0 → 0.20.0` (`MINOR`) -**Justification**: All changes are additive optional fields on existing types (`SlotProp.defaultComposition`, `PropConfigurations` value union widening, `Component.examples`) or entirely new types (`Composition`, `CompositionKind`, `Compositions`) and new optional root-level schema keys (`compositions`). No existing field is removed, renamed, narrowed, or made required. Per Constitution §III: additive types and new optional fields → MINOR. +**Justification**: All changes are additive: +- New optional field `Component.examples` on an existing type +- New union members (`CompositionRef`) added to `Children` — existing valid values (`string[]`, `PropBinding`) remain valid +- `PropConfigurations` value union widened — existing scalar values remain valid +- New types (`Composition`, `CompositionKind`, `CompositionRef`, `Compositions`) with no removal or narrowing + +Per Constitution §III: additive types and new optional fields → MINOR. --- ## Consequences -- `Composition` is a first-class type in the schema, co-equal with `Component` as a structural concept -- All four composition scales — slot default, example, layout, page — are expressible with a single type using `kind` as a classifier; no separate type proliferation -- Named compositions are catalogued at the root level of the spec output under `compositions`; tooling can enumerate, filter, and render them without traversing individual component records -- Slot default content is declared as `SlotProp.defaultComposition: string` pointing to a root-level named composition — the string `default` field retains backward compatibility as a free-form description or is deprecated in a future ADR -- Component examples are declared as `Component.examples: string[]` referencing root-level compositions — components remain lean; their usage demonstrations are decoupled from their structural definition -- Inline `Composition` in `propConfigurations` (the ADR-025 pattern) is preserved — parent components can express slot content they author without naming or cataloguing the fragment -- Recursive composition depth is tractable: `Composition.anatomy` elements carry `instanceOf` references, and `propConfigurations` can carry inline compositions for nested slot content; consumers resolve the tree by walking the flat catalogue rather than parsing unbounded schema nesting -- ADR-025 ("Flowing Content into a Nested Instance's Slot") is superseded — its `Composition` type and `PropConfigurations` widening are absorbed into this ADR with the extended shape -- Future ADRs can add cross-composition reference (`Composition.uses?: string[]`), composition-level `$extensions` for Figma provenance, or `kind`-specific required fields once the base mechanism is established +- `Composition` is a first-class type in the schema, co-equal with `Component` as a structural concept; all four scales share one type, classified by `kind` +- Component authors can declare named compositions inline in `Component.examples`; slot defaults and usage examples are discoverable alongside the component that owns them +- Slot default content is expressed in the variant/element layer via `CompositionRef` on the slot element's `children`, making it variant-sensitive at no additional schema cost +- Component consumers can flow inline `Composition` content into a child's slot via `PropConfigurations`, completing the parent-authoring pattern proposed in ADR-025 +- `PropBinding` in `PropConfigurations` values enables a parent to bind a nested instance's scalar prop to its own prop, alongside static values and compositions +- `Compositions` type is defined and ready for a follow-on ADR that introduces the `compositions.yaml` schema for system-scoped layout and page compositions +- ADR-025 ("Flowing Content into a Nested Instance's Slot") is superseded — its `Composition` shape and `PropConfigurations` widening are absorbed here with the extended type +- Future ADRs can add cross-composition references, `kind`-specific required fields, composition-level `$extensions` for Figma provenance, and the system-scoped file schema without further changes to the `Composition` type structure From 5dbc885edc97385b75e5c0274c1d17c2686ae105 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:34:43 -0400 Subject: [PATCH 03/20] =?UTF-8?q?feat(adr):=20revise=20ADR=20042=20?= =?UTF-8?q?=E2=80=94=20no=20abbreviations,=20$extensions=20for=20slot=20de?= =?UTF-8?q?faults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename CompositionRef → CompositionReference (then remove: not needed) - Remove CompositionReference from Children union — Children unchanged - Slot default composition lives in Element.$extensions['com.figma'] following established provenance-metadata pattern on Props/TokenReference - Add ElementExtensions + FigmaElementExtension types on Element - Slot defaults are Figma provenance, not public component API Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 204 +++++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 82 deletions(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 51e1ff7..e15b4f8 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -14,8 +14,8 @@ The schema represents individual components with rich fidelity — anatomy, prop Compositions appear at multiple scales in Figma-sourced design systems: -- **Slot-default content** — The specific elements placed inside a component's slot layer in Figma, representing the canonical default for that slot. For example, a `Card` component's `content` slot may have a `Body` text element and an `Action` button as its default composition. -- **Component examples** — Standalone instances of a component shown in context with all slots filled and prop values set. These demonstrate typical, ready-made usage (e.g., a `ProductCard` example with a featured image, title, and CTA button). +- **Slot-default content** — The specific elements Figma places inside a component's slot layer, representing what that slot shows by default. For example, a `Card` component's `content` slot may have a `Body` text element and an `Action` button as its default composition. +- **Component examples** — Standalone instances of a component shown in context with slots filled and prop values set. These demonstrate typical, ready-made usage (e.g., a `ProductCard` example with a featured image, title, and CTA button). - **Layout compositions** — Multi-component arrangements forming a portion of a UI: a filter grid with a data table, a sidebar with an accordion and checkboxes. Not a single component — a named assembly of collaborating components. - **Page compositions** — Full canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. @@ -27,16 +27,16 @@ DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") began addressing The four composition kinds split naturally into two scopes: -- **Component-scoped** (`slot-default`, `example`): Authored by the component designer. They live inside the component definition itself under `Component.examples`. Slot defaults express what Figma places in a slot layer by default; examples show the component used in ready-made contexts. These are variant-sensitive — a slot's composition reference can differ per variant, expressed through the variant's element bindings. -- **System-scoped** (`layout`, `page`): Independent of any single component. They live in a separate file (`compositions.yaml`, parallel to `components.yaml`). The schema for that file is a follow-on ADR; this ADR defines the type that powers it. +- **Component-scoped** (`slot-default`, `example`): Authored by the component designer, living inside the component definition under `Component.examples`. Slot defaults express what Figma places in a slot by default; examples show the component in ready-made contexts. +- **System-scoped** (`layout`, `page`): Independent of any single component, living in a separate file (`compositions.yaml`, parallel to `components.yaml`). The schema for that file is a follow-on ADR; this ADR defines the shared `Composition` type that powers it. -### Slot default content and the variant layer +### Slot default content: Figma provenance, not public API -A slot element in the anatomy (type: `slot`) receives its content through code — callers pass children to the slot prop. In the spec, the component author declares the default slot content by setting a `CompositionRef` (`{ $composition: "key" }`) on the slot element's `children` field within `default.elements` or a specific variant's `elements`. This follows the existing pattern where `children` may be a `PropBinding` (consumer-bound) — the new `CompositionRef` branch expresses author-defined default content. Since this lives in the variant layer, different variants can reference different compositions for the same slot. +A slot's default content — the specific elements Figma places inside the slot layer — is Figma-specific provenance data. It is not part of the component's public API: the API does not prescribe what consumers must pass into a slot. Accordingly, the slot default composition reference belongs in `$extensions['com.figma']` on the slot `Element` within the variant/element layer, following the same provenance-metadata pattern used by `PropExtensions` on props. Since this lives in `default.elements` or `variants[n].elements`, it is inherently variant-sensitive — different variants can declare different default compositions for the same slot. ### Consumer slot content and the parent component pattern -When a *consumer* of a component authors a parent that flows content into a child's slot — e.g., a `ProductCard` that places a `Title` and `Button` inside `Card`'s `content` slot — that content is expressed as an inline `Composition` value in `PropConfigurations`. This is the ADR-025 pattern, retained here. The two patterns are complementary: the component author defines named compositions (`Component.examples`); the component consumer authors inline compositions when filling a child's slot. +When a *consumer* of a component authors a parent that flows content into a child's slot — e.g., a `ProductCard` that places a `Title` and `Button` inside `Card`'s `content` slot — that content is expressed as an inline `Composition` value in `PropConfigurations`. This is the ADR-025 pattern, retained here. The two patterns are complementary: the component author declares named default compositions in Figma extension metadata; the component consumer authors inline compositions when filling a child's slot. --- @@ -46,8 +46,9 @@ When a *consumer* of a component authors a parent that flows content into a chil - **Additive-only changes** — all new fields are optional; no existing field is removed or narrowed → MINOR semver - **Type ↔ schema symmetry** — every new type field has a corresponding schema definition (Constitution §I) - **No runtime logic** — only type declarations and schema; no validation functions or algorithms (Constitution §II) -- **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant, not only at the component level; the existing variant/element layer is the right location -- **Consistent reference patterns** — `$composition` follows the established `$`-prefix convention (`$binding`, `$ref`, `$token`) for structured references in the schema +- **Figma provenance is not public API** — slot default content originates from Figma's design; it belongs in `$extensions['com.figma']` on the element, not in the public `Children` type or `SlotProp` +- **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant; the variant/element layer is the right location +- **`$extensions` consistency** — Figma-specific metadata on elements follows the same DTCG-derived `$extensions` pattern already established on `Props` and `TokenReference` - **Scale independence** — the same `Composition` type must work for a slot fragment (2 elements) and a page view (dozens of component instances); `kind` classifies without requiring separate types - **PropConfigurations completeness** — structured slot content flowing from a parent into a child component's slot must be expressible alongside scalar prop values - **Scope separation** — component-scoped compositions belong inside the component definition; system-scoped compositions (layouts, pages) belong in a separate file with a follow-on schema @@ -56,12 +57,12 @@ When a *consumer* of a component authors a parent that flows content into a chil ## Options Considered -### Option A: `CompositionRef` in `Children`, inline `Component.examples`, `PropConfigurations` widening *(Selected)* +### Option A: `Component.examples` inline, `$extensions` on `Element` for slot defaults, `PropConfigurations` widening *(Selected)* -Introduce `Composition` and `CompositionRef` types. Component-scoped compositions live inline in `Component.examples: Record`. The variant/element layer references named compositions via `CompositionRef` on the slot element's `children`. Consumer-side slot content uses an inline `Composition` in `PropConfigurations`. System-scoped compositions use `Compositions` (the same type) in a separate file defined by a follow-on ADR. +Introduce `Composition` as a type. Component-scoped compositions live inline in `Component.examples: Record`, classified by `kind`. Slot default content is declared as Figma provenance metadata via `$extensions['com.figma'].defaultComposition` on the slot `Element` within the variant layer — keeping `Children` clean and free of Figma-specific concerns. Consumer-side slot content uses an inline `Composition` in `PropConfigurations` (ADR-025 pattern). System-scoped compositions use the same `Composition` type in a separate file defined by a follow-on ADR. ```yaml -# Component with inline examples and variant-layer slot binding +# Component with inline examples and Figma-extension slot binding title: Card anatomy: root: @@ -96,7 +97,9 @@ examples: default: elements: contentSlot: - children: { $composition: cardBodyDefault } # references examples.cardBodyDefault + $extensions: + com.figma: + defaultComposition: cardBodyDefault # key in Component.examples ``` ```yaml @@ -126,46 +129,58 @@ default: ``` **Pros**: -- `CompositionRef` follows the `$`-prefix convention — unambiguous in the `Children` union alongside `string[]` and `PropBinding` -- Slot default content lives in the variant layer — naturally varies per variant, no SlotProp change needed -- `Component.examples` is inline — tools can read the full composition without a separate lookup +- `Children` stays unchanged — no Figma-specific branches in a core structural type +- `$extensions` on `Element` follows the established provenance-metadata pattern +- Slot default declarations are variant-sensitive through the existing element layer +- `Component.examples` is inline — tools read the full composition without a separate lookup - Author-defined (named) and consumer-defined (inline) composition patterns are distinct and complementary - All changes additive → MINOR **Cons / Trade-offs**: -- `Component.examples` can grow large for heavily-slotted components; schema has no length constraint (advisory concern, not a structural problem) -- Inline `Composition` in `PropConfigurations` requires consumers to handle two patterns: named (in `examples`) and inline (in `propConfigurations`) +- `Component.examples` can grow large for heavily-slotted components (advisory concern; schema has no length constraint) +- Inline `Composition` in `PropConfigurations` requires consumers to handle two patterns: named (author declares in `Component.examples`) and inline (consumer authors in `propConfigurations`) --- -### Option B: All compositions at root level, referenced by string key everywhere *(Rejected)* +### Option B: `CompositionReference` in `Children` *(Rejected)* -All compositions (including slot defaults and examples) live in a root-level `compositions` catalogue, keyed by name. Components and slot elements reference them by string key. No inline compositions. +Add a `CompositionReference = { $composition: string }` type as a new branch of `Children`, so a slot element's `children` field can directly reference a named composition. **Rejected because**: -- Component-scoped compositions (slot defaults, examples) are owned by one component and have no cross-component reuse value; forcing them to a shared root catalogue adds indirection without benefit -- Tooling that reads a component spec would need a separate catalogue lookup to resolve composition data -- String keys alone (without a `$`-prefix) cannot be distinguished from other string-valued fields; structured `CompositionRef` is required for unambiguous discrimination in the `Children` union +- `Children` is a public structural type used on all elements — adding a Figma-sourced reference branch pollutes it with provenance-specific semantics +- Slot default content is Figma-specific data (what Figma puts in the slot layer); it is not the slot's public API default. The `$extensions` pattern is the established home for such metadata. +- The `Children` union would become ambiguous in intent: `string[]` (layout children), `PropBinding` (consumer-bound), and `CompositionReference` (Figma provenance) have fundamentally different semantic roles --- -### Option C: Separate types per composition scale *(Rejected)* +### Option C: All compositions at root level, referenced by string key everywhere *(Rejected)* + +All compositions (including slot defaults and examples) live in a root-level `compositions` catalogue. Components and slot elements reference them by string key. + +**Rejected because**: +- Component-scoped compositions are owned by one component and have no cross-component reuse value; forcing them to a shared root catalogue adds indirection without benefit +- Tooling reading a component spec would need a separate catalogue lookup to resolve composition data +- String keys in structural fields cannot be distinguished from other string values without a structured reference type + +--- + +### Option D: Separate types per composition scale *(Rejected)* Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types. **Rejected because**: -- The structural shape is identical across scales (`anatomy`, `elements`, `layout`); scale differences are semantic, not structural -- Four types multiply the schema surface area and downstream import burden without adding constraint expressiveness +- The structural shape is identical across all scales (`anatomy`, `elements`, `layout`); scale is semantic, not structural +- Four types multiply schema surface area and downstream import burden without adding constraint expressiveness --- -### Option D: Absorb compositions into `Variant` *(Rejected)* +### Option E: Absorb compositions into `Variant` *(Rejected)* A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse `Variant` with a discriminating field. **Rejected because**: - `Variant` is bound to a single component's prop configuration state; it has no anatomy of its own -- `Composition` is a multi-element, multi-component fragment — structurally distinct from a variant +- `Composition` is a multi-element, multi-component fragment — structurally and conceptually distinct from a variant --- @@ -175,26 +190,22 @@ A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse | File | Change | Bump | |------|--------|------| -| New: `Composition.ts` | Add `CompositionKind`, `CompositionRef`, `Composition`, `Compositions` | MINOR | -| `Children.ts` | Widen `Children` to `string[] \| PropBinding \| CompositionRef` | MINOR | +| New: `Composition.ts` | Add `CompositionKind`, `Composition`, `Compositions` | MINOR | +| `Element.ts` | Add optional `$extensions?: ElementExtensions`; add `ElementExtensions`, `FigmaElementExtension` | MINOR | | `Component.ts` | Add optional `examples?: Record` | MINOR | | `PropConfigurations.ts` | Widen value union to `string \| number \| boolean \| PropBinding \| Composition` | MINOR | -| `index.ts` | Export `Composition`, `CompositionKind`, `CompositionRef`, `Compositions` | MINOR | +| `index.ts` | Export `Composition`, `CompositionKind`, `Compositions`, `ElementExtensions`, `FigmaElementExtension` | MINOR | **New file** (`types/Composition.ts`): ```yaml # CompositionKind — classification of a composition's scale and intent CompositionKind: - 'slot-default' # default content for a component's named slot + 'slot-default' # default content Figma places in a component's named slot 'example' # a complete, ready-made usage of a component with slots filled 'layout' # a multi-component partial-page arrangement (system-scoped) 'page' # a full canonical page view (system-scoped) -# CompositionRef — structured reference to a named composition -CompositionRef: - $composition: string # key in Component.examples (component-scoped) - # Composition — a pre-arranged grouping of component instances Composition: title?: string # human-readable label @@ -207,20 +218,49 @@ Composition: Compositions: Record ``` -**Widened `Children`** (`types/Children.ts`): +**Extended `Element`** (`types/Element.ts`): ```yaml # Before -Children: string[] | PropBinding +Element: + children?: Children + parent?: string | null + styles?: Styles + propConfigurations?: PropConfigurations + instanceOf?: string | PropBinding | SubcomponentRef + content?: string | PropBinding # After -Children: string[] | PropBinding | CompositionRef +Element: + children?: Children + parent?: string | null + styles?: Styles + propConfigurations?: PropConfigurations + instanceOf?: string | PropBinding | SubcomponentRef + content?: string | PropBinding + $extensions?: ElementExtensions # Figma-specific element metadata +``` + +**New types** (in `types/Element.ts`): + +```yaml +# Figma-specific metadata for a slot element's default composition +FigmaElementExtension: + defaultComposition?: string # key in Component.examples — only meaningful for slot-type elements + [key: string]: unknown # additional Figma metadata passes through + +# DTCG §5.2.3 platform-specific extensions for element definitions +ElementExtensions: + 'com.figma'?: FigmaElementExtension + [key: string]: unknown ``` ```yaml -# Usage — slot element in default.elements +# Usage — slot element in default.elements or variants[n].elements contentSlot: - children: { $composition: cardBodyDefault } # CompositionRef + $extensions: + com.figma: + defaultComposition: cardBodyDefault # references Component.examples.cardBodyDefault ``` **Extended `Component`** (`types/Component.ts`): @@ -266,8 +306,8 @@ PropConfigurations: Record` type is defined here and ready for that ADR to reference. -- **Cross-composition references** — One composition referencing another named composition via `CompositionRef` within its own `elements.*.children`. The type permits it structurally; a dedicated ADR can formalize the resolution semantics. +- **`compositions.yaml` file schema** — A schema file for system-scoped (`layout`, `page`) compositions living outside any component spec. Deferred to a follow-on ADR. The `Compositions = Record` type is defined here and ready for that ADR to reference. +- **Cross-composition references** — One composition referencing another named composition within its own element data. The type permits it structurally; a dedicated ADR can formalize resolution semantics. ### Notes - `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because minimal slot fragments may not need full styling or explicit ordering. -- `Composition.kind` is optional to preserve the inline `Composition` use case in `propConfigurations`, where kind classification is unnecessary noise. For named compositions in `Component.examples`, authors are expected to set `kind`; tooling may warn when it is absent. -- `CompositionRef.$composition` is a plain string key (not a JSON Pointer like `SubcomponentRef.$ref`) because it resolves locally within `Component.examples` — no pointer traversal is needed. -- `PropBinding` in `PropConfigurations` is a natural complement to inline `Composition`: it allows a parent component to bind a nested instance's scalar prop to the parent's own prop, alongside static values and composition-structured slot content. It follows the same `{ $binding: "..." }` shape already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. -- The slot default `CompositionRef` in `Children` is the author-side declaration; the inline `Composition` in `PropConfigurations` is the consumer-side declaration. These are not in conflict — they represent two distinct roles in the component hierarchy. -- Recursive composition is supported implicitly: `Composition.anatomy` elements can carry `instanceOf: string` (already supported); if that component has a slot, the composition's `elements` can carry an inline `Composition` in `propConfigurations`. Consumers resolve the tree by walking the named catalogue. No schema changes are needed for recursive support. +- `Composition.kind` is optional to preserve the inline `Composition` use case in `propConfigurations`, where kind classification adds noise. For named compositions in `Component.examples`, authors are expected to set `kind`; tooling may warn when it is absent. +- `FigmaElementExtension.defaultComposition` is a plain string key — it resolves locally within `Component.examples`. It is advisory: the schema cannot enforce that it references a valid key, and it is only meaningful when the element's anatomy type is `slot`. Validation is a consumer concern. +- `PropBinding` in `PropConfigurations` is a natural complement to inline `Composition`: it allows a parent component to bind a nested instance's scalar prop to the parent's own prop alongside static values and composition-structured slot content. It follows the same `{ $binding: "..." }` shape already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. +- The `$extensions['com.figma'].defaultComposition` (author-side, Figma provenance) and the inline `Composition` in `PropConfigurations` (consumer-side, parent authoring) are complementary — not in conflict. They represent two distinct roles: the component designer declares what Figma shows by default; the parent component author declares what they place in the slot when composing. +- Recursive composition is supported implicitly: `Composition.anatomy` elements can carry `instanceOf: string` (already supported); if that component has a slot, the composition's `elements` can carry an inline `Composition` in `propConfigurations`. No schema changes are needed for recursive support. --- @@ -393,8 +430,9 @@ examples: - **Parity check**: - `Composition { title?, kind?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` with same required/optional pattern - `CompositionKind` string literal union ↔ `#/definitions/CompositionKind` enum - - `CompositionRef { $composition: string }` ↔ `#/definitions/CompositionRef` with required `$composition` - - `Children = string[] | PropBinding | CompositionRef` ↔ `#/definitions/Children` updated `oneOf` (three branches) + - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` with optional `defaultComposition` + - `ElementExtensions { 'com.figma'?: FigmaElementExtension }` ↔ `#/definitions/ElementExtensions` + - `Element.$extensions?: ElementExtensions` ↔ `#/definitions/Element/properties/$extensions` - `PropConfigurations` value union `string | number | boolean | PropBinding | Composition` ↔ `additionalProperties.oneOf` with five branches - `Component.examples?: Record` ↔ `#/definitions/Component/properties/examples` patternProperties referencing `Composition` - `Compositions = Record` — TypeScript type defined; corresponding schema file deferred to follow-on ADR @@ -405,9 +443,9 @@ examples: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | Must detect and emit `Component.examples` (slot-default and example compositions) from Figma slot layers and example frames; must emit `CompositionRef` in `default.elements` and variant elements; must widen `PropConfigurations` emission to include inline `Composition` | Read new types from schema; implement composition detection; update output emitters | -| `specs-cli` | Recompile against updated types; CLI output includes `examples` key when present in component spec; `Children` and `PropConfigurations` output shapes expand | Recompile; no breaking consumer change — new optional keys appear in output | -| `specs-plugin-2` | Recompile; panel may display composition entries when present | Recompile; composition rendering is a follow-on capability — panel can pass through unknown composition data initially | +| `specs-from-figma` | Must detect and emit `Component.examples` from Figma slot layers and example frames; must emit `$extensions['com.figma'].defaultComposition` on slot elements in variant data; must widen `PropConfigurations` emission to include inline `Composition` | Read new types from schema; implement composition detection and Figma extension emission; update output emitters | +| `specs-cli` | Recompile against updated types; CLI output includes `examples` key and element `$extensions` when present in component spec | Recompile; no breaking consumer change — new optional keys appear in output | +| `specs-plugin-2` | Recompile; panel may display composition entries when present | Recompile; composition rendering is a follow-on capability — panel can pass through composition data initially | --- @@ -417,9 +455,9 @@ examples: **Justification**: All changes are additive: - New optional field `Component.examples` on an existing type -- New union members (`CompositionRef`) added to `Children` — existing valid values (`string[]`, `PropBinding`) remain valid +- New optional field `Element.$extensions` on an existing type +- New types (`Composition`, `CompositionKind`, `Compositions`, `ElementExtensions`, `FigmaElementExtension`) — no removal or narrowing - `PropConfigurations` value union widened — existing scalar values remain valid -- New types (`Composition`, `CompositionKind`, `CompositionRef`, `Compositions`) with no removal or narrowing Per Constitution §III: additive types and new optional fields → MINOR. @@ -429,9 +467,11 @@ Per Constitution §III: additive types and new optional fields → MINOR. - `Composition` is a first-class type in the schema, co-equal with `Component` as a structural concept; all four scales share one type, classified by `kind` - Component authors can declare named compositions inline in `Component.examples`; slot defaults and usage examples are discoverable alongside the component that owns them -- Slot default content is expressed in the variant/element layer via `CompositionRef` on the slot element's `children`, making it variant-sensitive at no additional schema cost +- Slot default content is Figma provenance metadata, expressed via `$extensions['com.figma'].defaultComposition` on the slot element in the variant layer — keeping `Children` clean and the public schema free of Figma-specific branches +- Slot default declarations are variant-sensitive: different variants can declare different default compositions for the same slot through the existing element layer - Component consumers can flow inline `Composition` content into a child's slot via `PropConfigurations`, completing the parent-authoring pattern proposed in ADR-025 -- `PropBinding` in `PropConfigurations` values enables a parent to bind a nested instance's scalar prop to its own prop, alongside static values and compositions +- `PropBinding` in `PropConfigurations` values enables a parent to bind a nested instance's scalar prop to its own prop alongside static values and compositions +- `Element` gains `$extensions` following the same DTCG-derived provenance-metadata pattern established on `Props` and `TokenReference` - `Compositions` type is defined and ready for a follow-on ADR that introduces the `compositions.yaml` schema for system-scoped layout and page compositions - ADR-025 ("Flowing Content into a Nested Instance's Slot") is superseded — its `Composition` shape and `PropConfigurations` widening are absorbed here with the extended type -- Future ADRs can add cross-composition references, `kind`-specific required fields, composition-level `$extensions` for Figma provenance, and the system-scoped file schema without further changes to the `Composition` type structure +- Future ADRs can add cross-composition references, `kind`-specific required fields, and the system-scoped file schema without further changes to the `Composition` type structure From 78593444f7012ac4458b10aabd6b44d21e1381e8 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:59:02 -0400 Subject: [PATCH 04/20] feat(adr): clarify slot-bound container constraint in ADR 042 defaultComposition is meaningful only when Element.children is a PropBinding (slot-bound container / Figma SlotNode). Container elements with string[] children are FrameNodes and must never carry it. No new element type introduced. Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index e15b4f8..41226b8 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -417,7 +417,7 @@ examples: - `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because minimal slot fragments may not need full styling or explicit ordering. - `Composition.kind` is optional to preserve the inline `Composition` use case in `propConfigurations`, where kind classification adds noise. For named compositions in `Component.examples`, authors are expected to set `kind`; tooling may warn when it is absent. -- `FigmaElementExtension.defaultComposition` is a plain string key — it resolves locally within `Component.examples`. It is advisory: the schema cannot enforce that it references a valid key, and it is only meaningful when the element's anatomy type is `slot`. Validation is a consumer concern. +- `FigmaElementExtension.defaultComposition` is a plain string key — it resolves locally within `Component.examples`. It is meaningful only when the element's `children` field is a `PropBinding` (i.e., the container is slot-bound — a SlotNode in Figma's node hierarchy). A container element whose `children` is `string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this pairing; it is a consumer validation concern. No new element type is introduced — the FrameNode/SlotNode distinction is determined entirely by the `children` binding, not by the anatomy `type` field. - `PropBinding` in `PropConfigurations` is a natural complement to inline `Composition`: it allows a parent component to bind a nested instance's scalar prop to the parent's own prop alongside static values and composition-structured slot content. It follows the same `{ $binding: "..." }` shape already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. - The `$extensions['com.figma'].defaultComposition` (author-side, Figma provenance) and the inline `Composition` in `PropConfigurations` (consumer-side, parent authoring) are complementary — not in conflict. They represent two distinct roles: the component designer declares what Figma shows by default; the parent component author declares what they place in the slot when composing. - Recursive composition is supported implicitly: `Composition.anatomy` elements can carry `instanceOf: string` (already supported); if that component has a slot, the composition's `elements` can carry an inline `Composition` in `propConfigurations`. No schema changes are needed for recursive support. From 6cc7303b31071279b4b54422d06906f86fc8012e Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:57:55 -0400 Subject: [PATCH 05/20] =?UTF-8?q?feat(adr):=20revise=20ADR=20042=20?= =?UTF-8?q?=E2=80=94=20ComponentExample=20discriminated=20union?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ComponentExample = SlotExample | InstanceExample (kind-discriminated) - SlotExample: kind+'slot'+anatomy+elements+layout — no inline nesting - InstanceExample: kind+propConfigurations (scalars)+slots (named refs) - Composition retained as structural base for system-scoped follow-on - PropConfigurations widens to PropBinding only; inline Composition deferred - Remove CompositionKind; layout/page kinds moved to follow-on ADR - All examples use aSlotProperty/aSlotElement naming Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 381 +++++++++++++++++++++--------------- 1 file changed, 224 insertions(+), 157 deletions(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 41226b8..cd167fc 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -4,7 +4,7 @@ **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) -**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(extends and absorbs its `Composition` type and `PropConfigurations` widening)* +**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` type; defers its `PropConfigurations` widening to a follow-on ADR)* --- @@ -14,29 +14,34 @@ The schema represents individual components with rich fidelity — anatomy, prop Compositions appear at multiple scales in Figma-sourced design systems: -- **Slot-default content** — The specific elements Figma places inside a component's slot layer, representing what that slot shows by default. For example, a `Card` component's `content` slot may have a `Body` text element and an `Action` button as its default composition. -- **Component examples** — Standalone instances of a component shown in context with slots filled and prop values set. These demonstrate typical, ready-made usage (e.g., a `ProductCard` example with a featured image, title, and CTA button). +- **Slot-filling examples** — The elements Figma places inside a component's slot layer, representing what that slot shows in a named example. For example, a `Card` component's slot may have a `Body` text element and an `Action` button as its default example content. +- **Instance examples** — A complete, pre-configured usage of a component: specific prop values set and all slots filled with named slot examples. These document ready-made usages (e.g., a `ProductCard` example showing a featured layout with a title, image, and CTA button). - **Layout compositions** — Multi-component arrangements forming a portion of a UI: a filter grid with a data table, a sidebar with an accordion and checkboxes. Not a single component — a named assembly of collaborating components. - **Page compositions** — Full canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. -Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. `PropConfigurations` accepts only scalar values — it cannot express structured slot content. There is no `Composition` type for tooling to discover, render, or validate against. +Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. There is no `Composition` or example type for tooling to discover, render, or validate against. -DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") began addressing the narrowest case: expressing inline slot content within a parent component's `propConfigurations` when a parent component flows defined content into a child instance's slot. That proposal introduced a `Composition` type with `anatomy`, `layout`, and `elements`. This ADR supersedes ADR-025 by adopting its shape, retaining the `PropConfigurations` widening, and extending the model to cover the full compositional range. +DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") introduced a `Composition` type for the inline case — a parent component flowing structured content into a child instance's slot via `PropConfigurations`. This ADR adopts the `Composition` structural shape from ADR-025 and extends the model to cover the component-example range. The `PropConfigurations` widening from ADR-025 (inline composition as a slot prop value) is deferred to a follow-on ADR, which will address how a parent component references named slot-filling examples defined on a child component rather than inlining arbitrarily nested content. ### Composition scoping -The four composition kinds split naturally into two scopes: +The four cases split into two scopes: -- **Component-scoped** (`slot-default`, `example`): Authored by the component designer, living inside the component definition under `Component.examples`. Slot defaults express what Figma places in a slot by default; examples show the component in ready-made contexts. -- **System-scoped** (`layout`, `page`): Independent of any single component, living in a separate file (`compositions.yaml`, parallel to `components.yaml`). The schema for that file is a follow-on ADR; this ADR defines the shared `Composition` type that powers it. +- **Component-scoped** (`slot`, `instance`): Authored by the component designer, living inside the component definition under `Component.examples`. Slot examples define the content for a named slot; instance examples show the whole component in a ready-made configuration. +- **System-scoped** (`layout`, `page`): Independent of any single component, living in a separate file (`compositions.yaml`, parallel to `components.yaml`). The schema for that file is a follow-on ADR; this ADR defines the `Composition` structural type that powers it. ### Slot default content: Figma provenance, not public API -A slot's default content — the specific elements Figma places inside the slot layer — is Figma-specific provenance data. It is not part of the component's public API: the API does not prescribe what consumers must pass into a slot. Accordingly, the slot default composition reference belongs in `$extensions['com.figma']` on the slot `Element` within the variant/element layer, following the same provenance-metadata pattern used by `PropExtensions` on props. Since this lives in `default.elements` or `variants[n].elements`, it is inherently variant-sensitive — different variants can declare different default compositions for the same slot. +A slot's default content — the elements Figma places inside the slot layer — is Figma-specific provenance. It is not part of the component's public API. Accordingly, the reference to a named slot example used as the Figma default belongs in `$extensions['com.figma']` on the slot-bound container `Element` within the variant/element layer, following the same pattern used by `PropExtensions` on props. Since this lives in `default.elements` or `variants[n].elements`, it is inherently variant-sensitive. -### Consumer slot content and the parent component pattern +### Slot-bound containers vs. plain frame containers -When a *consumer* of a component authors a parent that flows content into a child's slot — e.g., a `ProductCard` that places a `Title` and `Button` inside `Card`'s `content` slot — that content is expressed as an inline `Composition` value in `PropConfigurations`. This is the ADR-025 pattern, retained here. The two patterns are complementary: the component author declares named default compositions in Figma extension metadata; the component consumer authors inline compositions when filling a child's slot. +In Figma's node model, a container element may be either a plain `FrameNode` or a `SlotNode` (a frame whose children are bound to a slot prop). The schema does not use distinct element types to distinguish these — the distinction is determined entirely by the `Element.children` field: + +- `children: PropBinding` — slot-bound container (SlotNode); may carry `$extensions['com.figma'].defaultComposition` +- `children: string[]` — plain frame container (FrameNode); must never carry `defaultComposition` + +No new element type is introduced by this ADR. --- @@ -46,36 +51,38 @@ When a *consumer* of a component authors a parent that flows content into a chil - **Additive-only changes** — all new fields are optional; no existing field is removed or narrowed → MINOR semver - **Type ↔ schema symmetry** — every new type field has a corresponding schema definition (Constitution §I) - **No runtime logic** — only type declarations and schema; no validation functions or algorithms (Constitution §II) -- **Figma provenance is not public API** — slot default content originates from Figma's design; it belongs in `$extensions['com.figma']` on the element, not in the public `Children` type or `SlotProp` -- **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant; the variant/element layer is the right location -- **`$extensions` consistency** — Figma-specific metadata on elements follows the same DTCG-derived `$extensions` pattern already established on `Props` and `TokenReference` -- **Scale independence** — the same `Composition` type must work for a slot fragment (2 elements) and a page view (dozens of component instances); `kind` classifies without requiring separate types -- **PropConfigurations completeness** — structured slot content flowing from a parent into a child component's slot must be expressible alongside scalar prop values -- **Scope separation** — component-scoped compositions belong inside the component definition; system-scoped compositions (layouts, pages) belong in a separate file with a follow-on schema +- **No inline nesting** — inline anonymous compositions in `PropConfigurations` create unbounded recursive depth in the spec output; slot content must be expressed as named references, not inlined structures +- **Discriminated union for component examples** — `SlotExample` and `InstanceExample` are structurally distinct; a tagged union with `kind` as discriminator makes them unambiguous to tooling and JSON Schema validation +- **Figma provenance is not public API** — slot default content belongs in `$extensions['com.figma']` on the element, not in `Children` or `SlotProp` +- **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant through the existing element layer +- **`$extensions` consistency** — Figma-specific element metadata follows the DTCG-derived pattern already established on `Props` and `TokenReference` +- **Scale separation** — component-scoped examples belong inside the component definition; system-scoped compositions belong in a separate file with a follow-on schema --- ## Options Considered -### Option A: `Component.examples` inline, `$extensions` on `Element` for slot defaults, `PropConfigurations` widening *(Selected)* +### Option A: `ComponentExample = SlotExample | InstanceExample`, `$extensions` for Figma defaults *(Selected)* -Introduce `Composition` as a type. Component-scoped compositions live inline in `Component.examples: Record`, classified by `kind`. Slot default content is declared as Figma provenance metadata via `$extensions['com.figma'].defaultComposition` on the slot `Element` within the variant layer — keeping `Children` clean and free of Figma-specific concerns. Consumer-side slot content uses an inline `Composition` in `PropConfigurations` (ADR-025 pattern). System-scoped compositions use the same `Composition` type in a separate file defined by a follow-on ADR. +Introduce a `ComponentExample` discriminated union with two members: `SlotExample` (the anatomy and element bindings for a named slot's content) and `InstanceExample` (scalar prop values plus named slot-filling references). All slot content is expressed as named references — no inline nesting. Figma default content is declared via `$extensions['com.figma'].defaultComposition` on the slot-bound element. `Composition` is retained as a structural base type for system-scoped follow-on use. ```yaml -# Component with inline examples and Figma-extension slot binding +# Card — component with named examples and Figma-extension slot binding title: Card anatomy: root: type: container - contentSlot: - type: slot + aSlotElement: + type: container # slot-bound container — children bound to a slot prop + props: - content: + aSlotProperty: type: slot examples: cardBodyDefault: - kind: slot-default + kind: slot + slot: aSlotProperty title: Card – default body anatomy: body: @@ -94,73 +101,71 @@ examples: propConfigurations: label: "Learn more" + cardFeaturedExample: + kind: instance + title: Card – featured usage + slots: + aSlotProperty: cardBodyDefault # references examples.cardBodyDefault + default: elements: - contentSlot: + aSlotElement: + children: { $binding: "#/props/aSlotProperty" } $extensions: com.figma: - defaultComposition: cardBodyDefault # key in Component.examples + defaultComposition: cardBodyDefault # Figma provenance — not public API ``` ```yaml -# Parent component (ProductCard) flowing inline content into Card's slot -default: - elements: - card: - instanceOf: Card - propConfigurations: - content: # Card's slot prop — inline Composition value - anatomy: - title: - type: text - cta: - type: instance - instanceOf: Button - layout: - - title - - cta - elements: - title: - content: { $binding: "#/props/title" } - cta: - instanceOf: Button - propConfigurations: - label: { $binding: "#/props/ctaLabel" } +# ProductCard — InstanceExample fills a nested instance's slot +title: ProductCard +anatomy: + root: + type: container + aSlotElement: + type: instance + instanceOf: Card + +examples: + productCardFeatured: + kind: instance + title: ProductCard – featured + propConfigurations: + variant: featured + slots: + aSlotProperty: cardBodyDefault # fills Card's aSlotProperty ``` **Pros**: -- `Children` stays unchanged — no Figma-specific branches in a core structural type -- `$extensions` on `Element` follows the established provenance-metadata pattern -- Slot default declarations are variant-sensitive through the existing element layer -- `Component.examples` is inline — tools read the full composition without a separate lookup -- Author-defined (named) and consumer-defined (inline) composition patterns are distinct and complementary +- Named references prevent unbounded inline nesting — every slot filling is a string key regardless of hierarchy depth +- `SlotExample` and `InstanceExample` are structurally distinct and discriminated by `kind` +- Figma default content stays in `$extensions` — `Children` and `SlotProp` are unchanged +- `Composition` is cleanly separated as the structural base for future system-scoped use - All changes additive → MINOR **Cons / Trade-offs**: -- `Component.examples` can grow large for heavily-slotted components (advisory concern; schema has no length constraint) -- Inline `Composition` in `PropConfigurations` requires consumers to handle two patterns: named (author declares in `Component.examples`) and inline (consumer authors in `propConfigurations`) +- Cross-component slot references in `InstanceExample.slots` (filling a nested instance's slot from a different component's `examples`) require a resolution protocol that is deferred to a follow-on ADR +- `PropConfigurations` for slot values in parent components is deferred — the full parent-fills-child-slot mechanism remains in ADR-025 pending the follow-on --- -### Option B: `CompositionReference` in `Children` *(Rejected)* +### Option B: Inline `Composition` in `PropConfigurations` (ADR-025 pattern) *(Rejected)* -Add a `CompositionReference = { $composition: string }` type as a new branch of `Children`, so a slot element's `children` field can directly reference a named composition. +Allow inline anonymous `Composition` objects as values in `PropConfigurations` when filling a slot prop on a nested instance. **Rejected because**: -- `Children` is a public structural type used on all elements — adding a Figma-sourced reference branch pollutes it with provenance-specific semantics -- Slot default content is Figma-specific data (what Figma puts in the slot layer); it is not the slot's public API default. The `$extensions` pattern is the established home for such metadata. -- The `Children` union would become ambiguous in intent: `string[]` (layout children), `PropBinding` (consumer-bound), and `CompositionReference` (Figma provenance) have fundamentally different semantic roles +- Creates unbounded recursive nesting: a composition contains component instances with their own slots, whose fillings are also inline compositions, ad infinitum +- Inline anonymous compositions cannot be named, reused, or referenced from `InstanceExample.slots`; they are invisible to tooling that catalogues compositions --- -### Option C: All compositions at root level, referenced by string key everywhere *(Rejected)* +### Option C: Single `Composition` type with `kind` covering all scales *(Rejected)* -All compositions (including slot defaults and examples) live in a root-level `compositions` catalogue. Components and slot elements reference them by string key. +One `Composition` type with a `kind` field covering `slot-default`, `example`, `layout`, and `page`. **Rejected because**: -- Component-scoped compositions are owned by one component and have no cross-component reuse value; forcing them to a shared root catalogue adds indirection without benefit -- Tooling reading a component spec would need a separate catalogue lookup to resolve composition data -- String keys in structural fields cannot be distinguished from other string values without a structured reference type +- `SlotExample` (anatomy + slot binding) and `InstanceExample` (prop values + slot refs) are structurally incompatible — they cannot share a single type without making all fields optional or requiring runtime discrimination +- Mixing component-scoped and system-scoped kinds in one type conflates two different authoring contexts --- @@ -169,18 +174,8 @@ All compositions (including slot defaults and examples) live in a root-level `co Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types. **Rejected because**: -- The structural shape is identical across all scales (`anatomy`, `elements`, `layout`); scale is semantic, not structural -- Four types multiply schema surface area and downstream import burden without adding constraint expressiveness - ---- - -### Option E: Absorb compositions into `Variant` *(Rejected)* - -A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse `Variant` with a discriminating field. - -**Rejected because**: -- `Variant` is bound to a single component's prop configuration state; it has no anatomy of its own -- `Composition` is a multi-element, multi-component fragment — structurally and conceptually distinct from a variant +- `LayoutComposition` and `PageComposition` share the same structural shape as `Composition`; duplication without benefit +- Four separate types multiply schema surface area and downstream import burden --- @@ -190,32 +185,46 @@ A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse | File | Change | Bump | |------|--------|------| -| New: `Composition.ts` | Add `CompositionKind`, `Composition`, `Compositions` | MINOR | +| New: `Composition.ts` | Add `Composition`, `SlotExample`, `InstanceExample`, `ComponentExample`, `ComponentExamples` | MINOR | | `Element.ts` | Add optional `$extensions?: ElementExtensions`; add `ElementExtensions`, `FigmaElementExtension` | MINOR | -| `Component.ts` | Add optional `examples?: Record` | MINOR | -| `PropConfigurations.ts` | Widen value union to `string \| number \| boolean \| PropBinding \| Composition` | MINOR | -| `index.ts` | Export `Composition`, `CompositionKind`, `Compositions`, `ElementExtensions`, `FigmaElementExtension` | MINOR | +| `Component.ts` | Add optional `examples?: ComponentExamples` | MINOR | +| `PropConfigurations.ts` | Widen value union to include `PropBinding` | MINOR | +| `index.ts` | Export `Composition`, `SlotExample`, `InstanceExample`, `ComponentExample`, `ComponentExamples`, `ElementExtensions`, `FigmaElementExtension` | MINOR | **New file** (`types/Composition.ts`): ```yaml -# CompositionKind — classification of a composition's scale and intent -CompositionKind: - 'slot-default' # default content Figma places in a component's named slot - 'example' # a complete, ready-made usage of a component with slots filled - 'layout' # a multi-component partial-page arrangement (system-scoped) - 'page' # a full canonical page view (system-scoped) - -# Composition — a pre-arranged grouping of component instances +# Composition — raw structural content fragment +# Base shape used by SlotExample and by future system-scoped layout/page compositions Composition: - title?: string # human-readable label - kind?: CompositionKind # optional for inline propConfigurations use; expected for named compositions - anatomy: Anatomy # required — element type map for all instances in this fragment - elements?: Elements # optional — style/content/propConfigurations bindings per element - layout?: Layout # optional — ordering of fragment children - -# Compositions — convenience type for a named record of compositions -Compositions: Record + title?: string + anatomy: Anatomy # required + elements?: Elements + layout?: Layout + +# SlotExample — named content for a specific slot +SlotExample: + kind: 'slot' # discriminator + slot: string # name of the SlotProp this example fills + title?: string + anatomy: Anatomy # required + elements?: Elements + layout?: Layout + +# InstanceExample — a pre-configured usage of the whole component +InstanceExample: + kind: 'instance' # discriminator + title?: string + propConfigurations?: + Record # scalar prop values only + slots?: + Record # slot prop name → SlotExample key in Component.examples + +# ComponentExample — discriminated union for Component.examples +ComponentExample: SlotExample | InstanceExample + +# ComponentExamples — named record on Component +ComponentExamples: Record ``` **Extended `Element`** (`types/Element.ts`): @@ -238,26 +247,25 @@ Element: propConfigurations?: PropConfigurations instanceOf?: string | PropBinding | SubcomponentRef content?: string | PropBinding - $extensions?: ElementExtensions # Figma-specific element metadata + $extensions?: ElementExtensions # new — Figma-specific element metadata ``` **New types** (in `types/Element.ts`): ```yaml -# Figma-specific metadata for a slot element's default composition FigmaElementExtension: - defaultComposition?: string # key in Component.examples — only meaningful for slot-type elements - [key: string]: unknown # additional Figma metadata passes through + defaultComposition?: string # key in Component.examples — valid only when children is PropBinding + [key: string]: unknown -# DTCG §5.2.3 platform-specific extensions for element definitions ElementExtensions: 'com.figma'?: FigmaElementExtension [key: string]: unknown ``` ```yaml -# Usage — slot element in default.elements or variants[n].elements -contentSlot: +# Usage — slot-bound element in default.elements or variants[n].elements +aSlotElement: + children: { $binding: "#/props/aSlotProperty" } $extensions: com.figma: defaultComposition: cardBodyDefault # references Component.examples.cardBodyDefault @@ -287,7 +295,7 @@ Component: variants?: Variants invalidVariantCombinations?: PropConfigurations[] metadata?: Metadata - examples?: Record # component-scoped named compositions + examples?: ComponentExamples # new — named slot and instance examples ``` **Widened `PropConfigurations`** (`types/PropConfigurations.ts`): @@ -297,7 +305,16 @@ Component: PropConfigurations: Record # After -PropConfigurations: Record +PropConfigurations: Record +``` + +```yaml +# Usage — binding a nested instance's scalar prop to the parent's own prop +aSlotElement: + instanceOf: Card + propConfigurations: + variant: featured # static scalar + label: { $binding: "#/props/label" } # bound to parent's label prop ``` ### Schema changes (`schema/`) @@ -305,10 +322,12 @@ PropConfigurations: Record` type is defined here and ready for that ADR to reference. -- **Cross-composition references** — One composition referencing another named composition within its own element data. The type permits it structurally; a dedicated ADR can formalize resolution semantics. +- **`compositions.yaml` file schema** — Schema for system-scoped (`layout`, `page`) compositions in a separate file. Deferred to a follow-on ADR. The `Composition` structural type is defined here and ready. +- **Parent-fills-child-slot via `PropConfigurations`** — The mechanism for referencing a named `SlotExample` from a child component as a `PropConfigurations` slot value. Deferred to the follow-on that supersedes ADR-025. +- **Cross-component key resolution in `InstanceExample.slots`** — When `slots` fills a slot belonging to a nested child component, the key must resolve against that child's `Component.examples`. The resolution protocol is deferred to the follow-on ADR. +- **Nested slot filling within `SlotExample`** — When a `SlotExample` contains component instances that themselves have slots, filling those nested slots is deferred to the follow-on ADR. ### Notes -- `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because minimal slot fragments may not need full styling or explicit ordering. -- `Composition.kind` is optional to preserve the inline `Composition` use case in `propConfigurations`, where kind classification adds noise. For named compositions in `Component.examples`, authors are expected to set `kind`; tooling may warn when it is absent. -- `FigmaElementExtension.defaultComposition` is a plain string key — it resolves locally within `Component.examples`. It is meaningful only when the element's `children` field is a `PropBinding` (i.e., the container is slot-bound — a SlotNode in Figma's node hierarchy). A container element whose `children` is `string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this pairing; it is a consumer validation concern. No new element type is introduced — the FrameNode/SlotNode distinction is determined entirely by the `children` binding, not by the anatomy `type` field. -- `PropBinding` in `PropConfigurations` is a natural complement to inline `Composition`: it allows a parent component to bind a nested instance's scalar prop to the parent's own prop alongside static values and composition-structured slot content. It follows the same `{ $binding: "..." }` shape already used on `Element.content`, `Element.instanceOf`, and `Styles.visible`. -- The `$extensions['com.figma'].defaultComposition` (author-side, Figma provenance) and the inline `Composition` in `PropConfigurations` (consumer-side, parent authoring) are complementary — not in conflict. They represent two distinct roles: the component designer declares what Figma shows by default; the parent component author declares what they place in the slot when composing. -- Recursive composition is supported implicitly: `Composition.anatomy` elements can carry `instanceOf: string` (already supported); if that component has a slot, the composition's `elements` can carry an inline `Composition` in `propConfigurations`. No schema changes are needed for recursive support. +- `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because a minimal slot fragment may not need styling or explicit ordering. +- `SlotExample.slot` names the `SlotProp` the example fills, enabling tooling to associate the example with the correct slot without inspecting anatomy. +- `InstanceExample.slots` maps slot prop names to `SlotExample` keys in `Component.examples`. Deeper nesting — filling slots of instances within a `SlotExample` — is deferred to the follow-on ADR. +- `InstanceExample.propConfigurations` holds scalar values only (`string | number | boolean`). It is intentionally simpler than `Element.propConfigurations` (which now also accepts `PropBinding`): `InstanceExample` represents a documented configuration, not a live data binding. +- `FigmaElementExtension.defaultComposition` is valid only when `Element.children` is a `PropBinding` (slot-bound container / SlotNode in Figma). A container element with `children: string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this; it is a consumer validation concern. +- `PropBinding` in `PropConfigurations` allows a parent component to bind a nested instance's scalar prop to the parent's own prop, using the `{ $binding: "..." }` shape already established on `Element.content`, `Element.instanceOf`, and `Styles.visible`. --- @@ -428,14 +495,15 @@ examples: - **Symmetric**: Yes — every type field maps to a schema property - **Parity check**: - - `Composition { title?, kind?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` with same required/optional pattern - - `CompositionKind` string literal union ↔ `#/definitions/CompositionKind` enum - - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` with optional `defaultComposition` + - `Composition { title?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` + - `SlotExample { kind: 'slot', slot, title?, anatomy, elements?, layout? }` ↔ `#/definitions/SlotExample` + - `InstanceExample { kind: 'instance', title?, propConfigurations?, slots? }` ↔ `#/definitions/InstanceExample` + - `ComponentExample = SlotExample | InstanceExample` ↔ `#/definitions/ComponentExample` (`oneOf`) + - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` - `ElementExtensions { 'com.figma'?: FigmaElementExtension }` ↔ `#/definitions/ElementExtensions` - `Element.$extensions?: ElementExtensions` ↔ `#/definitions/Element/properties/$extensions` - - `PropConfigurations` value union `string | number | boolean | PropBinding | Composition` ↔ `additionalProperties.oneOf` with five branches - - `Component.examples?: Record` ↔ `#/definitions/Component/properties/examples` patternProperties referencing `Composition` - - `Compositions = Record` — TypeScript type defined; corresponding schema file deferred to follow-on ADR + - `PropConfigurations` value union `string | number | boolean | PropBinding` ↔ `additionalProperties.oneOf` (four branches) + - `Component.examples?: ComponentExamples` ↔ `#/definitions/Component/properties/examples` --- @@ -443,9 +511,9 @@ examples: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | Must detect and emit `Component.examples` from Figma slot layers and example frames; must emit `$extensions['com.figma'].defaultComposition` on slot elements in variant data; must widen `PropConfigurations` emission to include inline `Composition` | Read new types from schema; implement composition detection and Figma extension emission; update output emitters | -| `specs-cli` | Recompile against updated types; CLI output includes `examples` key and element `$extensions` when present in component spec | Recompile; no breaking consumer change — new optional keys appear in output | -| `specs-plugin-2` | Recompile; panel may display composition entries when present | Recompile; composition rendering is a follow-on capability — panel can pass through composition data initially | +| `specs-from-figma` | Must detect and emit `Component.examples` (`SlotExample` from slot layers, `InstanceExample` from example frames); must emit `$extensions['com.figma'].defaultComposition` on slot-bound elements in variant data | Read new types; implement example detection; update output emitters | +| `specs-cli` | Recompile; CLI output includes `examples` key and element `$extensions` when present | Recompile; no breaking change | +| `specs-plugin-2` | Recompile; example rendering is a follow-on capability | Recompile; pass through composition data initially | --- @@ -456,8 +524,8 @@ examples: **Justification**: All changes are additive: - New optional field `Component.examples` on an existing type - New optional field `Element.$extensions` on an existing type -- New types (`Composition`, `CompositionKind`, `Compositions`, `ElementExtensions`, `FigmaElementExtension`) — no removal or narrowing -- `PropConfigurations` value union widened — existing scalar values remain valid +- New types (`Composition`, `SlotExample`, `InstanceExample`, `ComponentExample`, `ComponentExamples`, `ElementExtensions`, `FigmaElementExtension`) — no removal or narrowing +- `PropConfigurations` value union widened to add `PropBinding` — existing scalar values remain valid Per Constitution §III: additive types and new optional fields → MINOR. @@ -465,13 +533,12 @@ Per Constitution §III: additive types and new optional fields → MINOR. ## Consequences -- `Composition` is a first-class type in the schema, co-equal with `Component` as a structural concept; all four scales share one type, classified by `kind` -- Component authors can declare named compositions inline in `Component.examples`; slot defaults and usage examples are discoverable alongside the component that owns them -- Slot default content is Figma provenance metadata, expressed via `$extensions['com.figma'].defaultComposition` on the slot element in the variant layer — keeping `Children` clean and the public schema free of Figma-specific branches -- Slot default declarations are variant-sensitive: different variants can declare different default compositions for the same slot through the existing element layer -- Component consumers can flow inline `Composition` content into a child's slot via `PropConfigurations`, completing the parent-authoring pattern proposed in ADR-025 -- `PropBinding` in `PropConfigurations` values enables a parent to bind a nested instance's scalar prop to its own prop alongside static values and compositions -- `Element` gains `$extensions` following the same DTCG-derived provenance-metadata pattern established on `Props` and `TokenReference` -- `Compositions` type is defined and ready for a follow-on ADR that introduces the `compositions.yaml` schema for system-scoped layout and page compositions -- ADR-025 ("Flowing Content into a Nested Instance's Slot") is superseded — its `Composition` shape and `PropConfigurations` widening are absorbed here with the extended type -- Future ADRs can add cross-composition references, `kind`-specific required fields, and the system-scoped file schema without further changes to the `Composition` type structure +- `Composition` is a first-class structural type in the schema; `SlotExample` and `InstanceExample` are its component-scoped manifestations +- Component authors declare named examples in `Component.examples`; slot content and usage configurations are discoverable alongside the component that owns them +- All slot-filling references are named strings — no inline anonymous compositions, no unbounded recursive nesting in the spec output +- Slot default content is Figma provenance metadata in `Element.$extensions['com.figma'].defaultComposition` — `Children` and `SlotProp` are unchanged +- `PropConfigurations` gains `PropBinding` — a parent component can bind a nested instance's scalar prop to its own prop, completing the pattern already used on `Element.content` and `Styles.visible` +- `Element` gains `$extensions` following the DTCG-derived provenance-metadata pattern established on `Props` and `TokenReference` +- `Composition` is defined and ready for a follow-on ADR introducing `compositions.yaml` for system-scoped layout and page compositions +- The parent-fills-child-slot pattern and cross-component slot key resolution are deferred to a follow-on ADR that supersedes ADR-025; ADR-025 remains open +- Future ADRs can add the system-scoped file schema, nested slot filling in `SlotExample`, and the full slot-filling mechanism without changes to the types established here From bfb1ffcd3e5a1f2cf716364d06edce914035f073 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:07:49 -0400 Subject: [PATCH 06/20] feat(adr): add ActionList sensitizing example and one-level-deep principle to ADR 042 - Replace Card/ProductCard examples with ActionList/ActionListItem throughout - Add sensitizing example section: illustrates 56 to 16 entry reduction via one-level-deep principle and no cross-component references - Add two new Decision Drivers: One level deep and No cross-component references - Update Option A Pros to call out scale reduction explicitly - Update Notes to explain the ActionListItem boundary and why it is intentional Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 177 +++++++++++++++++++++++++++--------- 1 file changed, 134 insertions(+), 43 deletions(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index cd167fc..a4b755e 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -14,8 +14,8 @@ The schema represents individual components with rich fidelity — anatomy, prop Compositions appear at multiple scales in Figma-sourced design systems: -- **Slot-filling examples** — The elements Figma places inside a component's slot layer, representing what that slot shows in a named example. For example, a `Card` component's slot may have a `Body` text element and an `Action` button as its default example content. -- **Instance examples** — A complete, pre-configured usage of a component: specific prop values set and all slots filled with named slot examples. These document ready-made usages (e.g., a `ProductCard` example showing a featured layout with a title, image, and CTA button). +- **Slot-filling examples** — The elements Figma places inside a component's slot layer, representing what that slot shows in a named example. For example, an `ActionListItem` component's `startVisual` slot may have a glyph element as its default example content. +- **Instance examples** — A complete, pre-configured usage of a component: specific prop values set and all slots filled with named slot examples. These document ready-made usages (e.g., an `ActionList` example showing the `danger` variant with three items). - **Layout compositions** — Multi-component arrangements forming a portion of a UI: a filter grid with a data table, a sidebar with an accordion and checkboxes. Not a single component — a named assembly of collaborating components. - **Page compositions** — Full canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. @@ -43,6 +43,16 @@ In Figma's node model, a container element may be either a plain `FrameNode` or No new element type is introduced by this ADR. +### Sensitizing example: ActionList and ActionListItem + +`ActionList` and `ActionListItem` illustrate why composition scale is a first-order concern. + +**ActionListItem** has two slot props — `startVisual` and `endVisual` — each accepting a glyph or avatar instance. Its examples are compact: one `SlotExample` per slot, one `InstanceExample` combining them — roughly 4 entries total. + +**ActionList** has one slot prop — `items` — accepting a sequence of `ActionListItem` instances. It also has 8 prop variants. A naive approach that fills each item's slot content directly on `ActionList` compounds immediately: 8 variants × 3 items × 2 slots per item = **56 slot examples** on `ActionList`, with references reaching across component boundaries into `ActionListItem`'s own slot definitions. + +The one-level-deep principle resolves this: each `ActionList` `SlotExample` declares only the anatomy of the `items` slot — three `ActionListItem` instances — without filling those instances' `startVisual` or `endVisual` slots. `ActionListItem` owns those. The count becomes **8 `SlotExample` + 8 `InstanceExample` = 16 entries** on `ActionList`; ~4 entries on `ActionListItem`. No cross-component references are needed. + --- ## Decision Drivers @@ -52,6 +62,8 @@ No new element type is introduced by this ADR. - **Type ↔ schema symmetry** — every new type field has a corresponding schema definition (Constitution §I) - **No runtime logic** — only type declarations and schema; no validation functions or algorithms (Constitution §II) - **No inline nesting** — inline anonymous compositions in `PropConfigurations` create unbounded recursive depth in the spec output; slot content must be expressed as named references, not inlined structures +- **One level deep** — a `SlotExample` declares the anatomy and element types of what fills the slot but does not recurse into those elements' own slot content; each component is the sole author of its own examples +- **No cross-component references** — all keys in `ComponentExamples` resolve within the same component definition; `InstanceExample.slots` references only keys within the same `Component.examples` - **Discriminated union for component examples** — `SlotExample` and `InstanceExample` are structurally distinct; a tagged union with `kind` as discriminator makes them unambiguous to tooling and JSON Schema validation - **Figma provenance is not public API** — slot default content belongs in `$extensions['com.figma']` on the element, not in `Children` or `SlotProp` - **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant through the existing element layer @@ -67,84 +79,162 @@ No new element type is introduced by this ADR. Introduce a `ComponentExample` discriminated union with two members: `SlotExample` (the anatomy and element bindings for a named slot's content) and `InstanceExample` (scalar prop values plus named slot-filling references). All slot content is expressed as named references — no inline nesting. Figma default content is declared via `$extensions['com.figma'].defaultComposition` on the slot-bound element. `Composition` is retained as a structural base type for system-scoped follow-on use. ```yaml -# Card — component with named examples and Figma-extension slot binding -title: Card +# ActionListItem — component with slot examples for its visual slot props +title: Action List Item anatomy: root: type: container - aSlotElement: - type: container # slot-bound container — children bound to a slot prop + startVisualSlot: + type: container # slot-bound container — children bound to startVisual prop + label: + type: text + endVisualSlot: + type: container # slot-bound container — children bound to endVisual prop props: - aSlotProperty: + startVisual: + type: slot + endVisual: type: slot examples: - cardBodyDefault: + iconStart: kind: slot - slot: aSlotProperty - title: Card – default body + slot: startVisual + title: Action List Item – icon in start visual anatomy: - body: - type: text - action: - type: instance - instanceOf: Button - layout: - - body - - action + icon: + type: glyph + elements: + icon: + content: search + + iconEnd: + kind: slot + slot: endVisual + title: Action List Item – icon in end visual + anatomy: + icon: + type: glyph elements: - body: - content: "Card body text" - action: - instanceOf: Button - propConfigurations: - label: "Learn more" - - cardFeaturedExample: + icon: + content: chevronRight + + withBothIcons: kind: instance - title: Card – featured usage + title: Action List Item – with both icons slots: - aSlotProperty: cardBodyDefault # references examples.cardBodyDefault + startVisual: iconStart # references examples.iconStart + endVisual: iconEnd # references examples.iconEnd default: elements: - aSlotElement: - children: { $binding: "#/props/aSlotProperty" } + startVisualSlot: + children: { $binding: "#/props/startVisual" } $extensions: com.figma: - defaultComposition: cardBodyDefault # Figma provenance — not public API + defaultComposition: iconStart # Figma provenance — not public API + endVisualSlot: + children: { $binding: "#/props/endVisual" } ``` ```yaml -# ProductCard — InstanceExample fills a nested instance's slot -title: ProductCard +# ActionList — 8 variants; one SlotExample per variant, one level deep +# SlotExample anatomy stops at the ActionListItem boundary — +# ActionListItem owns its own startVisual/endVisual slot examples +title: Action List anatomy: root: type: container - aSlotElement: - type: instance - instanceOf: Card + itemsSlot: + type: container # slot-bound container — children bound to items prop + +props: + items: + type: slot + variant: + type: string examples: - productCardFeatured: + defaultItems: + kind: slot + slot: items + title: Action List – default items + anatomy: + item1: + type: instance + instanceOf: ActionListItem + item2: + type: instance + instanceOf: ActionListItem + item3: + type: instance + instanceOf: ActionListItem + layout: + - item1 + - item2 + - item3 + # No elements — ActionListItem owns its startVisual/endVisual slot examples + + defaultUsage: + kind: instance + title: Action List – default usage + propConfigurations: + variant: default + slots: + items: defaultItems # references examples.defaultItems + + dangerItems: + kind: slot + slot: items + title: Action List – danger items + anatomy: + item1: + type: instance + instanceOf: ActionListItem + item2: + type: instance + instanceOf: ActionListItem + item3: + type: instance + instanceOf: ActionListItem + layout: + - item1 + - item2 + - item3 + + dangerUsage: kind: instance - title: ProductCard – featured + title: Action List – danger usage propConfigurations: - variant: featured + variant: danger slots: - aSlotProperty: cardBodyDefault # fills Card's aSlotProperty + items: dangerItems + + # ... pattern repeats for 6 more variants + # Total: 8 SlotExamples + 8 InstanceExamples = 16 entries on ActionList + # vs. naive cross-component recursion: 8 variants × 3 items × 2 slots = 48+ entries + +default: + elements: + itemsSlot: + children: { $binding: "#/props/items" } + $extensions: + com.figma: + defaultComposition: defaultItems # Figma provenance — not public API ``` **Pros**: - Named references prevent unbounded inline nesting — every slot filling is a string key regardless of hierarchy depth +- One-level-deep boundary keeps example counts proportional to a component's own variation surface — `ActionList` needs 16 entries, not 56 +- No cross-component references — each component's examples resolve entirely within that component's own `Component.examples` - `SlotExample` and `InstanceExample` are structurally distinct and discriminated by `kind` - Figma default content stays in `$extensions` — `Children` and `SlotProp` are unchanged - `Composition` is cleanly separated as the structural base for future system-scoped use - All changes additive → MINOR **Cons / Trade-offs**: -- Cross-component slot references in `InstanceExample.slots` (filling a nested instance's slot from a different component's `examples`) require a resolution protocol that is deferred to a follow-on ADR +- Filling slots of instances *within* a `SlotExample` (e.g., setting `ActionListItem`'s `startVisual` from `ActionList`'s context) requires a follow-on ADR to define the cross-component resolution protocol - `PropConfigurations` for slot values in parent components is deferred — the full parent-fills-child-slot mechanism remains in ADR-025 pending the follow-on --- @@ -478,13 +568,14 @@ examples: - **`compositions.yaml` file schema** — Schema for system-scoped (`layout`, `page`) compositions in a separate file. Deferred to a follow-on ADR. The `Composition` structural type is defined here and ready. - **Parent-fills-child-slot via `PropConfigurations`** — The mechanism for referencing a named `SlotExample` from a child component as a `PropConfigurations` slot value. Deferred to the follow-on that supersedes ADR-025. - **Cross-component key resolution in `InstanceExample.slots`** — When `slots` fills a slot belonging to a nested child component, the key must resolve against that child's `Component.examples`. The resolution protocol is deferred to the follow-on ADR. -- **Nested slot filling within `SlotExample`** — When a `SlotExample` contains component instances that themselves have slots, filling those nested slots is deferred to the follow-on ADR. +- **Nested slot filling within `SlotExample`** — When a `SlotExample` contains component instances that themselves have slots (e.g., `ActionListItem` instances inside `ActionList`'s `items` slot), filling those nested slots from the parent component is deferred to the follow-on ADR. Each component resolves its own slot content independently for now. ### Notes - `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because a minimal slot fragment may not need styling or explicit ordering. - `SlotExample.slot` names the `SlotProp` the example fills, enabling tooling to associate the example with the correct slot without inspecting anatomy. -- `InstanceExample.slots` maps slot prop names to `SlotExample` keys in `Component.examples`. Deeper nesting — filling slots of instances within a `SlotExample` — is deferred to the follow-on ADR. +- `InstanceExample.slots` maps slot prop names to `SlotExample` keys in the same `Component.examples`. All keys resolve within the same component definition — no cross-component references. Filling the slots of instances that appear *within* a `SlotExample` (e.g., setting `ActionListItem`'s `startVisual` from `ActionList`'s examples) is deferred to the follow-on ADR. +- The one-level-deep boundary is intentional: `ActionList`'s `SlotExample` for the `items` slot declares three `ActionListItem` instances but does not fill their `startVisual` or `endVisual` slots. `ActionListItem` owns those via its own `Component.examples`. This keeps the example count on each component proportional to that component's own variation surface, not to every descendant's. - `InstanceExample.propConfigurations` holds scalar values only (`string | number | boolean`). It is intentionally simpler than `Element.propConfigurations` (which now also accepts `PropBinding`): `InstanceExample` represents a documented configuration, not a live data binding. - `FigmaElementExtension.defaultComposition` is valid only when `Element.children` is a `PropBinding` (slot-bound container / SlotNode in Figma). A container element with `children: string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this; it is a consumer validation concern. - `PropBinding` in `PropConfigurations` allows a parent component to bind a nested instance's scalar prop to the parent's own prop, using the `{ $binding: "..." }` shape already established on `Element.content`, `Element.instanceOf`, and `Styles.visible`. From 8a0c2b2444579e7f27006c8a08065e4efff22eef Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:24:44 -0400 Subject: [PATCH 07/20] feat(adr): narrow ADR-042 to Composition structural type; stub 043-045 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-042 now covers only the Composition base type (anatomy, elements, layout). The four-ADR breakdown: 043 — Component Examples: InstanceExample + Component.examples 044 — Slot Content: SlotExample + Element extensions 045 — PropConfigurations PropBinding Example anchors on ActionListItem with state/title/description scalar props; slots are introduced in ADR-044. Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 589 +++++------------------------------- adr/INDEX.md | 5 +- 2 files changed, 72 insertions(+), 522 deletions(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index a4b755e..b3e36db 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -1,271 +1,92 @@ -# ADR: Composition as a First-Class Type +# ADR: Composition Structural Type **Branch**: `042-composition-type` **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) -**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` type; defers its `PropConfigurations` widening to a follow-on ADR)* +**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` shape; defers its `PropConfigurations` widening to ADR-045)* +**Extended by**: ADR-043 (Component Examples), ADR-044 (Slot Content), ADR-045 (PropConfigurations PropBinding) --- ## Context -The schema represents individual components with rich fidelity — anatomy, props, styling variants, layout. But the schema has no concept of *composition*: a pre-arranged grouping of component instances that expresses how components are combined in context. +The schema represents components individually but has no type for a *composition*: a named, reusable arrangement of component instances that expresses how components are combined in context. -Compositions appear at multiple scales in Figma-sourced design systems: +Compositions appear at four scales in Figma-sourced design systems: -- **Slot-filling examples** — The elements Figma places inside a component's slot layer, representing what that slot shows in a named example. For example, an `ActionListItem` component's `startVisual` slot may have a glyph element as its default example content. -- **Instance examples** — A complete, pre-configured usage of a component: specific prop values set and all slots filled with named slot examples. These document ready-made usages (e.g., an `ActionList` example showing the `danger` variant with three items). -- **Layout compositions** — Multi-component arrangements forming a portion of a UI: a filter grid with a data table, a sidebar with an accordion and checkboxes. Not a single component — a named assembly of collaborating components. -- **Page compositions** — Full canonical views: a default application screen with header, navigation, content area, and footer, each occupied by specific components in specific states. +- **Slot-filling examples** — content placed inside a component's slot layer in Figma (e.g., what fills `ActionListItem`'s `startVisual` slot by default) +- **Instance examples** — a fully-configured component usage (e.g., `ActionList` in its `danger` variant with three items) +- **Layout compositions** — multi-component arrangements forming a portion of a UI (a filter grid with a data table, a sidebar with checkboxes) +- **Page compositions** — full canonical views with specific components in specific states -Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. There is no `Composition` or example type for tooling to discover, render, or validate against. +Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. There is no structural type for tooling to discover, render, or validate against. -DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") introduced a `Composition` type for the inline case — a parent component flowing structured content into a child instance's slot via `PropConfigurations`. This ADR adopts the `Composition` structural shape from ADR-025 and extends the model to cover the component-example range. The `PropConfigurations` widening from ADR-025 (inline composition as a slot prop value) is deferred to a follow-on ADR, which will address how a parent component references named slot-filling examples defined on a child component rather than inlining arbitrarily nested content. +DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") proposed a `Composition` type for the inline parent-fills-child-slot case. This ADR adopts that structural shape as a named foundational type. How components store and reference compositions is addressed in ADR-043 (`InstanceExample`, `Component.examples`) and ADR-044 (`SlotExample`, `Element.$extensions`). ### Composition scoping -The four cases split into two scopes: +The four scales split into two authoring scopes: -- **Component-scoped** (`slot`, `instance`): Authored by the component designer, living inside the component definition under `Component.examples`. Slot examples define the content for a named slot; instance examples show the whole component in a ready-made configuration. -- **System-scoped** (`layout`, `page`): Independent of any single component, living in a separate file (`compositions.yaml`, parallel to `components.yaml`). The schema for that file is a follow-on ADR; this ADR defines the `Composition` structural type that powers it. +- **Component-scoped** (`slot`, `instance`) — authored by the component designer inside the component definition; covered by ADR-043 and ADR-044 +- **System-scoped** (`layout`, `page`) — independent of any single component, living in a separate `compositions.yaml` file parallel to `components.yaml`; schema for that file is a follow-on ADR -### Slot default content: Figma provenance, not public API - -A slot's default content — the elements Figma places inside the slot layer — is Figma-specific provenance. It is not part of the component's public API. Accordingly, the reference to a named slot example used as the Figma default belongs in `$extensions['com.figma']` on the slot-bound container `Element` within the variant/element layer, following the same pattern used by `PropExtensions` on props. Since this lives in `default.elements` or `variants[n].elements`, it is inherently variant-sensitive. - -### Slot-bound containers vs. plain frame containers - -In Figma's node model, a container element may be either a plain `FrameNode` or a `SlotNode` (a frame whose children are bound to a slot prop). The schema does not use distinct element types to distinguish these — the distinction is determined entirely by the `Element.children` field: - -- `children: PropBinding` — slot-bound container (SlotNode); may carry `$extensions['com.figma'].defaultComposition` -- `children: string[]` — plain frame container (FrameNode); must never carry `defaultComposition` - -No new element type is introduced by this ADR. - -### Sensitizing example: ActionList and ActionListItem - -`ActionList` and `ActionListItem` illustrate why composition scale is a first-order concern. - -**ActionListItem** has two slot props — `startVisual` and `endVisual` — each accepting a glyph or avatar instance. Its examples are compact: one `SlotExample` per slot, one `InstanceExample` combining them — roughly 4 entries total. - -**ActionList** has one slot prop — `items` — accepting a sequence of `ActionListItem` instances. It also has 8 prop variants. A naive approach that fills each item's slot content directly on `ActionList` compounds immediately: 8 variants × 3 items × 2 slots per item = **56 slot examples** on `ActionList`, with references reaching across component boundaries into `ActionListItem`'s own slot definitions. - -The one-level-deep principle resolves this: each `ActionList` `SlotExample` declares only the anatomy of the `items` slot — three `ActionListItem` instances — without filling those instances' `startVisual` or `endVisual` slots. `ActionListItem` owns those. The count becomes **8 `SlotExample` + 8 `InstanceExample` = 16 entries** on `ActionList`; ~4 entries on `ActionListItem`. No cross-component references are needed. +The `Composition` type established here serves both scopes. `SlotExample` (ADR-044) extends it for the component-scoped slot-filling case; the future system-scoped ADR will use it directly. --- ## Decision Drivers -- **Composition is a first-class concept** — components and compositions are peers in the schema's conceptual model; the type hierarchy must reflect this -- **Additive-only changes** — all new fields are optional; no existing field is removed or narrowed → MINOR semver -- **Type ↔ schema symmetry** — every new type field has a corresponding schema definition (Constitution §I) -- **No runtime logic** — only type declarations and schema; no validation functions or algorithms (Constitution §II) -- **No inline nesting** — inline anonymous compositions in `PropConfigurations` create unbounded recursive depth in the spec output; slot content must be expressed as named references, not inlined structures -- **One level deep** — a `SlotExample` declares the anatomy and element types of what fills the slot but does not recurse into those elements' own slot content; each component is the sole author of its own examples -- **No cross-component references** — all keys in `ComponentExamples` resolve within the same component definition; `InstanceExample.slots` references only keys within the same `Component.examples` -- **Discriminated union for component examples** — `SlotExample` and `InstanceExample` are structurally distinct; a tagged union with `kind` as discriminator makes them unambiguous to tooling and JSON Schema validation -- **Figma provenance is not public API** — slot default content belongs in `$extensions['com.figma']` on the element, not in `Children` or `SlotProp` -- **Variant-sensitive slot defaults** — slot default compositions must be expressible per variant through the existing element layer -- **`$extensions` consistency** — Figma-specific element metadata follows the DTCG-derived pattern already established on `Props` and `TokenReference` -- **Scale separation** — component-scoped examples belong inside the component definition; system-scoped compositions belong in a separate file with a follow-on schema +- **Composition is a first-class concept** — components and compositions are peers in the schema's conceptual model; a named type is required +- **Additive-only** — no existing type is changed → MINOR semver +- **Type ↔ schema symmetry** — every type field has a corresponding schema property (Constitution §I) +- **No runtime logic** — type declarations and schema only (Constitution §II) +- **Shared shape across scopes** — component-scoped and system-scoped compositions are structurally identical; one type serves both --- ## Options Considered -### Option A: `ComponentExample = SlotExample | InstanceExample`, `$extensions` for Figma defaults *(Selected)* +### Option A: Named standalone `Composition` type *(Selected)* -Introduce a `ComponentExample` discriminated union with two members: `SlotExample` (the anatomy and element bindings for a named slot's content) and `InstanceExample` (scalar prop values plus named slot-filling references). All slot content is expressed as named references — no inline nesting. Figma default content is declared via `$extensions['com.figma'].defaultComposition` on the slot-bound element. `Composition` is retained as a structural base type for system-scoped follow-on use. +Define `Composition` as a named structural type with `anatomy` (required), `elements?`, `layout?`, and an optional `title?` for human-readable labeling. No `kind` discriminator here — discrimination is the responsibility of the extending types (`SlotExample` in ADR-044, and future system-scoped types). -```yaml -# ActionListItem — component with slot examples for its visual slot props -title: Action List Item -anatomy: - root: - type: container - startVisualSlot: - type: container # slot-bound container — children bound to startVisual prop - label: - type: text - endVisualSlot: - type: container # slot-bound container — children bound to endVisual prop - -props: - startVisual: - type: slot - endVisual: - type: slot - -examples: - iconStart: - kind: slot - slot: startVisual - title: Action List Item – icon in start visual - anatomy: - icon: - type: glyph - elements: - icon: - content: search - - iconEnd: - kind: slot - slot: endVisual - title: Action List Item – icon in end visual - anatomy: - icon: - type: glyph - elements: - icon: - content: chevronRight - - withBothIcons: - kind: instance - title: Action List Item – with both icons - slots: - startVisual: iconStart # references examples.iconStart - endVisual: iconEnd # references examples.iconEnd - -default: - elements: - startVisualSlot: - children: { $binding: "#/props/startVisual" } - $extensions: - com.figma: - defaultComposition: iconStart # Figma provenance — not public API - endVisualSlot: - children: { $binding: "#/props/endVisual" } -``` +The example below shows a `Composition` describing an `ActionListItem` instance with scalar prop values — no slots yet (slots are introduced in ADR-044): ```yaml -# ActionList — 8 variants; one SlotExample per variant, one level deep -# SlotExample anatomy stops at the ActionListItem boundary — -# ActionListItem owns its own startVisual/endVisual slot examples -title: Action List +# Composition — named structural content fragment +# Example: an ActionListItem with scalar props configured +title: Action List Item – default state anatomy: - root: - type: container - itemsSlot: - type: container # slot-bound container — children bound to items prop - -props: - items: - type: slot - variant: - type: string - -examples: - defaultItems: - kind: slot - slot: items - title: Action List – default items - anatomy: - item1: - type: instance - instanceOf: ActionListItem - item2: - type: instance - instanceOf: ActionListItem - item3: - type: instance - instanceOf: ActionListItem - layout: - - item1 - - item2 - - item3 - # No elements — ActionListItem owns its startVisual/endVisual slot examples - - defaultUsage: - kind: instance - title: Action List – default usage - propConfigurations: - variant: default - slots: - items: defaultItems # references examples.defaultItems - - dangerItems: - kind: slot - slot: items - title: Action List – danger items - anatomy: - item1: - type: instance - instanceOf: ActionListItem - item2: - type: instance - instanceOf: ActionListItem - item3: - type: instance - instanceOf: ActionListItem - layout: - - item1 - - item2 - - item3 - - dangerUsage: - kind: instance - title: Action List – danger usage + item: + type: instance + instanceOf: ActionListItem +elements: + item: propConfigurations: - variant: danger - slots: - items: dangerItems - - # ... pattern repeats for 6 more variants - # Total: 8 SlotExamples + 8 InstanceExamples = 16 entries on ActionList - # vs. naive cross-component recursion: 8 variants × 3 items × 2 slots = 48+ entries - -default: - elements: - itemsSlot: - children: { $binding: "#/props/items" } - $extensions: - com.figma: - defaultComposition: defaultItems # Figma provenance — not public API + state: default + title: Browse all issues + description: 12 open · 3 closed +layout: + - item ``` **Pros**: -- Named references prevent unbounded inline nesting — every slot filling is a string key regardless of hierarchy depth -- One-level-deep boundary keeps example counts proportional to a component's own variation surface — `ActionList` needs 16 entries, not 56 -- No cross-component references — each component's examples resolve entirely within that component's own `Component.examples` -- `SlotExample` and `InstanceExample` are structurally distinct and discriminated by `kind` -- Figma default content stays in `$extensions` — `Children` and `SlotProp` are unchanged -- `Composition` is cleanly separated as the structural base for future system-scoped use -- All changes additive → MINOR +- Clean, minimal foundational type — easy to extend in ADR-044 (`SlotExample`) and the system-scoped follow-on +- Single shared shape for component-scoped and system-scoped use +- `anatomy` required — every composition declares its element type map +- `elements?` and `layout?` optional — a minimal fragment may only need anatomy **Cons / Trade-offs**: -- Filling slots of instances *within* a `SlotExample` (e.g., setting `ActionListItem`'s `startVisual` from `ActionList`'s context) requires a follow-on ADR to define the cross-component resolution protocol -- `PropConfigurations` for slot values in parent components is deferred — the full parent-fills-child-slot mechanism remains in ADR-025 pending the follow-on - ---- - -### Option B: Inline `Composition` in `PropConfigurations` (ADR-025 pattern) *(Rejected)* - -Allow inline anonymous `Composition` objects as values in `PropConfigurations` when filling a slot prop on a nested instance. - -**Rejected because**: -- Creates unbounded recursive nesting: a composition contains component instances with their own slots, whose fillings are also inline compositions, ad infinitum -- Inline anonymous compositions cannot be named, reused, or referenced from `InstanceExample.slots`; they are invisible to tooling that catalogues compositions - ---- - -### Option C: Single `Composition` type with `kind` covering all scales *(Rejected)* - -One `Composition` type with a `kind` field covering `slot-default`, `example`, `layout`, and `page`. - -**Rejected because**: -- `SlotExample` (anatomy + slot binding) and `InstanceExample` (prop values + slot refs) are structurally incompatible — they cannot share a single type without making all fields optional or requiring runtime discrimination -- Mixing component-scoped and system-scoped kinds in one type conflates two different authoring contexts +- Standalone — no consuming type lands until ADR-043 and ADR-044; the type is not usable in isolation --- -### Option D: Separate types per composition scale *(Rejected)* +### Option B: Inline shape, no named type *(Rejected)* -Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types. +Define the shape inline at each use site (`SlotExample`, system-scoped types) rather than as a shared named type. -**Rejected because**: -- `LayoutComposition` and `PageComposition` share the same structural shape as `Composition`; duplication without benefit -- Four separate types multiply schema surface area and downstream import burden +**Rejected because**: `SlotExample` and system-scoped compositions share identical fields; duplicating the shape without a named base creates schema drift and prevents a single `$ref` anchor. --- @@ -275,157 +96,31 @@ Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageCompos | File | Change | Bump | |------|--------|------| -| New: `Composition.ts` | Add `Composition`, `SlotExample`, `InstanceExample`, `ComponentExample`, `ComponentExamples` | MINOR | -| `Element.ts` | Add optional `$extensions?: ElementExtensions`; add `ElementExtensions`, `FigmaElementExtension` | MINOR | -| `Component.ts` | Add optional `examples?: ComponentExamples` | MINOR | -| `PropConfigurations.ts` | Widen value union to include `PropBinding` | MINOR | -| `index.ts` | Export `Composition`, `SlotExample`, `InstanceExample`, `ComponentExample`, `ComponentExamples`, `ElementExtensions`, `FigmaElementExtension` | MINOR | +| New: `Composition.ts` | Add `Composition` type | MINOR | +| `index.ts` | Export `Composition` | MINOR | -**New file** (`types/Composition.ts`): +**New type** (`types/Composition.ts`): ```yaml -# Composition — raw structural content fragment -# Base shape used by SlotExample and by future system-scoped layout/page compositions Composition: - title?: string - anatomy: Anatomy # required - elements?: Elements - layout?: Layout - -# SlotExample — named content for a specific slot -SlotExample: - kind: 'slot' # discriminator - slot: string # name of the SlotProp this example fills - title?: string - anatomy: Anatomy # required + title?: string # human-readable label + anatomy: Anatomy # required — declares the element type map elements?: Elements layout?: Layout - -# InstanceExample — a pre-configured usage of the whole component -InstanceExample: - kind: 'instance' # discriminator - title?: string - propConfigurations?: - Record # scalar prop values only - slots?: - Record # slot prop name → SlotExample key in Component.examples - -# ComponentExample — discriminated union for Component.examples -ComponentExample: SlotExample | InstanceExample - -# ComponentExamples — named record on Component -ComponentExamples: Record -``` - -**Extended `Element`** (`types/Element.ts`): - -```yaml -# Before -Element: - children?: Children - parent?: string | null - styles?: Styles - propConfigurations?: PropConfigurations - instanceOf?: string | PropBinding | SubcomponentRef - content?: string | PropBinding - -# After -Element: - children?: Children - parent?: string | null - styles?: Styles - propConfigurations?: PropConfigurations - instanceOf?: string | PropBinding | SubcomponentRef - content?: string | PropBinding - $extensions?: ElementExtensions # new — Figma-specific element metadata -``` - -**New types** (in `types/Element.ts`): - -```yaml -FigmaElementExtension: - defaultComposition?: string # key in Component.examples — valid only when children is PropBinding - [key: string]: unknown - -ElementExtensions: - 'com.figma'?: FigmaElementExtension - [key: string]: unknown -``` - -```yaml -# Usage — slot-bound element in default.elements or variants[n].elements -aSlotElement: - children: { $binding: "#/props/aSlotProperty" } - $extensions: - com.figma: - defaultComposition: cardBodyDefault # references Component.examples.cardBodyDefault -``` - -**Extended `Component`** (`types/Component.ts`): - -```yaml -# Before -Component: - title: string - anatomy: Anatomy - props?: Props - subcomponents?: Subcomponents - default: Variant - variants?: Variants - invalidVariantCombinations?: PropConfigurations[] - metadata?: Metadata - -# After -Component: - title: string - anatomy: Anatomy - props?: Props - subcomponents?: Subcomponents - default: Variant - variants?: Variants - invalidVariantCombinations?: PropConfigurations[] - metadata?: Metadata - examples?: ComponentExamples # new — named slot and instance examples -``` - -**Widened `PropConfigurations`** (`types/PropConfigurations.ts`): - -```yaml -# Before -PropConfigurations: Record - -# After -PropConfigurations: Record -``` - -```yaml -# Usage — binding a nested instance's scalar prop to the parent's own prop -aSlotElement: - instanceOf: Card - propConfigurations: - variant: featured # static scalar - label: { $binding: "#/props/label" } # bound to parent's label prop ``` ### Schema changes (`schema/`) | File | Change | Bump | |------|--------|------| -| `component.schema.json` | Add `Composition` definition | MINOR | -| `component.schema.json` | Add `SlotExample` definition | MINOR | -| `component.schema.json` | Add `InstanceExample` definition | MINOR | -| `component.schema.json` | Add `ComponentExample` definition (`oneOf` discriminated by `kind`) | MINOR | -| `component.schema.json` | Add `FigmaElementExtension` and `ElementExtensions` definitions | MINOR | -| `component.schema.json` | Add `$extensions` property to `Element` definition | MINOR | -| `component.schema.json` | Update `PropConfigurations` `additionalProperties` to include `PropBinding` | MINOR | -| `component.schema.json` | Add `examples` property to `Component` definition | MINOR | +| `component.schema.json` | Add `#/definitions/Composition` | MINOR | **New definition** (`#/definitions/Composition`): ```yaml Composition: type: object - description: "Raw structural content fragment used as the base shape for SlotExample and future system-scoped compositions." + description: "Named structural content fragment. Base shape for SlotExample (ADR-044) and system-scoped layout/page compositions." required: [anatomy] properties: title: @@ -439,162 +134,26 @@ Composition: additionalProperties: false ``` -**New definition** (`#/definitions/SlotExample`): - -```yaml -SlotExample: - type: object - description: "Named content for a specific slot: element anatomy, bindings, and layout for the elements that fill the slot." - required: [kind, slot, anatomy] - properties: - kind: - type: string - enum: [slot] - slot: - type: string - description: "The SlotProp name this example fills." - title: - type: string - anatomy: - $ref: "#/definitions/Anatomy" - elements: - $ref: "#/definitions/Elements" - layout: - $ref: "#/definitions/Layout" - additionalProperties: false -``` - -**New definition** (`#/definitions/InstanceExample`): - -```yaml -InstanceExample: - type: object - description: "A pre-configured usage of the whole component: scalar prop values and named slot-filling references." - required: [kind] - properties: - kind: - type: string - enum: [instance] - title: - type: string - propConfigurations: - type: object - description: "Scalar prop values for this example." - additionalProperties: - oneOf: - - type: string - - type: number - - type: boolean - slots: - type: object - description: "Maps slot prop names to SlotExample keys in Component.examples." - additionalProperties: - type: string - additionalProperties: false -``` - -**New definition** (`#/definitions/ComponentExample`): - -```yaml -ComponentExample: - oneOf: - - $ref: "#/definitions/SlotExample" - - $ref: "#/definitions/InstanceExample" -``` - -**New definitions** (`#/definitions/FigmaElementExtension` and `#/definitions/ElementExtensions`): - -```yaml -FigmaElementExtension: - type: object - properties: - defaultComposition: - type: string - description: "Key of a SlotExample in Component.examples. Valid only when Element.children is a PropBinding (slot-bound container)." - additionalProperties: true - -ElementExtensions: - type: object - properties: - "com.figma": - $ref: "#/definitions/FigmaElementExtension" - additionalProperties: true -``` - -**Updated `Element`** — add to `#/definitions/Element/properties`: - -```yaml -$extensions: - $ref: "#/definitions/ElementExtensions" -``` - -**Updated `PropConfigurations`** (`#/definitions/PropConfigurations`): - -```yaml -# Before -PropConfigurations: - type: object - additionalProperties: - oneOf: - - type: string - - type: number - - type: boolean - -# After -PropConfigurations: - type: object - additionalProperties: - oneOf: - - type: string - - type: number - - type: boolean - - $ref: "#/definitions/PropBinding" -``` - -**New property** (`Component.examples` in `#/definitions/Component/properties`): - -```yaml -examples: - type: object - description: "Named slot and instance examples for this component." - patternProperties: - "^[a-zA-Z0-9_-]+$": - $ref: "#/definitions/ComponentExample" - additionalProperties: false -``` - ### Out of scope for this ADR -- **`compositions.yaml` file schema** — Schema for system-scoped (`layout`, `page`) compositions in a separate file. Deferred to a follow-on ADR. The `Composition` structural type is defined here and ready. -- **Parent-fills-child-slot via `PropConfigurations`** — The mechanism for referencing a named `SlotExample` from a child component as a `PropConfigurations` slot value. Deferred to the follow-on that supersedes ADR-025. -- **Cross-component key resolution in `InstanceExample.slots`** — When `slots` fills a slot belonging to a nested child component, the key must resolve against that child's `Component.examples`. The resolution protocol is deferred to the follow-on ADR. -- **Nested slot filling within `SlotExample`** — When a `SlotExample` contains component instances that themselves have slots (e.g., `ActionListItem` instances inside `ActionList`'s `items` slot), filling those nested slots from the parent component is deferred to the follow-on ADR. Each component resolves its own slot content independently for now. +- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-044 +- **`InstanceExample`** and **`Component.examples`** — see ADR-043 +- **`Element.$extensions`** and `defaultComposition` — see ADR-044 +- **`PropConfigurations` PropBinding widening** — see ADR-045 +- **`compositions.yaml` file schema** — follow-on ADR after ADR-044 ### Notes -- `Composition.anatomy` is required — every composition must declare its element type map. `elements` and `layout` are optional because a minimal slot fragment may not need styling or explicit ordering. -- `SlotExample.slot` names the `SlotProp` the example fills, enabling tooling to associate the example with the correct slot without inspecting anatomy. -- `InstanceExample.slots` maps slot prop names to `SlotExample` keys in the same `Component.examples`. All keys resolve within the same component definition — no cross-component references. Filling the slots of instances that appear *within* a `SlotExample` (e.g., setting `ActionListItem`'s `startVisual` from `ActionList`'s examples) is deferred to the follow-on ADR. -- The one-level-deep boundary is intentional: `ActionList`'s `SlotExample` for the `items` slot declares three `ActionListItem` instances but does not fill their `startVisual` or `endVisual` slots. `ActionListItem` owns those via its own `Component.examples`. This keeps the example count on each component proportional to that component's own variation surface, not to every descendant's. -- `InstanceExample.propConfigurations` holds scalar values only (`string | number | boolean`). It is intentionally simpler than `Element.propConfigurations` (which now also accepts `PropBinding`): `InstanceExample` represents a documented configuration, not a live data binding. -- `FigmaElementExtension.defaultComposition` is valid only when `Element.children` is a `PropBinding` (slot-bound container / SlotNode in Figma). A container element with `children: string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this; it is a consumer validation concern. -- `PropBinding` in `PropConfigurations` allows a parent component to bind a nested instance's scalar prop to the parent's own prop, using the `{ $binding: "..." }` shape already established on `Element.content`, `Element.instanceOf`, and `Styles.visible`. +- `Composition.anatomy` is required — every composition must declare its element type map; `elements` and `layout` are optional because a minimal slot fragment may only need the type declarations +- No `kind` field on `Composition` itself — the `kind` discriminator is added by the consuming types (`SlotExample` adds `kind: 'slot'`; future system-scoped types add their own) +- `Composition` is not placed directly on `Component` — it is the structural base; `Component.examples` (ADR-043) and `SlotExample` (ADR-044) are the consumer-facing entry points --- ## Type ↔ Schema Impact -- **Symmetric**: Yes — every type field maps to a schema property -- **Parity check**: - - `Composition { title?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` - - `SlotExample { kind: 'slot', slot, title?, anatomy, elements?, layout? }` ↔ `#/definitions/SlotExample` - - `InstanceExample { kind: 'instance', title?, propConfigurations?, slots? }` ↔ `#/definitions/InstanceExample` - - `ComponentExample = SlotExample | InstanceExample` ↔ `#/definitions/ComponentExample` (`oneOf`) - - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` - - `ElementExtensions { 'com.figma'?: FigmaElementExtension }` ↔ `#/definitions/ElementExtensions` - - `Element.$extensions?: ElementExtensions` ↔ `#/definitions/Element/properties/$extensions` - - `PropConfigurations` value union `string | number | boolean | PropBinding` ↔ `additionalProperties.oneOf` (four branches) - - `Component.examples?: ComponentExamples` ↔ `#/definitions/Component/properties/examples` +- **Symmetric**: Yes +- **Parity check**: `Composition { title?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` --- @@ -602,9 +161,9 @@ examples: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | Must detect and emit `Component.examples` (`SlotExample` from slot layers, `InstanceExample` from example frames); must emit `$extensions['com.figma'].defaultComposition` on slot-bound elements in variant data | Read new types; implement example detection; update output emitters | -| `specs-cli` | Recompile; CLI output includes `examples` key and element `$extensions` when present | Recompile; no breaking change | -| `specs-plugin-2` | Recompile; example rendering is a follow-on capability | Recompile; pass through composition data initially | +| `specs-from-figma` | No immediate output change; `Composition` is foundational for ADR-043/044 | Recompile when 043/044 land | +| `specs-cli` | Recompile | No change | +| `specs-plugin-2` | Recompile | No change | --- @@ -612,24 +171,12 @@ examples: **Version bump**: `0.19.0 → 0.20.0` (`MINOR`) -**Justification**: All changes are additive: -- New optional field `Component.examples` on an existing type -- New optional field `Element.$extensions` on an existing type -- New types (`Composition`, `SlotExample`, `InstanceExample`, `ComponentExample`, `ComponentExamples`, `ElementExtensions`, `FigmaElementExtension`) — no removal or narrowing -- `PropConfigurations` value union widened to add `PropBinding` — existing scalar values remain valid - -Per Constitution §III: additive types and new optional fields → MINOR. +**Justification**: Adds new type `Composition` — purely additive; no existing type is removed or narrowed → MINOR per Constitution §III. --- ## Consequences -- `Composition` is a first-class structural type in the schema; `SlotExample` and `InstanceExample` are its component-scoped manifestations -- Component authors declare named examples in `Component.examples`; slot content and usage configurations are discoverable alongside the component that owns them -- All slot-filling references are named strings — no inline anonymous compositions, no unbounded recursive nesting in the spec output -- Slot default content is Figma provenance metadata in `Element.$extensions['com.figma'].defaultComposition` — `Children` and `SlotProp` are unchanged -- `PropConfigurations` gains `PropBinding` — a parent component can bind a nested instance's scalar prop to its own prop, completing the pattern already used on `Element.content` and `Styles.visible` -- `Element` gains `$extensions` following the DTCG-derived provenance-metadata pattern established on `Props` and `TokenReference` -- `Composition` is defined and ready for a follow-on ADR introducing `compositions.yaml` for system-scoped layout and page compositions -- The parent-fills-child-slot pattern and cross-component slot key resolution are deferred to a follow-on ADR that supersedes ADR-025; ADR-025 remains open -- Future ADRs can add the system-scoped file schema, nested slot filling in `SlotExample`, and the full slot-filling mechanism without changes to the types established here +- `Composition` is a named structural type in the schema, available as a base for `SlotExample` (ADR-044) and future system-scoped layout and page composition types +- No existing type is changed; no downstream consumers are broken +- ADR-043, ADR-044, and ADR-045 complete the composition model; this ADR is the foundation they build on diff --git a/adr/INDEX.md b/adr/INDEX.md index c055f21..ef6d75b 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,7 +4,10 @@ | # | Title | Highlights | |---|-------|------------| -| 042 | Composition as a First-Class Type | | +| 045 | PropConfigurations PropBinding | | +| 044 | Slot Content — SlotExample and Element Extensions | | +| 043 | Component Examples — InstanceExample and Component.examples | | +| 042 | Composition Structural Type | | | 041 | Layout Positioning — Constraint-Based Naming | | | 035 | Make Config Properties with Defaults Optional | | | 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | From 3443aef57a6bbf29c989d17ded98b04dad65b61f Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:30:35 -0400 Subject: [PATCH 08/20] =?UTF-8?q?feat(adr):=20author=20ADRs=20043-045=20?= =?UTF-8?q?=E2=80=94=20component=20examples,=20slot=20content,=20PropBindi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 043: InstanceExample + ComponentExamples + Component.examples ActionListItem anchors the example with state/title/description scalar props InstanceExample.slots forward-compatible; meaningful once 044 lands 044: SlotExample + ElementExtensions + Element.$extensions ActionListItem startVisual/endVisual slots; ActionList items slot one level deep 16 entries on ActionList (not 56); one-level-deep principle; no cross-component refs Widens ComponentExamples to ComponentExample = InstanceExample | SlotExample 045: Widen PropConfigurations to include PropBinding Completes binding pattern already on Element.content, instanceOf, Styles.visible Co-Authored-By: Claude Sonnet 4.6 --- adr/043-component-examples.md | 281 +++++++++++++++ adr/044-slot-content.md | 463 +++++++++++++++++++++++++ adr/045-prop-configurations-binding.md | 167 +++++++++ adr/INDEX.md | 8 +- 4 files changed, 915 insertions(+), 4 deletions(-) create mode 100644 adr/043-component-examples.md create mode 100644 adr/044-slot-content.md create mode 100644 adr/045-prop-configurations-binding.md diff --git a/adr/043-component-examples.md b/adr/043-component-examples.md new file mode 100644 index 0000000..ef71970 --- /dev/null +++ b/adr/043-component-examples.md @@ -0,0 +1,281 @@ +# ADR: Component Examples — InstanceExample and Component.examples + +**Branch**: `043-component-examples` +**Created**: 2026-04-29 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**Depends on**: [ADR-042 — Composition Structural Type](042-composition-type) +**Extended by**: [ADR-044 — Slot Content](044-slot-content) *(adds `SlotExample`, widens `ComponentExamples`, adds `InstanceExample.slots`)* + +--- + +## Context + +ADR-042 established `Composition` as the structural base type. That type has no authoring home yet — no field on `Component` accepts it, and no consumer-facing entry point exists. + +Components need a named catalogue of pre-configured examples: a documented set of specific prop values (and, later, slot configurations) that expresses canonical usages. Tooling can discover and render these without inspecting the raw variant tree. Authors can reference them by key instead of repeating configuration inline. + +The simplest case — and the right starting point — is an example defined entirely by scalar prop values: `state`, `title`, `description`, and similar string or boolean props. No slot content yet; slots are addressed in ADR-044. + +`InstanceExample` is the type for this case. It captures: +- An optional human-readable label (`title`) +- Scalar prop values (`propConfigurations`) +- Slot references (`slots`) — present in the type now; meaningful once ADR-044 introduces `SlotExample` + +`Component.examples` is the named record that holds `InstanceExample` entries (and, after ADR-044, `SlotExample` entries too). + +--- + +## Decision Drivers + +- **Named record, not array** — examples must be referenceable by key; `InstanceExample.slots` and `Element.$extensions['com.figma'].defaultComposition` (ADR-044) both point to keys, not indices +- **`kind` discriminator** — `InstanceExample` will share the `ComponentExamples` record with `SlotExample` (ADR-044); `kind: 'instance'` makes the type unambiguous to tooling and JSON Schema `oneOf` validation +- **Scalar-only `propConfigurations`** — `InstanceExample` represents a documented configuration for human readers and tooling, not a live data binding; `PropBinding` belongs in `Element.propConfigurations` (ADR-045), not here +- **No cross-component references** — `InstanceExample.slots` values are keys within the same `Component.examples`; no key resolution across component definitions +- **Additive-only** — new optional field `Component.examples`; no existing type changed → MINOR +- **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) +- **No runtime logic** — type declarations and schema only (Constitution §II) + +--- + +## Options Considered + +### Option A: `InstanceExample` as a discriminated record member *(Selected)* + +Add `InstanceExample { kind: 'instance', title?, propConfigurations?, slots? }` and a named record `ComponentExamples` on `Component`. The `kind` field discriminates against `SlotExample` (ADR-044) in the shared `ComponentExamples` record. + +```yaml +# ActionListItem — instance examples covering scalar prop variants +title: Action List Item +anatomy: + root: + type: container + label: + type: text + description: + type: text + +props: + state: + type: string + title: + type: string + description: + type: string + +examples: + defaultState: + kind: instance + title: Action List Item – default + propConfigurations: + state: default + title: Browse all issues + description: 12 open · 3 closed + + activeState: + kind: instance + title: Action List Item – active + propConfigurations: + state: active + title: Browse all issues + description: 12 open · 3 closed + + dangerState: + kind: instance + title: Action List Item – danger + propConfigurations: + state: danger + title: Delete branch + description: This action cannot be undone +``` + +**Pros**: +- Named keys enable reference-by-string from `InstanceExample.slots` and `defaultComposition` without type coupling +- `kind` discriminator makes `InstanceExample` and `SlotExample` unambiguous in the shared record +- Scalar-only `propConfigurations` keeps `InstanceExample` simple and human-readable +- `slots?` field present now — forward-compatible once ADR-044 introduces `SlotExample` keys + +**Cons / Trade-offs**: +- `slots` values forward-reference `SlotExample` keys that do not exist until ADR-044; tooling cannot validate slot references until ADR-044 lands + +--- + +### Option B: Flat scalar map without `kind` *(Rejected)* + +Store examples as `Record>` — a named map of prop value maps, with no type wrapper. + +**Rejected because**: No discriminator means tooling cannot tell an `InstanceExample` from a `SlotExample` without structural inspection. JSON Schema cannot use `oneOf` without a discriminating property. Adding `SlotExample` later would require a MAJOR-level type change. + +--- + +### Option C: Extend `Variant` for examples *(Rejected)* + +Reuse the existing `Variant` type for examples — a `Variant` already has `configuration?`, `elements?`, `layout?`. + +**Rejected because**: `Variant` represents a display state driven by prop combination. `InstanceExample` is a documented usage configuration with human intent. They are conceptually distinct; merging them would force `Variant` to carry optional fields that apply to only one of its two roles. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| New: `ComponentExample.ts` | Add `InstanceExample`, `ComponentExamples` | MINOR | +| `Component.ts` | Add optional `examples?: ComponentExamples` | MINOR | +| `index.ts` | Export `InstanceExample`, `ComponentExamples` | MINOR | + +**New types** (`types/ComponentExample.ts`): + +```yaml +# InstanceExample — a pre-configured usage of the whole component +InstanceExample: + kind: 'instance' # discriminator + title?: string + propConfigurations?: + Record # scalar prop values only + slots?: + Record # slot prop name → key in Component.examples + # resolved entry must be a SlotExample (ADR-044) + +# ComponentExamples — named record on Component +# Widened in ADR-044 to Record +ComponentExamples: Record +``` + +**Extended `Component`** (`types/Component.ts`): + +```yaml +# Before +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + +# After +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + examples?: ComponentExamples # new — named instance and slot examples +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Add `#/definitions/InstanceExample` | MINOR | +| `component.schema.json` | Add `#/definitions/ComponentExamples` | MINOR | +| `component.schema.json` | Add `examples` property to `#/definitions/Component` | MINOR | + +**New definition** (`#/definitions/InstanceExample`): + +```yaml +InstanceExample: + type: object + description: "A pre-configured usage of the whole component: scalar prop values and named slot-filling references." + required: [kind] + properties: + kind: + type: string + enum: [instance] + title: + type: string + propConfigurations: + type: object + description: "Scalar prop values for this example. Binding is not permitted here." + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + slots: + type: object + description: "Maps slot prop names to SlotExample keys in Component.examples (see ADR-044)." + additionalProperties: + type: string + additionalProperties: false +``` + +**New definition** (`#/definitions/ComponentExamples`): + +```yaml +ComponentExamples: + type: object + description: "Named examples for this component. Widened in ADR-044 to include SlotExample entries." + patternProperties: + "^[a-zA-Z0-9_-]+$": + $ref: "#/definitions/InstanceExample" + additionalProperties: false +``` + +**New property** in `#/definitions/Component/properties`: + +```yaml +examples: + $ref: "#/definitions/ComponentExamples" + description: "Named instance and slot examples for this component." +``` + +### Out of scope for this ADR + +- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-044 +- **Widening of `ComponentExamples`** to include `SlotExample` — see ADR-044 +- **`Element.$extensions`** and `defaultComposition` — see ADR-044 +- **`PropConfigurations` PropBinding** — see ADR-045 + +### Notes + +- `InstanceExample.propConfigurations` is scalar-only (`string | number | boolean`). Prop binding (`PropBinding`) belongs in `Element.propConfigurations`, which represents live data flow. `InstanceExample` represents a documented configuration — human-intended, not runtime-driven. +- `InstanceExample.slots` is defined here but only becomes meaningful in ADR-044 when `SlotExample` entries can appear in `Component.examples`. Schema validation cannot enforce that `slots` values resolve to `SlotExample` keys until ADR-044 widens `ComponentExamples`. +- All `slots` values resolve within the same `Component.examples` — no cross-component key references. +- `ComponentExamples` uses `patternProperties` rather than `additionalProperties` on an object schema to satisfy Draft 7's handling of `$ref` alongside `additionalProperties: false`. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `InstanceExample { kind, title?, propConfigurations?, slots? }` ↔ `#/definitions/InstanceExample` + - `ComponentExamples = Record` ↔ `#/definitions/ComponentExamples` (`patternProperties`) + - `Component.examples?: ComponentExamples` ↔ `#/definitions/Component/properties/examples` + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Must detect and emit `Component.examples` with `InstanceExample` entries from example frames | Read new types; implement instance example detection | +| `specs-cli` | Recompile; output includes `examples` key when present | Recompile; no breaking change | +| `specs-plugin-2` | Recompile; example rendering is a follow-on capability | Recompile; pass through example data initially | + +--- + +## Semver Decision + +**Version bump**: `0.19.0 → 0.20.0` (`MINOR`) + +**Justification**: All changes are additive — new optional field `Component.examples`, new types `InstanceExample` and `ComponentExamples`; no existing type is removed or narrowed → MINOR per Constitution §III. + +--- + +## Consequences + +- `Component.examples` is a first-class named record on `Component`; example configurations are discoverable alongside the component that owns them +- `InstanceExample` expresses a complete scalar-prop configuration in a single, referenceable entry +- All example keys are plain strings — no inline nesting, no cross-component references +- `InstanceExample.slots` is forward-compatible: slot references compile and validate as strings now; ADR-044 populates the target side +- `ComponentExamples` will be widened in ADR-044 to accept `SlotExample` entries alongside `InstanceExample` entries diff --git a/adr/044-slot-content.md b/adr/044-slot-content.md new file mode 100644 index 0000000..1dcb9bd --- /dev/null +++ b/adr/044-slot-content.md @@ -0,0 +1,463 @@ +# ADR: Slot Content — SlotExample and Element Extensions + +**Branch**: `044-slot-content` +**Created**: 2026-04-29 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-043 — Component Examples](043-component-examples) + +--- + +## Context + +ADR-042 established `Composition` as the structural base type. ADR-043 established `InstanceExample` and `Component.examples` for scalar-prop-configured usages. + +A second, structurally distinct example type is needed: one that describes the *content* placed inside a component's slot layer rather than the component's own prop configuration. This is a `SlotExample` — a named, reusable anatomy of elements that fills a specific slot. + +Two related needs come with it: + +1. **Figma-side default** — Figma places elements inside a slot layer when it renders the default state. That default is provenance metadata, not public API; it belongs on the `Element` in `default.elements` or `variants[n].elements` via a Figma-specific `$extensions` field, pointing back to a named entry in `Component.examples`. + +2. **Widening `ComponentExamples`** — The named record introduced in ADR-043 must accept `SlotExample` entries alongside `InstanceExample` entries. `InstanceExample.slots` (also introduced in ADR-043 as a forward-compatible field) becomes meaningful here: its values now resolve to `SlotExample` keys. + +### Sensitizing example: ActionList and ActionListItem + +`ActionList` and `ActionListItem` show why the one-level-deep rule matters. + +**ActionListItem** has two slot props — `startVisual` and `endVisual`. Its `SlotExample` entries each describe the anatomy of what fills one slot (a single glyph icon). Three entries total cover both slots and a combined `InstanceExample`. + +**ActionList** has one slot prop — `items` — and 8 prop variants. A naive approach that fills each item's `startVisual` and `endVisual` directly on `ActionList` reaches across component boundaries: 8 variants × 3 items × 2 slots = **56** entries. + +The one-level-deep rule resolves this: `ActionList`'s `SlotExample` for `items` declares only the anatomy (three `ActionListItem` instances) — not their slot content. `ActionListItem` owns those slot examples. The count becomes **8 SlotExamples + 8 InstanceExamples = 16** on `ActionList`; ~4 on `ActionListItem`. + +### Slot-bound containers vs. plain frame containers + +In Figma's node model a container element is either a `FrameNode` (static children) or a `SlotNode` (children bound to a slot prop). The schema makes no type distinction — the difference is determined entirely by `Element.children`: + +- `children: PropBinding` — slot-bound container (SlotNode); may carry `$extensions['com.figma'].defaultComposition` +- `children: string[]` — plain frame container (FrameNode); must never carry `defaultComposition` + +No new element type is introduced by this ADR. + +--- + +## Decision Drivers + +- **One level deep** — a `SlotExample` declares the anatomy and element types of what fills the slot but does not recurse into those elements' own slot content; each component is the sole author of its own examples +- **No cross-component references** — all keys in `ComponentExamples` resolve within the same component definition; a `SlotExample`'s anatomy may reference `instanceOf: SomeComponent` but does not reach into that component's examples +- **`SlotExample` extends `Composition`** — same three content fields (`anatomy`, `elements?`, `layout?`) plus `kind: 'slot'` and `slot`; the structural base must not be redefined +- **Figma provenance is not public API** — slot default content is Figma-specific; it belongs in `$extensions['com.figma']` on the element, following the DTCG-derived pattern established on `Props` and `TokenReference` +- **Variant-sensitive slot defaults** — `$extensions` lives in `default.elements` or `variants[n].elements`, making it naturally per-variant +- **Additive-only** — all new fields are optional; `ComponentExamples` is widened not narrowed → MINOR +- **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) +- **No runtime logic** — type declarations and schema only (Constitution §II) + +--- + +## Options Considered + +### Option A: `SlotExample` extends `Composition`; `$extensions` on `Element` *(Selected)* + +`SlotExample` adds `kind: 'slot'` and `slot: string` to the `Composition` shape. `ComponentExamples` is widened to a `ComponentExample = InstanceExample | SlotExample` discriminated union. `Element.$extensions['com.figma'].defaultComposition` holds the reference back to a `SlotExample` key. + +```yaml +# ActionListItem — SlotExample entries for startVisual and endVisual +title: Action List Item +anatomy: + root: + type: container + startVisualSlot: + type: container # slot-bound — children: PropBinding + label: + type: text + description: + type: text + endVisualSlot: + type: container # slot-bound — children: PropBinding + +props: + state: + type: string + title: + type: string + description: + type: string + startVisual: + type: slot + endVisual: + type: slot + +examples: + # --- InstanceExamples from ADR-043, shown for context --- + defaultState: + kind: instance + title: Action List Item – default + propConfigurations: + state: default + title: Browse all issues + description: 12 open · 3 closed + + # --- SlotExamples introduced in this ADR --- + iconStart: + kind: slot + slot: startVisual + title: Action List Item – icon in start visual + anatomy: + icon: + type: glyph + elements: + icon: + content: search + + iconEnd: + kind: slot + slot: endVisual + title: Action List Item – icon in end visual + anatomy: + icon: + type: glyph + elements: + icon: + content: chevronRight + + withBothIcons: + kind: instance + title: Action List Item – with icons in both visuals + propConfigurations: + state: default + title: Browse all issues + slots: + startVisual: iconStart # references examples.iconStart — SlotExample + endVisual: iconEnd # references examples.iconEnd — SlotExample + +default: + elements: + startVisualSlot: + children: { $binding: "#/props/startVisual" } + $extensions: + com.figma: + defaultComposition: iconStart # Figma provenance — not public API + endVisualSlot: + children: { $binding: "#/props/endVisual" } +``` + +```yaml +# ActionList — one SlotExample per variant; one level deep +# SlotExample anatomy stops at the ActionListItem boundary — +# ActionListItem owns its own startVisual/endVisual examples +title: Action List +anatomy: + root: + type: container + itemsSlot: + type: container # slot-bound — children: PropBinding + +props: + variant: + type: string + items: + type: slot + +examples: + defaultItems: + kind: slot + slot: items + title: Action List – default items + anatomy: + item1: + type: instance + instanceOf: ActionListItem + item2: + type: instance + instanceOf: ActionListItem + item3: + type: instance + instanceOf: ActionListItem + layout: + - item1 + - item2 + - item3 + # No elements — ActionListItem owns its startVisual/endVisual slot examples + + defaultUsage: + kind: instance + title: Action List – default usage + propConfigurations: + variant: default + slots: + items: defaultItems + + dangerItems: + kind: slot + slot: items + title: Action List – danger items + anatomy: + item1: + type: instance + instanceOf: ActionListItem + item2: + type: instance + instanceOf: ActionListItem + item3: + type: instance + instanceOf: ActionListItem + layout: + - item1 + - item2 + - item3 + + dangerUsage: + kind: instance + title: Action List – danger usage + propConfigurations: + variant: danger + slots: + items: dangerItems + + # ... pattern repeats for 6 more variants + # Total: 8 SlotExamples + 8 InstanceExamples = 16 entries + # vs. naive cross-component recursion: 8 variants × 3 items × 2 slots = 48+ entries + +default: + elements: + itemsSlot: + children: { $binding: "#/props/items" } + $extensions: + com.figma: + defaultComposition: defaultItems # Figma provenance — not public API +``` + +**Pros**: +- One-level-deep boundary keeps ActionList's example count at 16, not 56+ +- No cross-component references — all keys resolve within each component's own `Component.examples` +- `SlotExample` shares the `Composition` shape — no structural duplication +- `$extensions` follows the DTCG-derived provenance pattern; `Children` and `SlotProp` are unchanged +- All changes additive → MINOR + +**Cons / Trade-offs**: +- Filling slots of instances *within* a `SlotExample` (e.g., setting `ActionListItem`'s `startVisual` from `ActionList`'s context) is deferred to a follow-on ADR; the boundary is one level only + +--- + +### Option B: Slot default on `SlotProp.default` *(Rejected)* + +Store the default composition reference on `SlotProp.default` rather than on the element. + +**Rejected because**: `SlotProp` is a public prop API type; slot default content is Figma provenance. Mixing platform-specific provenance into the public API type creates coupling that violates the `$extensions` separation established for props and token references. + +--- + +### Option C: Separate slot-example file *(Rejected)* + +Store slot examples in a separate file alongside the component, rather than in `Component.examples`. + +**Rejected because**: Slot examples are component-specific — they describe content for a named slot on that component. They belong in the component definition for discoverability and for `InstanceExample.slots` to resolve keys by string without cross-file reference. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `ComponentExample.ts` | Add `SlotExample`; add `ComponentExample = InstanceExample \| SlotExample`; widen `ComponentExamples` | MINOR | +| `InstanceExample.ts` | *(no change — `slots?` was defined in ADR-043)* | — | +| `Element.ts` | Add optional `$extensions?: ElementExtensions`; add `ElementExtensions`, `FigmaElementExtension` | MINOR | +| `index.ts` | Export `SlotExample`, `ComponentExample`, `ElementExtensions`, `FigmaElementExtension` | MINOR | + +**Updated `ComponentExample.ts`**: + +```yaml +# SlotExample — named content for a specific slot +SlotExample: + kind: 'slot' # discriminator + slot: string # name of the SlotProp this example fills + title?: string + anatomy: Anatomy # required — declares the content's element type map + elements?: Elements + layout?: Layout + +# ComponentExample — discriminated union for Component.examples +ComponentExample: InstanceExample | SlotExample + +# ComponentExamples — widened from ADR-043 +# Before: Record +# After: Record +ComponentExamples: Record +``` + +**New types** (in `types/Element.ts`): + +```yaml +FigmaElementExtension: + defaultComposition?: string # key in Component.examples — valid only when children is PropBinding + [key: string]: unknown # open for future Figma-specific fields + +ElementExtensions: + 'com.figma'?: FigmaElementExtension + [key: string]: unknown # open for future platform extensions +``` + +**Extended `Element`** (`types/Element.ts`): + +```yaml +# Before +Element: + children?: Children + parent?: string | null + styles?: Styles + propConfigurations?: PropConfigurations + instanceOf?: string | PropBinding | SubcomponentRef + content?: string | PropBinding + +# After +Element: + children?: Children + parent?: string | null + styles?: Styles + propConfigurations?: PropConfigurations + instanceOf?: string | PropBinding | SubcomponentRef + content?: string | PropBinding + $extensions?: ElementExtensions # new — platform-specific element metadata +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Add `#/definitions/SlotExample` | MINOR | +| `component.schema.json` | Add `#/definitions/ComponentExample` (`oneOf` discriminated by `kind`) | MINOR | +| `component.schema.json` | Update `#/definitions/ComponentExamples` to use `ComponentExample` | MINOR | +| `component.schema.json` | Add `#/definitions/FigmaElementExtension` | MINOR | +| `component.schema.json` | Add `#/definitions/ElementExtensions` | MINOR | +| `component.schema.json` | Add `$extensions` to `#/definitions/Element/properties` | MINOR | + +**New definition** (`#/definitions/SlotExample`): + +```yaml +SlotExample: + type: object + description: "Named content for a specific slot: anatomy, element bindings, and layout for the elements that fill the slot." + required: [kind, slot, anatomy] + properties: + kind: + type: string + enum: [slot] + slot: + type: string + description: "The SlotProp name this example fills." + title: + type: string + anatomy: + $ref: "#/definitions/Anatomy" + elements: + $ref: "#/definitions/Elements" + layout: + $ref: "#/definitions/Layout" + additionalProperties: false +``` + +**New definition** (`#/definitions/ComponentExample`): + +```yaml +ComponentExample: + oneOf: + - $ref: "#/definitions/InstanceExample" + - $ref: "#/definitions/SlotExample" +``` + +**Updated `#/definitions/ComponentExamples`** (widens `patternProperties` target): + +```yaml +# Before (ADR-043) +patternProperties: + "^[a-zA-Z0-9_-]+$": + $ref: "#/definitions/InstanceExample" + +# After +patternProperties: + "^[a-zA-Z0-9_-]+$": + $ref: "#/definitions/ComponentExample" +``` + +**New definitions** (`#/definitions/FigmaElementExtension` and `#/definitions/ElementExtensions`): + +```yaml +FigmaElementExtension: + type: object + properties: + defaultComposition: + type: string + description: "Key of a SlotExample in Component.examples. Valid only when Element.children is a PropBinding." + additionalProperties: true + +ElementExtensions: + type: object + properties: + "com.figma": + $ref: "#/definitions/FigmaElementExtension" + additionalProperties: true +``` + +**New property** in `#/definitions/Element/properties`: + +```yaml +$extensions: + $ref: "#/definitions/ElementExtensions" +``` + +### Out of scope for this ADR + +- **Nested slot filling within `SlotExample`** — When a `SlotExample` contains component instances that themselves have slots (e.g., `ActionListItem` instances in `ActionList`'s `items` slot), filling those nested slots from the parent context is deferred to a follow-on ADR. Each component resolves its own slot content independently. +- **`compositions.yaml` file schema** — system-scoped (`layout`, `page`) compositions; a follow-on ADR. + +### Notes + +- `SlotExample.slot` names the `SlotProp` the example fills, enabling tooling to associate the example with the correct slot without inspecting anatomy. +- `SlotExample.anatomy` is required — every slot example must declare the element type map for its content; `elements` and `layout` are optional because a minimal slot fragment may only need the type declarations. +- The one-level-deep boundary is intentional: `ActionList`'s `SlotExample` for the `items` slot declares three `ActionListItem` instances but does not fill their `startVisual` or `endVisual` slots. `ActionListItem` owns those. This keeps each component's example count proportional to its own variation surface, not to every descendant's. +- `FigmaElementExtension.defaultComposition` is valid only when `Element.children` is a `PropBinding` (slot-bound container). A container with `children: string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this constraint; it is a consumer validation concern. +- `ElementExtensions` and `FigmaElementExtension` use `additionalProperties: true` — they are open extension objects by design, following the DTCG pattern. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `SlotExample { kind: 'slot', slot, title?, anatomy, elements?, layout? }` ↔ `#/definitions/SlotExample` + - `ComponentExample = InstanceExample | SlotExample` ↔ `#/definitions/ComponentExample` (`oneOf`) + - `ComponentExamples` widened to `Record` ↔ `patternProperties` updated + - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` + - `ElementExtensions { 'com.figma'?: FigmaElementExtension }` ↔ `#/definitions/ElementExtensions` + - `Element.$extensions?: ElementExtensions` ↔ `#/definitions/Element/properties/$extensions` + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Must detect and emit `SlotExample` from slot layers; must emit `$extensions['com.figma'].defaultComposition` on slot-bound elements in variant data | Read new types; implement slot example detection; update output emitters | +| `specs-cli` | Recompile; output includes `SlotExample` entries and element `$extensions` when present | Recompile; no breaking change | +| `specs-plugin-2` | Recompile; slot example rendering is a follow-on capability | Recompile; pass through data initially | + +--- + +## Semver Decision + +**Version bump**: `0.19.0 → 0.20.0` (`MINOR`) + +**Justification**: All changes are additive — new types, new optional fields, `ComponentExamples` value union widened (existing `InstanceExample` entries remain valid). No existing type is removed or narrowed → MINOR per Constitution §III. + +--- + +## Consequences + +- `SlotExample` is a named structural type that defines what fills a specific slot; it shares the `Composition` shape and lives in `Component.examples` alongside `InstanceExample` entries +- `InstanceExample.slots` (defined in ADR-043) is now meaningful: its values resolve to `SlotExample` keys in the same `Component.examples` +- `Element.$extensions['com.figma'].defaultComposition` wires Figma's default slot content to a named `SlotExample` — `Children` and `SlotProp` are unchanged +- The one-level-deep rule keeps composition scale manageable: each component owns its examples, slot anatomy stops at nested component boundaries +- `ElementExtensions` is an open extension object — future Figma-specific or platform-specific element metadata can be added without a new ADR +- Filling slots of instances within a `SlotExample` is deferred to a follow-on ADR; the one-level-deep boundary is the current accepted limit diff --git a/adr/045-prop-configurations-binding.md b/adr/045-prop-configurations-binding.md new file mode 100644 index 0000000..ad128d6 --- /dev/null +++ b/adr/045-prop-configurations-binding.md @@ -0,0 +1,167 @@ +# ADR: PropConfigurations PropBinding + +**Branch**: `045-prop-configurations-binding` +**Created**: 2026-04-29 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**Depends on**: [ADR-008 — Introduce PropBinding](008-prop-binding) *(establishes `PropBinding`)* + +--- + +## Context + +`PropConfigurations` is used on `Element` to configure a nested component instance's props — for example, setting a nested `Button`'s `variant` to `"primary"`. Its current type accepts only scalar values: + +```yaml +PropConfigurations: Record +``` + +A parent component's element layer may need to *pass through* a prop from the parent to a nested instance — for example, a `Card` that exposes a `variant` prop and forwards it to an internal `Button`. Today, this requires a static scalar value; the forwarding cannot be expressed. + +`PropBinding` (`{ $binding: string }`) already exists for exactly this purpose: it appears on `Element.content`, `Element.instanceOf`, and `Styles.visible` to bind a field's value to a parent prop at data-emission time. Widening `PropConfigurations` to also accept `PropBinding` completes the binding model uniformly across all element-level fields. + +This ADR does **not** affect `InstanceExample.propConfigurations` (ADR-043), which intentionally remains scalar-only — it represents a human-authored documented configuration, not a live data binding. + +--- + +## Decision Drivers + +- **Consistent binding model** — `PropBinding` is already the established pattern for pass-through bindings on elements; `PropConfigurations` is the only element-level field that cannot participate +- **Additive-only** — existing scalar values remain valid; the union is widened not replaced → MINOR +- **`InstanceExample.propConfigurations` stays scalar** — that type represents a documented usage configuration; binding belongs in `Element.propConfigurations` only +- **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) +- **No runtime logic** — type declarations and schema only (Constitution §II) + +--- + +## Options Considered + +### Option A: Widen `PropConfigurations` value union to include `PropBinding` *(Selected)* + +Add `PropBinding` as a fourth branch alongside `string`, `number`, and `boolean`. + +```yaml +# Before — static scalar only +aNestedButton: + type: instance + instanceOf: Button + propConfigurations: + variant: primary + +# After — static scalar or pass-through binding +aNestedButton: + type: instance + instanceOf: Button + propConfigurations: + variant: primary # static scalar — unchanged + label: { $binding: "#/props/buttonLabel" } # bound to parent's buttonLabel prop + disabled: { $binding: "#/props/disabled" } # bound to parent's disabled prop +``` + +**Pros**: +- Completes the binding pattern already established on `Element.content`, `Element.instanceOf`, and `Styles.visible` +- Existing scalar values are fully backward-compatible — no migration +- `InstanceExample.propConfigurations` is a separate type and is unaffected + +**Cons / Trade-offs**: +- Tooling that processes `PropConfigurations` must now handle both scalar and `PropBinding` values; this is an extension, not a breaking change + +--- + +### Option B: Separate `propBindings` field *(Rejected)* + +Add a new `propBindings?: Record` field on `Element` alongside `propConfigurations`. + +**Rejected because**: Splitting static values and bindings across two fields is awkward to author and consume. A single `propConfigurations` field that accepts both is simpler and consistent with how `Element.content` handles the same duality (`string | PropBinding`). + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `PropConfigurations.ts` | Widen value union to include `PropBinding` | MINOR | + +**Updated type** (`types/PropConfigurations.ts`): + +```yaml +# Before +PropConfigurations: Record + +# After +PropConfigurations: Record +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Update `#/definitions/PropConfigurations` `additionalProperties` to add `PropBinding` branch | MINOR | + +**Updated definition** (`#/definitions/PropConfigurations`): + +```yaml +# Before +PropConfigurations: + type: object + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + +# After +PropConfigurations: + type: object + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + - $ref: "#/definitions/PropBinding" +``` + +### Out of scope for this ADR + +- **`InstanceExample.propConfigurations`** — remains `Record` by design; see ADR-043 +- **Slot value binding in `PropConfigurations`** — passing a slot prop through to a nested instance's slot prop via `PropBinding` is related but deferred; this ADR covers scalar prop pass-through only + +### Notes + +- `PropBinding` in `PropConfigurations` uses the `{ $binding: "..." }` shape established in ADR-008. The `$binding` path follows the same JSON Pointer convention used on `Element.content` and `Styles.visible` (e.g., `"#/props/label"`). +- The distinction between `Element.propConfigurations` (live binding — `PropBinding` permitted) and `InstanceExample.propConfigurations` (documented configuration — scalars only) is intentional and must be maintained in implementations. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: `PropConfigurations` value union `string | number | boolean | PropBinding` ↔ `additionalProperties.oneOf` (four branches, fourth is `$ref: "#/definitions/PropBinding"`) + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Must emit `PropBinding` values in `propConfigurations` where a nested prop is bound to a parent prop | Handle `PropBinding` branch when processing element prop configurations | +| `specs-cli` | Recompile; no output change until `specs-from-figma` emits `PropBinding` values | Recompile | +| `specs-plugin-2` | Recompile | No change | + +--- + +## Semver Decision + +**Version bump**: `0.19.0 → 0.20.0` (`MINOR`) + +**Justification**: `PropConfigurations` value union is widened — existing scalar values remain valid; no value is removed or narrowed → MINOR per Constitution §III. + +--- + +## Consequences + +- `Element.propConfigurations` can express both static scalar prop values and pass-through bindings to parent props in a single field +- The binding pattern established by ADR-008 (`PropBinding`) is now uniformly available across all element-level value fields: `content`, `instanceOf`, `styles.visible`, and `propConfigurations` +- `InstanceExample.propConfigurations` is not affected — it remains scalar-only by design diff --git a/adr/INDEX.md b/adr/INDEX.md index ef6d75b..d68da9e 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,10 +4,10 @@ | # | Title | Highlights | |---|-------|------------| -| 045 | PropConfigurations PropBinding | | -| 044 | Slot Content — SlotExample and Element Extensions | | -| 043 | Component Examples — InstanceExample and Component.examples | | -| 042 | Composition Structural Type | | +| 045 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding`; completes the binding model on element-level fields | +| 044 | Slot Content — SlotExample and Element Extensions | Add `SlotExample` (extends `Composition`); widen `ComponentExamples`; add `Element.$extensions` + `defaultComposition` for Figma provenance | +| 043 | Component Examples — InstanceExample and Component.examples | Add `InstanceExample` and `ComponentExamples`; add `Component.examples?` named record | +| 042 | Composition Structural Type | Add `Composition { title?, anatomy, elements?, layout? }` — structural base for slot examples and system-scoped compositions | | 041 | Layout Positioning — Constraint-Based Naming | | | 035 | Make Config Properties with Defaults Optional | | | 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | From a2d92057d6d76eb55f018d9bc0cc891a7ad01576 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:04:09 -0400 Subject: [PATCH 09/20] feat(adr): strengthen ADR-042 options; require elements+layout; fix example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace thin two-option section with four substantive options exploring: - Anatomy/elements convergence (B — rejected: breaks SlotExample inheritance) - All three required (A — selected) - elements optional (C — rejected: anatomy-only is degenerate) - layout optional (D — rejected: implied order is invisible convention) - Fix example: ActionListItem with root/label/description, not a single 'item' - Add description? field alongside title? - Note nested-instance-with-slot constraint explicitly in Notes - Note anticipated metadata extensions (tags, deprecated, guidelines) - Propagate required elements+layout to ADR-044 SlotExample Co-Authored-By: Claude Sonnet 4.6 --- adr/042-composition-type.md | 124 ++++++++++++++++++++++++++---------- adr/044-slot-content.md | 15 +++-- 2 files changed, 101 insertions(+), 38 deletions(-) diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index b3e36db..c29d338 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -39,7 +39,7 @@ The `Composition` type established here serves both scopes. `SlotExample` (ADR-0 - **Composition is a first-class concept** — components and compositions are peers in the schema's conceptual model; a named type is required - **Additive-only** — no existing type is changed → MINOR semver -- **Type ↔ schema symmetry** — every type field has a corresponding schema property (Constitution §I) +- **Type ↔ schema symmetry** — every type field has a schema counterpart (Constitution §I) - **No runtime logic** — type declarations and schema only (Constitution §II) - **Shared shape across scopes** — component-scoped and system-scoped compositions are structurally identical; one type serves both @@ -47,46 +47,98 @@ The `Composition` type established here serves both scopes. `SlotExample` (ADR-0 ## Options Considered -### Option A: Named standalone `Composition` type *(Selected)* +Four distinct questions shape the design space: -Define `Composition` as a named structural type with `anatomy` (required), `elements?`, `layout?`, and an optional `title?` for human-readable labeling. No `kind` discriminator here — discrimination is the responsibility of the extending types (`SlotExample` in ADR-044, and future system-scoped types). +1. **Should `anatomy` and `elements` be converged?** — The split exists in `Component`/`Variant` to support variant-sensitive element data. A composition has no variants. Does the split still earn its weight here? +2. **Are `elements` and `layout` required or optional?** — An anatomy-only composition is structurally just a type map. Does meaningful content require both? +3. **How much metadata belongs on a composition now?** — `title` is the obvious start. What else is warranted, and what should be noted as anticipated extension points? +4. **When a composition's element is an instance with a slot, how is that slot filled?** — This is a constraint on the type's scope that must be stated explicitly. -The example below shows a `Composition` describing an `ActionListItem` instance with scalar prop values — no slots yet (slots are introduced in ADR-044): +--- + +### Option A: Separate anatomy and elements; all three fields required *(Selected)* + +Keep the `Anatomy`/`Elements` split inherited from `Variant` for consistency. Require all three content fields — `anatomy`, `elements`, and `layout` — so a composition is always a complete structural + content + layout declaration. Add `description?` alongside `title?` for documentation tooling. ```yaml # Composition — named structural content fragment -# Example: an ActionListItem with scalar props configured -title: Action List Item – default state +# Example: ActionListItem core content with text elements +title: Action List Item – default +description: Default text content for a standard list item with label and secondary description. anatomy: - item: - type: instance - instanceOf: ActionListItem + root: + type: container + label: + type: text + description: + type: text elements: - item: - propConfigurations: - state: default - title: Browse all issues - description: 12 open · 3 closed + label: + content: Browse all issues + description: + content: 12 open · 3 closed layout: - - item + - root: + - label + - description ``` +Note: `root` carries no element-level data and is absent from `elements`. The `elements` map is sparse — only elements with content, styles, or configurations need entries. + **Pros**: -- Clean, minimal foundational type — easy to extend in ADR-044 (`SlotExample`) and the system-scoped follow-on -- Single shared shape for component-scoped and system-scoped use -- `anatomy` required — every composition declares its element type map -- `elements?` and `layout?` optional — a minimal fragment may only need anatomy +- Completeness guarantee — every composition is a full structural declaration; no anatomy-only fragments that are indistinguishable from plain `Anatomy` +- `layout` required forces authors to express ordering intent, even when trivial (`[icon]` for a single glyph) +- Consistent with `Variant` field names, easing authoring and migration +- `description?` supports documentation tooling now; further metadata is anticipated **Cons / Trade-offs**: -- Standalone — no consuming type lands until ADR-043 and ADR-044; the type is not usable in isolation +- When a composition's anatomy contains only instance elements with nothing meaningful to set on them (e.g., a slot that holds three `ActionListItem` instances with no per-instance element data), `elements` must be an empty record `{}`. This is explicit but adds authoring noise. +- `layout` is trivially `[iconName]` for single-element compositions such as a glyph in a visual slot; requiring it adds overhead for that common case. --- -### Option B: Inline shape, no named type *(Rejected)* +### Option B: Converge `anatomy` and `elements` into a single map *(Rejected)* + +Since compositions have no variant tree, the split that motivates keeping `anatomy` and `elements` separate in `Component` does not apply. A single `entries` map where each entry holds both type metadata and element data would be a simpler authoring surface: -Define the shape inline at each use site (`SlotExample`, system-scoped types) rather than as a shared named type. +```yaml +# Converged model (not selected) +anatomy: + root: + type: container + label: + type: text + content: Browse all issues # type + data in one entry + description: + type: text + content: 12 open · 3 closed +``` -**Rejected because**: `SlotExample` and system-scoped compositions share identical fields; duplicating the shape without a named base creates schema drift and prevents a single `$ref` anchor. +**Rejected because**: +- `SlotExample` (ADR-044) must extend `Composition`. If `Composition` uses a converged map, `SlotExample` must too — breaking the Anatomy/Elements pattern used throughout the rest of the schema. +- If a composition is later promoted to a full component (a natural authoring workflow), the converged map must be split back into `anatomy` + `elements` + `variants`. Keeping them separate makes that migration a no-op. +- `AnatomyElement` (`type`, `detectedIn?`, `instanceOf?`) and `Element` (`children`, `styles`, `propConfigurations`, `content`) have non-overlapping fields. Merging them creates a hybrid type that is neither and must be maintained independently. + +--- + +### Option C: `anatomy` required; `elements` and `layout` optional *(Rejected)* + +Keep the minimal surface from the ADR-025 draft: only `anatomy` is required; `elements` and `layout` are optional for cases where a composition is purely structural. + +**Rejected because**: +- An anatomy-only composition — `anatomy` with no `elements` and no `layout` — is structurally identical to an `Anatomy` record. It carries no information beyond element types and names; it does not describe a *composition* in any meaningful sense. +- Making `elements` optional permits this degenerate case without a schema-level guard. +- Downstream consumers cannot distinguish an authored anatomy-only composition (intentional) from one where the author simply forgot to add content data. + +--- + +### Option D: `anatomy` and `elements` required; `layout` optional *(Rejected)* + +A middle position: require content data but leave layout optional, with anatomy key order as the implied default. + +**Rejected because**: +- "Implied order" is an invisible convention that tooling must either assume or reject. Making `layout` required is a small authoring cost that eliminates ambiguity, especially for compositions with nested containers where order is load-bearing. +- The only case where `layout` is genuinely noise is a single-element composition — and even there, `layout: [elementName]` is one line and makes intent explicit. --- @@ -103,10 +155,11 @@ Define the shape inline at each use site (`SlotExample`, system-scoped types) ra ```yaml Composition: - title?: string # human-readable label - anatomy: Anatomy # required — declares the element type map - elements?: Elements - layout?: Layout + title?: string # human-readable label + description?: string # purpose and usage notes for documentation tooling + anatomy: Anatomy # required — declares the element type map + elements: Elements # required — element-level content, styles, prop configurations + layout: Layout # required — tree ordering of elements ``` ### Schema changes (`schema/`) @@ -121,10 +174,12 @@ Composition: Composition: type: object description: "Named structural content fragment. Base shape for SlotExample (ADR-044) and system-scoped layout/page compositions." - required: [anatomy] + required: [anatomy, elements, layout] properties: title: type: string + description: + type: string anatomy: $ref: "#/definitions/Anatomy" elements: @@ -141,19 +196,22 @@ Composition: - **`Element.$extensions`** and `defaultComposition` — see ADR-044 - **`PropConfigurations` PropBinding widening** — see ADR-045 - **`compositions.yaml` file schema** — follow-on ADR after ADR-044 +- **Nested slot filling in composition elements** — see below ### Notes -- `Composition.anatomy` is required — every composition must declare its element type map; `elements` and `layout` are optional because a minimal slot fragment may only need the type declarations -- No `kind` field on `Composition` itself — the `kind` discriminator is added by the consuming types (`SlotExample` adds `kind: 'slot'`; future system-scoped types add their own) -- `Composition` is not placed directly on `Component` — it is the structural base; `Component.examples` (ADR-043) and `SlotExample` (ADR-044) are the consumer-facing entry points +- **Nested instance with a slot** — when `elements` contains an `instance` element whose component has slot props, those slots cannot be filled from within this `Composition`. The composition can set scalar prop values via `propConfigurations` on that instance, but slot content resolution is deferred to the mechanism introduced in ADR-044 (one level deep) and its follow-on. This is a deliberate constraint, not an oversight. +- **`elements` is sparse** — not every anatomy element needs a corresponding `elements` entry. An element with no content, styles, or prop configurations can be omitted from `elements`. The `elements: {}` case (all anatomy elements are instances with no element-level data) is valid and explicit. +- **No `kind` field** — discrimination is the responsibility of the extending types (`SlotExample` adds `kind: 'slot'`; future system-scoped types add their own). +- **`Composition` is not placed directly on `Component`** — it is the structural base; `Component.examples` (ADR-043) and `SlotExample` (ADR-044) are the consumer-facing entry points. +- **Anticipated metadata extensions** — `title` and `description` are the authoring-time metadata for this ADR. Anticipated follow-on extensions include `tags?: string[]` for cataloguing, `deprecated?: boolean` for lifecycle management, and `guidelines?: string` for usage guidance. These are deferred to a follow-on ADR once the consuming types are established and usage patterns are known. --- ## Type ↔ Schema Impact - **Symmetric**: Yes -- **Parity check**: `Composition { title?, anatomy, elements?, layout? }` ↔ `#/definitions/Composition` +- **Parity check**: `Composition { title?, description?, anatomy, elements, layout }` ↔ `#/definitions/Composition`; `required: [anatomy, elements, layout]` --- @@ -178,5 +236,7 @@ Composition: ## Consequences - `Composition` is a named structural type in the schema, available as a base for `SlotExample` (ADR-044) and future system-scoped layout and page composition types +- All three content fields (`anatomy`, `elements`, `layout`) are required — a composition is always a complete structural declaration, never a degenerate anatomy-only fragment +- `description?` is the first step toward a richer authoring-time metadata model; `tags`, `deprecated`, and `guidelines` are identified follow-on extension points - No existing type is changed; no downstream consumers are broken - ADR-043, ADR-044, and ADR-045 complete the composition model; this ADR is the foundation they build on diff --git a/adr/044-slot-content.md b/adr/044-slot-content.md index 1dcb9bd..9b9403a 100644 --- a/adr/044-slot-content.md +++ b/adr/044-slot-content.md @@ -45,7 +45,7 @@ No new element type is introduced by this ADR. - **One level deep** — a `SlotExample` declares the anatomy and element types of what fills the slot but does not recurse into those elements' own slot content; each component is the sole author of its own examples - **No cross-component references** — all keys in `ComponentExamples` resolve within the same component definition; a `SlotExample`'s anatomy may reference `instanceOf: SomeComponent` but does not reach into that component's examples -- **`SlotExample` extends `Composition`** — same three content fields (`anatomy`, `elements?`, `layout?`) plus `kind: 'slot'` and `slot`; the structural base must not be redefined +- **`SlotExample` extends `Composition`** — same three content fields (`anatomy`, `elements`, `layout`) plus `kind: 'slot'` and `slot`; the structural base must not be redefined - **Figma provenance is not public API** — slot default content is Figma-specific; it belongs in `$extensions['com.figma']` on the element, following the DTCG-derived pattern established on `Props` and `TokenReference` - **Variant-sensitive slot defaults** — `$extensions` lives in `default.elements` or `variants[n].elements`, making it naturally per-variant - **Additive-only** — all new fields are optional; `ComponentExamples` is widened not narrowed → MINOR @@ -274,9 +274,10 @@ SlotExample: kind: 'slot' # discriminator slot: string # name of the SlotProp this example fills title?: string + description?: string anatomy: Anatomy # required — declares the content's element type map - elements?: Elements - layout?: Layout + elements: Elements # required — sparse map; {} is valid for instance-only anatomy + layout: Layout # required — tree ordering of elements # ComponentExample — discriminated union for Component.examples ComponentExample: InstanceExample | SlotExample @@ -339,7 +340,7 @@ Element: SlotExample: type: object description: "Named content for a specific slot: anatomy, element bindings, and layout for the elements that fill the slot." - required: [kind, slot, anatomy] + required: [kind, slot, anatomy, elements, layout] properties: kind: type: string @@ -349,6 +350,8 @@ SlotExample: description: "The SlotProp name this example fills." title: type: string + description: + type: string anatomy: $ref: "#/definitions/Anatomy" elements: @@ -415,7 +418,7 @@ $extensions: ### Notes - `SlotExample.slot` names the `SlotProp` the example fills, enabling tooling to associate the example with the correct slot without inspecting anatomy. -- `SlotExample.anatomy` is required — every slot example must declare the element type map for its content; `elements` and `layout` are optional because a minimal slot fragment may only need the type declarations. +- `SlotExample.anatomy`, `elements`, and `layout` are all required — every slot example is a complete structural declaration. When the anatomy contains only instance elements with no element-level data to set, `elements` is `{}` (explicitly empty). A single-element composition requires `layout: [elementName]`. - The one-level-deep boundary is intentional: `ActionList`'s `SlotExample` for the `items` slot declares three `ActionListItem` instances but does not fill their `startVisual` or `endVisual` slots. `ActionListItem` owns those. This keeps each component's example count proportional to its own variation surface, not to every descendant's. - `FigmaElementExtension.defaultComposition` is valid only when `Element.children` is a `PropBinding` (slot-bound container). A container with `children: string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this constraint; it is a consumer validation concern. - `ElementExtensions` and `FigmaElementExtension` use `additionalProperties: true` — they are open extension objects by design, following the DTCG pattern. @@ -426,7 +429,7 @@ $extensions: - **Symmetric**: Yes - **Parity check**: - - `SlotExample { kind: 'slot', slot, title?, anatomy, elements?, layout? }` ↔ `#/definitions/SlotExample` + - `SlotExample { kind: 'slot', slot, title?, description?, anatomy, elements, layout }` ↔ `#/definitions/SlotExample`; `required: [kind, slot, anatomy, elements, layout]` - `ComponentExample = InstanceExample | SlotExample` ↔ `#/definitions/ComponentExample` (`oneOf`) - `ComponentExamples` widened to `Record` ↔ `patternProperties` updated - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` From 13171467e8015c8880519ac052a3cb675f39b837 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 6 May 2026 17:17:43 -0400 Subject: [PATCH 10/20] chore: start @directededges/specs-schema v0.21.0 development --- packages/schema/CHANGELOG.md | 9 +++++++++ packages/schema/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index aea256e..be3dfb0 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the Specs schema will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - Unreleased + +### Added + +### Changed + +### Removed + + ## [0.20.0] - 2026-05-06 Adds configurable color output format (`Config.format.color`) supporting nine format options from hex strings to structured DTCG Color objects. Renames `ColorValue` to `ColorObject` for specificity and widens `ColorStyle`, `Shadow.color`, and `GradientStop.color` to accept formatted color strings alongside structured objects and token references. diff --git a/packages/schema/package.json b/packages/schema/package.json index 37ceff0..1797020 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.20.0", + "version": "0.21.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", From 57a578ccd09a288a9dfd0e307a1713bee8da657c Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 6 May 2026 17:17:48 -0400 Subject: [PATCH 11/20] chore: start @directededges/specs-cli v0.14.0 development --- packages/cli/CHANGELOG.md | 9 +++++++++ packages/cli/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d4c626b..6d330cd 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@directededges/specs-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - Unreleased + +### Added + +### Changed + +### Removed + + ## [0.13.0] - 2026-05-06 Adds configurable color output format (`config.format.color`) with nine options from hex strings to structured DTCG Color objects. Fixes EISDIR crash when outputDirectory targets a directory, and corrects config template URLs. diff --git a/packages/cli/package.json b/packages/cli/package.json index 8ac5449..ff41b16 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.13.0", + "version": "0.14.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", From 89a83d3687c6016be16c4e452b8346269a614aa7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 7 May 2026 06:22:52 -0400 Subject: [PATCH 12/20] chore: reserve ADR-044 in INDEX Co-Authored-By: Claude Opus 4.6 --- adr/INDEX.md | 1 + 1 file changed, 1 insertion(+) diff --git a/adr/INDEX.md b/adr/INDEX.md index 3a0db5f..4b6947b 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,7 @@ | # | Title | Highlights | |---|-------|------------| +| 044 | Duplicate Layer Name Disambiguation | | | 043 | Custom Color Format Configuration | | | 042 | Composition as a First-Class Type | | | 041 | Layout Positioning — Constraint-Based Naming | | From 783043c288416447db5dbaa880ee39434b9bb003 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 7 May 2026 15:18:06 -0400 Subject: [PATCH 13/20] feat(site): redesign doc site layout and navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2x page grid padding and nav–content gutter at wide viewports - Increase sidebar item spacing (+4px) and padding (+2px → 8px) - Remove distinct surface colors and borders between nav/sidebar/content - Force dark mode and hide theme toggle - Move GitHub and Figma Plugin links from header to top of sidebar Co-Authored-By: Claude Opus 4.6 --- site/astro.config.mjs | 2 + site/src/components/Sidebar.astro | 68 +++++++++++++++++++++++++++ site/src/components/ThemeSelect.astro | 9 ++++ site/src/custom.css | 68 +++++++++++++++++++++++++-- 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 site/src/components/Sidebar.astro create mode 100644 site/src/components/ThemeSelect.astro diff --git a/site/astro.config.mjs b/site/astro.config.mjs index be125e7..bd2b07d 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -16,6 +16,8 @@ export default defineConfig({ }, components: { SocialIcons: './src/components/SocialIcons.astro', + ThemeSelect: './src/components/ThemeSelect.astro', + Sidebar: './src/components/Sidebar.astro', }, customCss: ['./src/custom.css'], head: [ diff --git a/site/src/components/Sidebar.astro b/site/src/components/Sidebar.astro new file mode 100644 index 0000000..1e2ac8b --- /dev/null +++ b/site/src/components/Sidebar.astro @@ -0,0 +1,68 @@ +--- +import type { Props } from '@astrojs/starlight/props'; +import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter'; +import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro'; +import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro'; + +const GITHUB_URL = 'https://github.com/DirectedEdges/specs'; +const FIGMA_PLUGIN_URL = 'https://www.figma.com/community/plugin/1549454283615386215/specs-2-formerly-anova'; +const GITHUB_PATH = 'M12 .3a12 12 0 0 0-3.8 23.38c.6.12.83-.26.83-.57L9 21.07c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.83 1.24 1.83 1.24 1.08 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 0 1 1.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0 0 12 .3Z'; +const FIGMA_PATH = 'M47.7206 95.4082C29.9906 95.4082 15.6176 109.776 15.6176 127.5C15.6176 145.224 29.9906 159.592 47.7206 159.592H80.6912V127.5V95.4082H47.7206ZM128.412 79.7959L129.279 79.7959C147.009 79.7959 161.382 65.4279 161.382 47.7041C161.382 29.9802 147.009 15.6122 129.279 15.6122H96.3088V79.7959L128.412 79.7959ZM155.448 87.602C168.429 79.0765 177 64.3908 177 47.7041C177 21.3578 155.635 0 129.279 0H96.3088H88.5H80.6912H47.7206C21.3652 0 0 21.3578 0 47.7041C0 64.3908 8.57068 79.0765 21.5515 87.602C8.57068 96.1276 0 110.813 0 127.5C0 144.187 8.57067 158.872 21.5515 167.398C8.57067 175.923 0 190.609 0 207.296C0 233.697 21.6358 255 47.9363 255C74.4764 255 96.3088 233.503 96.3088 206.862V175.204V167.398V162.796C104.785 170.505 116.05 175.204 128.412 175.204H129.279C155.635 175.204 177 153.846 177 127.5C177 110.813 168.429 96.1276 155.448 87.602ZM129.279 95.4082L128.412 95.4082C110.682 95.4082 96.3088 109.776 96.3088 127.5C96.3088 145.224 110.682 159.592 128.412 159.592H129.279C147.009 159.592 161.382 145.224 161.382 127.5C161.382 109.776 147.009 95.4082 129.279 95.4082ZM15.6176 207.296C15.6176 189.572 29.9906 175.204 47.7206 175.204H80.6912V206.862C80.6912 224.771 65.9608 239.388 47.9363 239.388C30.1515 239.388 15.6176 224.965 15.6176 207.296ZM80.6912 79.7959H47.7206C29.9906 79.7959 15.6176 65.4279 15.6176 47.7041C15.6176 29.9802 29.9906 15.6122 47.7206 15.6122H80.6912V79.7959Z'; + +const { sidebar } = Astro.props; +--- + + + + + + + +
+ +
+ + diff --git a/site/src/components/ThemeSelect.astro b/site/src/components/ThemeSelect.astro new file mode 100644 index 0000000..e0b8c30 --- /dev/null +++ b/site/src/components/ThemeSelect.astro @@ -0,0 +1,9 @@ +--- +// Force dark theme, hide the theme toggle. +--- + diff --git a/site/src/custom.css b/site/src/custom.css index 3420f2a..d47954d 100644 --- a/site/src/custom.css +++ b/site/src/custom.css @@ -1,3 +1,65 @@ +/* ── Layout overrides ── */ + +/* 2× page padding and nav–content gutter at wide viewports */ +@media (min-width: 72rem) { + /* Content area padding */ + .content-panel { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + /* Top nav bar padding */ + .header { + padding-left: 3rem !important; + padding-right: 3rem !important; + gap: 3rem !important; + } + /* Nav–content gutter */ + .main-pane { + --sl-nav-gap: 3rem; + } +} + +/* Remove distinct surface color and borders from nav/sidebar */ +.header, +nav.sidebar { + background-color: var(--sl-color-bg) !important; +} +.sidebar-pane { + background-color: var(--sl-color-bg) !important; +} +/* Remove all divider borders */ +.header { + border-bottom-color: transparent !important; +} +.sidebar-pane { + border-inline-end-color: transparent !important; +} +.content-panel + .content-panel { + border-top-color: transparent !important; +} +.sidebar-content ul ul { + border-inline-start-color: transparent !important; +} + +/* +4px spacing between left nav items */ +.sidebar-content li { + margin-top: calc(0.75rem + 4px); +} +.sidebar-content ul ul li { + margin-top: 0; +} + +/* +2px padding within each left nav item (default 0.2em → 8px) */ +.sidebar-content { + --sl-sidebar-item-padding-inline: calc(0.5rem + 2px); +} +.sidebar-content a { + padding-block: calc(0.2em + 5.2px); +} +.sidebar-content .group-label { + padding-block: calc(0.3em + 2px); +} + /* Make top-level sidebar links match the style of links inside accordions */ ul.top-level > li > a.large { font-size: var(--sl-text-sm); @@ -63,9 +125,9 @@ ul.top-level > li > a.large[aria-current='page']:focus { background-color: var(--sl-color-text-accent); } -/* Gap between header social links */ -.social-link + .social-link { - margin-left: 0.75rem; +/* Hide social links in header (moved to sidebar) */ +.social-icons { + display: none !important; } /* Site title in top nav bar — neutral instead of accent-colored */ From 4ac7c7c619fd0994d26ffdab8adc83f10ccbce3c Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 11 May 2026 08:27:38 -0400 Subject: [PATCH 14/20] docs(adr-044): redesign slot content around Component.slotContent + SlotBinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantial revisions following review: - Drop SlotExample type; reuse Composition directly - Drop the per-entry `slot` field — content entries are slot-agnostic - Make slot content a sibling field (`Component.slotContent`) rather than a discriminated-union peer of `InstanceExample` in `Component.examples` - Move Figma default-fill reference into `SlotBinding.$extensions['com.figma'].default` (inside `Element.children`, not a sibling on `Element`) to reflect that it is design-tool authoring provenance that code consumers must correctly ignore - Defer recursion (nested slot filling) and its motivating example to a follow-on ADR; keep this ADR scoped to one level deep - Add rejected options for Element-sibling field, content widening - Tighten Context and trim Option A example to the minimum needed to show both reference sites Co-Authored-By: Claude Opus 4.7 (1M context) --- adr/044-slot-content.md | 571 ++++++++++++++++++---------------------- 1 file changed, 251 insertions(+), 320 deletions(-) diff --git a/adr/044-slot-content.md b/adr/044-slot-content.md index 9b9403a..d595358 100644 --- a/adr/044-slot-content.md +++ b/adr/044-slot-content.md @@ -1,4 +1,4 @@ -# ADR: Slot Content — SlotExample and Element Extensions +# ADR: Slot Content — Component.slotContent and SlotBinding **Branch**: `044-slot-content` **Created**: 2026-04-29 @@ -12,43 +12,32 @@ ADR-042 established `Composition` as the structural base type. ADR-043 established `InstanceExample` and `Component.examples` for scalar-prop-configured usages. -A second, structurally distinct example type is needed: one that describes the *content* placed inside a component's slot layer rather than the component's own prop configuration. This is a `SlotExample` — a named, reusable anatomy of elements that fills a specific slot. +A second authoring need is *content placed inside a component's slot layer* — a named, reusable arrangement of elements that fills a slot. Three decisions follow: -Two related needs come with it: +1. **What type expresses slot content?** — `Composition` (ADR-042) is already the structural shape. Does this ADR introduce a new wrapper type, or use `Composition` directly? -1. **Figma-side default** — Figma places elements inside a slot layer when it renders the default state. That default is provenance metadata, not public API; it belongs on the `Element` in `default.elements` or `variants[n].elements` via a Figma-specific `$extensions` field, pointing back to a named entry in `Component.examples`. +2. **Where on `Component` does it live?** — Bundled into `Component.examples` as a discriminated-union peer of `InstanceExample`, or hosted on its own sibling field? ADR-043 left `ComponentExamples` typed narrowly so this ADR could resolve the question. -2. **Widening `ComponentExamples`** — The named record introduced in ADR-043 must accept `SlotExample` entries alongside `InstanceExample` entries. `InstanceExample.slots` (also introduced in ADR-043 as a forward-compatible field) becomes meaningful here: its values now resolve to `SlotExample` keys. +3. **How does the default variant reference it?** — A slot-bound container in `default.elements` or `variants[n].elements` needs a way to point at the content placed inside its slot layer in the design file. The reference must be expressible per-variant, colocated with the binding it defaults, and recognizable to consumers as design-tool authoring metadata so that consumers without a "default slot data" concept (e.g., code components, which resolve missing slots through logic) can correctly ignore it. -### Sensitizing example: ActionList and ActionListItem +This ADR scopes slot content to **one level deep**: a slot-content entry declares its own anatomy and may contain component instances, but does not reach into those instances' own slot fills. Filling nested slots from a parent context has its own design space and is deferred to a follow-on ADR. -`ActionList` and `ActionListItem` show why the one-level-deep rule matters. - -**ActionListItem** has two slot props — `startVisual` and `endVisual`. Its `SlotExample` entries each describe the anatomy of what fills one slot (a single glyph icon). Three entries total cover both slots and a combined `InstanceExample`. - -**ActionList** has one slot prop — `items` — and 8 prop variants. A naive approach that fills each item's `startVisual` and `endVisual` directly on `ActionList` reaches across component boundaries: 8 variants × 3 items × 2 slots = **56** entries. - -The one-level-deep rule resolves this: `ActionList`'s `SlotExample` for `items` declares only the anatomy (three `ActionListItem` instances) — not their slot content. `ActionListItem` owns those slot examples. The count becomes **8 SlotExamples + 8 InstanceExamples = 16** on `ActionList`; ~4 on `ActionListItem`. - -### Slot-bound containers vs. plain frame containers - -In Figma's node model a container element is either a `FrameNode` (static children) or a `SlotNode` (children bound to a slot prop). The schema makes no type distinction — the difference is determined entirely by `Element.children`: - -- `children: PropBinding` — slot-bound container (SlotNode); may carry `$extensions['com.figma'].defaultComposition` -- `children: string[]` — plain frame container (FrameNode); must never carry `defaultComposition` - -No new element type is introduced by this ADR. +The existing `Element.children` discriminant (`string[] | PropBinding` — plain-frame children vs. slot-bound binding) is reused here; no new element type is introduced. --- ## Decision Drivers -- **One level deep** — a `SlotExample` declares the anatomy and element types of what fills the slot but does not recurse into those elements' own slot content; each component is the sole author of its own examples -- **No cross-component references** — all keys in `ComponentExamples` resolve within the same component definition; a `SlotExample`'s anatomy may reference `instanceOf: SomeComponent` but does not reach into that component's examples -- **`SlotExample` extends `Composition`** — same three content fields (`anatomy`, `elements`, `layout`) plus `kind: 'slot'` and `slot`; the structural base must not be redefined -- **Figma provenance is not public API** — slot default content is Figma-specific; it belongs in `$extensions['com.figma']` on the element, following the DTCG-derived pattern established on `Props` and `TokenReference` -- **Variant-sensitive slot defaults** — `$extensions` lives in `default.elements` or `variants[n].elements`, making it naturally per-variant -- **Additive-only** — all new fields are optional; `ComponentExamples` is widened not narrowed → MINOR +- **One level deep (scope of this ADR)** — slot content declares its own anatomy but does not recurse into nested instances' slot fills; recursion is deferred to a follow-on ADR +- **No cross-component references** — all keys resolve within the same component definition; an entry's anatomy may reference `instanceOf: SomeComponent` but does not reach into that component's content +- **Reuse `Composition` directly** — a slot-content entry is structurally identical to a `Composition`; introducing a new type that wraps it would add a name without adding structure +- **Slot binding at the reference site, not on the content** — the same content (e.g., a glyph icon) may fill different slots; binding it to one slot inside the entry forks identical content into per-slot copies +- **Separation of concerns over union neatness** — `InstanceExample` documents a usage of the whole component; slot content declares fill for a named slot. They differ in purpose, in author intent, and in which other types reference them. A flat sibling on `Component` makes that split explicit; a discriminated union papers over it +- **Slot defaults are design-tool provenance, not binding semantics** — Figma stores "what's placed inside the slot layer" as part of the file; code components handle missing slots through logic, not data. The reference must be expressed in a way consumers can correctly ignore — `$extensions['com.figma']` is exactly that mechanism (DTCG pattern, used elsewhere in the schema for Props and TokenReference) +- **Default colocated with binding** — the slot binding and its design-tool default describe the same object ("this slot-bound container, and what Figma shows in it"); they live on the same `SlotBinding`, not on `Element` +- **`PropBinding` stays narrow** — `PropBinding` is generic and used by `content`, `instanceOf`, and `visible` as well. The Figma-default-fill field is meaningful only for slot bindings on `children`, so it goes on a children-specific extension (`SlotBinding`), not on `PropBinding` itself +- **Variant-sensitive defaults** — `Element.children` lives in `default.elements` or `variants[n].elements`, so a per-variant Figma default falls out naturally without any new mechanism +- **Additive-only** — all new fields are optional; no existing type narrows → MINOR - **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) - **No runtime logic** — type declarations and schema only (Constitution §II) @@ -56,372 +45,314 @@ No new element type is introduced by this ADR. ## Options Considered -### Option A: `SlotExample` extends `Composition`; `$extensions` on `Element` *(Selected)* +### Option A: `Component.slotContent: Record` + `SlotBinding.$extensions['com.figma'].default` *(Selected)* + +A new top-level field `Component.slotContent` holds named `Composition` entries — *sibling* to `Component.examples` (which remains `Record` from ADR-043). No new type is introduced for slot content; `Composition` is reused as-is. + +A new interface `SlotBinding` extends `PropBinding` with an optional `$extensions` field carrying platform-specific metadata. `Children` widens from `string[] | PropBinding` to `string[] | SlotBinding`. Because `Children` lives on `Element`, anything on a `SlotBinding` appears naturally in `default.elements[name]` and `variants[n].elements[name]` — per-variant variation falls out for free. -`SlotExample` adds `kind: 'slot'` and `slot: string` to the `Composition` shape. `ComponentExamples` is widened to a `ComponentExample = InstanceExample | SlotExample` discriminated union. `Element.$extensions['com.figma'].defaultComposition` holds the reference back to a `SlotExample` key. +Two reference sites point into `Component.slotContent`: + +1. **`InstanceExample.slots[slotName]`** — *authored documentation, meaningful to all consumers.* Defined in ADR-043; values resolve to `slotContent` keys. Lives as a plain field on `InstanceExample`, not in `$extensions`, because a slot reference inside an authored example is part of the documentation, not Figma-specific provenance. +2. **`SlotBinding.$extensions['com.figma'].default`** — *Figma authoring metadata, ignored by code consumers.* References the content Figma renders inside the slot layer when no consumer override is supplied. Lives in `$extensions` because code consumers resolve missing slots through component logic (not data) and must correctly skip the field. + +Minimal example — one slot, one content entry, both reference sites visible: ```yaml -# ActionListItem — SlotExample entries for startVisual and endVisual -title: Action List Item +# ActionListItem (only fields that demonstrate Option A are shown) anatomy: - root: - type: container - startVisualSlot: - type: container # slot-bound — children: PropBinding - label: - type: text - description: - type: text - endVisualSlot: - type: container # slot-bound — children: PropBinding + startVisualSlot: { type: container } # slot-bound props: - state: - type: string - title: - type: string - description: - type: string - startVisual: - type: slot - endVisual: - type: slot + startVisual: { type: slot } examples: - # --- InstanceExamples from ADR-043, shown for context --- - defaultState: - kind: instance - title: Action List Item – default - propConfigurations: - state: default - title: Browse all issues - description: 12 open · 3 closed - - # --- SlotExamples introduced in this ADR --- - iconStart: - kind: slot - slot: startVisual - title: Action List Item – icon in start visual - anatomy: - icon: - type: glyph - elements: - icon: - content: search + withIcon: + slots: + startVisual: searchIcon # reference site #1: InstanceExample.slots - iconEnd: - kind: slot - slot: endVisual - title: Action List Item – icon in end visual +slotContent: + searchIcon: anatomy: - icon: - type: glyph + icon: { type: glyph } elements: icon: - content: chevronRight - - withBothIcons: - kind: instance - title: Action List Item – with icons in both visuals - propConfigurations: - state: default - title: Browse all issues - slots: - startVisual: iconStart # references examples.iconStart — SlotExample - endVisual: iconEnd # references examples.iconEnd — SlotExample + content: search + styles: + width: 16 + height: 16 + layout: [icon] default: elements: startVisualSlot: - children: { $binding: "#/props/startVisual" } - $extensions: - com.figma: - defaultComposition: iconStart # Figma provenance — not public API - endVisualSlot: - children: { $binding: "#/props/endVisual" } + children: + $binding: "#/props/startVisual" + $extensions: + com.figma: + default: searchIcon # reference site #2: SlotBinding extension ``` -```yaml -# ActionList — one SlotExample per variant; one level deep -# SlotExample anatomy stops at the ActionListItem boundary — -# ActionListItem owns its own startVisual/endVisual examples -title: Action List -anatomy: - root: - type: container - itemsSlot: - type: container # slot-bound — children: PropBinding +#### Naming the new field on `Component` -props: - variant: - type: string - items: - type: slot +Candidate terms considered: -examples: - defaultItems: - kind: slot - slot: items - title: Action List – default items - anatomy: - item1: - type: instance - instanceOf: ActionListItem - item2: - type: instance - instanceOf: ActionListItem - item3: - type: instance - instanceOf: ActionListItem - layout: - - item1 - - item2 - - item3 - # No elements — ActionListItem owns its startVisual/endVisual slot examples - - defaultUsage: - kind: instance - title: Action List – default usage - propConfigurations: - variant: default - slots: - items: defaultItems +- **`slotContent`** *(Selected)* — Figma's own term for what lives inside a slot layer. Reads naturally cross-platform: Web Components, Vue, and Svelte all use ``; SwiftUI/Compose use "slot APIs" / content closures; React's render-prop pattern is widely described as "slots." "Content" is the universal noun for what fills one. Pairs cleanly with `examples` as a sibling without role overlap. +- **`slotExamples`** — parallels `examples` symmetrically, but reads as "examples of slots" rather than "content for slots," which understates what's authored (a complete composition, not an exemplar). +- **`slotCompositions`** — accurate to ADR-042's `Composition` base, but `Composition` is a structural-shape word; authors don't think of slot fills as "compositions" in everyday language. +- **`compositions`** — overreaches: ADR-042 reserves the unqualified term for the system-scoped `compositions.yaml` bucket (layout and page compositions). Using it here would force a rename later. +- **`slots`** — collides with `SlotProp` use of "slots" on `Component.props` and on `InstanceExample.slots`; ambiguous. +- **`fills`** / **`slotFills`** — short but jargony; not established in any of the target platform vocabularies. - dangerItems: - kind: slot - slot: items - title: Action List – danger items - anatomy: - item1: - type: instance - instanceOf: ActionListItem - item2: - type: instance - instanceOf: ActionListItem - item3: - type: instance - instanceOf: ActionListItem - layout: - - item1 - - item2 - - item3 - - dangerUsage: - kind: instance - title: Action List – danger usage - propConfigurations: - variant: danger - slots: - items: dangerItems +`slotContent` wins on platform alignment (Figma + Web Components + Vue + Svelte all use "slot"; "content" is the standard noun for what fills one), on authoring clarity (it names what's authored, not the form), and on namespace cleanliness (no collision with `SlotProp` or with ADR-042's `compositions.yaml`). - # ... pattern repeats for 6 more variants - # Total: 8 SlotExamples + 8 InstanceExamples = 16 entries - # vs. naive cross-component recursion: 8 variants × 3 items × 2 slots = 48+ entries +#### Naming the field inside `$extensions['com.figma']` -default: - elements: - itemsSlot: - children: { $binding: "#/props/items" } - $extensions: - com.figma: - defaultComposition: defaultItems # Figma provenance — not public API -``` +The field lives at `SlotBinding.$extensions['com.figma'].default`. Inside the `com.figma` namespace, "default" is unambiguous — it reads as "Figma's default" by virtue of its enclosing key — and there is no collision risk because the scope is one extension object. + +- **`default`** *(Selected)* — concise, reads naturally in scope (`$extensions.com.figma.default` = "Figma's default"). The enclosing namespace already qualifies it. +- **`defaultContent`** — verbose given the namespace already implies "Figma's default for this slot binding." +- **`defaultComposition`** — accurate but the value isn't typed `Composition` directly; it's a *key into* `slotContent`. "Default" plus the obvious scope is clearer. +- **`slotContent`** — would mirror `Component.slotContent`'s name but invert its meaning (singular key vs. record); confusing. **Pros**: -- One-level-deep boundary keeps ActionList's example count at 16, not 56+ -- No cross-component references — all keys resolve within each component's own `Component.examples` -- `SlotExample` shares the `Composition` shape — no structural duplication -- `$extensions` follows the DTCG-derived provenance pattern; `Children` and `SlotProp` are unchanged -- All changes additive → MINOR +- No new type for slot content — `Composition` carries the structure it already defines +- Slot-content entries are slot-agnostic; one icon entry can be referenced as `startVisual` *or* `endVisual` without duplication +- The Figma default-fill key lives on the slot binding inside `$extensions['com.figma']` — colocated with `$binding`, no name collision with `content`, and correctly marked as design-tool provenance that code consumers ignore +- Per-variant Figma defaults fall out for free: `children` already lives on `Element`, which is mapped through `default`/`variants` +- `examples` and `slotContent` are siblings with one purpose each; no `kind` discriminator on either +- ADR-043's `ComponentExamples` is not widened; this ADR is purely additive +- Future system-scoped `compositions.yaml` (ADR-042 follow-on) lives at the same conceptual level without competing with either field **Cons / Trade-offs**: -- Filling slots of instances *within* a `SlotExample` (e.g., setting `ActionListItem`'s `startVisual` from `ActionList`'s context) is deferred to a follow-on ADR; the boundary is one level only +- Two namespaces to teach instead of one +- A consumer that wants "everything authored on this component" must read both fields and union them +- One-level-deep boundary; recursion deferred to a follow-on ADR +- Schema cannot enforce that `$extensions['com.figma'].default` keys exist in `slotContent` — consumer validation concern. (The "only valid for slot-bound" constraint *is* enforced structurally — `$extensions` only exists in the `SlotBinding` arm of `Children`.) --- -### Option B: Slot default on `SlotProp.default` *(Rejected)* +### Option B: New `SlotExample` type with a `slot` field; entries bundled in `Component.examples`; reference via `Element.$extensions` *(Rejected)* -Store the default composition reference on `SlotProp.default` rather than on the element. +Introduce `SlotExample extends Composition` with an added `slot: string`, widen `Component.examples` to a `ComponentExample = InstanceExample | SlotExample` discriminated union (each entry carries a `kind`), and reference defaults via `Element.$extensions['com.figma'].defaultComposition` — i.e., put the extension on `Element` rather than on the slot binding. -**Rejected because**: `SlotProp` is a public prop API type; slot default content is Figma provenance. Mixing platform-specific provenance into the public API type creates coupling that violates the `$extensions` separation established for props and token references. +**Rejected because**: +- The `slot` field hard-codes a content entry to a single slot; identical content used in two slots must be duplicated. +- `SlotExample` adds no structure over `Composition` once `slot` is removed — it's an alias for an alias. +- The `kind` discriminator is pure schema bookkeeping with no authoring value; the two types don't share an audience (`InstanceExample` is consumed by docs/rendering tooling that wants whole-component usages; slot content is consumed by slot-rendering and by Figma's default-fill reference). Bundling forces every consumer to filter by `kind`. +- The `$extensions` *placement* is wrong — putting it on `Element` separates the default from the binding it defaults. Option A keeps the extension but moves it onto `SlotBinding`, where it belongs. +- The `$extensions` *use* is correct — the Figma default-content reference *is* design-tool provenance (code components don't have a "default slot data" concept; defaults in code are logic). Earlier drafts that put a plain `default: string` field on `SlotBinding` (no `$extensions` wrapper) misrepresented the field as binding-level semantics that all consumers should honor; code consumers should ignore it. --- -### Option C: Separate slot-example file *(Rejected)* +### Option C: Slot default on `SlotProp.default` *(Rejected)* -Store slot examples in a separate file alongside the component, rather than in `Component.examples`. +Store the default-content reference on `SlotProp.default` rather than on the element. -**Rejected because**: Slot examples are component-specific — they describe content for a named slot on that component. They belong in the component definition for discoverability and for `InstanceExample.slots` to resolve keys by string without cross-file reference. +**Rejected because**: `SlotProp` is the public prop API surface. Default content is per-variant element data — variant-sensitive, and tied to a specific element in the tree (the slot-bound container), not to the prop definition. Per-variant variation cannot live on the prop definition. --- -## Decision +### Option D: Separate slot-content file *(Rejected)* -### Type changes (`types/`) +Store slot content in a separate file alongside the component, rather than in the component definition. -| File | Change | Bump | -|------|--------|------| -| `ComponentExample.ts` | Add `SlotExample`; add `ComponentExample = InstanceExample \| SlotExample`; widen `ComponentExamples` | MINOR | -| `InstanceExample.ts` | *(no change — `slots?` was defined in ADR-043)* | — | -| `Element.ts` | Add optional `$extensions?: ElementExtensions`; add `ElementExtensions`, `FigmaElementExtension` | MINOR | -| `index.ts` | Export `SlotExample`, `ComponentExample`, `ElementExtensions`, `FigmaElementExtension` | MINOR | +**Rejected because**: Slot-content entries are component-specific — they describe content authored to fill named slots on that component. They belong in the component definition for discoverability and so `InstanceExample.slots` and the Figma default reference can resolve keys by string without cross-file reference. + +--- + +### Option E: New top-level field on `Element` (sibling of `content` and `children`) *(Rejected)* -**Updated `ComponentExample.ts`**: +Place the Figma default-fill reference as a new field directly on `Element` — under names like `defaultContent`, `defaultChildren`, or `defaultSlotContent` — alongside `content` and `children`. ```yaml -# SlotExample — named content for a specific slot -SlotExample: - kind: 'slot' # discriminator - slot: string # name of the SlotProp this example fills - title?: string - description?: string - anatomy: Anatomy # required — declares the content's element type map - elements: Elements # required — sparse map; {} is valid for instance-only anatomy - layout: Layout # required — tree ordering of elements - -# ComponentExample — discriminated union for Component.examples -ComponentExample: InstanceExample | SlotExample - -# ComponentExamples — widened from ADR-043 -# Before: Record -# After: Record -ComponentExamples: Record +default: + elements: + itemsSlot: + children: { $binding: "#/props/items" } + defaultContent: defaultItems # sibling of children ``` -**New types** (in `types/Element.ts`): +**Rejected because**: +- Names like `defaultContent` collide conceptually with `content` (text body of leaves) and with `children` (layout structure), regardless of which name is chosen. The reader naturally reads `defaultContent` as "default for `content`" — which it isn't. +- The reference is separated from the binding it defaults: `children` holds the slot binding; the new field holds the default. Two objects, one fact. +- It pollutes `Element`'s public surface with a field whose meaning depends on the shape of a sibling field (`children` must be a slot binding for the field to be valid) and whose audience is a single platform (Figma authoring tools). Public-surface fields should be meaningful to all consumers and structurally self-contained. + +Option A's `SlotBinding.$extensions['com.figma'].default` colocates with the binding and lives in the namespace that correctly signals "design-tool provenance, code consumers ignore." + +--- + +### Option F: Widen `Element.content` to accept slot-content keys *(Rejected)* + +Reuse the existing `Element.content` field. When `Element.children` is a slot binding, interpret `content`'s string value as a key into `Component.slotContent` instead of as literal text. ```yaml -FigmaElementExtension: - defaultComposition?: string # key in Component.examples — valid only when children is PropBinding - [key: string]: unknown # open for future Figma-specific fields +default: + elements: + itemsSlot: + children: { $binding: "#/props/items" } + content: defaultItems # interpreted as slotContent key here + label: + content: "Browse all issues" # interpreted as text leaf body here +``` + +**Rejected because**: +- `content` is already typed `string | PropBinding`. Making the same `string` mean "literal text" in one element shape and "slotContent key" in another is a conditional that JSON Schema cannot express. +- Consumers would have to branch on `children`'s shape to interpret a string in `content`. A field's meaning should not depend on a sibling field's shape. +- It conflates two different kinds of content (text body vs. composition reference) under one name and loses the design-tool-provenance framing that `$extensions['com.figma']` provides. + +--- -ElementExtensions: - 'com.figma'?: FigmaElementExtension - [key: string]: unknown # open for future platform extensions +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Component.ts` | Add optional `slotContent?: Record` | MINOR | +| `Children.ts` | Add `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension`; widen `Children` from `string[] \| PropBinding` to `string[] \| SlotBinding` | MINOR | +| `index.ts` | Export `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension` | MINOR | + +No new type file for slot-content entries. `SlotContent` is not introduced as a named alias — `Record` is small and explicit; an alias would obscure that this field is just a named map of `Composition` entries. + +**Widening `Children`** (`types/Children.ts`): + +```ts +import { PropBinding } from './PropBinding.js'; + +/** + * Figma-specific extension on a SlotBinding. + * Carries Figma authoring metadata for the slot — not honored by code consumers. + */ +export interface FigmaSlotBindingExtension { + /** + * Key in Component.slotContent — Figma's authoring default for this slot + * (the content placed inside the slot layer in the design file). Code + * consumers handle missing slots through component logic and ignore this. + */ + default?: string; + [key: string]: unknown; +} + +/** Open extension bag on a SlotBinding for platform-specific metadata. */ +export interface SlotBindingExtensions { + 'com.figma'?: FigmaSlotBindingExtension; + [key: string]: unknown; +} + +/** A slot binding: a PropBinding to a slot prop, with optional platform extensions. */ +export interface SlotBinding extends PropBinding { + $extensions?: SlotBindingExtensions; +} + +export type Children = string[] | SlotBinding; ``` -**Extended `Element`** (`types/Element.ts`): +`SlotBinding extends PropBinding`, so existing `children: { $binding: "#/props/items" }` values still validate (the new `$extensions` field is optional). `PropBinding` itself is unchanged — its other consumers (`content`, `instanceOf`, `visible`) are unaffected. + +**Extended `Component`** (`types/Component.ts`): ```yaml -# Before -Element: - children?: Children - parent?: string | null - styles?: Styles - propConfigurations?: PropConfigurations - instanceOf?: string | PropBinding | SubcomponentRef - content?: string | PropBinding +# Before (after ADR-043) +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + examples?: ComponentExamples # InstanceExample only — ADR-043 # After -Element: - children?: Children - parent?: string | null - styles?: Styles - propConfigurations?: PropConfigurations - instanceOf?: string | PropBinding | SubcomponentRef - content?: string | PropBinding - $extensions?: ElementExtensions # new — platform-specific element metadata +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + examples?: ComponentExamples # unchanged + slotContent?: Record # new — named, slot-agnostic content entries ``` +`Element` itself is **unchanged** — the new field lives inside `Element.children`, not as a sibling. + ### Schema changes (`schema/`) | File | Change | Bump | |------|--------|------| -| `component.schema.json` | Add `#/definitions/SlotExample` | MINOR | -| `component.schema.json` | Add `#/definitions/ComponentExample` (`oneOf` discriminated by `kind`) | MINOR | -| `component.schema.json` | Update `#/definitions/ComponentExamples` to use `ComponentExample` | MINOR | -| `component.schema.json` | Add `#/definitions/FigmaElementExtension` | MINOR | -| `component.schema.json` | Add `#/definitions/ElementExtensions` | MINOR | -| `component.schema.json` | Add `$extensions` to `#/definitions/Element/properties` | MINOR | +| `component.schema.json` | Add `slotContent` property to `#/definitions/Component` | MINOR | +| `component.schema.json` | Add `#/definitions/SlotBinding`, `#/definitions/SlotBindingExtensions`, `#/definitions/FigmaSlotBindingExtension`; update `#/definitions/Children` to use `SlotBinding` | MINOR | -**New definition** (`#/definitions/SlotExample`): +**New property** in `#/definitions/Component/properties`: ```yaml -SlotExample: +slotContent: type: object - description: "Named content for a specific slot: anatomy, element bindings, and layout for the elements that fill the slot." - required: [kind, slot, anatomy, elements, layout] - properties: - kind: - type: string - enum: [slot] - slot: - type: string - description: "The SlotProp name this example fills." - title: - type: string - description: - type: string - anatomy: - $ref: "#/definitions/Anatomy" - elements: - $ref: "#/definitions/Elements" - layout: - $ref: "#/definitions/Layout" + description: "Named slot-content entries for this component. Each entry is a Composition. Keys are referenced by InstanceExample.slots and by SlotBinding.$extensions['com.figma'].default." + patternProperties: + "^[a-zA-Z0-9_-]+$": + $ref: "#/definitions/Composition" additionalProperties: false ``` -**New definition** (`#/definitions/ComponentExample`): - -```yaml -ComponentExample: - oneOf: - - $ref: "#/definitions/InstanceExample" - - $ref: "#/definitions/SlotExample" -``` - -**Updated `#/definitions/ComponentExamples`** (widens `patternProperties` target): - -```yaml -# Before (ADR-043) -patternProperties: - "^[a-zA-Z0-9_-]+$": - $ref: "#/definitions/InstanceExample" - -# After -patternProperties: - "^[a-zA-Z0-9_-]+$": - $ref: "#/definitions/ComponentExample" -``` - -**New definitions** (`#/definitions/FigmaElementExtension` and `#/definitions/ElementExtensions`): +**New definitions** and updated `#/definitions/Children`: ```yaml -FigmaElementExtension: +FigmaSlotBindingExtension: type: object + description: "Figma-specific authoring metadata on a slot binding. Not honored by code consumers." properties: - defaultComposition: + default: type: string - description: "Key of a SlotExample in Component.examples. Valid only when Element.children is a PropBinding." + description: "Key in Component.slotContent — Figma's authoring default for this slot." additionalProperties: true -ElementExtensions: +SlotBindingExtensions: type: object + description: "Open extension bag on a SlotBinding for platform-specific metadata." properties: "com.figma": - $ref: "#/definitions/FigmaElementExtension" + $ref: "#/definitions/FigmaSlotBindingExtension" additionalProperties: true -``` -**New property** in `#/definitions/Element/properties`: +SlotBinding: + type: object + description: "Slot binding: a PropBinding to a slot prop, optionally with platform-specific authoring metadata in $extensions." + required: [$binding] + properties: + $binding: + type: string + description: "JSON Pointer to the bound slot prop, e.g. \"#/props/items\"." + $extensions: + $ref: "#/definitions/SlotBindingExtensions" + additionalProperties: false -```yaml -$extensions: - $ref: "#/definitions/ElementExtensions" +Children: + oneOf: + - type: array + items: { type: string } + - $ref: "#/definitions/SlotBinding" ``` ### Out of scope for this ADR -- **Nested slot filling within `SlotExample`** — When a `SlotExample` contains component instances that themselves have slots (e.g., `ActionListItem` instances in `ActionList`'s `items` slot), filling those nested slots from the parent context is deferred to a follow-on ADR. Each component resolves its own slot content independently. +- **Nested slot filling (recursion)** — When a slot-content entry contains component instances that themselves have slots, filling those nested slots from the parent context is a separate problem with its own design space. Deferred to a follow-on ADR. This ADR scopes slot content to one level only; each component resolves its own slot content independently. - **`compositions.yaml` file schema** — system-scoped (`layout`, `page`) compositions; a follow-on ADR. ### Notes -- `SlotExample.slot` names the `SlotProp` the example fills, enabling tooling to associate the example with the correct slot without inspecting anatomy. -- `SlotExample.anatomy`, `elements`, and `layout` are all required — every slot example is a complete structural declaration. When the anatomy contains only instance elements with no element-level data to set, `elements` is `{}` (explicitly empty). A single-element composition requires `layout: [elementName]`. -- The one-level-deep boundary is intentional: `ActionList`'s `SlotExample` for the `items` slot declares three `ActionListItem` instances but does not fill their `startVisual` or `endVisual` slots. `ActionListItem` owns those. This keeps each component's example count proportional to its own variation surface, not to every descendant's. -- `FigmaElementExtension.defaultComposition` is valid only when `Element.children` is a `PropBinding` (slot-bound container). A container with `children: string[]` is a plain FrameNode and must never carry `defaultComposition`. The schema cannot enforce this constraint; it is a consumer validation concern. -- `ElementExtensions` and `FigmaElementExtension` use `additionalProperties: true` — they are open extension objects by design, following the DTCG pattern. +- Slot-content entries are slot-agnostic. The same `Composition` (e.g., a single glyph icon) can be referenced from multiple `InstanceExample.slots` entries and from multiple slot-binding defaults; authors are not forced to duplicate identical content per slot. +- `SlotBinding.$extensions` is structurally available only when `Element.children` is a slot binding (the second arm of `Children`). A container with `children: string[]` cannot express a Figma default — the schema enforces this through the `Children` discriminant. +- Because `Element.children` lives in `default.elements` and `variants[n].elements`, different variants may declare different Figma defaults for the same slot-bound container with no special-case mechanism. +- `PropBinding` itself is unchanged. `SlotBinding extends PropBinding` adds `$extensions` only for the children-binding case; `PropBinding`'s other use sites (`content`, `instanceOf`, `visible`) cannot accidentally accept it. +- `InstanceExample.slots` does *not* live in `$extensions`. `InstanceExample` is an authored documentation construct; a slot reference inside one is part of the documentation and is meaningful to all consumers, not just Figma. +- `SlotBindingExtensions` and `FigmaSlotBindingExtension` use `additionalProperties: true` — open extension objects by design, following the DTCG pattern. +- Schema validation cannot enforce that `$extensions['com.figma'].default` and `InstanceExample.slots` values resolve to existing keys in `Component.slotContent` — consumer validation concern. --- @@ -429,12 +360,11 @@ $extensions: - **Symmetric**: Yes - **Parity check**: - - `SlotExample { kind: 'slot', slot, title?, description?, anatomy, elements, layout }` ↔ `#/definitions/SlotExample`; `required: [kind, slot, anatomy, elements, layout]` - - `ComponentExample = InstanceExample | SlotExample` ↔ `#/definitions/ComponentExample` (`oneOf`) - - `ComponentExamples` widened to `Record` ↔ `patternProperties` updated - - `FigmaElementExtension { defaultComposition?: string }` ↔ `#/definitions/FigmaElementExtension` - - `ElementExtensions { 'com.figma'?: FigmaElementExtension }` ↔ `#/definitions/ElementExtensions` - - `Element.$extensions?: ElementExtensions` ↔ `#/definitions/Element/properties/$extensions` + - `Component.slotContent?: Record` ↔ `#/definitions/Component/properties/slotContent` (`patternProperties` → `Composition`) + - `FigmaSlotBindingExtension { default?: string }` ↔ `#/definitions/FigmaSlotBindingExtension` + - `SlotBindingExtensions { 'com.figma'?: FigmaSlotBindingExtension }` ↔ `#/definitions/SlotBindingExtensions` + - `SlotBinding extends PropBinding { $extensions?: SlotBindingExtensions }` ↔ `#/definitions/SlotBinding` + - `Children = string[] | SlotBinding` ↔ `#/definitions/Children` (`oneOf`) --- @@ -442,9 +372,9 @@ $extensions: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | Must detect and emit `SlotExample` from slot layers; must emit `$extensions['com.figma'].defaultComposition` on slot-bound elements in variant data | Read new types; implement slot example detection; update output emitters | -| `specs-cli` | Recompile; output includes `SlotExample` entries and element `$extensions` when present | Recompile; no breaking change | -| `specs-plugin-2` | Recompile; slot example rendering is a follow-on capability | Recompile; pass through data initially | +| `specs-from-figma` | Must detect and emit `Component.slotContent` from slot layers; must emit `$extensions['com.figma'].default` on slot bindings in `default.elements` / `variants[n].elements` | Read new fields; implement slot-content detection; update output emitters | +| `specs-cli` | Recompile; output includes `slotContent` and `SlotBinding.$extensions` when present | Recompile; no breaking change | +| `specs-plugin-2` | Recompile; slot-content rendering is a follow-on capability | Recompile; pass through data initially | --- @@ -452,15 +382,16 @@ $extensions: **Version bump**: `0.19.0 → 0.20.0` (`MINOR`) -**Justification**: All changes are additive — new types, new optional fields, `ComponentExamples` value union widened (existing `InstanceExample` entries remain valid). No existing type is removed or narrowed → MINOR per Constitution §III. +**Justification**: All changes are additive — new optional `Component.slotContent` field; new `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension` interfaces; `Children` widened from `string[] | PropBinding` to `string[] | SlotBinding`, where `SlotBinding` is a structural superset of `PropBinding` (existing `children: { $binding }` values still validate). No new types for slot content (`Composition` from ADR-042 is reused). `PropBinding` and `Element` are unchanged. `ComponentExamples` from ADR-043 is unchanged. → MINOR per Constitution §III. --- ## Consequences -- `SlotExample` is a named structural type that defines what fills a specific slot; it shares the `Composition` shape and lives in `Component.examples` alongside `InstanceExample` entries -- `InstanceExample.slots` (defined in ADR-043) is now meaningful: its values resolve to `SlotExample` keys in the same `Component.examples` -- `Element.$extensions['com.figma'].defaultComposition` wires Figma's default slot content to a named `SlotExample` — `Children` and `SlotProp` are unchanged -- The one-level-deep rule keeps composition scale manageable: each component owns its examples, slot anatomy stops at nested component boundaries -- `ElementExtensions` is an open extension object — future Figma-specific or platform-specific element metadata can be added without a new ADR -- Filling slots of instances within a `SlotExample` is deferred to a follow-on ADR; the one-level-deep boundary is the current accepted limit +- `Component.examples` and `Component.slotContent` are siblings: each holds one type with one purpose. `examples` documents whole-component usages (`InstanceExample`); `slotContent` holds named `Composition` entries that fill slots. +- Slot-content entries are slot-agnostic; the binding to a specific slot happens at the reference site (`InstanceExample.slots`, `SlotBinding.$extensions['com.figma'].default`). Identical content used in multiple slots is authored once. +- `Composition` is reused directly with no wrapper type; ADR-042's structural type carries its own weight here. +- `SlotBinding.$extensions['com.figma'].default` colocates the Figma default-fill reference with the slot binding itself, inside `Element.children`. The `$extensions` framing correctly marks it as design-tool provenance — code consumers ignore it (defaults in code are logic, not data). No collision with `content`, `children`, or any top-level `Element` field. Per-variant Figma defaults fall out because `children` already lives on `Element`. +- `SlotBindingExtensions` is an open extension object — future Figma-specific or platform-specific slot-binding metadata can be added without a new ADR. +- `InstanceExample.slots` (defined in ADR-043) resolves into `Component.slotContent`. ADR-043's `ComponentExamples` type is not widened. +- This ADR scopes slot content to one level deep; recursion (filling slots of nested instances from a parent context) is deferred to a follow-on ADR. From d4787e3617080d2246d00051dc2c4c2a18c6e598 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 11 May 2026 08:27:42 -0400 Subject: [PATCH 15/20] docs(adr-045): tighten PropConfigurations PropBinding context and rationale Co-Authored-By: Claude Opus 4.7 (1M context) --- adr/045-prop-configurations-binding.md | 164 +++++++++++++++++++++---- 1 file changed, 141 insertions(+), 23 deletions(-) diff --git a/adr/045-prop-configurations-binding.md b/adr/045-prop-configurations-binding.md index ad128d6..27d2699 100644 --- a/adr/045-prop-configurations-binding.md +++ b/adr/045-prop-configurations-binding.md @@ -10,17 +10,54 @@ ## Context -`PropConfigurations` is used on `Element` to configure a nested component instance's props — for example, setting a nested `Button`'s `variant` to `"primary"`. Its current type accepts only scalar values: +`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: -```yaml -PropConfigurations: Record +```ts +// types/PropConfigurations.ts (today) +type PropConfigurations = Record; ``` -A parent component's element layer may need to *pass through* a prop from the parent to a nested instance — for example, a `Card` that exposes a `variant` prop and forwards it to an internal `Button`. Today, this requires a static scalar value; the forwarding cannot be expressed. +### Where `PropBinding` is already accepted + +`PropBinding` (`{ $binding: "#/props/" }`, 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: + +```ts +// 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 — the gap + // ... +}; +``` + +### The gap + +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: -`PropBinding` (`{ $binding: string }`) already exists for exactly this purpose: it appears on `Element.content`, `Element.instanceOf`, and `Styles.visible` to bind a field's value to a parent prop at data-emission time. Widening `PropConfigurations` to also accept `PropBinding` completes the binding model uniformly across all element-level fields. +```yaml +# Parent component declares its props +props: + label: { type: string } + +elements: + # ✅ Already works — Element.content accepts PropBinding + cardTitle: + type: text + content: { $binding: "#/props/label" } + + # ❌ Cannot express today — propConfigurations values must be scalar + nestedButton: + type: instance + instanceOf: Button + propConfigurations: + label: { $binding: "#/props/label" } # rejected by the current type +``` -This ADR does **not** affect `InstanceExample.propConfigurations` (ADR-043), which intentionally remains scalar-only — it represents a human-authored documented configuration, not a live data binding. +Widening `PropConfigurations` to also accept `PropBinding` closes this gap and makes the binding model uniform across every element-level value field. + +This ADR does **not** affect `InstanceExample.propConfigurations` (ADR-043), which stays scalar-only — it represents a human-authored documented configuration, not a live binding. --- @@ -40,22 +77,28 @@ This ADR does **not** affect `InstanceExample.propConfigurations` (ADR-043), whi Add `PropBinding` as a fourth branch alongside `string`, `number`, and `boolean`. -```yaml -# Before — static scalar only -aNestedButton: - type: instance - instanceOf: Button - propConfigurations: - variant: primary +```ts +// Before +type PropConfigurations = Record; -# After — static scalar or pass-through binding -aNestedButton: - type: instance - instanceOf: Button - propConfigurations: - variant: primary # static scalar — unchanged - label: { $binding: "#/props/buttonLabel" } # bound to parent's buttonLabel prop - disabled: { $binding: "#/props/disabled" } # bound to parent's disabled prop +// After +type PropConfigurations = Record; +``` + +In practice, a single `propConfigurations` block can mix static scalars and bindings: + +```yaml +# Parent component +props: + disabled: { type: boolean } + +elements: + nestedButton: + type: instance + instanceOf: Button + propConfigurations: + variant: primary # static scalar — already supported + disabled: { $binding: "#/props/disabled" } # NEW — forwarded from parent prop ``` **Pros**: @@ -70,9 +113,84 @@ aNestedButton: ### Option B: Separate `propBindings` field *(Rejected)* -Add a new `propBindings?: Record` field on `Element` alongside `propConfigurations`. +Add a sibling field on `Element` that only carries bindings; leave `propConfigurations` scalar-only. + +```ts +// types/Element.ts +type Element = { + propConfigurations?: PropConfigurations; // scalar-only, unchanged + propBindings?: Record; // NEW — bindings live here + // ... +}; +``` + +```yaml +nestedButton: + type: instance + instanceOf: Button + propConfigurations: + variant: primary + propBindings: + disabled: { $binding: "#/props/disabled" } +``` + +**Rejected because**: +- Two fields keyed by the same prop name invites collisions (`propConfigurations.disabled` *and* `propBindings.disabled`) with no obvious precedence rule — a class of bug Option A cannot have. +- Inconsistent with the precedent already set on `Element.content`, `Element.instanceOf`, and `Styles.visible`, which each carry the `scalar | PropBinding` union inline. Splitting here would be the only element-level field that treats bindings as a separate channel. +- 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 the binding as a magic string instead of an object, keeping the value union flat. + +```ts +type PropConfigurations = Record; +``` + +```yaml +nestedButton: + propConfigurations: + variant: primary + disabled: "$binding:#/props/disabled" # sentinel string +``` + +**Rejected because**: +- Ambiguous with legitimate string values — a `label` prop whose static value happens to start with `$binding:` is now indistinguishable from a binding. The object form (`{ $binding: ... }`) is unambiguous by construction. +- Diverges from ADR-008. Every other binding site in the schema uses the `{ $binding: string }` object shape; introducing a second encoding only for `propConfigurations` fractures 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 relationship from the *parent prop's* side, not the consuming element's side. + +```ts +// types/Props.ts +interface StringProp { + type: 'string'; + forwardsTo?: string[]; // e.g., ["#/elements/nestedButton/propConfigurations/disabled"] + // ... +} +``` + +**Rejected because**: +- Inverts the natural locality. A reader looking at `nestedButton` to understand "what is `disabled` set to?" would have to scan every parent prop's `forwardsTo` list. Element-local declarations keep cause and effect adjacent. +- Doesn't compose with the existing `PropBinding` model on `content`, `instanceOf`, and `styles.visible` — those are declared at the consumption site. A `forwardsTo` model on `Props` would have to either duplicate or replace them. +- A single parent prop forwarded to multiple targets becomes a list of pointers, which is harder to author and validate than per-site bindings. + +--- + +### Option E: Defer — treat prop forwarding as a consumer concern *(Rejected)* + +Do nothing in the schema. Document that consumers (`specs-from-figma`, code generators) should infer pass-through by matching prop names between parent and nested instance, or via codebase conventions. -**Rejected because**: Splitting static values and bindings across two fields is awkward to author and consume. A single `propConfigurations` field that accepts both is simpler and consistent with how `Element.content` handles the same duality (`string | PropBinding`). +**Rejected because**: +- Name-matching is a heuristic, not a contract. Two props can share a name without being a forwarding relationship, and a forwarding relationship can exist between differently-named props (`Card.label` → `Button.text`). +- The spec already commits to modeling bindings explicitly for `content`, `instanceOf`, and `styles.visible` (ADR-008). Leaving `propConfigurations` as the lone exception forces consumers to support two reasoning modes — explicit bindings everywhere except here. +- Figma's component-property bindings on instance swaps and text overrides are extracted directly; refusing to express the equivalent for nested-instance props discards information the source already provides. --- From b060c8446d748dc2ed8a50c4db41db9bfe155105 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 11 May 2026 08:30:04 -0400 Subject: [PATCH 16/20] docs(adr): renumber 043-045 to 046-048; reserve 049 for Nested Slot Compositions main owns ADR-043 (custom-color-format-config) and PR #60 owns ADR-044/045 (duplicate-layer-disambiguation, processing-provenance-signals). This branch's three ADRs collide with both. Move ours to the next free range so PR #60 and already-merged work remain valid: - 043-component-examples -> 046-component-examples - 044-slot-content -> 047-slot-content - 045-prop-configurations-binding -> 048-prop-configurations-binding Reserve 049 for the deferred recursion ADR (Nested Slot Compositions, called out as a follow-on in ADR-047). Stub authored. Updates all ADR-NNN cross-references and branch-name references inside our four ADRs and INDEX.md. INDEX.md description for the slot-content row also brought in line with the redesigned ADR-047 title (the prior description referenced removed concepts like SlotExample / defaultComposition). Indexing change only - no design decisions altered. Co-Authored-By: Claude Opus 4.7 (1M context) --- adr/042-composition-type.md | 34 ++++++------- ...-examples.md => 046-component-examples.md} | 44 ++++++++-------- ...44-slot-content.md => 047-slot-content.md} | 22 ++++---- ....md => 048-prop-configurations-binding.md} | 6 +-- adr/049-nested-slot-compositions.md | 51 +++++++++++++++++++ adr/INDEX.md | 7 +-- 6 files changed, 108 insertions(+), 56 deletions(-) rename adr/{043-component-examples.md => 046-component-examples.md} (90%) rename adr/{044-slot-content.md => 047-slot-content.md} (97%) rename adr/{045-prop-configurations-binding.md => 048-prop-configurations-binding.md} (98%) create mode 100644 adr/049-nested-slot-compositions.md diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index c29d338..5d3e559 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -4,8 +4,8 @@ **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) -**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` shape; defers its `PropConfigurations` widening to ADR-045)* -**Extended by**: ADR-043 (Component Examples), ADR-044 (Slot Content), ADR-045 (PropConfigurations PropBinding) +**Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` shape; defers its `PropConfigurations` widening to ADR-048)* +**Extended by**: ADR-046 (Component Examples), ADR-047 (Slot Content), ADR-048 (PropConfigurations PropBinding) --- @@ -22,16 +22,16 @@ Compositions appear at four scales in Figma-sourced design systems: Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. There is no structural type for tooling to discover, render, or validate against. -DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") proposed a `Composition` type for the inline parent-fills-child-slot case. This ADR adopts that structural shape as a named foundational type. How components store and reference compositions is addressed in ADR-043 (`InstanceExample`, `Component.examples`) and ADR-044 (`SlotExample`, `Element.$extensions`). +DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") proposed a `Composition` type for the inline parent-fills-child-slot case. This ADR adopts that structural shape as a named foundational type. How components store and reference compositions is addressed in ADR-046 (`InstanceExample`, `Component.examples`) and ADR-047 (`SlotExample`, `Element.$extensions`). ### Composition scoping The four scales split into two authoring scopes: -- **Component-scoped** (`slot`, `instance`) — authored by the component designer inside the component definition; covered by ADR-043 and ADR-044 +- **Component-scoped** (`slot`, `instance`) — authored by the component designer inside the component definition; covered by ADR-046 and ADR-047 - **System-scoped** (`layout`, `page`) — independent of any single component, living in a separate `compositions.yaml` file parallel to `components.yaml`; schema for that file is a follow-on ADR -The `Composition` type established here serves both scopes. `SlotExample` (ADR-044) extends it for the component-scoped slot-filling case; the future system-scoped ADR will use it directly. +The `Composition` type established here serves both scopes. `SlotExample` (ADR-047) extends it for the component-scoped slot-filling case; the future system-scoped ADR will use it directly. --- @@ -115,7 +115,7 @@ anatomy: ``` **Rejected because**: -- `SlotExample` (ADR-044) must extend `Composition`. If `Composition` uses a converged map, `SlotExample` must too — breaking the Anatomy/Elements pattern used throughout the rest of the schema. +- `SlotExample` (ADR-047) must extend `Composition`. If `Composition` uses a converged map, `SlotExample` must too — breaking the Anatomy/Elements pattern used throughout the rest of the schema. - If a composition is later promoted to a full component (a natural authoring workflow), the converged map must be split back into `anatomy` + `elements` + `variants`. Keeping them separate makes that migration a no-op. - `AnatomyElement` (`type`, `detectedIn?`, `instanceOf?`) and `Element` (`children`, `styles`, `propConfigurations`, `content`) have non-overlapping fields. Merging them creates a hybrid type that is neither and must be maintained independently. @@ -173,7 +173,7 @@ Composition: ```yaml Composition: type: object - description: "Named structural content fragment. Base shape for SlotExample (ADR-044) and system-scoped layout/page compositions." + description: "Named structural content fragment. Base shape for SlotExample (ADR-047) and system-scoped layout/page compositions." required: [anatomy, elements, layout] properties: title: @@ -191,19 +191,19 @@ Composition: ### Out of scope for this ADR -- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-044 -- **`InstanceExample`** and **`Component.examples`** — see ADR-043 -- **`Element.$extensions`** and `defaultComposition` — see ADR-044 -- **`PropConfigurations` PropBinding widening** — see ADR-045 -- **`compositions.yaml` file schema** — follow-on ADR after ADR-044 +- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-047 +- **`InstanceExample`** and **`Component.examples`** — see ADR-046 +- **`Element.$extensions`** and `defaultComposition` — see ADR-047 +- **`PropConfigurations` PropBinding widening** — see ADR-048 +- **`compositions.yaml` file schema** — follow-on ADR after ADR-047 - **Nested slot filling in composition elements** — see below ### Notes -- **Nested instance with a slot** — when `elements` contains an `instance` element whose component has slot props, those slots cannot be filled from within this `Composition`. The composition can set scalar prop values via `propConfigurations` on that instance, but slot content resolution is deferred to the mechanism introduced in ADR-044 (one level deep) and its follow-on. This is a deliberate constraint, not an oversight. +- **Nested instance with a slot** — when `elements` contains an `instance` element whose component has slot props, those slots cannot be filled from within this `Composition`. The composition can set scalar prop values via `propConfigurations` on that instance, but slot content resolution is deferred to the mechanism introduced in ADR-047 (one level deep) and its follow-on. This is a deliberate constraint, not an oversight. - **`elements` is sparse** — not every anatomy element needs a corresponding `elements` entry. An element with no content, styles, or prop configurations can be omitted from `elements`. The `elements: {}` case (all anatomy elements are instances with no element-level data) is valid and explicit. - **No `kind` field** — discrimination is the responsibility of the extending types (`SlotExample` adds `kind: 'slot'`; future system-scoped types add their own). -- **`Composition` is not placed directly on `Component`** — it is the structural base; `Component.examples` (ADR-043) and `SlotExample` (ADR-044) are the consumer-facing entry points. +- **`Composition` is not placed directly on `Component`** — it is the structural base; `Component.examples` (ADR-046) and `SlotExample` (ADR-047) are the consumer-facing entry points. - **Anticipated metadata extensions** — `title` and `description` are the authoring-time metadata for this ADR. Anticipated follow-on extensions include `tags?: string[]` for cataloguing, `deprecated?: boolean` for lifecycle management, and `guidelines?: string` for usage guidance. These are deferred to a follow-on ADR once the consuming types are established and usage patterns are known. --- @@ -219,7 +219,7 @@ Composition: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | No immediate output change; `Composition` is foundational for ADR-043/044 | Recompile when 043/044 land | +| `specs-from-figma` | No immediate output change; `Composition` is foundational for ADR-046/044 | Recompile when 046/047 land | | `specs-cli` | Recompile | No change | | `specs-plugin-2` | Recompile | No change | @@ -235,8 +235,8 @@ Composition: ## Consequences -- `Composition` is a named structural type in the schema, available as a base for `SlotExample` (ADR-044) and future system-scoped layout and page composition types +- `Composition` is a named structural type in the schema, available as a base for `SlotExample` (ADR-047) and future system-scoped layout and page composition types - All three content fields (`anatomy`, `elements`, `layout`) are required — a composition is always a complete structural declaration, never a degenerate anatomy-only fragment - `description?` is the first step toward a richer authoring-time metadata model; `tags`, `deprecated`, and `guidelines` are identified follow-on extension points - No existing type is changed; no downstream consumers are broken -- ADR-043, ADR-044, and ADR-045 complete the composition model; this ADR is the foundation they build on +- ADR-046, ADR-047, and ADR-048 complete the composition model; this ADR is the foundation they build on diff --git a/adr/043-component-examples.md b/adr/046-component-examples.md similarity index 90% rename from adr/043-component-examples.md rename to adr/046-component-examples.md index ef71970..c078e32 100644 --- a/adr/043-component-examples.md +++ b/adr/046-component-examples.md @@ -1,11 +1,11 @@ # ADR: Component Examples — InstanceExample and Component.examples -**Branch**: `043-component-examples` +**Branch**: `046-component-examples` **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type) -**Extended by**: [ADR-044 — Slot Content](044-slot-content) *(adds `SlotExample`, widens `ComponentExamples`, adds `InstanceExample.slots`)* +**Extended by**: [ADR-047 — Slot Content](047-slot-content) *(adds `SlotExample`, widens `ComponentExamples`, adds `InstanceExample.slots`)* --- @@ -15,22 +15,22 @@ ADR-042 established `Composition` as the structural base type. That type has no Components need a named catalogue of pre-configured examples: a documented set of specific prop values (and, later, slot configurations) that expresses canonical usages. Tooling can discover and render these without inspecting the raw variant tree. Authors can reference them by key instead of repeating configuration inline. -The simplest case — and the right starting point — is an example defined entirely by scalar prop values: `state`, `title`, `description`, and similar string or boolean props. No slot content yet; slots are addressed in ADR-044. +The simplest case — and the right starting point — is an example defined entirely by scalar prop values: `state`, `title`, `description`, and similar string or boolean props. No slot content yet; slots are addressed in ADR-047. `InstanceExample` is the type for this case. It captures: - An optional human-readable label (`title`) - Scalar prop values (`propConfigurations`) -- Slot references (`slots`) — present in the type now; meaningful once ADR-044 introduces `SlotExample` +- Slot references (`slots`) — present in the type now; meaningful once ADR-047 introduces `SlotExample` -`Component.examples` is the named record that holds `InstanceExample` entries (and, after ADR-044, `SlotExample` entries too). +`Component.examples` is the named record that holds `InstanceExample` entries (and, after ADR-047, `SlotExample` entries too). --- ## Decision Drivers -- **Named record, not array** — examples must be referenceable by key; `InstanceExample.slots` and `Element.$extensions['com.figma'].defaultComposition` (ADR-044) both point to keys, not indices -- **`kind` discriminator** — `InstanceExample` will share the `ComponentExamples` record with `SlotExample` (ADR-044); `kind: 'instance'` makes the type unambiguous to tooling and JSON Schema `oneOf` validation -- **Scalar-only `propConfigurations`** — `InstanceExample` represents a documented configuration for human readers and tooling, not a live data binding; `PropBinding` belongs in `Element.propConfigurations` (ADR-045), not here +- **Named record, not array** — examples must be referenceable by key; `InstanceExample.slots` and `Element.$extensions['com.figma'].defaultComposition` (ADR-047) both point to keys, not indices +- **`kind` discriminator** — `InstanceExample` will share the `ComponentExamples` record with `SlotExample` (ADR-047); `kind: 'instance'` makes the type unambiguous to tooling and JSON Schema `oneOf` validation +- **Scalar-only `propConfigurations`** — `InstanceExample` represents a documented configuration for human readers and tooling, not a live data binding; `PropBinding` belongs in `Element.propConfigurations` (ADR-048), not here - **No cross-component references** — `InstanceExample.slots` values are keys within the same `Component.examples`; no key resolution across component definitions - **Additive-only** — new optional field `Component.examples`; no existing type changed → MINOR - **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) @@ -42,7 +42,7 @@ The simplest case — and the right starting point — is an example defined ent ### Option A: `InstanceExample` as a discriminated record member *(Selected)* -Add `InstanceExample { kind: 'instance', title?, propConfigurations?, slots? }` and a named record `ComponentExamples` on `Component`. The `kind` field discriminates against `SlotExample` (ADR-044) in the shared `ComponentExamples` record. +Add `InstanceExample { kind: 'instance', title?, propConfigurations?, slots? }` and a named record `ComponentExamples` on `Component`. The `kind` field discriminates against `SlotExample` (ADR-047) in the shared `ComponentExamples` record. ```yaml # ActionListItem — instance examples covering scalar prop variants @@ -93,10 +93,10 @@ examples: - Named keys enable reference-by-string from `InstanceExample.slots` and `defaultComposition` without type coupling - `kind` discriminator makes `InstanceExample` and `SlotExample` unambiguous in the shared record - Scalar-only `propConfigurations` keeps `InstanceExample` simple and human-readable -- `slots?` field present now — forward-compatible once ADR-044 introduces `SlotExample` keys +- `slots?` field present now — forward-compatible once ADR-047 introduces `SlotExample` keys **Cons / Trade-offs**: -- `slots` values forward-reference `SlotExample` keys that do not exist until ADR-044; tooling cannot validate slot references until ADR-044 lands +- `slots` values forward-reference `SlotExample` keys that do not exist until ADR-047; tooling cannot validate slot references until ADR-047 lands --- @@ -137,10 +137,10 @@ InstanceExample: Record # scalar prop values only slots?: Record # slot prop name → key in Component.examples - # resolved entry must be a SlotExample (ADR-044) + # resolved entry must be a SlotExample (ADR-047) # ComponentExamples — named record on Component -# Widened in ADR-044 to Record +# Widened in ADR-047 to Record ComponentExamples: Record ``` @@ -202,7 +202,7 @@ InstanceExample: - type: boolean slots: type: object - description: "Maps slot prop names to SlotExample keys in Component.examples (see ADR-044)." + description: "Maps slot prop names to SlotExample keys in Component.examples (see ADR-047)." additionalProperties: type: string additionalProperties: false @@ -213,7 +213,7 @@ InstanceExample: ```yaml ComponentExamples: type: object - description: "Named examples for this component. Widened in ADR-044 to include SlotExample entries." + description: "Named examples for this component. Widened in ADR-047 to include SlotExample entries." patternProperties: "^[a-zA-Z0-9_-]+$": $ref: "#/definitions/InstanceExample" @@ -230,15 +230,15 @@ examples: ### Out of scope for this ADR -- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-044 -- **Widening of `ComponentExamples`** to include `SlotExample` — see ADR-044 -- **`Element.$extensions`** and `defaultComposition` — see ADR-044 -- **`PropConfigurations` PropBinding** — see ADR-045 +- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-047 +- **Widening of `ComponentExamples`** to include `SlotExample` — see ADR-047 +- **`Element.$extensions`** and `defaultComposition` — see ADR-047 +- **`PropConfigurations` PropBinding** — see ADR-048 ### Notes - `InstanceExample.propConfigurations` is scalar-only (`string | number | boolean`). Prop binding (`PropBinding`) belongs in `Element.propConfigurations`, which represents live data flow. `InstanceExample` represents a documented configuration — human-intended, not runtime-driven. -- `InstanceExample.slots` is defined here but only becomes meaningful in ADR-044 when `SlotExample` entries can appear in `Component.examples`. Schema validation cannot enforce that `slots` values resolve to `SlotExample` keys until ADR-044 widens `ComponentExamples`. +- `InstanceExample.slots` is defined here but only becomes meaningful in ADR-047 when `SlotExample` entries can appear in `Component.examples`. Schema validation cannot enforce that `slots` values resolve to `SlotExample` keys until ADR-047 widens `ComponentExamples`. - All `slots` values resolve within the same `Component.examples` — no cross-component key references. - `ComponentExamples` uses `patternProperties` rather than `additionalProperties` on an object schema to satisfy Draft 7's handling of `$ref` alongside `additionalProperties: false`. @@ -277,5 +277,5 @@ examples: - `Component.examples` is a first-class named record on `Component`; example configurations are discoverable alongside the component that owns them - `InstanceExample` expresses a complete scalar-prop configuration in a single, referenceable entry - All example keys are plain strings — no inline nesting, no cross-component references -- `InstanceExample.slots` is forward-compatible: slot references compile and validate as strings now; ADR-044 populates the target side -- `ComponentExamples` will be widened in ADR-044 to accept `SlotExample` entries alongside `InstanceExample` entries +- `InstanceExample.slots` is forward-compatible: slot references compile and validate as strings now; ADR-047 populates the target side +- `ComponentExamples` will be widened in ADR-047 to accept `SlotExample` entries alongside `InstanceExample` entries diff --git a/adr/044-slot-content.md b/adr/047-slot-content.md similarity index 97% rename from adr/044-slot-content.md rename to adr/047-slot-content.md index d595358..d437902 100644 --- a/adr/044-slot-content.md +++ b/adr/047-slot-content.md @@ -1,22 +1,22 @@ # ADR: Slot Content — Component.slotContent and SlotBinding -**Branch**: `044-slot-content` +**Branch**: `047-slot-content` **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) -**Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-043 — Component Examples](043-component-examples) +**Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Component Examples](046-component-examples) --- ## Context -ADR-042 established `Composition` as the structural base type. ADR-043 established `InstanceExample` and `Component.examples` for scalar-prop-configured usages. +ADR-042 established `Composition` as the structural base type. ADR-046 established `InstanceExample` and `Component.examples` for scalar-prop-configured usages. A second authoring need is *content placed inside a component's slot layer* — a named, reusable arrangement of elements that fills a slot. Three decisions follow: 1. **What type expresses slot content?** — `Composition` (ADR-042) is already the structural shape. Does this ADR introduce a new wrapper type, or use `Composition` directly? -2. **Where on `Component` does it live?** — Bundled into `Component.examples` as a discriminated-union peer of `InstanceExample`, or hosted on its own sibling field? ADR-043 left `ComponentExamples` typed narrowly so this ADR could resolve the question. +2. **Where on `Component` does it live?** — Bundled into `Component.examples` as a discriminated-union peer of `InstanceExample`, or hosted on its own sibling field? ADR-046 left `ComponentExamples` typed narrowly so this ADR could resolve the question. 3. **How does the default variant reference it?** — A slot-bound container in `default.elements` or `variants[n].elements` needs a way to point at the content placed inside its slot layer in the design file. The reference must be expressible per-variant, colocated with the binding it defaults, and recognizable to consumers as design-tool authoring metadata so that consumers without a "default slot data" concept (e.g., code components, which resolve missing slots through logic) can correctly ignore it. @@ -47,13 +47,13 @@ The existing `Element.children` discriminant (`string[] | PropBinding` — plain ### Option A: `Component.slotContent: Record` + `SlotBinding.$extensions['com.figma'].default` *(Selected)* -A new top-level field `Component.slotContent` holds named `Composition` entries — *sibling* to `Component.examples` (which remains `Record` from ADR-043). No new type is introduced for slot content; `Composition` is reused as-is. +A new top-level field `Component.slotContent` holds named `Composition` entries — *sibling* to `Component.examples` (which remains `Record` from ADR-046). No new type is introduced for slot content; `Composition` is reused as-is. A new interface `SlotBinding` extends `PropBinding` with an optional `$extensions` field carrying platform-specific metadata. `Children` widens from `string[] | PropBinding` to `string[] | SlotBinding`. Because `Children` lives on `Element`, anything on a `SlotBinding` appears naturally in `default.elements[name]` and `variants[n].elements[name]` — per-variant variation falls out for free. Two reference sites point into `Component.slotContent`: -1. **`InstanceExample.slots[slotName]`** — *authored documentation, meaningful to all consumers.* Defined in ADR-043; values resolve to `slotContent` keys. Lives as a plain field on `InstanceExample`, not in `$extensions`, because a slot reference inside an authored example is part of the documentation, not Figma-specific provenance. +1. **`InstanceExample.slots[slotName]`** — *authored documentation, meaningful to all consumers.* Defined in ADR-046; values resolve to `slotContent` keys. Lives as a plain field on `InstanceExample`, not in `$extensions`, because a slot reference inside an authored example is part of the documentation, not Figma-specific provenance. 2. **`SlotBinding.$extensions['com.figma'].default`** — *Figma authoring metadata, ignored by code consumers.* References the content Figma renders inside the slot layer when no consumer override is supplied. Lives in `$extensions` because code consumers resolve missing slots through component logic (not data) and must correctly skip the field. Minimal example — one slot, one content entry, both reference sites visible: @@ -121,7 +121,7 @@ The field lives at `SlotBinding.$extensions['com.figma'].default`. Inside the `c - The Figma default-fill key lives on the slot binding inside `$extensions['com.figma']` — colocated with `$binding`, no name collision with `content`, and correctly marked as design-tool provenance that code consumers ignore - Per-variant Figma defaults fall out for free: `children` already lives on `Element`, which is mapped through `default`/`variants` - `examples` and `slotContent` are siblings with one purpose each; no `kind` discriminator on either -- ADR-043's `ComponentExamples` is not widened; this ADR is purely additive +- ADR-046's `ComponentExamples` is not widened; this ADR is purely additive - Future system-scoped `compositions.yaml` (ADR-042 follow-on) lives at the same conceptual level without competing with either field **Cons / Trade-offs**: @@ -253,7 +253,7 @@ export type Children = string[] | SlotBinding; **Extended `Component`** (`types/Component.ts`): ```yaml -# Before (after ADR-043) +# Before (after ADR-046) Component: title: string anatomy: Anatomy @@ -263,7 +263,7 @@ Component: variants?: Variants invalidVariantCombinations?: PropConfigurations[] metadata?: Metadata - examples?: ComponentExamples # InstanceExample only — ADR-043 + examples?: ComponentExamples # InstanceExample only — ADR-046 # After Component: @@ -382,7 +382,7 @@ Children: **Version bump**: `0.19.0 → 0.20.0` (`MINOR`) -**Justification**: All changes are additive — new optional `Component.slotContent` field; new `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension` interfaces; `Children` widened from `string[] | PropBinding` to `string[] | SlotBinding`, where `SlotBinding` is a structural superset of `PropBinding` (existing `children: { $binding }` values still validate). No new types for slot content (`Composition` from ADR-042 is reused). `PropBinding` and `Element` are unchanged. `ComponentExamples` from ADR-043 is unchanged. → MINOR per Constitution §III. +**Justification**: All changes are additive — new optional `Component.slotContent` field; new `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension` interfaces; `Children` widened from `string[] | PropBinding` to `string[] | SlotBinding`, where `SlotBinding` is a structural superset of `PropBinding` (existing `children: { $binding }` values still validate). No new types for slot content (`Composition` from ADR-042 is reused). `PropBinding` and `Element` are unchanged. `ComponentExamples` from ADR-046 is unchanged. → MINOR per Constitution §III. --- @@ -393,5 +393,5 @@ Children: - `Composition` is reused directly with no wrapper type; ADR-042's structural type carries its own weight here. - `SlotBinding.$extensions['com.figma'].default` colocates the Figma default-fill reference with the slot binding itself, inside `Element.children`. The `$extensions` framing correctly marks it as design-tool provenance — code consumers ignore it (defaults in code are logic, not data). No collision with `content`, `children`, or any top-level `Element` field. Per-variant Figma defaults fall out because `children` already lives on `Element`. - `SlotBindingExtensions` is an open extension object — future Figma-specific or platform-specific slot-binding metadata can be added without a new ADR. -- `InstanceExample.slots` (defined in ADR-043) resolves into `Component.slotContent`. ADR-043's `ComponentExamples` type is not widened. +- `InstanceExample.slots` (defined in ADR-046) resolves into `Component.slotContent`. ADR-046's `ComponentExamples` type is not widened. - This ADR scopes slot content to one level deep; recursion (filling slots of nested instances from a parent context) is deferred to a follow-on ADR. diff --git a/adr/045-prop-configurations-binding.md b/adr/048-prop-configurations-binding.md similarity index 98% rename from adr/045-prop-configurations-binding.md rename to adr/048-prop-configurations-binding.md index 27d2699..e383dc4 100644 --- a/adr/045-prop-configurations-binding.md +++ b/adr/048-prop-configurations-binding.md @@ -1,6 +1,6 @@ # ADR: PropConfigurations PropBinding -**Branch**: `045-prop-configurations-binding` +**Branch**: `048-prop-configurations-binding` **Created**: 2026-04-29 **Status**: DRAFT **Deciders**: Nathan Curtis (author) @@ -57,7 +57,7 @@ elements: Widening `PropConfigurations` to also accept `PropBinding` closes this gap and makes the binding model uniform across every element-level value field. -This ADR does **not** affect `InstanceExample.propConfigurations` (ADR-043), which stays scalar-only — it represents a human-authored documented configuration, not a live binding. +This ADR does **not** affect `InstanceExample.propConfigurations` (ADR-046), which stays scalar-only — it represents a human-authored documented configuration, not a live binding. --- @@ -243,7 +243,7 @@ PropConfigurations: ### Out of scope for this ADR -- **`InstanceExample.propConfigurations`** — remains `Record` by design; see ADR-043 +- **`InstanceExample.propConfigurations`** — remains `Record` by design; see ADR-046 - **Slot value binding in `PropConfigurations`** — passing a slot prop through to a nested instance's slot prop via `PropBinding` is related but deferred; this ADR covers scalar prop pass-through only ### Notes diff --git a/adr/049-nested-slot-compositions.md b/adr/049-nested-slot-compositions.md new file mode 100644 index 0000000..b4273ef --- /dev/null +++ b/adr/049-nested-slot-compositions.md @@ -0,0 +1,51 @@ +# ADR: Nested Slot Compositions + +**Branch**: `049-nested-slot-compositions` +**Created**: 2026-05-11 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**Depends on**: [ADR-047 — Slot Content](047-slot-content) + +--- + +## Context + +ADR-047 ("Slot Content") scopes slot-content entries to **one level deep**: a `Composition` in `Component.slotContent` declares its own anatomy and may contain component instances, but does not reach into those instances' own slot fills. Each component is the sole author of its own content. + +The motivating tension is recursion. The ActionList / ActionListItem case shows why the boundary matters: + +- **ActionListItem** has two slot props (`startVisual`, `endVisual`) and ~3 prop variants. Per-slot single-glyph entries plus a few instance examples cover its surface in a handful of `slotContent` entries. +- **ActionList** has one slot prop (`items`) and ~8 prop variants. A naive recursion that fills each `ActionListItem`'s `startVisual` and `endVisual` from `ActionList`'s context reaches across component boundaries: 8 variants × 3 items × 2 slots = **48+** entries on `ActionList` alone. +- The one-level-deep rule keeps the count proportional to each component's own surface (~16 entries on `ActionList`, ~4 on `ActionListItem`) by deferring nested-slot resolution to the owning component. + +That deferral is correct as a default but leaves a real authoring need open: sometimes a parent *does* need to specify what fills a nested instance's slot in a particular composition (e.g., "in `ActionList`'s `danger` items, every `ActionListItem` gets a trash icon in `endVisual`"). This ADR opens the design space for that case. + +--- + +## Decision Drivers + +*(to be specified — likely include: scale must remain proportional to the parent's own variation, not multiplicative across descendants; reuse `Component.slotContent` keys rather than introducing inline composition; preserve "each component owns its own content" as the default; offer an explicit, opt-in mechanism for cross-boundary fill)* + +--- + +## Options Considered + +*(to be specified)* + +--- + +## Decision + +*(to be specified)* + +--- + +## Out of scope for this ADR + +- **`compositions.yaml` file schema** — system-scoped (`layout`, `page`) compositions; a separate follow-on ADR. + +--- + +## Consequences + +*(to be specified)* diff --git a/adr/INDEX.md b/adr/INDEX.md index d68da9e..ec0374d 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,9 +4,10 @@ | # | Title | Highlights | |---|-------|------------| -| 045 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding`; completes the binding model on element-level fields | -| 044 | Slot Content — SlotExample and Element Extensions | Add `SlotExample` (extends `Composition`); widen `ComponentExamples`; add `Element.$extensions` + `defaultComposition` for Figma provenance | -| 043 | Component Examples — InstanceExample and Component.examples | Add `InstanceExample` and `ComponentExamples`; add `Component.examples?` named record | +| 049 | Nested Slot Compositions | Fill nested instances' slots from a parent context; recursion follow-on to ADR-047 | +| 048 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding`; completes the binding model on element-level fields | +| 047 | Slot Content — Component.slotContent and SlotBinding | Add `Component.slotContent: Record`; add `SlotBinding` extending `PropBinding` with `$extensions['com.figma'].default` for Figma authoring provenance | +| 046 | Component Examples — InstanceExample and Component.examples | Add `InstanceExample` and `ComponentExamples`; add `Component.examples?` named record | | 042 | Composition Structural Type | Add `Composition { title?, anatomy, elements?, layout? }` — structural base for slot examples and system-scoped compositions | | 041 | Layout Positioning — Constraint-Based Naming | | | 035 | Make Config Properties with Defaults Optional | | From 005f3921587c65151c6993bbf37fc1f40e35c78d Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 11 May 2026 10:41:35 -0400 Subject: [PATCH 17/20] docs(adr-046): remove slots field and kind discriminator Slot fill is unified under Element.propConfigurations per ADR-049, so InstanceExample.slots becomes redundant. With slot fills handled separately, Component.examples only ever holds InstanceExample entries and the kind discriminator carries no information. Updates Decision Drivers, Options A and B, Notes, Consequences, schema definition, and parity check to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- adr/046-component-examples.md | 71 ++++++++++++----------------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/adr/046-component-examples.md b/adr/046-component-examples.md index c078e32..da616b8 100644 --- a/adr/046-component-examples.md +++ b/adr/046-component-examples.md @@ -5,7 +5,7 @@ **Status**: DRAFT **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type) -**Extended by**: [ADR-047 — Slot Content](047-slot-content) *(adds `SlotExample`, widens `ComponentExamples`, adds `InstanceExample.slots`)* +**Extended by**: [ADR-047 — Slot Content](047-slot-content), [ADR-049 — Nested Slot Compositions](049-nested-slot-compositions) *(slot fill is expressed via `propConfigurations`; this ADR no longer carries a separate `slots` field)* --- @@ -13,25 +13,24 @@ ADR-042 established `Composition` as the structural base type. That type has no authoring home yet — no field on `Component` accepts it, and no consumer-facing entry point exists. -Components need a named catalogue of pre-configured examples: a documented set of specific prop values (and, later, slot configurations) that expresses canonical usages. Tooling can discover and render these without inspecting the raw variant tree. Authors can reference them by key instead of repeating configuration inline. +Components need a named catalogue of pre-configured examples: a documented set of specific prop values that expresses canonical usages. Tooling can discover and render these without inspecting the raw variant tree. Authors can reference them by key instead of repeating configuration inline. -The simplest case — and the right starting point — is an example defined entirely by scalar prop values: `state`, `title`, `description`, and similar string or boolean props. No slot content yet; slots are addressed in ADR-047. +This ADR covers the simplest case: an example defined entirely by scalar prop values (`state`, `title`, `description`, and similar string/number/boolean props). Slot fills — including the recursive composition story — are scoped out and handled by ADR-049 through `Element.propConfigurations`, not via a dedicated field on `InstanceExample`. `InstanceExample` is the type for this case. It captures: - An optional human-readable label (`title`) - Scalar prop values (`propConfigurations`) -- Slot references (`slots`) — present in the type now; meaningful once ADR-047 introduces `SlotExample` -`Component.examples` is the named record that holds `InstanceExample` entries (and, after ADR-047, `SlotExample` entries too). +`Component.examples` is the named record that holds `InstanceExample` entries. --- ## Decision Drivers -- **Named record, not array** — examples must be referenceable by key; `InstanceExample.slots` and `Element.$extensions['com.figma'].defaultComposition` (ADR-047) both point to keys, not indices -- **`kind` discriminator** — `InstanceExample` will share the `ComponentExamples` record with `SlotExample` (ADR-047); `kind: 'instance'` makes the type unambiguous to tooling and JSON Schema `oneOf` validation +- **Named record, not array** — examples must be referenceable by key; `Element.$extensions['com.figma'].defaultComposition` (ADR-047) and the slot-fill mechanism (ADR-049) both point to keys, not indices +- **No discriminator field** — `Component.examples` will only ever contain `InstanceExample` entries (slot fills are `Element.propConfigurations` per ADR-049; Figma authoring defaults are `Component.slotContent` per ADR-047). No `kind` field is needed because no other shape competes for the same record - **Scalar-only `propConfigurations`** — `InstanceExample` represents a documented configuration for human readers and tooling, not a live data binding; `PropBinding` belongs in `Element.propConfigurations` (ADR-048), not here -- **No cross-component references** — `InstanceExample.slots` values are keys within the same `Component.examples`; no key resolution across component definitions +- **Scalar-prop scope here; slot fill is ADR-049 territory** — `InstanceExample` documents scalar prop usages only. Filling slot props is part of the broader composition-recursion story and is handled through `Element.propConfigurations` per ADR-049, not via a dedicated field on `InstanceExample` - **Additive-only** — new optional field `Component.examples`; no existing type changed → MINOR - **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) - **No runtime logic** — type declarations and schema only (Constitution §II) @@ -40,9 +39,9 @@ The simplest case — and the right starting point — is an example defined ent ## Options Considered -### Option A: `InstanceExample` as a discriminated record member *(Selected)* +### Option A: `InstanceExample` as a record member *(Selected)* -Add `InstanceExample { kind: 'instance', title?, propConfigurations?, slots? }` and a named record `ComponentExamples` on `Component`. The `kind` field discriminates against `SlotExample` (ADR-047) in the shared `ComponentExamples` record. +Add `InstanceExample { title?, propConfigurations? }` and a named record `ComponentExamples` on `Component`. No discriminator field — `ComponentExamples` only holds one shape. ```yaml # ActionListItem — instance examples covering scalar prop variants @@ -65,7 +64,6 @@ props: examples: defaultState: - kind: instance title: Action List Item – default propConfigurations: state: default @@ -73,7 +71,6 @@ examples: description: 12 open · 3 closed activeState: - kind: instance title: Action List Item – active propConfigurations: state: active @@ -81,7 +78,6 @@ examples: description: 12 open · 3 closed dangerState: - kind: instance title: Action List Item – danger propConfigurations: state: danger @@ -90,21 +86,20 @@ examples: ``` **Pros**: -- Named keys enable reference-by-string from `InstanceExample.slots` and `defaultComposition` without type coupling -- `kind` discriminator makes `InstanceExample` and `SlotExample` unambiguous in the shared record +- Named keys enable reference-by-string from `defaultComposition` (ADR-047) and from slot-fill mechanisms (ADR-049) without type coupling - Scalar-only `propConfigurations` keeps `InstanceExample` simple and human-readable -- `slots?` field present now — forward-compatible once ADR-047 introduces `SlotExample` keys +- No discriminator field — minimum surface, since no other shape shares the record **Cons / Trade-offs**: -- `slots` values forward-reference `SlotExample` keys that do not exist until ADR-047; tooling cannot validate slot references until ADR-047 lands +- None at the scope of this ADR; slot fill is intentionally deferred to ADR-049 --- -### Option B: Flat scalar map without `kind` *(Rejected)* +### Option B: Flat scalar map *(Rejected)* -Store examples as `Record>` — a named map of prop value maps, with no type wrapper. +Store examples as `Record>` — a named map of prop value maps, with no wrapping object. -**Rejected because**: No discriminator means tooling cannot tell an `InstanceExample` from a `SlotExample` without structural inspection. JSON Schema cannot use `oneOf` without a discriminating property. Adding `SlotExample` later would require a MAJOR-level type change. +**Rejected because**: There's no place for per-example metadata. `title` (and any future additive fields like `description`, `tags`, `deprecated`, `guidelines`) would have to be smuggled into the prop-name space with reserved-key conventions, which collides with real prop names and adds parser complexity. The `InstanceExample` wrapper is a thin object that gives metadata a clean home for free. --- @@ -131,16 +126,12 @@ Reuse the existing `Variant` type for examples — a `Variant` already has `conf ```yaml # InstanceExample — a pre-configured usage of the whole component InstanceExample: - kind: 'instance' # discriminator title?: string propConfigurations?: Record # scalar prop values only - slots?: - Record # slot prop name → key in Component.examples - # resolved entry must be a SlotExample (ADR-047) + # slot prop fills are NOT here — see ADR-049 # ComponentExamples — named record on Component -# Widened in ADR-047 to Record ComponentExamples: Record ``` @@ -184,27 +175,18 @@ Component: ```yaml InstanceExample: type: object - description: "A pre-configured usage of the whole component: scalar prop values and named slot-filling references." - required: [kind] + description: "A pre-configured usage of the whole component: scalar prop values." properties: - kind: - type: string - enum: [instance] title: type: string propConfigurations: type: object - description: "Scalar prop values for this example. Binding is not permitted here." + description: "Scalar prop values for this example. Binding and slot fills are not permitted here." additionalProperties: oneOf: - type: string - type: number - type: boolean - slots: - type: object - description: "Maps slot prop names to SlotExample keys in Component.examples (see ADR-047)." - additionalProperties: - type: string additionalProperties: false ``` @@ -213,7 +195,7 @@ InstanceExample: ```yaml ComponentExamples: type: object - description: "Named examples for this component. Widened in ADR-047 to include SlotExample entries." + description: "Named examples for this component." patternProperties: "^[a-zA-Z0-9_-]+$": $ref: "#/definitions/InstanceExample" @@ -230,16 +212,14 @@ examples: ### Out of scope for this ADR -- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-047 -- **Widening of `ComponentExamples`** to include `SlotExample` — see ADR-047 -- **`Element.$extensions`** and `defaultComposition` — see ADR-047 -- **`PropConfigurations` PropBinding** — see ADR-048 +- **`Component.slotContent`** and **`SlotBinding.$extensions['com.figma'].default`** — Figma authoring defaults for component slots; see ADR-047 +- **`Element.propConfigurations` PropBinding** — see ADR-048 +- **Cross-boundary slot fill (recursion)** — filling a nested instance's slot prop from a parent context, including all forms of inline-Composition or named-composition reference; see ADR-049 ### Notes - `InstanceExample.propConfigurations` is scalar-only (`string | number | boolean`). Prop binding (`PropBinding`) belongs in `Element.propConfigurations`, which represents live data flow. `InstanceExample` represents a documented configuration — human-intended, not runtime-driven. -- `InstanceExample.slots` is defined here but only becomes meaningful in ADR-047 when `SlotExample` entries can appear in `Component.examples`. Schema validation cannot enforce that `slots` values resolve to `SlotExample` keys until ADR-047 widens `ComponentExamples`. -- All `slots` values resolve within the same `Component.examples` — no cross-component key references. +- `InstanceExample` does *not* include slot-fill information. Filling a slot is filling a prop; the value form (Composition object, named-composition key, etc.) lives on `Element.propConfigurations` and is settled by ADR-049. Earlier drafts of this ADR carried a `slots: Record` field on `InstanceExample` as a placeholder; that field has been removed in favor of the unified mechanism. - `ComponentExamples` uses `patternProperties` rather than `additionalProperties` on an object schema to satisfy Draft 7's handling of `$ref` alongside `additionalProperties: false`. --- @@ -248,7 +228,7 @@ examples: - **Symmetric**: Yes - **Parity check**: - - `InstanceExample { kind, title?, propConfigurations?, slots? }` ↔ `#/definitions/InstanceExample` + - `InstanceExample { title?, propConfigurations? }` ↔ `#/definitions/InstanceExample` - `ComponentExamples = Record` ↔ `#/definitions/ComponentExamples` (`patternProperties`) - `Component.examples?: ComponentExamples` ↔ `#/definitions/Component/properties/examples` @@ -277,5 +257,4 @@ examples: - `Component.examples` is a first-class named record on `Component`; example configurations are discoverable alongside the component that owns them - `InstanceExample` expresses a complete scalar-prop configuration in a single, referenceable entry - All example keys are plain strings — no inline nesting, no cross-component references -- `InstanceExample.slots` is forward-compatible: slot references compile and validate as strings now; ADR-047 populates the target side -- `ComponentExamples` will be widened in ADR-047 to accept `SlotExample` entries alongside `InstanceExample` entries +- Slot fill is *not* part of `InstanceExample`; it lives on `Element.propConfigurations` per ADR-049, keeping `InstanceExample` cleanly scoped to scalar-prop documentation From f16654bff728623728b48e9c285903f2e771bbe4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 11 May 2026 10:41:42 -0400 Subject: [PATCH 18/20] docs(adr-049): draft options A-D for nested slot compositions; add research scratchpads ADR-049 now compares four shapes for cross-boundary slot fill: A. Nested only (inline Composition under propConfigurations) B. Flat only (key reference, discriminated as { \$slotContent: }) C. Both forms accepted D. Sibling field (Element.slotFills) Key decisions captured: - Recursion depth is unbounded (Decision Driver, not Out of Scope) - Cycle detection is a constraint, enforced by consumer validators - Layout direction lives in styles.layoutMode, not at the slot boundary - Named compositions can live in Component.slotContent (component-scoped) OR in 1+ external composition files (system-scoped, ADR-042 follow-on) - Discriminated reference form { \$slotContent: } parallels PropBinding Scratchpads in adr/research/049/ demonstrate the design space concretely through a deeply-nested filter results page (Page > Row > Accordion > CheckboxGroup > Checkbox > custom-children) in both nested and flat forms. The flat form is system-scoped (spans multiple components). ADR-047 simplifications (drop \$extensions / SlotBinding) and field-naming questions (slotContent vs compositions; \$slotContent vs \$composition) flagged for follow-up rather than rolled in. Co-Authored-By: Claude Opus 4.7 (1M context) --- adr/049-nested-slot-compositions.md | 223 +++++++++++++++++++++++++-- adr/research/049/example.flat.yaml | 190 +++++++++++++++++++++++ adr/research/049/example.nested.yaml | 162 +++++++++++++++++++ 3 files changed, 563 insertions(+), 12 deletions(-) create mode 100644 adr/research/049/example.flat.yaml create mode 100644 adr/research/049/example.nested.yaml diff --git a/adr/049-nested-slot-compositions.md b/adr/049-nested-slot-compositions.md index b4273ef..c3c2545 100644 --- a/adr/049-nested-slot-compositions.md +++ b/adr/049-nested-slot-compositions.md @@ -4,48 +4,247 @@ **Created**: 2026-05-11 **Status**: DRAFT **Deciders**: Nathan Curtis (author) -**Depends on**: [ADR-047 — Slot Content](047-slot-content) +**Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Component Examples](046-component-examples), [ADR-047 — Slot Content](047-slot-content), [ADR-048 — PropConfigurations PropBinding](048-prop-configurations-binding) --- ## Context -ADR-047 ("Slot Content") scopes slot-content entries to **one level deep**: a `Composition` in `Component.slotContent` declares its own anatomy and may contain component instances, but does not reach into those instances' own slot fills. Each component is the sole author of its own content. +ADRs 042, 046, 047, and 048 together define every authoring shape for a *single* composition; what's missing is the recursive case — when a `Composition` itself contains an instance whose component has slot props, how the parent context fills those nested slots. -The motivating tension is recursion. The ActionList / ActionListItem case shows why the boundary matters: +The motivating scenario is a multi-component page composition rendered through deeply-nested instances — for example `Page.body → Row.children → Accordion.children → CheckboxGroup.children → Checkbox.children`. Two scratchpads in [`adr/research/049/`](research/049/) work a realistic filter-results page through both nested and flat forms; the abstract examples within Options A and B below are condensed for in-line readability. -- **ActionListItem** has two slot props (`startVisual`, `endVisual`) and ~3 prop variants. Per-slot single-glyph entries plus a few instance examples cover its surface in a handful of `slotContent` entries. -- **ActionList** has one slot prop (`items`) and ~8 prop variants. A naive recursion that fills each `ActionListItem`'s `startVisual` and `endVisual` from `ActionList`'s context reaches across component boundaries: 8 variants × 3 items × 2 slots = **48+** entries on `ActionList` alone. -- The one-level-deep rule keeps the count proportional to each component's own surface (~16 entries on `ActionList`, ~4 on `ActionListItem`) by deferring nested-slot resolution to the owning component. +### Constraints this ADR ships with -That deferral is correct as a default but leaves a real authoring need open: sometimes a parent *does* need to specify what fills a nested instance's slot in a particular composition (e.g., "in `ActionList`'s `danger` items, every `ActionListItem` gets a trash icon in `endVisual`"). This ADR opens the design space for that case. +Three constraints are settled in advance; the option comparison below is bounded by them: + +- **Named compositions resolve in two registries by scope, not one.** Component-scoped named compositions — including ones authored to support recursion within a single component — live in that component's `slotContent` (ADR-047), where they sit alongside the Figma-authoring-default entries. System-scoped compositions (page-level, layout-level, anything that spans multiple components) live in 1+ external composition files (file format deferred to ADR-042's follow-on). The mechanism for *reaching* a key is the same in both cases; the home depends on the composition's scope. +- **No cycles, ever.** A composition reference chain `A → B → … → A` (direct or indirect) is forbidden. Tooling MUST detect and reject cycles at validation time. The schema cannot enforce this on its own; the constraint is normative for consumer validators. +- **Layout direction is a `styles.layoutMode` concern, not a boundary concern.** When a slot fill contains multiple items, `HORIZONTAL` / `VERTICAL` lives on either the consumer's slot-bound container or on a wrapping container element inside the fill. No new boundary field. + +### Decisions this ADR has to make + +- **Headline:** nested-only, flat-only, both, or sibling-field — see Options A–D. +- **Value union for `Element.propConfigurations.`:** what shapes does the slot-prop arm of the union accept once the headline lands? Two sub-questions: + - *Direct shape* — Composition object, key reference, both? + - *Form of the reference* — a bare `string` (the lightest), or a discriminated reference type (e.g., `{ $slotContent: '' }` mirroring how `PropBinding` uses `$binding`, or reusing/extending `SlotBinding`'s shape from ADR-047)? The discriminated form avoids the schema's "is this a typo or a key" ambiguity in the bare-string approach. + +That's it. Other concerns once flagged here — recursion depth, validation, aliasing semantics — are addressed below either as Decision Drivers, as Option-level pros/cons, or as tool/runtime concerns the schema does not own. --- ## Decision Drivers -*(to be specified — likely include: scale must remain proportional to the parent's own variation, not multiplicative across descendants; reuse `Component.slotContent` keys rather than introducing inline composition; preserve "each component owns its own content" as the default; offer an explicit, opt-in mechanism for cross-boundary fill)* +- **Locality vs. reuse** — inline keeps the tree visible top-to-bottom; named-and-keyed enables one composition to be referenced from many call sites. +- **Aliasing as a compaction lever — favors flat.** Repeated structural patterns (four identical `Card` instances in a grid, the same `iconStart` slot fill across many `ActionListItem` instances) collapse to a single named definition under flat form; compaction grows linearly with repetition. Inline form forces verbatim duplication every time. +- **Indentation budget** — deep recursion pushes inline leaves past comfortable line widths. +- **Author intent transparency** — flat naming forces a description of *what each piece is*; inline lets authors skip naming for one-offs. +- **Recursion depth is unbounded.** The schema does not cap depth, ever. Pages, layouts, and large compositions can recurse arbitrarily; any cap would be arbitrary and would break legitimate authored content. Operational concerns about deeply-nested content (legibility, performance) are project-level, not schema-level. +- **One canonical syntax vs. per-case author choice** — single form keeps the schema small and consumers simple; both forms let authors match syntax to situation but compound the surface. +- **Additive-only** — every option below ships as MINOR; no existing type narrows. +- **Type ↔ schema symmetry** — Constitution §I. +- **No runtime logic** — Constitution §II. --- ## Options Considered -*(to be specified)* +### Option A: Nested only — inline `Composition` under `propConfigurations.` + +`Element.propConfigurations.` value union widens to accept `Composition` (in addition to the scalar and `PropBinding` arms from ADR-048). Slot fill is always written inline at the call site. + +**Two-tier abstract example.** A `parent` element instances `Parent` (which has slot `body`); the body's fill instances `Mid` (which has slot `items`); the items' fill is a text leaf: + +```yaml +elements: + parent: + instanceOf: Parent + propConfigurations: + body: # ← level-1: inline Composition + anatomy: + mid: { type: instance, instanceOf: Mid } + elements: + mid: + propConfigurations: + items: # ← level-2: inline Composition (deeper) + anatomy: + leaf: { type: text } + elements: + leaf: { content: Hello } + layout: [leaf] + layout: [mid] +``` + +**Pros**: +- Locality — entire tree visible without cross-references +- No external file required — composition is self-contained at the call site +- Smallest schema delta — one union widening +- One canonical form; tooling and consumers handle a single shape + +**Cons**: +- Indentation grows linearly with recursion depth; deep trees push leaves off-screen +- **No aliasing** — identical sub-compositions duplicate verbatim. Four identical `Card` instances in a grid become four full `Composition` blocks; a glyph icon used in twenty `ActionListItem` slots becomes twenty inline copies +- Sub-compositions are anonymous; no addressable name for tooling, docs, or audits to reference +- Encourages bespoke one-off authoring over a vocabulary of named patterns + +--- + +### Option B: Flat only — registry key under `propConfigurations.` + +`Element.propConfigurations.` value union widens to accept a registry key resolving to a named `Composition`. The key resolves into either `Component.slotContent` (component-scoped) or an external composition file (system-scoped) per the registry constraint above. + +The reference can be a bare `string` (shown below for brevity) or a discriminated form like `{ $slotContent: '' }` — see decision 2 in Context. + +**Two-tier abstract example.** Same `Parent → Mid → leaf` hierarchy as Option A, expressed as three sibling records — every composition stays at top-level indentation regardless of how deep its referencer sits. The reference shape is discriminated (`{ $slotContent: '' }`) to parallel `PropBinding`'s `$binding` (see decision 2): + +```yaml +root: # entry composition + anatomy: + parent: { type: instance, instanceOf: Parent } + elements: + parent: + propConfigurations: + body: { $slotContent: parentBody } # ← level-1: discriminated reference + layout: [parent] + +parentBody: # named, top-level + anatomy: + mid: { type: instance, instanceOf: Mid } + elements: + mid: + propConfigurations: + items: { $slotContent: midItems } # ← level-2: discriminated reference + layout: [mid] + +midItems: # named, top-level + anatomy: + leaf: { type: text } + elements: + leaf: { content: Hello } + layout: [leaf] +``` + +A composition referenced from N call sites appears as the same `{ $slotContent: }` reference repeated N times — one definition, many uses. + +**Pros**: +- Indentation stays bounded regardless of recursion depth +- **Aliasing is a first-class strength** — one definition, many call sites. Four identical `Card` instances reduce to one `card` composition referenced four times; reusable patterns like `iconStart` become a single named building block. Compaction grows with repetition +- Naming forces authors to describe each piece's identity rather than its position +- Compositions become inspectable and addressable by tooling, docs, validation + +**Cons**: +- Naming overhead for one-off sub-compositions that exist solely as a one-time fill +- Reading the whole tree requires jumping between records — locality is lost +- Schema cannot validate that key references resolve (cross-field reference checking) — consumer responsibility, including cycle detection (per Constraints) +- For system-scoped compositions, requires the external composition file format to land (deferred — ADR-042 follow-on); component-scoped recursion can use existing `Component.slotContent` immediately + +--- + +### Option C: Both forms accepted — `Composition | string` per call site + +`Element.propConfigurations.` accepts both an inline `Composition` and a registry-key `string`. Authors choose per case: inline for tightly-coupled one-offs, key-ref for reusable named patterns. + +**Pros**: +- Matches the actual range of authoring intent — different cases warrant different forms +- Aliasing strength of flat preserved where it matters; locality of nested preserved where it matters +- Migration is easy in either direction (extract to external file; inline a single-use entry) + +**Cons**: +- Two ways to express the same thing — less canonical, weaker convention +- Tooling and consumers must handle both shapes +- Style inconsistency across (and within) codebases unless project conventions are imposed + +--- + +### Option D: Sibling field on `Element` — `slotFills?: Record` + +Leave `Element.propConfigurations` alone. Add a dedicated `slotFills` field whose value is a record mapping slot-prop names to either a `Composition` or a key reference. + +**Pros**: +- Preserves the ADR-046 reasoning that `propConfigurations` is scalar-and-binding territory +- Schema discrimination is structural and obvious — different fields for different value classes +- Composition values and scalar values stay in different physical fields; consumers can ignore one without parsing the other + +**Cons**: +- Re-entrenches a split that the unified-`propConfigurations` framing arguably resolves more cleanly (slot is just a prop; filling it is filling a prop) +- Two fields (`propConfigurations` and `slotFills`) where one might do; consumers must read both to assemble the full prop configuration of an instance +- Adds a second field to the recursion story without simplifying any other aspect of it --- ## Decision -*(to be specified)* +*To be specified once the headline option settles. This ADR is currently at the option-comparison stage.* --- ## Out of scope for this ADR -- **`compositions.yaml` file schema** — system-scoped (`layout`, `page`) compositions; a separate follow-on ADR. +- **External composition file format** — the schema for the standalone composition file(s) (possibly multiple per project) that hold named compositions for Options B/C/D. A separate ADR (ADR-042 follow-on) lands after the headline option here is settled. +- **Reuse / aliasing runtime semantics** — when a flat-form composition is referenced from N sites, whether each realization is an independent instance (per-site identity for stateful interactions) or an alias is a runtime/consumer concern. The schema describes the static reference; runtime semantics live elsewhere. (The structural *strength* of aliasing-as-compaction is in scope and addressed by Options B and C.) + +### Tool / consumer concerns (acknowledged, not scoped) + +These are real and necessary, but they live in tools (validators, the CLI, the plugin), not in the schema: + +- **Cycle detection** (per the Constraints section) — `A → B → … → A` MUST be rejected +- **Key-reference resolution** — confirming a string key under `propConfigurations.` resolves to an existing composition +- **Slot-fit validation** — confirming a composition matches the slot-prop expectations at the consume site (e.g., constraints from ADR-028) + +The schema does not encode these; tooling owns them. + +--- + +## Type ↔ Schema Impact + +Concrete sketch for **Option B (flat-only)**, illustrative — *not* a selection. The same shape narrative applies in spirit to A (Composition arm), C (both arms), and D (sibling field); the headline choice settles which becomes the authoritative section here. + +The sketch below assumes the discriminated-reference form for the registry key (`SlotContentRef`), which mirrors the existing `PropBinding` pattern and avoids the bare-string ambiguity. A bare-`string` form is the lighter alternative; the choice is decision 2 in Context. + +**Type changes** (Option B, discriminated-reference form): +- New type `SlotContentRef = { $slotContent: string }` — a key reference into either `Component.slotContent` (component-scoped) or an external composition file (system-scoped). Pattern parallels `PropBinding = { $binding: string }` from ADR-008. +- `Element.propConfigurations` value union widens from `string | number | boolean | PropBinding` (post-ADR-048) to `string | number | boolean | PropBinding | SlotContentRef`. +- No change to `Composition` itself (ADR-042); no change to `Element`'s other fields. `SlotBinding` (ADR-047) remains scoped to `Element.children`; `SlotContentRef` is the propConfigurations-side counterpart for the same registry. + +**Schema changes** (Option B, discriminated-reference form): +- New `#/definitions/SlotContentRef` with `$slotContent: string` — single-property object, `additionalProperties: false`. +- `#/definitions/Element/properties/propConfigurations/additionalProperties/oneOf` gains a `$ref: "#/definitions/SlotContentRef"` arm. +- No new top-level registry definition here; the system-scoped external file format lands in the follow-on ADR (ADR-042's deferred `compositions.yaml`). + +**Why not reuse `SlotBinding` directly** (briefly): `SlotBinding` is `{ $binding, $extensions? }` and lives on `Element.children` of slot-*owning* components, expressing the runtime binding of children to a slot prop. The propConfigurations-side reference goes the other direction — a *consumer* setting a nested instance's slot value — so the semantic role differs, even though the resolution target (a `slotContent` key) overlaps. A parallel-but-distinct `SlotContentRef` keeps each shape's role legible. If the pattern proves redundant in practice, a future MAJOR could unify them. + +**Symmetric**: Yes, with the caveat that the schema cannot enforce cross-field reference resolution (consumer validation concern per Constraints). + +--- + +## Downstream Impact + +| Consumer | Impact (any option) | Action required | +|----------|--------------------|-----------------| +| `specs-from-figma` | Must detect cross-boundary slot fills and emit them in the chosen form | Read new union; implement nested-fill or registry-emission path | +| `specs-cli` | Recompile; output includes the new union arm | Recompile; no breaking change | +| `specs-plugin-2` | Recompile; cross-boundary slot rendering is a follow-on capability | Recompile; pass through data initially | + +For Options B/C/D specifically, consumer validators take on cycle-detection and key-resolution responsibilities (per Constraints / Tool concerns). + +--- + +## Semver Decision + +**Version bump**: MINOR. + +**Justification**: Every option is additive — value-union widening or new optional field. No existing type is removed or narrowed. --- ## Consequences -*(to be specified)* +*Specifics depend on the headline option. These hold across all options:* + +- `Component.slotContent` (ADR-047) becomes a dual-purpose registry: it holds Figma authoring defaults *and* component-scoped recursive composition entries. Both are `Composition` values; both reach via the same key mechanism; their roles differ at the call site (`SlotBinding.$extensions['com.figma'].default` for the former, `propConfigurations.` for the latter). +- System-scoped compositions (page, layout) live in external composition files (ADR-042 follow-on) and resolve via the same reference form. +- Page-level compositions become expressible end-to-end without relying on per-component slot fills alone. +- Cycle detection becomes a normative requirement on consumer validators; the schema cannot enforce it. +- Recursion depth is unbounded; large authored content recurses freely. +- Layout direction inside a slot fill is expressed through existing `styles.layoutMode` on either the consumer's slot-bound container or on a wrapping container element inside the fill — no new boundary mechanism is needed. +- Reuse vs. locality becomes an explicit author choice (Option C), an enforced single style (A or B), or a structural separation (D). diff --git a/adr/research/049/example.flat.yaml b/adr/research/049/example.flat.yaml new file mode 100644 index 0000000..6e15676 --- /dev/null +++ b/adr/research/049/example.flat.yaml @@ -0,0 +1,190 @@ +# Scratchpad: ADR 049 — Nested Slot Compositions (FLAT FORM) +# +# Compare with ./example.nested.yaml — same hierarchy, nested form +# (Compositions written inline as the value of `propConfigurations.`). +# +# Scope: this file represents a SYSTEM-SCOPED composition file (an entire page +# spanning multiple components — Page, Row, Accordion, CheckboxGroup, Checkbox, +# Badge). Component-scoped recursive compositions (a single component's +# `slotContent` referencing its own sibling entries) would live inside the +# Component definition rather than in a standalone file like this one. +# +# Reference form: discriminated — `{ $slotContent: }` — paralleling +# `PropBinding`'s `$binding` shape. The bare-string alternative (`children: +# pageHeader`) is the lighter form but loses local type-discriminability; +# see ADR-049 Context decision 2. +# +# Example: Filter Results Page expressed as a record of named Compositions. +# `root` is the entry point (the Composition that fills `Page.body`); every +# other key is a sibling Composition referenced by name from another's +# `propConfigurations.` field. No indentation grows with depth — +# each composition stays at file-top level regardless of how deep in the +# tree its referencer sits. +# +# Framing — slot fill is by key reference: +# `propConfigurations.: ` where `` names a Composition +# defined as a sibling record in this file. The schema-level value union +# for `propConfigurations.` therefore needs to accept *at least* +# a string key (this form) and ideally also an inline Composition (nested +# form), letting authors choose per case. +# +# Why flat: +# - Each Composition is reusable — `makeOptions` could be referenced +# from any number of CheckboxGroup fills, not just this one +# - Indentation stays bounded; deep trees stay readable +# - Compositions become inspectable / addressable as named units +# - Naming forces authors to describe what each piece *is*, not just where +# it's used +# +# Why nested: +# - Locality: the entire tree is visible without jumping between records +# - No naming overhead for one-off sub-compositions +# - Matches how a designer might think about a one-off page layout +# +# Open questions for ADR-049 (carry-over from example.nested.yaml plus +# flat-specific): +# - Accept both inline-Composition and key-ref forms for +# `propConfigurations.`, or pick one? +# - Where do the named compositions live — inside `Component.slotContent`, +# a separate `compositions.yaml` file (per ADR-042 follow-on), or both? +# - Naming conventions / collision rules for the registry? +# - How does the schema express the union (Composition | string) when the +# string is meaningful only as a key into a separate registry? +# +# Hierarchy (same as example.nested.yaml): +# Page.body +# ├─ Row "Filter Page Header" (Row.children) +# │ ├─ Breadcrumbs +# │ ├─ Heading +# │ └─ Tabs +# └─ Row "Filter Grid" (Row.children) +# ├─ Sidebar (container) +# │ └─ Accordion (label="Filter 1", open=true) ← Accordion.children +# │ └─ CheckboxGroup ← CheckboxGroup.children +# │ ├─ Checkbox (label="Ford") +# │ ├─ Checkbox (label="Mercedes") +# │ └─ Checkbox (custom=true) ← Checkbox.children +# │ ├─ Text "Toyota" +# │ └─ Badge (appearance=new, label="New") +# └─ Main (container) +# ├─ Card +# ├─ Card +# ├─ Card +# └─ Card + +# ─── root: entry point — fills Page.body ──────────────────────────────────── +root: + title: Filter Results Page + description: | + Two-row page layout — "Filter Page Header" (breadcrumbs, heading, tabs) + above "Filter Grid" (sidebar with an Accordion of Checkboxes, main with + four Cards). + anatomy: + filterPageHeader: + type: instance + instanceOf: Row + filterGrid: + type: instance + instanceOf: Row + elements: + filterPageHeader: + propConfigurations: + children: { $slotContent: pageHeader } # → composition `pageHeader` + filterGrid: + propConfigurations: + children: { $slotContent: pageGrid } # → composition `pageGrid` + layout: + - filterPageHeader + - filterGrid + +# ─── pageHeader: three stacked items in the header row ────────────────────── +pageHeader: + anatomy: + breadcrumbs: { type: instance, instanceOf: Breadcrumbs } + heading: { type: instance, instanceOf: Heading } + tabs: { type: instance, instanceOf: Tabs } + elements: + heading: + propConfigurations: + content: Filter Results + layout: + - breadcrumbs + - heading + - tabs + +# ─── pageGrid: two-column grid for the filter results ─────────────────────── +pageGrid: + anatomy: + sidebar: { type: container } + make: { type: instance, instanceOf: Accordion } + main: { type: container } + card1: { type: instance, instanceOf: Card } + card2: { type: instance, instanceOf: Card } + card3: { type: instance, instanceOf: Card } + card4: { type: instance, instanceOf: Card } + elements: + make: + propConfigurations: + label: Filter 1 + open: true + children: { $slotContent: carBrandFilter } # → composition `carBrandFilter` + layout: + - sidebar: + - make + - main: + - card1 + - card2 + - card3 + - card4 + +# ─── carBrandFilter: what's inside the "Filter 1" Accordion when open ─────── +carBrandFilter: + anatomy: + checkboxGroup: + type: instance + instanceOf: CheckboxGroup + elements: + checkboxGroup: + propConfigurations: + children: { $slotContent: makeOptions } # → composition `makeOptions` + layout: + - checkboxGroup + +# ─── makeOptions: the three Checkbox instances inside the CheckboxGroup ─ +makeOptions: + anatomy: + make1: { type: instance, instanceOf: Checkbox } + make2: { type: instance, instanceOf: Checkbox } + make3: { type: instance, instanceOf: Checkbox } + elements: + make1: { propConfigurations: { label: Ford } } + make2: { propConfigurations: { label: Mercedes } } + make3: + propConfigurations: + custom: true + children: { $slotContent: newToyota } # → composition `newToyota` + layout: + - make1 + - make2 + - make3 + +# ─── newToyota: text + badge inside the custom Toyota checkbox ────── +newToyota: + anatomy: + root: { type: container } + toyotaText: { type: text } + newBadge: { type: instance, instanceOf: Badge } + elements: + root: + styles: + layoutMode: HORIZONTAL + toyotaText: + content: Toyota + newBadge: + propConfigurations: + appearance: new + label: New + layout: + - root: + - toyotaText + - newBadge diff --git a/adr/research/049/example.nested.yaml b/adr/research/049/example.nested.yaml new file mode 100644 index 0000000..53eabd9 --- /dev/null +++ b/adr/research/049/example.nested.yaml @@ -0,0 +1,162 @@ +# Scratchpad: ADR 049 — Nested Slot Compositions (NESTED FORM) +# +# Compare with ./example.flat.yaml — same hierarchy, flat form (named +# compositions referenced by key from propConfigurations). +# +# Example: Filter Results Page rendered through deeply-nested component +# instances. This Composition fills `Page.body`. Each nested instance has its +# own slot prop(s) whose values are themselves Compositions written inline, +# producing growing indentation as the tree deepens — the readability problem +# ADR-049 must address. +# +# Framing — slots fill through propConfigurations: +# A slot is a prop (type: "slot"). Filling a slot means setting that prop's +# value, the same way a scalar prop is set. So instead of a separate `slots:` +# field on Element, this example uses `propConfigurations: { : ... }` +# where the value is a Composition (or, eventually, a key-ref into a +# slotContent registry). +# +# Implication for ADR-046: +# `InstanceExample.slots: Record` may be redundant — it could +# merge into `propConfigurations` if we widen propConfigurations' value union +# to accept Composition (and/or a slotContent-key string typed as such). +# Worth raising as a follow-up. +# +# Open syntactic questions (for ADR-049 to resolve): +# - Inline Composition (as written) vs. key-ref into a Component.slotContent +# registry, vs. both forms accepted on the same field? +# - Value-union for `propConfigurations[slotName]` — which forms are valid? +# (Composition object, slotContent key, PropBinding pass-through, ...) +# - Should layout-tree nesting (e.g. `- sidebar: [make]`) be allowed, +# or must children always be declared on the container element? +# - What enforces or expresses recursion depth? Is there a cap? +# +# Hierarchy: +# Page.body +# ├─ Row "Filter Page Header" (Row.children) +# │ ├─ Breadcrumbs +# │ ├─ Heading +# │ └─ Tabs +# └─ Row "Filter Grid" (Row.children) +# ├─ Sidebar (container) +# │ └─ Accordion (label="Filter 1", open=true) ← Accordion.children +# │ └─ CheckboxGroup ← CheckboxGroup.children +# │ ├─ Checkbox (label="Ford") +# │ ├─ Checkbox (label="Mercedes") +# │ └─ Checkbox (custom=true) ← Checkbox.children +# │ ├─ Text "Toyota" +# │ └─ Badge (appearance=new, label="New") +# └─ Main (container) +# ├─ Card +# ├─ Card +# ├─ Card +# └─ Card + +title: Filter Results Page +description: | + Two-row page layout — "Filter Page Header" (breadcrumbs, heading, tabs) + above "Filter Grid" (sidebar with an Accordion of Checkboxes, main with + four Cards). Used to demonstrate the recursion needs of ADR-049. + +anatomy: + filterPageHeader: + type: instance + instanceOf: Row + filterGrid: + type: instance + instanceOf: Row + +elements: + filterPageHeader: + propConfigurations: + # Row.children slot — value is a Composition + children: + anatomy: + breadcrumbs: { type: instance, instanceOf: Breadcrumbs } + heading: { type: instance, instanceOf: Heading } + tabs: { type: instance, instanceOf: Tabs } + elements: + heading: + propConfigurations: + content: Filter Results + layout: + - breadcrumbs + - heading + - tabs + + filterGrid: + propConfigurations: + # Row.children slot — value is a Composition + children: + anatomy: + sidebar: { type: container } + make: { type: instance, instanceOf: Accordion } + main: { type: container } + card1: { type: instance, instanceOf: Card } + card2: { type: instance, instanceOf: Card } + card3: { type: instance, instanceOf: Card } + card4: { type: instance, instanceOf: Card } + elements: + make: + propConfigurations: + label: Filter 1 + open: true + # Accordion.children slot — value is a Composition (recursion ↓) + children: + anatomy: + checkboxGroup: + type: instance + instanceOf: CheckboxGroup + elements: + checkboxGroup: + propConfigurations: + # CheckboxGroup.children slot — value is a Composition (recursion ↓) + children: + anatomy: + make1: { type: instance, instanceOf: Checkbox } + make2: { type: instance, instanceOf: Checkbox } + make3: { type: instance, instanceOf: Checkbox } + elements: + make1: { propConfigurations: { label: Ford } } + make2: { propConfigurations: { label: Mercedes } } + make3: + propConfigurations: + custom: true + # Checkbox.children slot — value is a Composition (recursion ↓) + children: + anatomy: + root: { type: container } + toyotaText: { type: text } + newBadge: { type: instance, instanceOf: Badge } + elements: + root: + styles: + layoutMode: HORIZONTAL + toyotaText: + content: Toyota + newBadge: + propConfigurations: + appearance: new + label: New + layout: + - root: + - toyotaText + - newBadge + layout: + - make1 + - make2 + - make3 + layout: + - checkboxGroup + layout: + - sidebar: + - make + - main: + - card1 + - card2 + - card3 + - card4 + +layout: + - filterPageHeader + - filterGrid From 84af67fee97d1080ff1182fb1e64b62706107324 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 11 May 2026 12:42:32 -0400 Subject: [PATCH 19/20] docs(adr-046,047,049): JSON Pointer references; rename to instanceExamples; add Pill scratchpad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated changes across the composition ADRs: 1. Reference form is JSON Pointer, paralleling ADR-008 PropBinding. - ADR-049's discriminated reference becomes { $composition: } (rotated from $slotContent; pointer makes the registry home explicit). - ADR-047's SlotBinding.$extensions['com.figma'].default value becomes a JSON Pointer to a Composition (was a bare slotContent key). - Scratchpads updated: example.flat.yaml wraps records under a top-level `compositions:` namespace so absolute pointers resolve; example.pill.yaml (new) wraps under `components: pill:` for the same reason. 2. Rename ComponentExamples → InstanceExamples; Component.examples → Component.instanceExamples. Disambiguates against Props.examples and Anatomy.examples (which carry sample content, not InstanceExample entries). Cited in ADR-046 Semver section as the first ADR-side application of Constitution §VI rule 3 (no code-platform consensus on this specs-schema-specific authoring construct). 3. New cross-ADR scratchpad example.pill.yaml — a Pill component that exercises ADR-008 (PropBinding), ADR-046 (instanceExamples), ADR-047 (slotContent + SlotBinding with Figma authoring default), and ADR-049 (discriminated $composition reference) in one file. Three documented usages span the surface: configuredLabel (custom:false + label scalar), composedLabel (custom:true filled with Figma's default composition), removableLabel (custom:true filled with text + remove glyph composition). Cleanup along the way: ADR-047 had stale references to the removed InstanceExample.slots field (six sites in Notes / Pros / Consequences / Out of scope / Option D rejection). All updated to point at the unified propConfigurations. mechanism per ADR-049. The Option A example in ADR-047 was wrapped under `components: actionListItem:` and updated to JSON Pointer references, replacing the pre-rename `examples.slots` form with `instanceExamples.propConfigurations.: { $composition }`. ADR-042 forward references (lines 25, 195, 206) updated from Component.examples to Component.instanceExamples. INDEX.md row 046 updated to reflect the new title and field name. Co-Authored-By: Claude Opus 4.7 (1M context) --- adr/042-composition-type.md | 6 +- adr/046-component-examples.md | 56 +++--- adr/047-slot-content.md | 122 +++++++------ adr/049-nested-slot-compositions.md | 84 ++++----- adr/INDEX.md | 2 +- adr/research/049/example.flat.yaml | 270 +++++++++++++--------------- adr/research/049/example.pill.yaml | 134 ++++++++++++++ 7 files changed, 401 insertions(+), 273 deletions(-) create mode 100644 adr/research/049/example.pill.yaml diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 5d3e559..637d101 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -22,7 +22,7 @@ Compositions appear at four scales in Figma-sourced design systems: Today, the schema cannot represent any of these. `SlotProp.default` holds only a descriptive string. There is no structural type for tooling to discover, render, or validate against. -DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") proposed a `Composition` type for the inline parent-fills-child-slot case. This ADR adopts that structural shape as a named foundational type. How components store and reference compositions is addressed in ADR-046 (`InstanceExample`, `Component.examples`) and ADR-047 (`SlotExample`, `Element.$extensions`). +DRAFT ADR-025 ("Flowing Content into a Nested Instance's Slot") proposed a `Composition` type for the inline parent-fills-child-slot case. This ADR adopts that structural shape as a named foundational type. How components store and reference compositions is addressed in ADR-046 (`InstanceExample`, `Component.instanceExamples`) and ADR-047 (`SlotExample`, `Element.$extensions`). ### Composition scoping @@ -192,7 +192,7 @@ Composition: ### Out of scope for this ADR - **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-047 -- **`InstanceExample`** and **`Component.examples`** — see ADR-046 +- **`InstanceExample`** and **`Component.instanceExamples`** — see ADR-046 - **`Element.$extensions`** and `defaultComposition` — see ADR-047 - **`PropConfigurations` PropBinding widening** — see ADR-048 - **`compositions.yaml` file schema** — follow-on ADR after ADR-047 @@ -203,7 +203,7 @@ Composition: - **Nested instance with a slot** — when `elements` contains an `instance` element whose component has slot props, those slots cannot be filled from within this `Composition`. The composition can set scalar prop values via `propConfigurations` on that instance, but slot content resolution is deferred to the mechanism introduced in ADR-047 (one level deep) and its follow-on. This is a deliberate constraint, not an oversight. - **`elements` is sparse** — not every anatomy element needs a corresponding `elements` entry. An element with no content, styles, or prop configurations can be omitted from `elements`. The `elements: {}` case (all anatomy elements are instances with no element-level data) is valid and explicit. - **No `kind` field** — discrimination is the responsibility of the extending types (`SlotExample` adds `kind: 'slot'`; future system-scoped types add their own). -- **`Composition` is not placed directly on `Component`** — it is the structural base; `Component.examples` (ADR-046) and `SlotExample` (ADR-047) are the consumer-facing entry points. +- **`Composition` is not placed directly on `Component`** — it is the structural base; `Component.instanceExamples` (ADR-046) and `SlotExample` (ADR-047) are the consumer-facing entry points. - **Anticipated metadata extensions** — `title` and `description` are the authoring-time metadata for this ADR. Anticipated follow-on extensions include `tags?: string[]` for cataloguing, `deprecated?: boolean` for lifecycle management, and `guidelines?: string` for usage guidance. These are deferred to a follow-on ADR once the consuming types are established and usage patterns are known. --- diff --git a/adr/046-component-examples.md b/adr/046-component-examples.md index da616b8..740c05d 100644 --- a/adr/046-component-examples.md +++ b/adr/046-component-examples.md @@ -1,4 +1,4 @@ -# ADR: Component Examples — InstanceExample and Component.examples +# ADR: Component Instance Examples — InstanceExample and Component.instanceExamples **Branch**: `046-component-examples` **Created**: 2026-04-29 @@ -21,17 +21,17 @@ This ADR covers the simplest case: an example defined entirely by scalar prop va - An optional human-readable label (`title`) - Scalar prop values (`propConfigurations`) -`Component.examples` is the named record that holds `InstanceExample` entries. +`Component.instanceExamples` is the named record that holds `InstanceExample` entries. --- ## Decision Drivers - **Named record, not array** — examples must be referenceable by key; `Element.$extensions['com.figma'].defaultComposition` (ADR-047) and the slot-fill mechanism (ADR-049) both point to keys, not indices -- **No discriminator field** — `Component.examples` will only ever contain `InstanceExample` entries (slot fills are `Element.propConfigurations` per ADR-049; Figma authoring defaults are `Component.slotContent` per ADR-047). No `kind` field is needed because no other shape competes for the same record +- **No discriminator field** — `Component.instanceExamples` will only ever contain `InstanceExample` entries (slot fills are `Element.propConfigurations` per ADR-049; Figma authoring defaults are `Component.slotContent` per ADR-047). No `kind` field is needed because no other shape competes for the same record - **Scalar-only `propConfigurations`** — `InstanceExample` represents a documented configuration for human readers and tooling, not a live data binding; `PropBinding` belongs in `Element.propConfigurations` (ADR-048), not here - **Scalar-prop scope here; slot fill is ADR-049 territory** — `InstanceExample` documents scalar prop usages only. Filling slot props is part of the broader composition-recursion story and is handled through `Element.propConfigurations` per ADR-049, not via a dedicated field on `InstanceExample` -- **Additive-only** — new optional field `Component.examples`; no existing type changed → MINOR +- **Additive-only** — new optional field `Component.instanceExamples`; no existing type changed → MINOR - **Type ↔ schema symmetry** — every field has a schema counterpart (Constitution §I) - **No runtime logic** — type declarations and schema only (Constitution §II) @@ -41,7 +41,7 @@ This ADR covers the simplest case: an example defined entirely by scalar prop va ### Option A: `InstanceExample` as a record member *(Selected)* -Add `InstanceExample { title?, propConfigurations? }` and a named record `ComponentExamples` on `Component`. No discriminator field — `ComponentExamples` only holds one shape. +Add `InstanceExample { title?, propConfigurations? }` and a named record `InstanceExamples` on `Component`. No discriminator field — `InstanceExamples` only holds one shape. ```yaml # ActionListItem — instance examples covering scalar prop variants @@ -62,7 +62,7 @@ props: description: type: string -examples: +instanceExamples: defaultState: title: Action List Item – default propConfigurations: @@ -117,11 +117,11 @@ Reuse the existing `Variant` type for examples — a `Variant` already has `conf | File | Change | Bump | |------|--------|------| -| New: `ComponentExample.ts` | Add `InstanceExample`, `ComponentExamples` | MINOR | -| `Component.ts` | Add optional `examples?: ComponentExamples` | MINOR | -| `index.ts` | Export `InstanceExample`, `ComponentExamples` | MINOR | +| New: `InstanceExample.ts` | Add `InstanceExample`, `InstanceExamples` | MINOR | +| `Component.ts` | Add optional `examples?: InstanceExamples` | MINOR | +| `index.ts` | Export `InstanceExample`, `InstanceExamples` | MINOR | -**New types** (`types/ComponentExample.ts`): +**New types** (`types/InstanceExample.ts`): ```yaml # InstanceExample — a pre-configured usage of the whole component @@ -131,8 +131,8 @@ InstanceExample: Record # scalar prop values only # slot prop fills are NOT here — see ADR-049 -# ComponentExamples — named record on Component -ComponentExamples: Record +# InstanceExamples — named record on Component (field: instanceExamples) +InstanceExamples: Record ``` **Extended `Component`** (`types/Component.ts`): @@ -159,7 +159,7 @@ Component: variants?: Variants invalidVariantCombinations?: PropConfigurations[] metadata?: Metadata - examples?: ComponentExamples # new — named instance and slot examples + instanceExamples?: InstanceExamples # new — named usages of this component (scalar prop configurations) ``` ### Schema changes (`schema/`) @@ -167,8 +167,8 @@ Component: | File | Change | Bump | |------|--------|------| | `component.schema.json` | Add `#/definitions/InstanceExample` | MINOR | -| `component.schema.json` | Add `#/definitions/ComponentExamples` | MINOR | -| `component.schema.json` | Add `examples` property to `#/definitions/Component` | MINOR | +| `component.schema.json` | Add `#/definitions/InstanceExamples` | MINOR | +| `component.schema.json` | Add `instanceExamples` property to `#/definitions/Component` | MINOR | **New definition** (`#/definitions/InstanceExample`): @@ -190,10 +190,10 @@ InstanceExample: additionalProperties: false ``` -**New definition** (`#/definitions/ComponentExamples`): +**New definition** (`#/definitions/InstanceExamples`): ```yaml -ComponentExamples: +InstanceExamples: type: object description: "Named examples for this component." patternProperties: @@ -205,9 +205,9 @@ ComponentExamples: **New property** in `#/definitions/Component/properties`: ```yaml -examples: - $ref: "#/definitions/ComponentExamples" - description: "Named instance and slot examples for this component." +instanceExamples: + $ref: "#/definitions/InstanceExamples" + description: "Named instance examples (documented usages) for this component." ``` ### Out of scope for this ADR @@ -220,7 +220,7 @@ examples: - `InstanceExample.propConfigurations` is scalar-only (`string | number | boolean`). Prop binding (`PropBinding`) belongs in `Element.propConfigurations`, which represents live data flow. `InstanceExample` represents a documented configuration — human-intended, not runtime-driven. - `InstanceExample` does *not* include slot-fill information. Filling a slot is filling a prop; the value form (Composition object, named-composition key, etc.) lives on `Element.propConfigurations` and is settled by ADR-049. Earlier drafts of this ADR carried a `slots: Record` field on `InstanceExample` as a placeholder; that field has been removed in favor of the unified mechanism. -- `ComponentExamples` uses `patternProperties` rather than `additionalProperties` on an object schema to satisfy Draft 7's handling of `$ref` alongside `additionalProperties: false`. +- `InstanceExamples` uses `patternProperties` rather than `additionalProperties` on an object schema to satisfy Draft 7's handling of `$ref` alongside `additionalProperties: false`. --- @@ -229,8 +229,8 @@ examples: - **Symmetric**: Yes - **Parity check**: - `InstanceExample { title?, propConfigurations? }` ↔ `#/definitions/InstanceExample` - - `ComponentExamples = Record` ↔ `#/definitions/ComponentExamples` (`patternProperties`) - - `Component.examples?: ComponentExamples` ↔ `#/definitions/Component/properties/examples` + - `InstanceExamples = Record` ↔ `#/definitions/InstanceExamples` (`patternProperties`) + - `Component.instanceExamples?: InstanceExamples` ↔ `#/definitions/Component/properties/instanceExamples` --- @@ -238,8 +238,8 @@ examples: | Consumer | Impact | Action required | |----------|--------|-----------------| -| `specs-from-figma` | Must detect and emit `Component.examples` with `InstanceExample` entries from example frames | Read new types; implement instance example detection | -| `specs-cli` | Recompile; output includes `examples` key when present | Recompile; no breaking change | +| `specs-from-figma` | Must detect and emit `Component.instanceExamples` with `InstanceExample` entries from example frames | Read new types; implement instance example detection | +| `specs-cli` | Recompile; output includes `instanceExamples` key when present | Recompile; no breaking change | | `specs-plugin-2` | Recompile; example rendering is a follow-on capability | Recompile; pass through example data initially | --- @@ -248,13 +248,15 @@ examples: **Version bump**: `0.19.0 → 0.20.0` (`MINOR`) -**Justification**: All changes are additive — new optional field `Component.examples`, new types `InstanceExample` and `ComponentExamples`; no existing type is removed or narrowed → MINOR per Constitution §III. +**Justification**: All changes are additive — new optional field `Component.instanceExamples`, new types `InstanceExample` and `InstanceExamples`; no existing type is removed or narrowed → MINOR per Constitution §III. + +**Naming Governance** (Constitution §VI): `instanceExamples` qualifies the field against the lower-level `examples` patterns reused in `Props` (sample values) and `Anatomy` (sample content). Code-platform alignment is weak for this concept — it's a specs-schema-specific authoring construct — so this is rule 3 (no code-platform consensus; ambiguity inside the schema is the deciding factor). --- ## Consequences -- `Component.examples` is a first-class named record on `Component`; example configurations are discoverable alongside the component that owns them +- `Component.instanceExamples` is a first-class named record on `Component`; example configurations are discoverable alongside the component that owns them - `InstanceExample` expresses a complete scalar-prop configuration in a single, referenceable entry - All example keys are plain strings — no inline nesting, no cross-component references - Slot fill is *not* part of `InstanceExample`; it lives on `Element.propConfigurations` per ADR-049, keeping `InstanceExample` cleanly scoped to scalar-prop documentation diff --git a/adr/047-slot-content.md b/adr/047-slot-content.md index d437902..0bb089a 100644 --- a/adr/047-slot-content.md +++ b/adr/047-slot-content.md @@ -10,13 +10,13 @@ ## Context -ADR-042 established `Composition` as the structural base type. ADR-046 established `InstanceExample` and `Component.examples` for scalar-prop-configured usages. +ADR-042 established `Composition` as the structural base type. ADR-046 established `InstanceExample` and `Component.instanceExamples` for scalar-prop-configured usages. A second authoring need is *content placed inside a component's slot layer* — a named, reusable arrangement of elements that fills a slot. Three decisions follow: 1. **What type expresses slot content?** — `Composition` (ADR-042) is already the structural shape. Does this ADR introduce a new wrapper type, or use `Composition` directly? -2. **Where on `Component` does it live?** — Bundled into `Component.examples` as a discriminated-union peer of `InstanceExample`, or hosted on its own sibling field? ADR-046 left `ComponentExamples` typed narrowly so this ADR could resolve the question. +2. **Where on `Component` does it live?** — Bundled into `Component.instanceExamples` as a discriminated-union peer of `InstanceExample`, or hosted on its own sibling field? ADR-046 left `InstanceExamples` typed narrowly so this ADR could resolve the question. 3. **How does the default variant reference it?** — A slot-bound container in `default.elements` or `variants[n].elements` needs a way to point at the content placed inside its slot layer in the design file. The reference must be expressible per-variant, colocated with the binding it defaults, and recognizable to consumers as design-tool authoring metadata so that consumers without a "default slot data" concept (e.g., code components, which resolve missing slots through logic) can correctly ignore it. @@ -47,50 +47,54 @@ The existing `Element.children` discriminant (`string[] | PropBinding` — plain ### Option A: `Component.slotContent: Record` + `SlotBinding.$extensions['com.figma'].default` *(Selected)* -A new top-level field `Component.slotContent` holds named `Composition` entries — *sibling* to `Component.examples` (which remains `Record` from ADR-046). No new type is introduced for slot content; `Composition` is reused as-is. +A new top-level field `Component.slotContent` holds named `Composition` entries — *sibling* to `Component.instanceExamples` (which remains `Record` from ADR-046). No new type is introduced for slot content; `Composition` is reused as-is. A new interface `SlotBinding` extends `PropBinding` with an optional `$extensions` field carrying platform-specific metadata. `Children` widens from `string[] | PropBinding` to `string[] | SlotBinding`. Because `Children` lives on `Element`, anything on a `SlotBinding` appears naturally in `default.elements[name]` and `variants[n].elements[name]` — per-variant variation falls out for free. Two reference sites point into `Component.slotContent`: -1. **`InstanceExample.slots[slotName]`** — *authored documentation, meaningful to all consumers.* Defined in ADR-046; values resolve to `slotContent` keys. Lives as a plain field on `InstanceExample`, not in `$extensions`, because a slot reference inside an authored example is part of the documentation, not Figma-specific provenance. +1. **`Element.propConfigurations.`** — *authored documentation/usage, meaningful to all consumers.* When an `InstanceExample` (ADR-046) sets a slot prop, or when any nested-instance element fills its slot via the cross-boundary mechanism (ADR-049), the value resolves to a Composition in `slotContent`. Lives as a plain prop value, not in `$extensions`, because the reference is part of authored documentation/usage, not Figma-specific provenance. 2. **`SlotBinding.$extensions['com.figma'].default`** — *Figma authoring metadata, ignored by code consumers.* References the content Figma renders inside the slot layer when no consumer override is supplied. Lives in `$extensions` because code consumers resolve missing slots through component logic (not data) and must correctly skip the field. Minimal example — one slot, one content entry, both reference sites visible: ```yaml -# ActionListItem (only fields that demonstrate Option A are shown) -anatomy: - startVisualSlot: { type: container } # slot-bound - -props: - startVisual: { type: slot } - -examples: - withIcon: - slots: - startVisual: searchIcon # reference site #1: InstanceExample.slots - -slotContent: - searchIcon: +# ActionListItem (only fields that demonstrate Option A are shown). +# Assumed to live at `#/components/actionListItem` so JSON Pointer references resolve. +components: + actionListItem: anatomy: - icon: { type: glyph } - elements: - icon: - content: search - styles: - width: 16 - height: 16 - layout: [icon] + startVisualSlot: { type: container } # slot-bound + + props: + startVisual: { type: slot } + + instanceExamples: + withIcon: + propConfigurations: + startVisual: + $composition: "#/components/actionListItem/slotContent/searchIcon" # reference site #1 (ADR-049) + + slotContent: + searchIcon: + anatomy: + icon: { type: glyph } + elements: + icon: + content: search + styles: + width: 16 + height: 16 + layout: [icon] -default: - elements: - startVisualSlot: - children: - $binding: "#/props/startVisual" - $extensions: - com.figma: - default: searchIcon # reference site #2: SlotBinding extension + default: + elements: + startVisualSlot: + children: + $binding: "#/components/actionListItem/props/startVisual" + $extensions: + com.figma: + default: "#/components/actionListItem/slotContent/searchIcon" # reference site #2 (Figma authoring default) ``` #### Naming the new field on `Component` @@ -101,19 +105,18 @@ Candidate terms considered: - **`slotExamples`** — parallels `examples` symmetrically, but reads as "examples of slots" rather than "content for slots," which understates what's authored (a complete composition, not an exemplar). - **`slotCompositions`** — accurate to ADR-042's `Composition` base, but `Composition` is a structural-shape word; authors don't think of slot fills as "compositions" in everyday language. - **`compositions`** — overreaches: ADR-042 reserves the unqualified term for the system-scoped `compositions.yaml` bucket (layout and page compositions). Using it here would force a rename later. -- **`slots`** — collides with `SlotProp` use of "slots" on `Component.props` and on `InstanceExample.slots`; ambiguous. +- **`slots`** — collides with `SlotProp` use of "slots" on `Component.props`; ambiguous. - **`fills`** / **`slotFills`** — short but jargony; not established in any of the target platform vocabularies. `slotContent` wins on platform alignment (Figma + Web Components + Vue + Svelte all use "slot"; "content" is the standard noun for what fills one), on authoring clarity (it names what's authored, not the form), and on namespace cleanliness (no collision with `SlotProp` or with ADR-042's `compositions.yaml`). #### Naming the field inside `$extensions['com.figma']` -The field lives at `SlotBinding.$extensions['com.figma'].default`. Inside the `com.figma` namespace, "default" is unambiguous — it reads as "Figma's default" by virtue of its enclosing key — and there is no collision risk because the scope is one extension object. +The field lives at `SlotBinding.$extensions['com.figma'].default`. Inside the `com.figma` namespace, "default" is unambiguous — it reads as "Figma's default" by virtue of its enclosing key — and there is no collision risk because the scope is one extension object. The field's *value* is a JSON Pointer to a `Composition` (in `slotContent` or in an external composition file), matching the form ADR-049 uses for cross-boundary slot fill references. - **`default`** *(Selected)* — concise, reads naturally in scope (`$extensions.com.figma.default` = "Figma's default"). The enclosing namespace already qualifies it. - **`defaultContent`** — verbose given the namespace already implies "Figma's default for this slot binding." -- **`defaultComposition`** — accurate but the value isn't typed `Composition` directly; it's a *key into* `slotContent`. "Default" plus the obvious scope is clearer. -- **`slotContent`** — would mirror `Component.slotContent`'s name but invert its meaning (singular key vs. record); confusing. +- **`defaultComposition`** — accurate but redundant given the value type is documented; "default" plus the obvious scope is clearer. **Pros**: - No new type for slot content — `Composition` carries the structure it already defines @@ -121,20 +124,20 @@ The field lives at `SlotBinding.$extensions['com.figma'].default`. Inside the `c - The Figma default-fill key lives on the slot binding inside `$extensions['com.figma']` — colocated with `$binding`, no name collision with `content`, and correctly marked as design-tool provenance that code consumers ignore - Per-variant Figma defaults fall out for free: `children` already lives on `Element`, which is mapped through `default`/`variants` - `examples` and `slotContent` are siblings with one purpose each; no `kind` discriminator on either -- ADR-046's `ComponentExamples` is not widened; this ADR is purely additive +- ADR-046's `InstanceExamples` is not widened; this ADR is purely additive - Future system-scoped `compositions.yaml` (ADR-042 follow-on) lives at the same conceptual level without competing with either field **Cons / Trade-offs**: - Two namespaces to teach instead of one - A consumer that wants "everything authored on this component" must read both fields and union them - One-level-deep boundary; recursion deferred to a follow-on ADR -- Schema cannot enforce that `$extensions['com.figma'].default` keys exist in `slotContent` — consumer validation concern. (The "only valid for slot-bound" constraint *is* enforced structurally — `$extensions` only exists in the `SlotBinding` arm of `Children`.) +- Schema cannot enforce that `$extensions['com.figma'].default` JSON Pointers resolve to actual compositions — consumer validation concern. (The "only valid for slot-bound" constraint *is* enforced structurally — `$extensions` only exists in the `SlotBinding` arm of `Children`.) --- -### Option B: New `SlotExample` type with a `slot` field; entries bundled in `Component.examples`; reference via `Element.$extensions` *(Rejected)* +### Option B: New `SlotExample` type with a `slot` field; entries bundled in `Component.instanceExamples`; reference via `Element.$extensions` *(Rejected)* -Introduce `SlotExample extends Composition` with an added `slot: string`, widen `Component.examples` to a `ComponentExample = InstanceExample | SlotExample` discriminated union (each entry carries a `kind`), and reference defaults via `Element.$extensions['com.figma'].defaultComposition` — i.e., put the extension on `Element` rather than on the slot binding. +Introduce `SlotExample extends Composition` with an added `slot: string`, widen `Component.instanceExamples` to a `ComponentExample = InstanceExample | SlotExample` discriminated union (each entry carries a `kind`), and reference defaults via `Element.$extensions['com.figma'].defaultComposition` — i.e., put the extension on `Element` rather than on the slot binding. **Rejected because**: - The `slot` field hard-codes a content entry to a single slot; identical content used in two slots must be duplicated. @@ -157,7 +160,7 @@ Store the default-content reference on `SlotProp.default` rather than on the ele Store slot content in a separate file alongside the component, rather than in the component definition. -**Rejected because**: Slot-content entries are component-specific — they describe content authored to fill named slots on that component. They belong in the component definition for discoverability and so `InstanceExample.slots` and the Figma default reference can resolve keys by string without cross-file reference. +**Rejected because**: Slot-content entries are component-specific — they describe content authored to fill named slots on that component. They belong in the component definition for discoverability and so `propConfigurations.` references and the Figma default reference can resolve via JSON Pointer paths inside the same component spec. --- @@ -226,9 +229,12 @@ import { PropBinding } from './PropBinding.js'; */ export interface FigmaSlotBindingExtension { /** - * Key in Component.slotContent — Figma's authoring default for this slot - * (the content placed inside the slot layer in the design file). Code - * consumers handle missing slots through component logic and ignore this. + * JSON Pointer to a Composition — Figma's authoring default for this slot + * (the content placed inside the slot layer in the design file). Resolves + * to a Composition in `Component.slotContent` (component-scoped, e.g. + * `"#/components/pill/slotContent/composedLabel"`) or in an external + * composition file (system-scoped). Code consumers handle missing slots + * through component logic and ignore this. */ default?: string; [key: string]: unknown; @@ -263,7 +269,7 @@ Component: variants?: Variants invalidVariantCombinations?: PropConfigurations[] metadata?: Metadata - examples?: ComponentExamples # InstanceExample only — ADR-046 + instanceExamples?: InstanceExamples # InstanceExample only — ADR-046 # After Component: @@ -275,7 +281,7 @@ Component: variants?: Variants invalidVariantCombinations?: PropConfigurations[] metadata?: Metadata - examples?: ComponentExamples # unchanged + instanceExamples?: InstanceExamples # unchanged slotContent?: Record # new — named, slot-agnostic content entries ``` @@ -293,7 +299,7 @@ Component: ```yaml slotContent: type: object - description: "Named slot-content entries for this component. Each entry is a Composition. Keys are referenced by InstanceExample.slots and by SlotBinding.$extensions['com.figma'].default." + description: "Named slot-content entries for this component. Each entry is a Composition. Entries are referenced via JSON Pointer (e.g. `\"#/components/pill/slotContent/composedLabel\"`) from SlotBinding.$extensions['com.figma'].default and from Element.propConfigurations slot-prop entries (ADR-049 CompositionRef)." patternProperties: "^[a-zA-Z0-9_-]+$": $ref: "#/definitions/Composition" @@ -309,7 +315,7 @@ FigmaSlotBindingExtension: properties: default: type: string - description: "Key in Component.slotContent — Figma's authoring default for this slot." + description: "JSON Pointer to a Composition — Figma's authoring default for this slot. Resolves to a Composition in Component.slotContent (component-scoped, e.g. `\"#/components/pill/slotContent/composedLabel\"`) or in an external composition file (system-scoped)." additionalProperties: true SlotBindingExtensions: @@ -327,7 +333,7 @@ SlotBinding: properties: $binding: type: string - description: "JSON Pointer to the bound slot prop, e.g. \"#/props/items\"." + description: "JSON Pointer to the bound slot prop, e.g. \"#/components/pill/props/children\"." $extensions: $ref: "#/definitions/SlotBindingExtensions" additionalProperties: false @@ -346,13 +352,13 @@ Children: ### Notes -- Slot-content entries are slot-agnostic. The same `Composition` (e.g., a single glyph icon) can be referenced from multiple `InstanceExample.slots` entries and from multiple slot-binding defaults; authors are not forced to duplicate identical content per slot. +- Slot-content entries are slot-agnostic. The same `Composition` (e.g., a single glyph icon) can be referenced from multiple `propConfigurations.` sites (in InstanceExamples or in nested-instance fills per ADR-049) and from multiple slot-binding defaults; authors are not forced to duplicate identical content per slot. - `SlotBinding.$extensions` is structurally available only when `Element.children` is a slot binding (the second arm of `Children`). A container with `children: string[]` cannot express a Figma default — the schema enforces this through the `Children` discriminant. - Because `Element.children` lives in `default.elements` and `variants[n].elements`, different variants may declare different Figma defaults for the same slot-bound container with no special-case mechanism. - `PropBinding` itself is unchanged. `SlotBinding extends PropBinding` adds `$extensions` only for the children-binding case; `PropBinding`'s other use sites (`content`, `instanceOf`, `visible`) cannot accidentally accept it. -- `InstanceExample.slots` does *not* live in `$extensions`. `InstanceExample` is an authored documentation construct; a slot reference inside one is part of the documentation and is meaningful to all consumers, not just Figma. +- Slot fills inside an `InstanceExample` (or any other `propConfigurations`-based call site) do *not* live in `$extensions`. They are authored documentation/usage and are meaningful to all consumers, not just Figma. - `SlotBindingExtensions` and `FigmaSlotBindingExtension` use `additionalProperties: true` — open extension objects by design, following the DTCG pattern. -- Schema validation cannot enforce that `$extensions['com.figma'].default` and `InstanceExample.slots` values resolve to existing keys in `Component.slotContent` — consumer validation concern. +- Schema validation cannot enforce that `$extensions['com.figma'].default` JSON Pointers and ADR-049 `$composition` references resolve to existing compositions — consumer validation concern. --- @@ -382,16 +388,16 @@ Children: **Version bump**: `0.19.0 → 0.20.0` (`MINOR`) -**Justification**: All changes are additive — new optional `Component.slotContent` field; new `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension` interfaces; `Children` widened from `string[] | PropBinding` to `string[] | SlotBinding`, where `SlotBinding` is a structural superset of `PropBinding` (existing `children: { $binding }` values still validate). No new types for slot content (`Composition` from ADR-042 is reused). `PropBinding` and `Element` are unchanged. `ComponentExamples` from ADR-046 is unchanged. → MINOR per Constitution §III. +**Justification**: All changes are additive — new optional `Component.slotContent` field; new `SlotBinding`, `SlotBindingExtensions`, `FigmaSlotBindingExtension` interfaces; `Children` widened from `string[] | PropBinding` to `string[] | SlotBinding`, where `SlotBinding` is a structural superset of `PropBinding` (existing `children: { $binding }` values still validate). No new types for slot content (`Composition` from ADR-042 is reused). `PropBinding` and `Element` are unchanged. `InstanceExamples` from ADR-046 is unchanged. → MINOR per Constitution §III. --- ## Consequences -- `Component.examples` and `Component.slotContent` are siblings: each holds one type with one purpose. `examples` documents whole-component usages (`InstanceExample`); `slotContent` holds named `Composition` entries that fill slots. -- Slot-content entries are slot-agnostic; the binding to a specific slot happens at the reference site (`InstanceExample.slots`, `SlotBinding.$extensions['com.figma'].default`). Identical content used in multiple slots is authored once. +- `Component.instanceExamples` and `Component.slotContent` are siblings: each holds one type with one purpose. `instanceExamples` documents whole-component usages (`InstanceExample`); `slotContent` holds named `Composition` entries that fill slots. +- Slot-content entries are slot-agnostic; the binding to a specific slot happens at the reference site (`propConfigurations.` per ADR-049, `SlotBinding.$extensions['com.figma'].default`). Identical content used in multiple slots is authored once. - `Composition` is reused directly with no wrapper type; ADR-042's structural type carries its own weight here. - `SlotBinding.$extensions['com.figma'].default` colocates the Figma default-fill reference with the slot binding itself, inside `Element.children`. The `$extensions` framing correctly marks it as design-tool provenance — code consumers ignore it (defaults in code are logic, not data). No collision with `content`, `children`, or any top-level `Element` field. Per-variant Figma defaults fall out because `children` already lives on `Element`. - `SlotBindingExtensions` is an open extension object — future Figma-specific or platform-specific slot-binding metadata can be added without a new ADR. -- `InstanceExample.slots` (defined in ADR-046) resolves into `Component.slotContent`. ADR-046's `ComponentExamples` type is not widened. +- Slot fills via `propConfigurations.` (the unified mechanism per ADR-049) resolve into `Component.slotContent` via JSON Pointer. ADR-046's `InstanceExamples` type is not widened. - This ADR scopes slot content to one level deep; recursion (filling slots of nested instances from a parent context) is deferred to a follow-on ADR. diff --git a/adr/049-nested-slot-compositions.md b/adr/049-nested-slot-compositions.md index c3c2545..c045a2e 100644 --- a/adr/049-nested-slot-compositions.md +++ b/adr/049-nested-slot-compositions.md @@ -25,9 +25,9 @@ Three constraints are settled in advance; the option comparison below is bounded ### Decisions this ADR has to make - **Headline:** nested-only, flat-only, both, or sibling-field — see Options A–D. -- **Value union for `Element.propConfigurations.`:** what shapes does the slot-prop arm of the union accept once the headline lands? Two sub-questions: +- **Value union for `Element.propConfigurations.`:** what shapes does the slot-prop arm of the union accept once the headline lands? - *Direct shape* — Composition object, key reference, both? - - *Form of the reference* — a bare `string` (the lightest), or a discriminated reference type (e.g., `{ $slotContent: '' }` mirroring how `PropBinding` uses `$binding`, or reusing/extending `SlotBinding`'s shape from ADR-047)? The discriminated form avoids the schema's "is this a typo or a key" ambiguity in the bare-string approach. + - *Form of the reference* — discriminated `{ $composition: }` where `` is a JSON Pointer (working assumption, paralleling `PropBinding.$binding` from ADR-008). The pointer's path makes the registry home explicit in the wire format (`#/components//slotContent/` for component-scoped; `#/compositions/` for system-scoped). Bare-string form was the lighter alternative but loses local type-discriminability and the explicit scope path. That's it. Other concerns once flagged here — recursion depth, validation, aliasing semantics — are addressed below either as Decision Drivers, as Option-level pros/cons, or as tool/runtime concerns the schema does not own. @@ -89,42 +89,43 @@ elements: --- -### Option B: Flat only — registry key under `propConfigurations.` +### Option B: Flat only — `{ $composition: }` under `propConfigurations.` -`Element.propConfigurations.` value union widens to accept a registry key resolving to a named `Composition`. The key resolves into either `Component.slotContent` (component-scoped) or an external composition file (system-scoped) per the registry constraint above. +`Element.propConfigurations.` value union widens to accept a discriminated reference `{ $composition: }` where `` is a JSON Pointer (absolute, paralleling `PropBinding.$binding` from ADR-008). The pointer resolves into either `Component.slotContent` (component-scoped, e.g. `#/components/pill/slotContent/composedLabel`) or an external composition file (system-scoped, e.g. `#/compositions/pageHeader`) per the registry constraint above. -The reference can be a bare `string` (shown below for brevity) or a discriminated form like `{ $slotContent: '' }` — see decision 2 in Context. - -**Two-tier abstract example.** Same `Parent → Mid → leaf` hierarchy as Option A, expressed as three sibling records — every composition stays at top-level indentation regardless of how deep its referencer sits. The reference shape is discriminated (`{ $slotContent: '' }`) to parallel `PropBinding`'s `$binding` (see decision 2): +**Two-tier abstract example.** Same `Parent → Mid → leaf` hierarchy as Option A, expressed as three sibling records under a `compositions:` registry. Every composition stays at top-level indentation regardless of how deep its referencer sits; references use JSON Pointers that make the registry home explicit: ```yaml -root: # entry composition - anatomy: - parent: { type: instance, instanceOf: Parent } - elements: - parent: - propConfigurations: - body: { $slotContent: parentBody } # ← level-1: discriminated reference - layout: [parent] - -parentBody: # named, top-level - anatomy: - mid: { type: instance, instanceOf: Mid } - elements: - mid: - propConfigurations: - items: { $slotContent: midItems } # ← level-2: discriminated reference - layout: [mid] - -midItems: # named, top-level - anatomy: - leaf: { type: text } - elements: - leaf: { content: Hello } - layout: [leaf] +compositions: + root: # entry composition + anatomy: + parent: { type: instance, instanceOf: Parent } + elements: + parent: + propConfigurations: + body: + $composition: "#/compositions/parentBody" # ← level-1: pointer reference + layout: [parent] + + parentBody: + anatomy: + mid: { type: instance, instanceOf: Mid } + elements: + mid: + propConfigurations: + items: + $composition: "#/compositions/midItems" # ← level-2: pointer reference + layout: [mid] + + midItems: + anatomy: + leaf: { type: text } + elements: + leaf: { content: Hello } + layout: [leaf] ``` -A composition referenced from N call sites appears as the same `{ $slotContent: }` reference repeated N times — one definition, many uses. +A composition referenced from N call sites appears as the same `{ $composition: }` reference repeated N times — one definition, many uses. **Pros**: - Indentation stays bounded regardless of recursion depth @@ -199,19 +200,18 @@ The schema does not encode these; tooling owns them. Concrete sketch for **Option B (flat-only)**, illustrative — *not* a selection. The same shape narrative applies in spirit to A (Composition arm), C (both arms), and D (sibling field); the headline choice settles which becomes the authoritative section here. -The sketch below assumes the discriminated-reference form for the registry key (`SlotContentRef`), which mirrors the existing `PropBinding` pattern and avoids the bare-string ambiguity. A bare-`string` form is the lighter alternative; the choice is decision 2 in Context. - -**Type changes** (Option B, discriminated-reference form): -- New type `SlotContentRef = { $slotContent: string }` — a key reference into either `Component.slotContent` (component-scoped) or an external composition file (system-scoped). Pattern parallels `PropBinding = { $binding: string }` from ADR-008. -- `Element.propConfigurations` value union widens from `string | number | boolean | PropBinding` (post-ADR-048) to `string | number | boolean | PropBinding | SlotContentRef`. -- No change to `Composition` itself (ADR-042); no change to `Element`'s other fields. `SlotBinding` (ADR-047) remains scoped to `Element.children`; `SlotContentRef` is the propConfigurations-side counterpart for the same registry. +**Type changes** (Option B): +- New type `CompositionRef = { $composition: string }` — a JSON Pointer reference resolving to a `Composition` in either `Component.slotContent` (component-scoped, e.g. `"#/components/pill/slotContent/composedLabel"`) or an external composition file (system-scoped, e.g. `"#/compositions/pageHeader"`). Pattern parallels `PropBinding = { $binding: string }` from ADR-008; the string is a JSON Pointer, not an opaque key. +- `Element.propConfigurations` value union widens from `string | number | boolean | PropBinding` (post-ADR-048) to `string | number | boolean | PropBinding | CompositionRef`. +- No change to `Composition` itself (ADR-042); no change to `Element`'s other fields. `SlotBinding` (ADR-047) remains scoped to `Element.children`; `CompositionRef` is the propConfigurations-side counterpart for the same registry. +- Knock-on for ADR-047: `SlotBinding.$extensions['com.figma'].default` becomes a JSON Pointer (same form, same resolution rules) for consistency, replacing the previously-bare key string. -**Schema changes** (Option B, discriminated-reference form): -- New `#/definitions/SlotContentRef` with `$slotContent: string` — single-property object, `additionalProperties: false`. -- `#/definitions/Element/properties/propConfigurations/additionalProperties/oneOf` gains a `$ref: "#/definitions/SlotContentRef"` arm. +**Schema changes** (Option B): +- New `#/definitions/CompositionRef` with required `$composition: string` (JSON Pointer pattern documented via `description`); single-property object, `additionalProperties: false`. +- `#/definitions/Element/properties/propConfigurations/additionalProperties/oneOf` gains a `$ref: "#/definitions/CompositionRef"` arm. - No new top-level registry definition here; the system-scoped external file format lands in the follow-on ADR (ADR-042's deferred `compositions.yaml`). -**Why not reuse `SlotBinding` directly** (briefly): `SlotBinding` is `{ $binding, $extensions? }` and lives on `Element.children` of slot-*owning* components, expressing the runtime binding of children to a slot prop. The propConfigurations-side reference goes the other direction — a *consumer* setting a nested instance's slot value — so the semantic role differs, even though the resolution target (a `slotContent` key) overlaps. A parallel-but-distinct `SlotContentRef` keeps each shape's role legible. If the pattern proves redundant in practice, a future MAJOR could unify them. +**Why not reuse `SlotBinding` directly** (briefly): `SlotBinding` is `{ $binding, $extensions? }` and lives on `Element.children` of slot-*owning* components, expressing the runtime binding of children to a slot prop. The `propConfigurations`-side reference goes the other direction — a *consumer* setting a nested instance's slot value — so the semantic role differs, even though the resolution target (a `Composition` in slotContent or an external file) overlaps. A parallel-but-distinct `CompositionRef` keeps each shape's role legible. If the pattern proves redundant in practice, a future MAJOR could unify them. **Symmetric**: Yes, with the caveat that the schema cannot enforce cross-field reference resolution (consumer validation concern per Constraints). diff --git a/adr/INDEX.md b/adr/INDEX.md index 80404fb..d939065 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -7,7 +7,7 @@ | 049 | Nested Slot Compositions | Recursion follow-on to ADR-047: fill nested instances' slots from a parent context (reserved, draft on `042-composition-type` branch) | | 048 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding` (reserved, draft on `042-composition-type` branch) | | 047 | Slot Content — Component.slotContent and SlotBinding | Add `Component.slotContent: Record` + `SlotBinding` extending `PropBinding` with `$extensions['com.figma'].default` (reserved, draft on `042-composition-type` branch) | -| 046 | Component Examples — InstanceExample and Component.examples | Add `InstanceExample` and `ComponentExamples`; add `Component.examples?` named record (reserved, draft on `042-composition-type` branch) | +| 046 | Component Instance Examples — InstanceExample and Component.instanceExamples | Add `InstanceExample` and `InstanceExamples`; add `Component.instanceExamples?` named record (reserved, draft on `042-composition-type` branch) | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | | 043 | Custom Color Format Configuration | | diff --git a/adr/research/049/example.flat.yaml b/adr/research/049/example.flat.yaml index 6e15676..4780908 100644 --- a/adr/research/049/example.flat.yaml +++ b/adr/research/049/example.flat.yaml @@ -5,28 +5,17 @@ # # Scope: this file represents a SYSTEM-SCOPED composition file (an entire page # spanning multiple components — Page, Row, Accordion, CheckboxGroup, Checkbox, -# Badge). Component-scoped recursive compositions (a single component's -# `slotContent` referencing its own sibling entries) would live inside the -# Component definition rather than in a standalone file like this one. +# Badge). Records live under a top-level `compositions:` namespace so that +# JSON Pointer references resolve cleanly. Component-scoped recursive +# compositions would live inside a Component definition's `slotContent` +# (see ./example.pill.yaml) rather than in a standalone file like this one. # -# Reference form: discriminated — `{ $slotContent: }` — paralleling -# `PropBinding`'s `$binding` shape. The bare-string alternative (`children: -# pageHeader`) is the lighter form but loses local type-discriminability; -# see ADR-049 Context decision 2. -# -# Example: Filter Results Page expressed as a record of named Compositions. -# `root` is the entry point (the Composition that fills `Page.body`); every -# other key is a sibling Composition referenced by name from another's -# `propConfigurations.` field. No indentation grows with depth — -# each composition stays at file-top level regardless of how deep in the -# tree its referencer sits. -# -# Framing — slot fill is by key reference: -# `propConfigurations.: ` where `` names a Composition -# defined as a sibling record in this file. The schema-level value union -# for `propConfigurations.` therefore needs to accept *at least* -# a string key (this form) and ideally also an inline Composition (nested -# form), letting authors choose per case. +# Reference form: discriminated `{ $composition: }` where +# is a JSON Pointer (absolute from the document root), paralleling ADR-008's +# `$binding` shape. The pointer's path makes the registry home explicit in +# the wire format — system-scoped here resolves under `#/compositions/...`; +# component-scoped pointers under `#/components//slotContent/...` +# (see ./example.pill.yaml). # # Why flat: # - Each Composition is reusable — `makeOptions` could be referenced @@ -41,16 +30,6 @@ # - No naming overhead for one-off sub-compositions # - Matches how a designer might think about a one-off page layout # -# Open questions for ADR-049 (carry-over from example.nested.yaml plus -# flat-specific): -# - Accept both inline-Composition and key-ref forms for -# `propConfigurations.`, or pick one? -# - Where do the named compositions live — inside `Component.slotContent`, -# a separate `compositions.yaml` file (per ADR-042 follow-on), or both? -# - Naming conventions / collision rules for the registry? -# - How does the schema express the union (Composition | string) when the -# string is meaningful only as a key into a separate registry? -# # Hierarchy (same as example.nested.yaml): # Page.body # ├─ Row "Filter Page Header" (Row.children) @@ -72,119 +51,126 @@ # ├─ Card # └─ Card -# ─── root: entry point — fills Page.body ──────────────────────────────────── -root: - title: Filter Results Page - description: | - Two-row page layout — "Filter Page Header" (breadcrumbs, heading, tabs) - above "Filter Grid" (sidebar with an Accordion of Checkboxes, main with - four Cards). - anatomy: - filterPageHeader: - type: instance - instanceOf: Row - filterGrid: - type: instance - instanceOf: Row - elements: - filterPageHeader: - propConfigurations: - children: { $slotContent: pageHeader } # → composition `pageHeader` - filterGrid: - propConfigurations: - children: { $slotContent: pageGrid } # → composition `pageGrid` - layout: - - filterPageHeader - - filterGrid +compositions: + + # ─── root: entry point — fills Page.body ────────────────────────────────── + root: + title: Filter Results Page + description: | + Two-row page layout — "Filter Page Header" (breadcrumbs, heading, tabs) + above "Filter Grid" (sidebar with an Accordion of Checkboxes, main with + four Cards). + anatomy: + filterPageHeader: + type: instance + instanceOf: Row + filterGrid: + type: instance + instanceOf: Row + elements: + filterPageHeader: + propConfigurations: + children: + $composition: "#/compositions/pageHeader" + filterGrid: + propConfigurations: + children: + $composition: "#/compositions/pageGrid" + layout: + - filterPageHeader + - filterGrid -# ─── pageHeader: three stacked items in the header row ────────────────────── -pageHeader: - anatomy: - breadcrumbs: { type: instance, instanceOf: Breadcrumbs } - heading: { type: instance, instanceOf: Heading } - tabs: { type: instance, instanceOf: Tabs } - elements: - heading: - propConfigurations: - content: Filter Results - layout: - - breadcrumbs - - heading - - tabs + # ─── pageHeader: three stacked items in the header row ──────────────────── + pageHeader: + anatomy: + breadcrumbs: { type: instance, instanceOf: Breadcrumbs } + heading: { type: instance, instanceOf: Heading } + tabs: { type: instance, instanceOf: Tabs } + elements: + heading: + propConfigurations: + content: Filter Results + layout: + - breadcrumbs + - heading + - tabs -# ─── pageGrid: two-column grid for the filter results ─────────────────────── -pageGrid: - anatomy: - sidebar: { type: container } - make: { type: instance, instanceOf: Accordion } - main: { type: container } - card1: { type: instance, instanceOf: Card } - card2: { type: instance, instanceOf: Card } - card3: { type: instance, instanceOf: Card } - card4: { type: instance, instanceOf: Card } - elements: - make: - propConfigurations: - label: Filter 1 - open: true - children: { $slotContent: carBrandFilter } # → composition `carBrandFilter` - layout: - - sidebar: - - make - - main: - - card1 - - card2 - - card3 - - card4 + # ─── pageGrid: two-column grid for the filter results ───────────────────── + pageGrid: + anatomy: + sidebar: { type: container } + make: { type: instance, instanceOf: Accordion } + main: { type: container } + card1: { type: instance, instanceOf: Card } + card2: { type: instance, instanceOf: Card } + card3: { type: instance, instanceOf: Card } + card4: { type: instance, instanceOf: Card } + elements: + make: + propConfigurations: + label: Filter 1 + open: true + children: + $composition: "#/compositions/carBrandFilter" + layout: + - sidebar: + - make + - main: + - card1 + - card2 + - card3 + - card4 -# ─── carBrandFilter: what's inside the "Filter 1" Accordion when open ─────── -carBrandFilter: - anatomy: - checkboxGroup: - type: instance - instanceOf: CheckboxGroup - elements: - checkboxGroup: - propConfigurations: - children: { $slotContent: makeOptions } # → composition `makeOptions` - layout: - - checkboxGroup + # ─── carBrandFilter: what's inside the "Filter 1" Accordion when open ───── + carBrandFilter: + anatomy: + checkboxGroup: + type: instance + instanceOf: CheckboxGroup + elements: + checkboxGroup: + propConfigurations: + children: + $composition: "#/compositions/makeOptions" + layout: + - checkboxGroup -# ─── makeOptions: the three Checkbox instances inside the CheckboxGroup ─ -makeOptions: - anatomy: - make1: { type: instance, instanceOf: Checkbox } - make2: { type: instance, instanceOf: Checkbox } - make3: { type: instance, instanceOf: Checkbox } - elements: - make1: { propConfigurations: { label: Ford } } - make2: { propConfigurations: { label: Mercedes } } - make3: - propConfigurations: - custom: true - children: { $slotContent: newToyota } # → composition `newToyota` - layout: - - make1 - - make2 - - make3 + # ─── makeOptions: the three Checkbox instances inside the CheckboxGroup ─── + makeOptions: + anatomy: + make1: { type: instance, instanceOf: Checkbox } + make2: { type: instance, instanceOf: Checkbox } + make3: { type: instance, instanceOf: Checkbox } + elements: + make1: { propConfigurations: { label: Ford } } + make2: { propConfigurations: { label: Mercedes } } + make3: + propConfigurations: + custom: true + children: + $composition: "#/compositions/newToyota" + layout: + - make1 + - make2 + - make3 -# ─── newToyota: text + badge inside the custom Toyota checkbox ────── -newToyota: - anatomy: - root: { type: container } - toyotaText: { type: text } - newBadge: { type: instance, instanceOf: Badge } - elements: - root: - styles: - layoutMode: HORIZONTAL - toyotaText: - content: Toyota - newBadge: - propConfigurations: - appearance: new - label: New - layout: - - root: - - toyotaText - - newBadge + # ─── newToyota: text + badge inside the custom Toyota checkbox ──────────── + newToyota: + anatomy: + root: { type: container } + toyotaText: { type: text } + newBadge: { type: instance, instanceOf: Badge } + elements: + root: + styles: + layoutMode: HORIZONTAL + toyotaText: + content: Toyota + newBadge: + propConfigurations: + appearance: new + label: New + layout: + - root: + - toyotaText + - newBadge diff --git a/adr/research/049/example.pill.yaml b/adr/research/049/example.pill.yaml new file mode 100644 index 0000000..218e8de --- /dev/null +++ b/adr/research/049/example.pill.yaml @@ -0,0 +1,134 @@ +# Scratchpad: cross-ADR example — Pill component +# +# Sibling to example.nested.yaml and example.flat.yaml. Where those two +# demonstrate ADR-049's nested-vs-flat recursion shapes for a system-scoped +# page composition, this file demonstrates COMPONENT-SCOPED machinery — +# ADR-046 (Component.instanceExamples / InstanceExample) and ADR-047 +# (Component.slotContent + SlotBinding with the Figma authoring default) — +# with ADR-049's $composition discriminated reference connecting them at the +# InstanceExample call sites. +# +# Reference form: JSON Pointer, absolute from the document root, matching +# ADR-008's $binding precedent. The Pill component is wrapped under +# components.pill so every reference in this file literally resolves. +# +# Pill component contract: +# prop custom: boolean — toggles which variant is active +# prop label: string — bound to a text layer when custom:false +# prop children: slot — slot-bound container when custom:true +# +# Variant custom:false — labelText layer's characters bound to `label` prop +# via PropBinding (ADR-008). Authoring example sets +# label = "{configured label}". +# Variant custom:true — childrenSlot's children bound via SlotBinding +# (ADR-047). Figma authoring default points to the +# `composedLabel` composition in slotContent. +# +# Three documented usages cover the surface (instanceExamples below): +# 1. configuredLabel — custom:false + label scalar +# 2. composedLabel — custom:true + children fills with composedLabel composition +# 3. removableLabel — custom:true + children fills with removableLabelContent + +components: + pill: + title: Pill + + anatomy: + root: + type: container + labelText: + type: text # rendered when custom:false; characters bound to label prop + childrenSlot: + type: container # rendered when custom:true; slot-bound + + props: + custom: + type: boolean + label: + type: string + children: + type: slot + + # ─── default variant (custom:false): text layer bound to label prop ───── + default: + configuration: + custom: false + elements: + root: + children: [labelText] + labelText: + content: { $binding: "#/components/pill/props/label" } # ADR-008 PropBinding + layout: + - root: + - labelText + + # ─── variants[0] (custom:true): children slot rendered ────────────────── + variants: + - configuration: + custom: true + elements: + root: + children: [childrenSlot] + childrenSlot: + children: # ADR-047 SlotBinding + $binding: "#/components/pill/props/children" + $extensions: + com.figma: + default: "#/components/pill/slotContent/composedLabel" # Figma authoring default + layout: + - root: + - childrenSlot + + # ─── Component.slotContent (ADR-047): named Compositions ──────────────── + slotContent: + # Figma's authoring default for the children slot. Reused by the + # `composedLabel` InstanceExample below. + composedLabel: + title: Default composed label + anatomy: + label: + type: text + elements: + label: + content: "{composed label}" + layout: [label] + + # Text + remove-x glyph. Referenced from the `removableLabel` InstanceExample. + removableLabelContent: + title: Removable label (text + remove glyph) + anatomy: + label: + type: text + removeIcon: + type: glyph + elements: + label: + content: "{removeable label}" + removeIcon: + content: x + styles: + width: 16 + height: 16 + layout: [label, removeIcon] + + # ─── Component.instanceExamples (ADR-046, post-rename): documented usages + instanceExamples: + configuredLabel: + title: Pill with configured label + propConfigurations: + custom: false + label: "{configured label}" # ADR-046 scalar prop value + + composedLabel: + title: Pill using the default composed label + propConfigurations: + custom: true + children: + $composition: "#/components/pill/slotContent/composedLabel" # ADR-049 + + removableLabel: + title: Pill with text + remove glyph + propConfigurations: + custom: true + children: + $composition: "#/components/pill/slotContent/removableLabelContent" From d297319dac9bdb645b16705d25aef1631fbdd3cf Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 12 May 2026 08:18:34 -0400 Subject: [PATCH 20/20] Extensions to examples and reviews --- .../049/big-decisions.GPT5-4-reaction.md | 124 ++++++++++ ...big-decisions.GPT5-4.architect-reaction.md | 199 +++++++++++++++ .../049/big-decisions.gemini-reaction.md | 28 +++ adr/research/049/big-decisions.md | 230 ++++++++++++++++++ .../example.component.alt1-checkboxGroup.yaml | 192 +++++++++++++++ ....yaml => example.component.alt2-pill.yaml} | 0 ...l => example.composition.alt1-nested.yaml} | 0 ...aml => example.composition.alt2-flat.yaml} | 0 8 files changed, 773 insertions(+) create mode 100644 adr/research/049/big-decisions.GPT5-4-reaction.md create mode 100644 adr/research/049/big-decisions.GPT5-4.architect-reaction.md create mode 100644 adr/research/049/big-decisions.gemini-reaction.md create mode 100644 adr/research/049/big-decisions.md create mode 100644 adr/research/049/example.component.alt1-checkboxGroup.yaml rename adr/research/049/{example.pill.yaml => example.component.alt2-pill.yaml} (100%) rename adr/research/049/{example.nested.yaml => example.composition.alt1-nested.yaml} (100%) rename adr/research/049/{example.flat.yaml => example.composition.alt2-flat.yaml} (100%) diff --git a/adr/research/049/big-decisions.GPT5-4-reaction.md b/adr/research/049/big-decisions.GPT5-4-reaction.md new file mode 100644 index 0000000..a910805 --- /dev/null +++ b/adr/research/049/big-decisions.GPT5-4-reaction.md @@ -0,0 +1,124 @@ +# Reaction to Big Decisions + +Based on ADR-042, ADR-046, ADR-047, ADR-048, ADR-049, the 049 scratchpads, and a narrow read of the current schema surface, the contract feels strongest when it stays opinionated rather than flexible. + +## Overall read + +The cleanest spine across these ADRs is: + +- `Composition` is a reusable structural value. +- `slotContent` is the component-scoped registry for named compositions. +- `propConfigurations` is where a nested instance's prop value gets set. +- Figma-authored defaults stay in `$extensions` on the slot binding. + +The options that preserve those roles read coherently. The options that blur role, storage location, or value form feel weaker. + +## Reactions by decision + +### 1. Flat vs. nested for compositions + +My strongest reaction is that ADR-049 should likely land on **flat only**. + +The flat scratchpad scales materially better once recursion is real. The nested scratchpad is readable for one or two levels, but by the time the example reaches `Accordion -> CheckboxGroup -> Checkbox -> Badge`, the indentation cost is already doing real damage. That is exactly the kind of pressure a schema should absorb rather than push onto authors. + +Flat form also fits the direction already established by ADR-048. `propConfigurations` is the place where a nested instance's prop value is expressed. Adding a `CompositionRef` there is coherent. By contrast: + +- **Nested only** keeps locality but loses aliasing and becomes hard to read quickly. +- **Both forms** is tempting, but it weakens canonicality right where the model is starting to become clear. +- **Sibling `slotFills`** undoes the unification ADR-048 just established by splitting one concept across two fields. + +So my current preference is: + +- `propConfigurations.` accepts `{ $composition: "" }` +- component-scoped references resolve into `slotContent` +- system-scoped references resolve into external composition files once that follow-on ADR lands + +### 2. Consolidated `examples` vs. sibling `slotContent` + `instanceExamples` + +I favor keeping **two sibling fields**. + +`instanceExamples` and `slotContent` are not just two storage shapes for similar things. They represent different authoring intents: + +- `instanceExamples` documents whole-component usages +- `slotContent` defines reusable structural content fragments + +The consolidated `examples` shape adds discriminator bookkeeping without buying enough structural clarity. It also forces every consumer to filter by `kind`, even when that consumer only cares about one category. + +The CheckboxGroup scratchpad is useful as pressure-testing, but it reads to me more like evidence that references are working than evidence that consolidation is needed. + +### 3. Consolidated vs. separated `anatomy` / `elements` in `Composition` + +I would keep **`anatomy` and `elements` separated**. + +The current examples do not show enough authoring pain to justify breaking consistency with `Variant`. The split supports a stable mental model across the schema and keeps composition-to-component promotion straightforward. A converged map is superficially simpler, but it creates a one-off hybrid shape that no longer matches the rest of the contract. + +This seems like the right place to prefer structural consistency over slight authoring convenience. + +### 4. Naming the composition shape + +`Composition` still looks like the best available name. + +It is somewhat abstract, but the alternatives appear worse: + +- `Fragment` brings React baggage +- `Layout` collides with an existing field +- `Content` collides with `Element.content` +- `Slot` collides with slot props +- `Block`, `View`, and `Scene` each import unrelated vocabulary + +So my reaction is not that `Composition` is perfect, but that it is the least misleading. + +### 5. Naming the `$composition` reference + +I would keep **`$composition` with a JSON Pointer value**. + +That choice has three concrete strengths: + +- it parallels `$binding` +- it keeps scope explicit in the wire format +- it avoids ambiguous bare-key references + +I would avoid `$ref` because schema readers already associate that with JSON Schema semantics. + +### 6. Where the Figma authoring default lives + +I agree with ADR-047's selected position: keep the default on **`SlotBinding.$extensions['com.figma'].default`**. + +That is the right semantic home. The field describes Figma authoring provenance attached to a slot binding. It is not general `content`, and it is not a universal runtime rule every consumer should honor. Putting it in `$extensions` makes that boundary explicit. + +### 7. Unify `SlotBinding` and `CompositionRef`, or keep parallel + +I would keep them **parallel but distinct**. + +They may both resolve to a `Composition`, but they do different jobs in the contract: + +- `SlotBinding` binds a slot prop +- `CompositionRef` sets a slot prop value + +That distinction is enough to justify two shapes for now. Unifying them would reduce surface area slightly but blur role in a part of the model where role matters. + +### 8. Naming the consolidated field, if consolidation lands + +If consolidation ever lands anyway, `examples` is probably the right field name. But my stronger reaction is that the field should not land at all. + +### 9. Naming the `kind` values, if consolidation lands + +If consolidation lands anyway, I do **not** think `nestedSlotContent` should be its own `kind`. + +That distinction looks reference-site-derived, not shape-derived. At the schema level, it still appears to be a `Composition`. The recursion role seems inferable from where it is referenced. + +If a distinction is truly needed later, a second axis such as `role` would be cleaner than multiplying kinds that share the same structure. + +## Net assessment + +The contract is strongest when it remains explicit about three separations: + +- structure vs. usage (`Composition` vs. `InstanceExample`) +- authored slot content vs. authored whole-component examples (`slotContent` vs. `instanceExamples`) +- binding semantics vs. provenance metadata (`SlotBinding` vs. `$extensions.com.figma.default`) + +The flat-reference approach for ADR-049 reinforces those separations instead of weakening them. + +## Main caveat + +My only real caveat is sequencing. **Flat only** becomes substantially more convincing once the external system-scoped compositions file is specified, because then the model is complete end to end. Even so, based on the current read, the flat direction already looks like the stronger schema choice. \ No newline at end of file diff --git a/adr/research/049/big-decisions.GPT5-4.architect-reaction.md b/adr/research/049/big-decisions.GPT5-4.architect-reaction.md new file mode 100644 index 0000000..3edda89 --- /dev/null +++ b/adr/research/049/big-decisions.GPT5-4.architect-reaction.md @@ -0,0 +1,199 @@ +# Gemini Architect Reaction + +## Overall read + +The direction is getting stronger as ADR-042, 047, and 048 converge on a coherent model: `Composition` is the reusable structural unit, slot fill is treated as prop assignment rather than as a separate semantic channel, and Figma provenance is being kept out of the core contract via `$extensions['com.figma']`. + +From an architecture perspective, the remaining decisions should optimize for three things above all else: + +1. One durable canonical form on the wire. +2. Clear semantic boundaries between authored content, consumer references, and tool-specific provenance. +3. A schema surface that stays legible after more composition use cases arrive, not just the first nested-slot case. + +My strongest reaction is that ADR-049 should resolve toward a stricter, more canonical model rather than a more expressive one. The examples make it clear that the real danger is not lack of power. It is drift, ambiguity, and a public contract that starts elegant and becomes difficult to teach. + +--- + +## 1. Flat vs. nested for compositions + +Recommendation: choose **flat only**. + +This is the most important decision, and the examples make the tradeoff fairly stark. The nested form is attractive at first read because it preserves locality, but it does not scale. Once recursion reaches real page depth, the author must parse structure, instance configuration, and slot filling across several indentation regimes at once. That is a readability problem now, and a maintenance problem later. + +The flat form has the better long-term properties: + +- It gives compositions stable names and addresses. +- It makes reuse explicit instead of accidental. +- It keeps depth from turning into formatting noise. +- It creates a surface that tooling can inspect, validate, diff, catalog, and potentially render independently. + +The architectural point here is that nested form optimizes for authoring convenience at the call site, while flat form optimizes for system comprehension. For a shared schema, system comprehension should win. + +I would avoid accepting both forms. "Both" feels user-friendly but is usually the start of contract entropy. Once two equivalent encodings are allowed, every downstream consumer pays for that flexibility forever, and every style guide has to recover a convention the schema declined to enforce. + +If there is a desire to preserve nested author ergonomics, that should live in tooling as an authoring affordance that compiles down to the flat wire format, not in the schema itself. + +--- + +## 2. Consolidated `examples` vs. sibling `slotContent` + `instanceExamples` + +Recommendation: keep the **sibling fields**. + +The current split is semantically cleaner than the consolidated `examples` alternative. + +`instanceExamples` and `slotContent` are not merely two storage locations for the same kind of thing. They represent different author intents: + +- `instanceExamples` documents whole-component usage. +- `slotContent` defines reusable compositions that fill slots. + +That distinction is worth preserving directly in the contract. A unified `examples` record would trade a slightly tidier container for a more ambiguous public API, and would push semantic discrimination onto `kind` values that every consumer must filter and interpret. + +That is the wrong kind of unification. It compresses storage, not meaning. + +The CheckboxGroup scratchpad is useful because it reveals the pressure toward consolidation, but it also shows the cost: once `nestedSlotContent` appears, the schema starts naming roles that are really properties of reference context, not properties of the composition itself. That is a sign the model is being bent around organization rather than around stable semantics. + +My recommendation is: + +- keep `slotContent` +- keep `instanceExamples` +- do not introduce `nestedSlotContent` as a schema-level kind + +If a composition is structurally the same thing, it should stay the same thing. The call site already tells you whether it is being used as a top-level slot fill or a nested one. + +--- + +## 3. Separated vs. converged `anatomy` / `elements` + +Recommendation: keep them **separated**. + +ADR-042 made the right choice here. + +The most important durability argument is not just consistency with `Variant`; it is preservation of conceptual roles: + +- `anatomy` declares what exists +- `elements` declares what is configured about what exists + +That separation is a strong modeling property. It keeps structural identity distinct from authored values and makes sparse `elements` possible without weakening the structural declaration. + +A converged map would feel lighter in a toy example, but over time it would blur concerns and create a second style of expressing structure in the schema. It would also make promotion from composition to component more awkward, exactly where consistency should help most. + +If authors later complain about verbosity, the right response is likely tooling support or better examples, not a second structural idiom. + +--- + +## 4. Naming the composition shape + +Recommendation: keep **`Composition`**. + +It is not perfect, but it is the best option in the current set because it is broad enough to survive expansion across slot, instance-adjacent, layout, and page scopes without pulling the concept toward a narrower metaphor. + +Most alternatives are more evocative and less durable: + +- `Fragment` is overloaded by React. +- `Layout` is already occupied. +- `Content` is too generic. +- `View` and `Block` are overloaded across ecosystems. + +`Composition` is slightly abstract, but public schema names benefit from being slightly abstract when the concept is foundational and cross-cutting. + +--- + +## 5. Naming the composition reference + +Recommendation: keep **`$composition`** and keep the value as a **JSON Pointer**. + +This is the right level of explicitness. It parallels `$binding` well enough to feel like part of the same family without collapsing the semantics together. + +The more important part is the JSON Pointer value. That makes scope explicit in the payload rather than implicit in convention. Given the deliberate split between component-scoped and system-scoped registries, that explicitness is a strength, not noise. + +I would resist any move back toward bare key strings. They are lighter to type and heavier to reason about. + +--- + +## 6. Where the Figma authoring default lives + +Recommendation: keep **`SlotBinding.$extensions['com.figma'].default`**. + +This feels architecturally sound. + +The decision correctly preserves a boundary between: + +- core authored contract +- platform-specific provenance + +That distinction matters. Figma's default rendered slot content is important source information, but it is not universal component semantics. Encoding it in `$extensions` is the right signal to consumers and leaves the core contract clean. + +I would only suggest one discipline here: maintain a very hard line that anything under `$extensions['com.figma']` is observational or provenance-oriented, not normative for non-Figma consumers. If that line blurs later, the namespace stops doing its job. + +--- + +## 7. Unify `SlotBinding` and `CompositionRef`, or keep them parallel + +Recommendation: keep them **parallel but distinct**. + +They are similar in shape but not in role. + +- `SlotBinding` expresses that an element's children are bound to a slot prop. +- `CompositionRef` expresses that a consumer is assigning a composition value to a slot prop. + +Those are mirror-adjacent concepts, not the same concept. + +Trying to unify them would reduce surface area nominally while increasing cognitive load materially. A schema this structural should prefer a small amount of duplication over role ambiguity. + +That said, I would be careful to keep their naming, pointer semantics, and documentation closely aligned so the model still feels coherent. + +--- + +## 8. If consolidation ever lands anyway + +If the project eventually chooses a consolidated field despite the concerns above, I would still avoid `nestedSlotContent` as a distinct kind. + +The separate kind is a smell. It encodes placement history into the type system. A composition should not change identity because it is referenced one level deeper. + +If consolidation happens, I would prefer the narrowest possible enum: + +- `instance` +- `slotContent` + +and let nestedness be inferred from reference topology. + +--- + +## 9. What still needs tightening + +The ADR set is directionally coherent, but a few durability details need firmer treatment before this becomes a stable public contract. + +### Validation responsibilities + +Cycle detection, pointer resolution, and slot-fit validation are correctly identified as tool responsibilities, but they should be stated more normatively and more uniformly. Right now they read as acknowledged consequences. For a contract this reference-heavy, they are part of the effective architecture. + +I would want a follow-on ADR or an explicit validation section that says, in substance: + +- consumers MUST reject unresolved composition pointers +- consumers MUST reject reference cycles +- consumers SHOULD validate slot compatibility at the consume site + +Without that, the schema risks becoming formally clear and operationally uneven. + +### Registry boundaries + +The split between component-scoped and system-scoped registries is sound, but it should remain very explicit that this is a scoping distinction, not a type distinction. The same reference form reaching two homes is good. Introducing subtly different semantics by home would be dangerous. + +### One canonical wire format + +This is worth stating plainly in ADR-049 once chosen. If the schema accepts only one representation for nested compositions, future ADRs should treat alternate authoring forms as tooling concerns unless there is a compelling interoperability reason to elevate them into the contract. + +--- + +## Bottom line + +If I were optimizing for clarity and durability, I would choose the following package of decisions: + +- **Flat-only** nested composition references via `$composition` and JSON Pointer +- **Sibling** `slotContent` and `instanceExamples`, not a unified `examples` bucket +- **Separated** `anatomy` and `elements` inside `Composition` +- Keep the names **`Composition`** and **`$composition`** +- Keep Figma defaults on **`SlotBinding.$extensions['com.figma'].default`** +- Keep **`SlotBinding`** and **`CompositionRef`** distinct + +That package is not the shortest surface area, but it is the clearest one. More importantly, it creates a contract that can absorb future composition work without having to explain multiple equivalent encodings or role-specific pseudo-types that exist mainly to organize examples. \ No newline at end of file diff --git a/adr/research/049/big-decisions.gemini-reaction.md b/adr/research/049/big-decisions.gemini-reaction.md new file mode 100644 index 0000000..9091e25 --- /dev/null +++ b/adr/research/049/big-decisions.gemini-reaction.md @@ -0,0 +1,28 @@ +# Downstream Implementer Reaction: "Big Decisions" + +From the perspective of a downstream toolchain—encompassing a code-generation factory (for React, iOS, Android), an analysis suite (for monitoring design system health/reuse), and a documentation authoring pipeline—here is a reaction to the open structural and naming choices. + +--- + +### 1. Flat vs. Nested Compositions (ADR-049) -> **Strongly Prefer Flat (Option B)** +* **Code Generation Pipelines:** Generating code for flat, named references aligns perfectly with modern component architecture. A shared `$composition` reference maps seamlessly to a standalone helper function, a separate sub-component, or an imported variable. The "Nested" (inline) approach guarantees massive, bloated render trees and forces the code generator to attempt highly complex AST deduplication. +* **Analysis Suites:** Health metrics require tracking pattern reuse. Deeply nested inline objects disguise duplicated effort. A flat, pointer-based registry allows analysis tools to instantly track reference frequency and identify orphaned designs. +* **Documentation Pipelines:** Documentation thrives on explicit intent and named concepts. Authors specifying flat compositions inherently provide titles/keys that become the headers for generated Storybook instances or Doc site snippets. + +### 2. Consolidated `examples` vs. Sibling Fields -> **Prefer Sibling Fields** +* **Code Generation & Docs:** Grouping conceptually distinct payloads into one array forces every downstream consumer to immediately apply a `.filter(e => e.kind === ...)` step. `instanceExamples` (top-level usage demonstrations) power interactive playgrounds and public documentation. `slotContent` (internal component fills) power automated testing fixtures and default slot rendering paths. They have different audiences and lifecycles. Schema-level structural isolation is much safer for type safety. + +### 3. Consolidated vs. Separated `anatomy` / `elements` -> **Prefer Separated** +* **Code Generation:** Keeping `anatomy` separated from `elements` in `Composition` means the downstream parsing pipeline can reuse the exact same logic it uses for processing a `Variant`. Downstream factories often treat `anatomy` as the shape of the render tree (the JSX nodes), and `elements` as the data payload (the props bound to those nodes). Converging them would break architectural symmetry and complicate type generation logic. + +### 4 & 5. Naming `$composition` and JSON Pointers -> **Strongly Support JSON Pointers** +* **Analysis & Code Generation:** Reusing the exact JSON Pointer syntax from `$binding` (`#/props/label`) for `$composition` (`#/compositions/pageHeader`) is a massive win. Tooling can leverage a unified reference-resolution utility traversing the specification. Using `$composition` as the key distinguishes semantic intent perfectly from a generic schema `$ref`, making AST walking unambiguous. + +### 6. Where the Figma Default Lives -> **Strongly Support `$extensions['com.figma']`** +* **Code Generation:** This is arguably the most crucial decision for platform implementers. In React, Android, and iOS, the handling of an empty slot relies on conditional rendering or default prop values hardcoded in the application layer—*not* injected data. Placing authoring metadata inside `$extensions['com.figma']` safely quarantines it. Code factories can blanket-ignore `$extensions` to avoid emitting unwanted hardcoded Figma fallback elements. + +### 7. Unifying `SlotBinding` and `CompositionRef` -> **Prefer Parallel-but-Distinct** +* **AST Parsing:** Although both resolve to structural data, they describe inverse relationship directions. `SlotBinding` is an *inbound* mapping (how an external prop maps into an element). `CompositionRef` is an *outbound* mapping (how an element outputs to an external composition). Keeping them distinct avoids parsing ambiguity when the tool is building its relationship graph. + +### 8 & 9. `kind` values (If Consolidation Lands) -> **Avoid `nestedSlotContent` discrimination** +* **Analysis:** If Option 2 does land on consolidation, avoid tracking topological depth in the `kind` enum. To a structural parser, `slotContent` and `nestedSlotContent` are identical: they are both structural trees resolving from a reference. Storing reference depth at the definition level adds artificial complexity to the schema typing. Keep the definition structurally unaware of its calling context. \ No newline at end of file diff --git a/adr/research/049/big-decisions.md b/adr/research/049/big-decisions.md new file mode 100644 index 0000000..c1e59a4 --- /dev/null +++ b/adr/research/049/big-decisions.md @@ -0,0 +1,230 @@ +# Big Decisions — Composition / Slot Content / Nested Compositions + +A skimmable cheat sheet of the open structural and naming choices across +ADR-042, ADR-046, ADR-047, ADR-048, and ADR-049. Each entry frames the +question, lists the options, points at the ADR (or scratchpad) that carries +the fuller argument, and notes the current leaning where one exists. + +The order below is roughly "biggest blast radius first" — decisions earlier +in the list cascade into shapes later in the list. + +--- + +## 1. Flat vs. nested for compositions (ADR-049 headline) + +**Question:** When a composition's element instances have their own slots, +how is the parent context's slot fill expressed under +`Element.propConfigurations.`? + +**Options:** +- **A — Nested only.** Inline `Composition` at the call site. Locality; + unbounded indentation; no aliasing. +- **B — Flat only.** `{ $composition: }` reference into a + named registry. Aliasing is free; locality is lost; needs the external + file format for system-scoped uses. +- **C — Both forms accepted.** Author chooses per case. Maximum expressivity; + weakest canonical convention. +- **D — Sibling field on `Element` (`slotFills`).** Keeps + `propConfigurations` scalar/binding-only. Two fields where one might do. + +**Where:** ADR-049 §Options. Scratchpads `example.nested.yaml` (A) and +`example.flat.yaml` (B) show the two extremes on the same hierarchy. + +**Current leaning:** undecided. Examples in research currently mix forms; +the Pill and CheckboxGroup scratchpads both use **B**. + +--- + +## 2. Consolidated `examples` (with `kind`) vs. sibling `slotContent` + `instanceExamples` + +**Question:** Do component-scoped compositions live in one named record +discriminated by `kind`, or in two sibling fields each holding one shape? + +**Options:** +- **Two siblings (current ADR-046 + ADR-047 selection).** + `Component.instanceExamples: Record` and + `Component.slotContent: Record`. One purpose each; no + discriminator; reader must read both fields for "everything authored." +- **One consolidated `examples` record (alternative — `example.checkboxGroup.yaml`).** + Entries discriminate by `kind: instance | slotContent | nestedSlotContent`. + Single home; single resolution path; every consumer filters by `kind`. + +**Where:** ADR-047 §Option A (sibling, selected) vs. §Option B (bundled +union, rejected at the time). The `nestedSlotContent` kind in the +CheckboxGroup scratchpad is a new shape not in any ADR yet. + +**Current leaning:** ADRs select siblings; the CheckboxGroup scratchpad +re-opens the consolidation question with the three-kind framing. + +**Sub-question if consolidation lands:** is `nestedSlotContent` a separate +`kind`, or is it indistinguishable from `slotContent` at the schema level +(distinguished only by reference site)? + +--- + +## 3. Consolidated vs. separated `anatomy` / `elements` in `Composition` + +**Question:** Does `Composition` keep the `Anatomy` + `Elements` split +inherited from `Variant`, or converge them into a single map of entries that +each hold both type metadata and element data? + +**Options:** +- **Separated (ADR-042 selection).** Mirrors `Variant`; eases + composition→component promotion; `SlotExample` / `SlotContent` / + `nestedSlotContent` all inherit the same split. +- **Converged (`entries: { name: { type, content, styles, ... } }`).** + Simpler authoring surface; one map instead of two. Breaks the + Anatomy/Elements pattern used elsewhere; promoting a composition to a + component requires splitting it back. + +**Where:** ADR-042 §Options A/B. + +**Current leaning:** separated, selected in ADR-042. Worth re-opening only +if authoring-surface complaints accumulate in practice. + +--- + +## 4. Naming the composition shape + +**Working name:** `Composition` (ADR-042). + +**Alternatives considered or available:** +- `Composition` — current; structural-shape word +- `Fragment` — connotes "piece of UI"; collides with React vocabulary +- `Arrangement` — accurate but uncommon in design-system vocabulary +- `Layout` — collides with the `Layout` field already on `Variant` +- `Scene` — Figma-adjacent but evokes 3D / animation +- `Snippet` — code-adjacent; understates the structural completeness +- `Block` — short, but overloaded across CMS / editor / layout vocabularies +- `View` — overloaded across platforms (iOS, Android, Vue, MVC) +- `Slot` — collides with `SlotProp` on `Component.props` +- `Content` — too generic; collides with `Element.content` + +**Where:** ADR-042 (no naming section — current name is the working +choice). + +**Current leaning:** `Composition` until a stronger candidate surfaces. + +--- + +## 5. Naming the `$composition` reference + +**Working name:** `{ $composition: "" }` (ADR-049 §Option B). + +**Alternatives:** +- `$composition` — current; parallels `$binding` from ADR-008 +- `$ref` — JSON-Schema convention; collides with schema-internal `$ref` + semantics and may confuse readers +- `$compositionRef` — explicit; verbose +- `$fill` — short, slot-flavored, but loses the "what kind of thing is + being referenced" cue +- `$slotFill` — too narrow if compositions become reusable across non-slot + contexts +- `$use` — short and pattern-neutral; reads ambiguously +- `$pointer` — describes the form, not the role +- `$content` — collides with `Element.content` +- `$instance` — wrong; instances are anatomy-level, not reference-level + +**Related sub-question:** the *value* form — JSON Pointer (current) vs. +bare key string. ADR-049 §Constraints picks JSON Pointer for path-explicit +scope and parallelism with `$binding`. + +**Where:** ADR-049 §Constraints + §Type↔Schema Impact. + +**Current leaning:** `$composition` + JSON Pointer. + +--- + +## 6. Where the Figma authoring default lives + +**Question:** How does the default variant reference the content Figma +shows inside a slot layer? + +**Options:** +- **A — `SlotBinding.$extensions['com.figma'].default` (ADR-047 selection).** + Colocated with the slot binding; `$extensions` framing marks it as + design-tool provenance that code consumers ignore. +- **F — Widen `Element.content` to accept slot-content keys.** Same field, + two meanings depending on `children`'s shape. Schema can't express the + conditional. + +**Where:** ADR-047 §Options A/F. + +**Current leaning:** A, selected. + +--- + +## 7. Unify `SlotBinding` and `CompositionRef`, or keep parallel + +**Question:** `SlotBinding` (on `Element.children`, author-side) and +`CompositionRef` (on `propConfigurations.`, consume-side) both +resolve to a `Composition`. One type or two? + +**Options:** +- **Parallel-but-distinct (ADR-049 §Type Impact, current).** Different + semantic roles (binding-to-prop vs. setting-prop-value); each keeps its + own shape for legibility. +- **Unified.** One shape carries both; consumers branch on field location. + Smaller surface; muddier roles. + +**Where:** ADR-049 §"Why not reuse `SlotBinding` directly." + +**Current leaning:** parallel; revisit at a future MAJOR if redundancy +proves out in practice. + +--- + +## 8. Naming the consolidated field — only if Decision 2 lands + +**Working name (CheckboxGroup scratchpad):** `examples`. + +**Alternatives:** +- `examples` — current; pairs with `kind` discriminator +- `compositions` — accurate to the structural type; collides with the + system-scoped `compositions.yaml` ADR-042 follow-on reserves +- `cases` — short; reads as "use cases" +- `scenarios` — usage-flavored; verbose +- `usages` — direct; underused as a noun +- `fixtures` — testing-flavored +- `patterns` — broader than this field's scope +- `samples` — too generic + +**Current leaning:** `examples`, if consolidation lands. + +--- + +## 9. Naming the `kind` values — only if Decision 2 lands + +**Working values:** `instance` | `slotContent` | `nestedSlotContent`. + +**Alternatives & open questions:** +- `instance` vs. `usage` vs. `example` (the last collides with the field + name) +- `slotContent` vs. `slotFill` vs. `fill` vs. `content` +- `nestedSlotContent` — is the distinction load-bearing, or does the + reference site already make role obvious? Could collapse to + `slotContent` and let context discriminate. +- Two-axis alternative: `kind: composition | instance` plus a + `role: slot | nestedSlot` flag for compositions. + +**Where:** not in any ADR yet — introduced in `example.checkboxGroup.yaml`. + +**Current leaning:** the three-value flat enum, pending discussion of +whether `nestedSlotContent` carries its weight. + +--- + +## Cross-references + +- ADR-042 — `Composition` structural type +- ADR-046 — `InstanceExample` + `Component.instanceExamples` +- ADR-047 — `Component.slotContent` + `SlotBinding.$extensions` +- ADR-048 — `Element.propConfigurations` widened with `PropBinding` +- ADR-049 — nested slot compositions; flat vs. nested headline +- Scratchpads in this directory: + - `example.nested.yaml` — Option A (nested) at page scope + - `example.flat.yaml` — Option B (flat) at page scope + - `example.pill.yaml` — component-scoped with sibling + `slotContent` + `instanceExamples` + - `example.checkboxGroup.yaml` — component-scoped with consolidated + `examples` + `kind` (alternative to sibling fields) diff --git a/adr/research/049/example.component.alt1-checkboxGroup.yaml b/adr/research/049/example.component.alt1-checkboxGroup.yaml new file mode 100644 index 0000000..067d6da --- /dev/null +++ b/adr/research/049/example.component.alt1-checkboxGroup.yaml @@ -0,0 +1,192 @@ +# Scratchpad: cross-ADR alternative — CheckboxGroup component +# +# Sibling to example.pill.yaml. Same component-scoped machinery (ADR-042 +# Composition, ADR-046 InstanceExamples, ADR-047 slotContent, ADR-049 +# $composition references), but pushed one level deeper: a slot fill whose +# items themselves nest custom slot content. +# +# Alternative shape under exploration here: +# Instead of two sibling fields on Component — `slotContent` (ADR-047) and +# `instanceExamples` (ADR-046) — consolidate both into a single named record +# `examples` whose entries discriminate by `kind`: +# +# kind: instance — InstanceExample (ADR-046): +# whole-component usage; scalar + +# $composition references on propConfigurations +# kind: slotContent — Composition (ADR-047): named content that +# fills one of THIS component's slots +# kind: nestedSlotContent — Composition that fills the slot of an +# INSTANCE living inside a slotContent entry. +# Same structural shape as slotContent; the +# distinct kind names the recursion role so +# tooling can tell "fills my slot" from +# "fills a nested instance's slot" without +# inspecting reference sites. +# +# Reference form (unchanged from example.pill.yaml): JSON Pointer, absolute +# from the document root, paralleling ADR-008 $binding. With the consolidation +# above, every pointer into this component's compositions resolves under +# `#/components/checkboxGroup/examples/` rather than splitting between +# `/slotContent/` and `/instanceExamples/`. +# +# CheckboxGroup component contract: +# prop children: slot — slot-bound container holding Checkbox items +# +# Variant default — childrenSlot's children bound via SlotBinding +# (ADR-047). Figma authoring default points to the +# `defaultCheckboxes` slotContent entry — three plain +# Checkbox instances with their own label props set. +# +# Checkbox contract (referenced; not authored here): +# prop label: string — bound to the standard checkbox label +# prop custom: boolean — toggles which variant is active (mirrors Pill) +# prop children: slot — rendered when custom:true +# +# Two documented usages cover the surface (kind:instance entries below): +# 1. plainGroup — three checkboxes with label scalars (no custom children) +# 2. requiredGroup— three checkboxes, each with custom:true and children +# filled by the shared `requiredLabel` nestedSlotContent +# (a label text + requiredAsterisk laid out horizontally). +# +# Layout of the examples record (semantic, not file order): +# +# examples/ +# defaultCheckboxes kind: slotContent ← Figma slot default +# requiredCheckboxes kind: slotContent ← fills children for requiredGroup +# requiredLabel kind: nestedSlotContent ← fills each Checkbox.children +# plainGroup kind: instance ← whole-component usage +# requiredGroup kind: instance ← whole-component usage + +components: + checkboxGroup: + title: Checkbox Group + + anatomy: + root: + type: container + childrenSlot: + type: container # slot-bound + + props: + children: + type: slot + + # ─── default variant: children slot rendered, Figma default supplied ──── + default: + elements: + root: + children: [childrenSlot] + childrenSlot: + children: # ADR-047 SlotBinding + $binding: "#/components/checkboxGroup/props/children" + $extensions: + com.figma: + default: "#/components/checkboxGroup/examples/defaultCheckboxes" # Figma authoring default + layout: + - root: + - childrenSlot + + # ─── Component.examples (consolidated ADR-046 + ADR-047 + ADR-049) ────── + examples: + + # Figma's authoring default for the children slot. Three plain Checkbox + # instances; each sets its own scalar `label` prop, none use custom + # children. Also referenced by the `plainGroup` InstanceExample below. + defaultCheckboxes: + kind: slotContent + title: Default three checkboxes + anatomy: + checkbox1: { type: instance, instanceOf: Checkbox } + checkbox2: { type: instance, instanceOf: Checkbox } + checkbox3: { type: instance, instanceOf: Checkbox } + elements: + checkbox1: + propConfigurations: + label: Ford + checkbox2: + propConfigurations: + label: Mercedes + checkbox3: + propConfigurations: + label: Toyota + layout: + - checkbox1 + - checkbox2 + - checkbox3 + + # NestedSlotContent: a label text with a requiredAsterisk to its right. + # Slot-agnostic (per ADR-047) — referenced from each of the three + # Checkbox.children fills inside `requiredCheckboxes`. The "nested" + # kind names its role: it fills a slot on an instance that itself + # lives inside a slotContent entry on this component. + requiredLabel: + kind: nestedSlotContent + title: Label with required asterisk + anatomy: + root: { type: container } + label: { type: text } + requiredAsterisk: { type: glyph } + elements: + root: + styles: + layoutMode: HORIZONTAL + label: + content: "{required label}" + requiredAsterisk: + content: "*" + layout: + - root: + - label + - requiredAsterisk + + # SlotContent for `requiredGroup`: three Checkbox instances, each with + # custom:true and a children fill pointing at the shared `requiredLabel` + # nestedSlotContent above. One nested-content definition; three uses. + requiredCheckboxes: + kind: slotContent + title: Three checkboxes with required-label custom children + anatomy: + checkbox1: { type: instance, instanceOf: Checkbox } + checkbox2: { type: instance, instanceOf: Checkbox } + checkbox3: { type: instance, instanceOf: Checkbox } + elements: + checkbox1: + propConfigurations: + custom: true + children: + $composition: "#/components/checkboxGroup/examples/requiredLabel" # ADR-049 + checkbox2: + propConfigurations: + custom: true + children: + $composition: "#/components/checkboxGroup/examples/requiredLabel" + checkbox3: + propConfigurations: + custom: true + children: + $composition: "#/components/checkboxGroup/examples/requiredLabel" + layout: + - checkbox1 + - checkbox2 + - checkbox3 + + # InstanceExample (ADR-046) — whole-component usage with the default, + # un-customized slot fill. Same content Figma renders by default; named + # here so documentation tooling has an addressable handle on it. + plainGroup: + kind: instance + title: CheckboxGroup with three plain checkboxes + propConfigurations: + children: + $composition: "#/components/checkboxGroup/examples/defaultCheckboxes" # ADR-049 + + # InstanceExample (ADR-046) — whole-component usage with the + # required-label slot fill. Resolves through two layers: + # children → requiredCheckboxes (slotContent) + # → each checkbox.children → requiredLabel (nestedSlotContent) + requiredGroup: + kind: instance + title: CheckboxGroup with three required-label checkboxes + propConfigurations: + children: + $composition: "#/components/checkboxGroup/examples/requiredCheckboxes" diff --git a/adr/research/049/example.pill.yaml b/adr/research/049/example.component.alt2-pill.yaml similarity index 100% rename from adr/research/049/example.pill.yaml rename to adr/research/049/example.component.alt2-pill.yaml diff --git a/adr/research/049/example.nested.yaml b/adr/research/049/example.composition.alt1-nested.yaml similarity index 100% rename from adr/research/049/example.nested.yaml rename to adr/research/049/example.composition.alt1-nested.yaml diff --git a/adr/research/049/example.flat.yaml b/adr/research/049/example.composition.alt2-flat.yaml similarity index 100% rename from adr/research/049/example.flat.yaml rename to adr/research/049/example.composition.alt2-flat.yaml