diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 3865726..637d101 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -1,129 +1,144 @@ -# 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) *(extends and absorbs its `Composition` type proposal)* +**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) --- ## 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-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. +- **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 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. There is no structural 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") 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`). -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 scales split into two authoring scopes: + +- **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-047) 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) -- **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) +- **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 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 --- ## Options Considered -### Option A: Single `Composition` type with `kind`, flat root-level catalogue, `string` key references *(Selected)* +Four distinct questions shape the design space: -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. +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. -```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 -``` +--- + +### 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 -# SlotProp — new optional field -content: - type: slot - defaultComposition: cardDefault # references compositions.cardDefault +# Composition — named structural content fragment +# 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: + root: + type: container + label: + type: text + description: + type: text +elements: + label: + content: Browse all issues + description: + content: 12 open · 3 closed +layout: + - 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**: -- 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 +- 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**: -- `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 +- 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: Separate types per composition scale *(Rejected)* +### Option B: Converge `anatomy` and `elements` into a single map *(Rejected)* -Define `SlotComposition`, `ExampleComposition`, `LayoutComposition`, `PageComposition` as distinct types with different required/optional fields per scale. +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: + +```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**: -- 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 +- `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. --- -### Option C: Compositions as sub-records on `Component` only *(Rejected)* +### Option C: `anatomy` required; `elements` and `layout` optional *(Rejected)* -Attach compositions to the component that owns them (`Component.compositions?: Record`) and skip a root-level catalogue. +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**: -- 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 +- 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: Absorb compositions into `Variant` (reuse existing structure) *(Rejected)* +### Option D: `anatomy` and `elements` required; `layout` optional *(Rejected)* -A `Composition` has `elements` and `layout`, which `Variant` already has. Reuse `Variant` with a new discriminating field. +A middle position: require content data but leave layout optional, with anatomy key order as the implied default. **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 +- "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. --- @@ -133,121 +148,38 @@ 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 `Composition` type | MINOR | +| `index.ts` | Export `Composition` | MINOR | -**New file** (`types/Composition.ts`): +**New type** (`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 + 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/`) | 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 | +| `component.schema.json` | Add `#/definitions/Composition` | 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." - required: [anatomy] + description: "Named structural content fragment. Base shape for SlotExample (ADR-047) and system-scoped layout/page compositions." + required: [anatomy, elements, layout] properties: title: type: string - description: "Human-readable label for this composition." - kind: - $ref: "#/definitions/CompositionKind" + description: + type: string anatomy: $ref: "#/definitions/Anatomy" elements: @@ -257,95 +189,29 @@ Composition: 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`): +### Out of scope for this ADR -```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 -``` +- **`SlotExample`** — extends `Composition` with `kind: 'slot'` and `slot` field; see ADR-047 +- **`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 +- **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 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. +- **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.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. --- ## 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 +- **Symmetric**: Yes +- **Parity check**: `Composition { title?, description?, anatomy, elements, layout }` ↔ `#/definitions/Composition`; `required: [anatomy, elements, layout]` --- @@ -353,9 +219,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` | 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 | --- @@ -363,18 +229,14 @@ 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**: Adds new type `Composition` — purely additive; no existing type is removed or narrowed → MINOR per Constitution §III. --- ## 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 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-046, ADR-047, and ADR-048 complete the composition model; this ADR is the foundation they build on diff --git a/adr/046-component-examples.md b/adr/046-component-examples.md new file mode 100644 index 0000000..740c05d --- /dev/null +++ b/adr/046-component-examples.md @@ -0,0 +1,262 @@ +# ADR: Component Instance Examples — InstanceExample and Component.instanceExamples + +**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-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)* + +--- + +## 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 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. + +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`) + +`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.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.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) + +--- + +## Options Considered + +### Option A: `InstanceExample` as a record member *(Selected)* + +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 +title: Action List Item +anatomy: + root: + type: container + label: + type: text + description: + type: text + +props: + state: + type: string + title: + type: string + description: + type: string + +instanceExamples: + defaultState: + title: Action List Item – default + propConfigurations: + state: default + title: Browse all issues + description: 12 open · 3 closed + + activeState: + title: Action List Item – active + propConfigurations: + state: active + title: Browse all issues + description: 12 open · 3 closed + + dangerState: + 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 `defaultComposition` (ADR-047) and from slot-fill mechanisms (ADR-049) without type coupling +- Scalar-only `propConfigurations` keeps `InstanceExample` simple and human-readable +- No discriminator field — minimum surface, since no other shape shares the record + +**Cons / Trade-offs**: +- None at the scope of this ADR; slot fill is intentionally deferred to ADR-049 + +--- + +### Option B: Flat scalar map *(Rejected)* + +Store examples as `Record>` — a named map of prop value maps, with no wrapping object. + +**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. + +--- + +### 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: `InstanceExample.ts` | Add `InstanceExample`, `InstanceExamples` | MINOR | +| `Component.ts` | Add optional `examples?: InstanceExamples` | MINOR | +| `index.ts` | Export `InstanceExample`, `InstanceExamples` | MINOR | + +**New types** (`types/InstanceExample.ts`): + +```yaml +# InstanceExample — a pre-configured usage of the whole component +InstanceExample: + title?: string + propConfigurations?: + Record # scalar prop values only + # slot prop fills are NOT here — see ADR-049 + +# InstanceExamples — named record on Component (field: instanceExamples) +InstanceExamples: 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 + instanceExamples?: InstanceExamples # new — named usages of this component (scalar prop configurations) +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Add `#/definitions/InstanceExample` | MINOR | +| `component.schema.json` | Add `#/definitions/InstanceExamples` | MINOR | +| `component.schema.json` | Add `instanceExamples` 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." + properties: + title: + type: string + propConfigurations: + type: object + description: "Scalar prop values for this example. Binding and slot fills are not permitted here." + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + additionalProperties: false +``` + +**New definition** (`#/definitions/InstanceExamples`): + +```yaml +InstanceExamples: + type: object + description: "Named examples for this component." + patternProperties: + "^[a-zA-Z0-9_-]+$": + $ref: "#/definitions/InstanceExample" + additionalProperties: false +``` + +**New property** in `#/definitions/Component/properties`: + +```yaml +instanceExamples: + $ref: "#/definitions/InstanceExamples" + description: "Named instance examples (documented usages) for this component." +``` + +### Out of scope for this ADR + +- **`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` 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. +- `InstanceExamples` 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 { title?, propConfigurations? }` ↔ `#/definitions/InstanceExample` + - `InstanceExamples = Record` ↔ `#/definitions/InstanceExamples` (`patternProperties`) + - `Component.instanceExamples?: InstanceExamples` ↔ `#/definitions/Component/properties/instanceExamples` + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `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 | + +--- + +## Semver Decision + +**Version bump**: `0.19.0 → 0.20.0` (`MINOR`) + +**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.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 new file mode 100644 index 0000000..0bb089a --- /dev/null +++ b/adr/047-slot-content.md @@ -0,0 +1,403 @@ +# ADR: Slot Content — Component.slotContent and SlotBinding + +**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-046 — Component Examples](046-component-examples) + +--- + +## Context + +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.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. + +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. + +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 (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) + +--- + +## Options Considered + +### 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.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. **`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). +# Assumed to live at `#/components/actionListItem` so JSON Pointer references resolve. +components: + actionListItem: + anatomy: + 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: "#/components/actionListItem/props/startVisual" + $extensions: + com.figma: + default: "#/components/actionListItem/slotContent/searchIcon" # reference site #2 (Figma authoring default) +``` + +#### Naming the new field on `Component` + +Candidate terms considered: + +- **`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`; 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'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 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 +- 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-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` 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.instanceExamples`; reference via `Element.$extensions` *(Rejected)* + +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. +- `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: Slot default on `SlotProp.default` *(Rejected)* + +Store the default-content reference on `SlotProp.default` rather than on the element. + +**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. + +--- + +### Option D: Separate slot-content file *(Rejected)* + +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 `propConfigurations.` references and the Figma default reference can resolve via JSON Pointer paths inside the same component spec. + +--- + +### Option E: New top-level field on `Element` (sibling of `content` and `children`) *(Rejected)* + +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 +default: + elements: + itemsSlot: + children: { $binding: "#/props/items" } + defaultContent: defaultItems # sibling of children +``` + +**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 +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. + +--- + +## 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 { + /** + * 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; +} + +/** 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; +``` + +`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 (after ADR-046) +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + instanceExamples?: InstanceExamples # InstanceExample only — ADR-046 + +# After +Component: + title: string + anatomy: Anatomy + props?: Props + subcomponents?: Subcomponents + default: Variant + variants?: Variants + invalidVariantCombinations?: PropConfigurations[] + metadata?: Metadata + instanceExamples?: InstanceExamples # 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 `slotContent` property to `#/definitions/Component` | MINOR | +| `component.schema.json` | Add `#/definitions/SlotBinding`, `#/definitions/SlotBindingExtensions`, `#/definitions/FigmaSlotBindingExtension`; update `#/definitions/Children` to use `SlotBinding` | MINOR | + +**New property** in `#/definitions/Component/properties`: + +```yaml +slotContent: + type: object + 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" + additionalProperties: false +``` + +**New definitions** and updated `#/definitions/Children`: + +```yaml +FigmaSlotBindingExtension: + type: object + description: "Figma-specific authoring metadata on a slot binding. Not honored by code consumers." + properties: + default: + type: string + 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: + type: object + description: "Open extension bag on a SlotBinding for platform-specific metadata." + properties: + "com.figma": + $ref: "#/definitions/FigmaSlotBindingExtension" + additionalProperties: true + +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. \"#/components/pill/props/children\"." + $extensions: + $ref: "#/definitions/SlotBindingExtensions" + additionalProperties: false + +Children: + oneOf: + - type: array + items: { type: string } + - $ref: "#/definitions/SlotBinding" +``` + +### Out of scope for this ADR + +- **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 + +- 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. +- 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` JSON Pointers and ADR-049 `$composition` references resolve to existing compositions — consumer validation concern. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `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`) + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `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 | + +--- + +## Semver Decision + +**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. `InstanceExamples` from ADR-046 is unchanged. → MINOR per Constitution §III. + +--- + +## Consequences + +- `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. +- 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/048-prop-configurations-binding.md b/adr/048-prop-configurations-binding.md new file mode 100644 index 0000000..e383dc4 --- /dev/null +++ b/adr/048-prop-configurations-binding.md @@ -0,0 +1,285 @@ +# ADR: PropConfigurations PropBinding + +**Branch**: `048-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` 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: + +```ts +// types/PropConfigurations.ts (today) +type PropConfigurations = Record; +``` + +### 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: + +```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 +``` + +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-046), which stays scalar-only — it represents a human-authored documented configuration, not a live 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`. + +```ts +// Before +type PropConfigurations = Record; + +// 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**: +- 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 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**: +- 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. + +--- + +## 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-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 + +- `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/049-nested-slot-compositions.md b/adr/049-nested-slot-compositions.md new file mode 100644 index 0000000..c045a2e --- /dev/null +++ b/adr/049-nested-slot-compositions.md @@ -0,0 +1,250 @@ +# ADR: Nested Slot Compositions + +**Branch**: `049-nested-slot-compositions` +**Created**: 2026-05-11 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**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 + +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 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. + +### Constraints this ADR ships with + +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? + - *Direct shape* — Composition object, key reference, both? + - *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. + +--- + +## Decision Drivers + +- **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 + +### 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 — `{ $composition: }` under `propConfigurations.` + +`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. + +**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 +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 `{ $composition: }` 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 once the headline option settles. This ADR is currently at the option-comparison stage.* + +--- + +## Out of scope for this 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. + +**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): +- 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 `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). + +--- + +## 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 + +*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/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/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.component.alt2-pill.yaml b/adr/research/049/example.component.alt2-pill.yaml new file mode 100644 index 0000000..218e8de --- /dev/null +++ b/adr/research/049/example.component.alt2-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" diff --git a/adr/research/049/example.composition.alt1-nested.yaml b/adr/research/049/example.composition.alt1-nested.yaml new file mode 100644 index 0000000..53eabd9 --- /dev/null +++ b/adr/research/049/example.composition.alt1-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 diff --git a/adr/research/049/example.composition.alt2-flat.yaml b/adr/research/049/example.composition.alt2-flat.yaml new file mode 100644 index 0000000..4780908 --- /dev/null +++ b/adr/research/049/example.composition.alt2-flat.yaml @@ -0,0 +1,176 @@ +# 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). 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 `{ $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 +# 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 +# +# 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 + +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 + + # ─── 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: + $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: + $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 diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 7a324d7..4e38371 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,14 @@ 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.1] - 2026-05-08 Patch fix for `--split-concerns` output shape. diff --git a/packages/cli/package.json b/packages/cli/package.json index a0b5f29..ff41b16 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.13.1", + "version": "0.14.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", 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 ", 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 */