Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions adr/058-wrapper-collapse.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# ADR 058: Wrapper Collapse Config Flag
# ADR 058: Collapsing Wrapped Primitives

**Branch**: `058-wrapper-collapse`
**Created**: 2026-06-16
**Status**: DRAFT
**Status**: ACCEPTED
**Deciders**: Nathan Curtis (author)
**Supersedes**: *(none)*

Expand All @@ -12,8 +12,32 @@

A common Figma construction — particularly for text and icon primitives — is a component whose root layer is a plain, style-free container holding a single `text` or `glyph` child. The wrapper adds no semantic styling (no corner radius, strokes, spacing, effects, or background) and carries no slot binding. From a spec consumer's perspective, the root element *is* the leaf; the container is a Figma-side convenience with no design-system meaning.

Common design-system components that exhibit this pattern:

- **Heading / Title** — a frame named `Heading` or `Title` wrapping a single `Text` layer, where the frame itself carries no visual styling.
- **Paragraph / Body** — a `Paragraph` or `Body` frame wrapping a single `Text` layer with no border, background, or padding.
- **Label / Caption** — small typographic components structured identically: one unstyled frame around one text node.
- **Icon** — a frame wrapping a single vector/glyph node (e.g. a Figma component set for an icon library where each variant is a plain frame containing one `glyph`).

When the generator encounters such a component today, it faithfully emits the wrapper as `root` and the leaf as a child element. This produces a two-node anatomy and a `container` root type even though the component is semantically a single primitive. Consumers (code generators, documentation tools) must either detect and unwrap this pattern themselves or tolerate a structurally inflated spec.

**Collapse eligibility** — a component qualifies for collapse when *all* of the following are true:

- The root element's type is `container`.
- The root has exactly one anatomy-visible child (children present in both the Figma layer tree and the `Elements` collection after exclusions).
- That child's type is `text` or `glyph`.
- The child has no children of its own.
- The root carries no slot binding on its children.
- The root carries none of the following styles after default/zero values are stripped: `clipContent`, `cornerRadius`, `strokes`, `strokeAlign`, `strokeWeight`, `itemSpacing`, `padding`, `effects`, `backgroundColor`, `cornerSmoothing`.

**Collapse is blocked** by any of:

- A root that already is a `text` or `glyph` (no wrapper present).
- A root with more than one visible child (e.g. an icon with a background layer).
- A root with a slot binding on its children (the wrapper has semantic slot structure).
- Any non-default value on the disqualifying style keys above (e.g. a corner radius, a border, padding, or a fill color on the container).
- Any variant where the root fails the check — collapse is all-or-nothing across the component's variant set.

The gap: no `Config` flag exists to tell the generator to collapse this wrapper, leaving downstream tools to implement their own heuristics or accept unnecessary structural noise.

---
Expand All @@ -30,36 +54,37 @@ The gap: no `Config` flag exists to tell the generator to collapse this wrapper,

## Options Considered

### Option A: `processing.wrapperCollapse` *(Selected)*
### Option A: `processing.collapsePrimitiveWrapper` *(Selected)*

A boolean field on `Config.processing`. When `true`, the generator promotes the single text/glyph child to root and strips the container wrapper. Default: `false`.

**Name rationale**: Names the thing being removed (`wrapper`) and what happens to it (`collapse`). Consistent with other processing booleans (`slotConstraints`, `inferNumberProps`) which name the capability being activated. "Wrapper" is a code-platform-neutral term React, iOS, and Android all use it to describe decorative containers that carry no semantic substance.
**Name rationale**: Follows the verb-noun pattern established by `inferNumberProps` (infer + number props). `collapsePrimitiveWrapper` = collapse + primitive wrapper. "Primitive" identifies the element type being revealed (a leaf `text` or `glyph`, the primitive element types in the schema), and "wrapper" names the thing being removed — a code-platform-neutral term that React, iOS, and Android all use for decorative containers that carry no semantic substance. This is more precise than a bare noun phrase (`wrapperCollapse`, `leafCollapse`), which read as noun-verb or noun-noun compounds inconsistent with the rest of the `processing` block.

**Pros**:
- Additive optional field → MINOR bump only.
- `false` default leaves all existing specs unchanged.
- Name is self-describing and matches the processing-block pattern.
- Verb-noun form is consistent with `inferNumberProps`.
- "Primitive" scopes the eligible leaf types without needing to enumerate them in the field name.
- Symmetric: one field in type, one property in schema.

**Cons / Trade-offs**:
- Consumers must opt in explicitly; no auto-collapse for legacy files without a config update.
- Longer than a bare noun phrase; consumers must opt in explicitly.

---

### Option B: `processing.leafCollapse` *(Rejected)*

Same boolean, named from the leaf's perspective (the thing being promoted) rather than the wrapper's (the thing being removed).

**Rejected because**: "Collapse" as a verb applied to the leaf is misleading — the leaf isn't collapsed, it's promoted. The wrapper is what collapses. `wrapperCollapse` more accurately describes the observable effect (the container disappears) and avoids the semantic mismatch.
**Rejected because**: "Collapse" as a verb applied to the leaf is misleading — the leaf isn't collapsed, it's promoted. The wrapper is what collapses. `collapsePrimitiveWrapper` more accurately describes the observable effect (the container disappears) and avoids the semantic mismatch.

---

### Option C: `processing.promoteLeaf` *(Rejected)*

Active-verb framing — "promote the leaf to root."

**Rejected because**: Inconsistent with the existing processing-boolean naming convention, which favors noun phrases (`slotConstraints`, `inferNumberProps`, `wrapperCollapse`) over imperative verbs. Diverging from the established pattern increases cognitive friction for users reading a config file.
**Rejected because**: Describes the effect from the leaf's perspective rather than the operation being performed on the structure. "Promote" is also less precise — it applies broadly to any elevation operation, whereas "collapse" specifically means removing the intermediate container. `collapsePrimitiveWrapper` names both the subject (the primitive wrapper) and the action (collapse), making the config's effect self-evident without consulting documentation.

---

Expand All @@ -69,9 +94,9 @@ Active-verb framing — "promote the leaf to root."

| File | Change | Bump |
|------|--------|------|
| `types/Config.ts` | Add optional `wrapperCollapse?: boolean` to `Config.processing` | MINOR |
| `types/Config.ts` | Add required `wrapperCollapse: boolean` to `ResolvedConfig.processing` | MINOR |
| `types/Config.ts` | Add `wrapperCollapse: false` to `DEFAULT_CONFIG.processing` | MINOR |
| `types/Config.ts` | Add optional `collapsePrimitiveWrapper?: boolean` to `Config.processing` | MINOR |
| `types/Config.ts` | Add required `collapsePrimitiveWrapper: boolean` to `ResolvedConfig.processing` | MINOR |
| `types/Config.ts` | Add `collapsePrimitiveWrapper: false` to `DEFAULT_CONFIG.processing` | MINOR |

**Example — `Config` (partial, `types/Config.ts`)**:
```yaml
Expand All @@ -84,7 +109,7 @@ processing:
processing:
slotConstraints?: boolean
inferNumberProps?: boolean
wrapperCollapse?: boolean # optional — MINOR
collapsePrimitiveWrapper?: boolean # optional — MINOR
```

**Example — `ResolvedConfig` (partial, `types/Config.ts`)**:
Expand All @@ -98,7 +123,7 @@ processing:
processing:
slotConstraints: boolean
inferNumberProps: boolean
wrapperCollapse: boolean # required — mirrors resolved pattern
collapsePrimitiveWrapper: boolean # required — mirrors resolved pattern
```

**Example — `DEFAULT_CONFIG` (partial, `types/Config.ts`)**:
Expand All @@ -112,19 +137,19 @@ processing:
processing:
slotConstraints: false
inferNumberProps: false
wrapperCollapse: false
collapsePrimitiveWrapper: false
```

### Schema changes (`schema/`)

| File | Change | Bump |
|------|--------|------|
| `schema/workspace.schema.json` | Add `wrapperCollapse` boolean property under `#/definitions/Config/properties/processing/properties` | MINOR |
| `schema/workspace.schema.json` | Add `collapsePrimitiveWrapper` boolean property under `#/definitions/Config/properties/processing/properties` | MINOR |

**Example — new property (`schema/workspace.schema.json`)**:
```yaml
# Under #/definitions/Config/properties/processing/properties
wrapperCollapse:
collapsePrimitiveWrapper:
type: boolean
default: false
description: >
Expand All @@ -137,23 +162,23 @@ wrapperCollapse:

### Notes

- `wrapperCollapse` is not added to `required` in the schema — it is optional with a `default` of `false`, matching the pattern for `slotConstraints` and `inferNumberProps`.
- `collapsePrimitiveWrapper` is not added to `required` in the schema — it is optional with a `default` of `false`, matching the pattern for `slotConstraints` and `inferNumberProps`.
- The eligibility criteria (disqualifying container styles, slot-bound root, non-singleton children) are implementation details of `specs-from-figma` and are not expressed in the schema.

---

## Type ↔ Schema Impact

- **Symmetric**: Yes — one field added to `Config.processing` in `types/Config.ts`; one property added to `Config.properties.processing.properties` in `schema/workspace.schema.json`. Both carry `false` as default, both are optional in the user-facing contract.
- **Parity check**: `Config.processing.wrapperCollapse?: boolean` ↔ `#/definitions/Config/properties/processing/properties/wrapperCollapse` (`type: boolean`, `default: false`).
- **Parity check**: `Config.processing.collapsePrimitiveWrapper?: boolean` ↔ `#/definitions/Config/properties/processing/properties/collapsePrimitiveWrapper` (`type: boolean`, `default: false`). Both are absent from `required[]`.

---

## Downstream Impact

| Consumer | Impact | Action required |
|----------|--------|-----------------|
| `specs-from-figma` | New config field must be read from `ResolvedConfig` and used to gate the wrapper-collapse pass | Read `config.processing.wrapperCollapse`; run collapse only when `true` |
| `specs-from-figma` | New config field must be read from `ResolvedConfig` and used to gate the wrapper-collapse pass | Read `config.processing.collapsePrimitiveWrapper`; run collapse only when `true` |
| `specs-plugin-2` | Recompile against updated types; no behavioral change unless the user enables the flag | Recompile; optionally expose toggle in plugin UI |
| `specs-cli` | Recompile against updated types; config resolution and `DEFAULT_CONFIG` merge continue to work as-is | Recompile; no code changes required |

Expand All @@ -169,7 +194,7 @@ wrapperCollapse:

## Consequences

- Consumers can opt in to structurally simpler specs for wrapper-only components by setting `processing.wrapperCollapse: true` in their config.
- Consumers can opt in to structurally simpler specs for wrapper-only components by setting `processing.collapsePrimitiveWrapper: true` in their config.
- Existing specs and consumers are unaffected — `false` default reproduces the current behavior exactly.
- The anatomy `root` entry for a collapsed component will carry the leaf's element type (`text` or `glyph`) rather than `container`, and `$extensions.com.figma.originalName` will record the original Figma layer name.
- `layout` is cleared on collapsed variants (the wrapper's layout properties are not meaningful on a promoted primitive).
Expand Down
1 change: 1 addition & 0 deletions adr/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

| # | Title | Highlights |
|---|-------|------------|
| 058 | Collapsing Wrapped Primitives — `processing.collapsePrimitiveWrapper` | Add optional boolean to `Config.processing` (default false); strips plain container wrappers around a single text/glyph child and promotes the leaf to spec root |
| 057 | Fix `Metadata.generator.version` type: `number` → `string` | Corrects type mismatch — field holds semver strings (e.g. `"1.10.0"`) in all producers; was incorrectly typed as `number` |
| 055 | Variant State Classification via `processing.states` | Add `VariantStateEntry` type; add `Config.processing.states` — classifies Figma variant props as browser-driven or consumer-controlled for CSS selector and contract output |
| 051 | Platform Code-Syntax Token Profiles | Add `FIGMA_SYNTAX_WEB`/`_IOS`/`_ANDROID` to `Config.format.tokens`, emitting per-platform Figma code syntax with fallback to `TOKEN` |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"vitest": "^3.1.1"
},
"dependencies": {
"@directededges/specs-from-figma": "^0.19.0"
"@directededges/specs-from-figma": "file:../specs-from-figma"
}
}
4 changes: 1 addition & 3 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

### Changed

### Removed
`Config.processing.collapsePrimitiveWrapper` is now wired into the CLI. The config loader defaults it to `false` when absent, and the `init` template includes it as a commented-out option with a description of its behavior.


## [0.21.0] - 2026-06-15
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
"test": "cd ../.. && vitest -c vitest.config.ts packages/cli/tests"
},
"dependencies": {
"@directededges/specs-schema": "^0.25.0",
"@directededges/specs-from-figma": "^0.23.0",
"@directededges/specs-schema": "file:../../../specs/packages/schema",
"@directededges/specs-from-figma": "file:../../../specs-from-figma",
"commander": "^11.1.0",
"fs-extra": "^11.2.0",
"yaml": "^2.3.4",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/Config/ConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ export class ConfigLoader {
if (typeof corrected.processing.inferNumberProps !== 'boolean') {
corrected.processing.inferNumberProps = false;
}
if (typeof corrected.processing.collapsePrimitiveWrapper !== 'boolean') {
corrected.processing.collapsePrimitiveWrapper = false;
}

// Validate processing.subcomponents
if (corrected.processing.subcomponents !== undefined) {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/Config/ConfigTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ config:
# valid numbers are emitted as NumberProp instead of StringProp. (default: false)
# inferNumberProps: false

# Collapse primitive wrappers: when true, a component whose root is a plain
# container wrapping a single text or glyph element (no meaningful container
# styles, no slot bindings) is collapsed — the wrapper is stripped and the
# leaf becomes the spec root. All-or-nothing across variants. (default: false)
# collapsePrimitiveWrapper: false

include:
# Subcomponent inclusion is controlled by processing.subcomponents above.
# If the subcomponents block is present, subcomponents are included.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/tests/unit/config/ConfigTemplates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('ConfigTemplates', () => {
expect(template).toContain('match:');
expect(template).toContain('variantDepth');
expect(template).toContain('details');
expect(template).toContain('collapsePrimitiveWrapper');
});

it('should include format configuration section', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/schema/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `Config.processing.collapsePrimitiveWrapper` — strip plain container wrappers around a single text/glyph child and promote the leaf to spec root; defaults to false (ADR-058)

### Changed

### Removed
Expand Down
5 changes: 5 additions & 0 deletions packages/schema/schema/workspace.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@
"default": false,
"description": "Whether to consolidate slot constraints (anyOf, minChildren, maxChildren) from code-only props into the slot property"
},
"collapsePrimitiveWrapper": {
"type": "boolean",
"default": false,
"description": "When true, a component whose root is a plain container wrapping a single text or glyph element (no meaningful container styles, no slot bindings) is collapsed: the wrapper is stripped and the leaf becomes the spec root. All-or-nothing across variants."
},
"variantDepth": {
"type": "number",
"enum": [
Expand Down
19 changes: 18 additions & 1 deletion packages/schema/tests/Config.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const fullConfig: Config = {
glyphNamePattern: 'DS Icon Glyph /',
codeOnlyPropsPattern: 'Code only props',
slotConstraints: true,
collapsePrimitiveWrapper: false,
variantDepth: 9999,
details: 'LAYERED',
inferNumberProps: true,
Expand Down Expand Up @@ -180,7 +181,7 @@ const defaultTokensValue: typeof DEFAULT_CONFIG.format.tokens = 'TOKEN';
// ─── ResolvedConfig requires all defaultable fields ──────────────────────────

const resolved: ResolvedConfig = {
processing: { slotConstraints: false, variantDepth: 9999, details: 'LAYERED', inferNumberProps: false },
processing: { slotConstraints: false, collapsePrimitiveWrapper: false, variantDepth: 9999, details: 'LAYERED', inferNumberProps: false },
format: { output: 'JSON', keys: 'SAFE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX' },
include: { invalidVariants: false, invalidCombinations: true, emptyVariants: false, defaultSlotContent: false },
transformers: [],
Expand All @@ -198,6 +199,9 @@ const _dRequired: _DRequired = true;
type _SCRequired = ResolvedConfig['processing']['slotConstraints'] extends boolean ? true : never;
const _scRequired: _SCRequired = true;

type _CPWRequired = ResolvedConfig['processing']['collapsePrimitiveWrapper'] extends boolean ? true : never;
const _cpwRequired: _CPWRequired = true;

type _INPRequired = ResolvedConfig['processing']['inferNumberProps'] extends boolean ? true : never;
const _inpRequired: _INPRequired = true;

Expand Down Expand Up @@ -240,6 +244,19 @@ const _inferUndefined: Config['processing']['inferNumberProps'] = undefined;

const _slotConstraintsUndefined: Config['processing']['slotConstraints'] = undefined;

// ─── collapsePrimitiveWrapper is optional on Config ───────────────────────────

const _collapsePrimitiveWrapperUndefined: Config['processing']['collapsePrimitiveWrapper'] = undefined;

const configWithCollapse: Config = {
processing: { collapsePrimitiveWrapper: true },
format: {},
include: {},
};

// @ts-expect-error — collapsePrimitiveWrapper must be boolean
const _badCollapse: Config['processing']['collapsePrimitiveWrapper'] = 'yes';

// ─── format.color is optional on Config ─────────────────────────────────────

const _colorUndefined: Config['format']['color'] = undefined;
Expand Down
Loading