Skip to content

ADR 074-076, 084-085: primitives promote to component instances at capture - #382

Merged
nathanacurtis merged 5 commits into
adr/primitive-compositionfrom
adr/spec-time-promotion
Sep 3, 2026
Merged

ADR 074-076, 084-085: primitives promote to component instances at capture#382
nathanacurtis merged 5 commits into
adr/primitive-compositionfrom
adr/spec-time-promotion

Conversation

@nathanacurtis

Copy link
Copy Markdown
Member

Reworks the primitive-composition ADRs around a different conclusion: primitive layers in composed example content become instances of the design system's own components during capture, rather than being resolved per platform at emit time.

Stacked on adr/primitive-composition (#380) to keep the chain intact — this is ADRs only, no schema or type changes yet.

What changes

ADR
084 New Element.$extensions.com.figmapromotedPrimitive and styles
074 Rewrite Promotion happens at capture, in composed content only
075 Rewrite conventions.primitives — a declared table, plus scoring for selection
076 Rewrite Container promotion, children into the target's slot, stylesProp
085 New settings.spec.promotePrimitives, defaulting off

Why the emit-time model was reworked

The prior 074 kept type: text in the spec and had each platform generator resolve it, justified by platform neutrality. That neutrality is not at stake: dsText is a design system's component, and a spec is authored against exactly one design system. Naming it in the spec costs nothing in portability across React and Web Components, while the emit-time placement re-derives one design-system fact per platform and leaves composed content unable to distinguish "a text layer" from "the text component, drawn as a layer".

Two design systems drove the mapping shape. One ships three text components taking a named style; another ships one taking size and weight axes, with an icon whose appearance is an intent enum — error, warning — fed by fill colour. A token name cannot yield a prop value in the second case, one source must be able to reach several props, and the same source means different things in each. The prior 075 mapped concepts to prop names and left values alone; neither system is serviceable under that rule.

Scope

  • Composed content only. A component's variants.yaml still describes itself in primitive terms
  • Every removal is of something added in the same unreleased 0.31.0, so nothing published changes shape
  • All five are MINOR
  • promotePrimitives defaults off, so no existing workspace changes on upgrade

Notable decisions worth review

  • 076 Decision 2 — a promoted container's children are hoisted into a slotContentExamples entry and referenced as its slot fill, rather than kept as children on the instance. The alternative would put instanceOf and a child list on one element, a shape nothing in the contract defines. Cost: promotion now generates entry names, which must be deterministic for stable output
  • 075 selection is by score, not first match — the candidate resolving the most rules wins, ties by declaration order. At least one rule must resolve, so kind alone never promotes
  • 075 source is a plain string, not a schema enum. The honoured set is documented and implemented, keeping the validation surface off the Styles key set
  • 074 declares PrimitiveKind directly rather than as keyof PrimitiveBindings, since that interface is removed

The reasoning behind these, with the fixture evidence, is in the workspace model doc at projects/019-primitive-composition/MODEL-spec-time-resolution.md.

🤖 Generated with Claude Code

nathanacurtis and others added 5 commits September 2, 2026 21:34
Adds `$extensions` to `Element`, mirroring `AnatomyElement`, with
`com.figma.promotedPrimitive` and `com.figma.styles`. These record that a
primitive layer was promoted to a component instance and which styles the
promotion consumed, so the transformation is reversible from the spec alone.

Claims 084 in the ADR index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
074 inverts: primitives promote to component instances during capture, in
composed example content only. A component's own variants stay primitive.
Removes the emit-time PlatformConventions.primitives binding block; PrimitiveKind
survives as a standalone enum.

075 replaces the concept-keyed prop-name map with conventions.primitives — a
component-keyed table of rules from a style source to a partial props object,
keyed literally on token paths or raw scalars. Adds scoring for selection among
candidates sharing a kind. `source` stays out of the schema's validation surface.

076 drops the LayoutMode-keyed component union: a Row/Column/Box trio is three
entries selected by scoring. A promoted container's children are hoisted into a
slotContentExamples entry and referenced as its slot fill. Keeps stylesProp.

085 adds settings.spec.promotePrimitives, defaulting on, so an unpromoted
baseline can be captured without dismantling a workspace's conventions.

Claims 085 in the ADR index; clears stale highlights on the rewritten three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotion restructures composed example content, so a workspace elects it
rather than receiving it on upgrade. Matches collapsePrimitiveWrapper, the
capture-time normalization it most resembles. A conventions table is inert
until the setting is turned on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Types:
- Conventions: remove PrimitiveBindings and the three binding types; add
  root-level `primitives` (component-keyed PrimitiveEntry with kind + map of
  PrimitiveRule). PrimitiveKind declared directly rather than as a keyof.
  stylesProp stays a platform member and survives resolution.
- Element: add $extensions with com.figma.promotedPrimitive and .styles.
- Settings: add spec.promotePrimitives, defaulting false.

Schema: mirrored in conventions, component and settings schemas. PrimitiveRule
constrains exactly one of prop/values; `source` stays an unvalidated string so
the validation surface does not track the Styles key set.

Docs: rewrite the conventions primitives section, add a promote-primitives
settings page, add rows to elements and settings tables.

Type tests updated for the new shapes. tsc, schema validation and test-d all
pass. Version stays 0.31.0 — every removal is of an unreleased member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the release-note paragraph for promotion alongside the config split, and
records that stylesProp is now the only styling-prop level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nathanacurtis
nathanacurtis merged commit 55ca14e into adr/primitive-composition Sep 3, 2026
@nathanacurtis
nathanacurtis deleted the adr/spec-time-promotion branch September 3, 2026 11:57
nathanacurtis added a commit that referenced this pull request Sep 3, 2026
* docs(adr): draft conventions for primitive composition (ADR-073–077)

Five draft ADRs extending Conventions with spec-to-code primitive
bindings, so a text, glyph, or container layer in a composition
resolves to the design system's designated component per platform.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): fold Figma into the platform map and make image bindable

Revisions from review of ADR-073:

- Figma becomes a key in `conventions.platforms` rather than a sibling
  namespace. `Conventions` is unreleased, so the move is free
- Platform ids name implementations and stay flat: `react` and
  `web-components` are peers, not children of a `web` family
- `image` joins the primitive vocabulary, triggered by a non-null
  `Styles.backgroundImage`, so the layer-fill half of ADR-063 reaches a
  designated component per platform
- The boundary rule is restated as read-side vs write-side, a property
  of a member rather than of a platform

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): close the prop surface, rename to props/stylesProp, keep fills as styling

Revisions from review of ADR-075, plus the backgroundImage correction:

- `styleProps` becomes `props`; `stylePropName` becomes `stylesProp`
- `props` is closed per primitive: text maps textColor and typography,
  glyph maps fillColor and content, container maps layoutMode. Everything
  else is passed styling, enforced by additionalProperties: false
- Glyph size comes from sizing and layout styling, not a prop
- Glyph `content` maps to a `name` prop by default
- Unmatched prop values fall back to the component's own default
- A container's backgroundImage always stays styling; `image` is removed
  from the primitive vocabulary and the designated image component gets a
  per-platform code name at `platforms.<id>.images.component`
- ADR-076 no longer hoists `props`, since the closed sets are disjoint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): key props by concept; images are an attribute, not a primitive

- `props` keys name concepts, not spec members: `color` on both text and
  glyph (fed by textColor and fillColor), `content` on glyph, `direction`
  on container. Decouples the vocabulary from Styles
- Defaults become the concept's own name where no survey settles it, so
  glyph content defaults to `content` rather than React's `name`
- ADR-077 grounds `images.component` in the primitive-vs-attribute
  distinction: text, glyph and container are node kinds; an image is a
  paint on a node, so it needs its own convention. Removes the hedge that
  the member might be droppable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): replace read-side/write-side with encoding vs vocabulary

The pipeline runs both ways on both kinds of platform — code is read to
produce specs, and specs are written to produce Figma — so direction of
travel never distinguished the two member groups. What does:

- Encoding members say how a platform expresses something the spec models
  explicitly (name patterns, variant-prop classifications, containers)
- Vocabulary members say which of a platform's components implements a
  spec primitive

Both apply to any platform and in either direction. `images.match` and
`images.component` are now both classed as vocabulary — one question
answered in two languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): compose Conventions from one file per platform (ADR-078)

config/conventions/<platform>.yaml, discovered by convention, composing
into the single Conventions map ADR-073 defines. Filename is the platform
id, so a platform is declared in exactly one file and no merge rule is
needed. The single-file form stays valid; both present is an error.

No type changes — composition is a loader concern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): metadata carries only the producing platform's conventions (ADR-079)

Since ADR-073 made Conventions a platform-keyed map, metadata.conventions
has embedded every platform in the workspace. That is a defect, not just
noise: the drift check ADR-071 built the member for compares the whole
object, so a Compose vocabulary change marks every Figma-generated spec
as drifted.

Metadata now carries the single platform entry that produced the spec,
in the same shape as the artifact so the drift comparison stays direct,
with maxProperties: 1 enforcing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): make config/conventions/ the only layout (ADR-078 Decision 4)

The single-file config/conventions.yaml is introduced by the unreleased
CLI 0.28.0 — npm's latest is 0.27.0, and 0.28.0's own breaking change is
the move from specs.config.yaml to config/. It has never reached a
workspace outside this repo, so supporting it alongside the directory
form would preserve compatibility with something that never existed.

Directory only: one discovery path, no both-present error, no precedence
rule. `specs migrate config` emits the directory form, so no workspace
lands on a layout it would later migrate off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): inventory the real blast radius of the conventions move

Researched against the current branches and recorded in the two ADRs that
cause it — ADR-073 for the namespace move, ADR-078 for the file layout.

Notable findings:

- specs-plugin-2 takes a single line; settingsToSpecConfig is its only
  translation point from panel fields to SpecConfig
- specs-from-figma is wide but shallow: 42 call sites across 13 files,
  every one the same repoint of an object passed down from Component
- The CLI is the awkward one: 7 of its 24 sites are user-facing validation
  messages that quote conventions.figma.* paths, and analyzers/Keys.ts
  reads the path out of a spec's metadata rather than configuration
- bridge/server.ts reads config/conventions.yaml by literal path, bypassing
  ConfigLoader. Left behind it fails silently rather than erroring
- conventions.schema.json lists figma in required, so the schema change is
  not purely additive within that file

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): decide the bridge read path and template split in ADR-078

Promotes two items from "action required" into decided positions:

Decision 5 — bridge/server.ts consolidates onto ConfigLoader. Its
resolveSources() reads config/conventions.yaml and config/settings.yaml by
literal path with its own pre-split specs.config.yaml fallback, so the two
read paths actively disagree: ConfigLoader refuses an unmigrated workspace
and the bridge serves it. Consolidating removes the duplication, the
divergence and a failure mode that is silent by construction.

Decision 6 — the conventions template splits per platform, and specs init
scaffolds config/conventions/figma.yaml alone. Which implementations a
workspace targets is not knowable at init, and a commented placeholder
would claim a platform id no generator reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): record bridge consolidation and template split as consequences

Reverts them out of Options Considered. Neither is a decision this ADR
weighs alternatives for — they are effects of the layout change on code
that reads it, so they belong in Downstream Impact and Consequences.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): ADR-081 — a per-platform default example width

Each platform states the width it shows components at, in its own
config/conventions/<platform>.yaml. A Figma frame, a Storybook canvas and a
device preview are different canvases, so the value is a member of
PlatformConventions rather than a single workspace-wide number.

Names a third category of PlatformConventions member — presentation — alongside
the encoding and vocabulary groups ADR-077 defined, and cross-references it there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): rework ADR-081 as `defaultInstanceWidth`

- Renames the member: it governs the root of any render — a component, an
  instanceExample, a slotContentExample, a story — not examples alone, so
  "example" under-described it. Nothing below a root is affected; a parent
  sizes its children.
- Drops the invented third category of PlatformConventions member and reverts
  the cross-reference added to ADR-077. The member is added without a
  classification claim.
- Settles the absent case: the schema declares no default at any level, and each
  rendering tool falls back to 375. A resolved default cannot reach a platform
  with no conventions file at all (ADR-078), so the number belongs in the tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): scope ADR-081 to fill-width roots as `defaultFillWidth`

The member applies only when a root's layoutSizingHorizontal is FILL, and the
number is the width of a container the renderer creates for that root to fill —
not the instance's own width. Fixed and hugging roots are untouched, so the
member can never override what a design states.

Renamed from defaultInstanceWidth. Constitution VI rule 2: no code-platform
consensus exists, so the name follows Compose's Modifier.fillMaxWidth(), and it
matches the FILL value layoutSizingHorizontal already carries.

No height member is defined; Decision 5B records why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(schema): implement ADRs 073-079, 081 — platform-keyed conventions

Conventions become a platform-keyed map in which figma is one implementation
among react, web-components and swiftui, and the shape gains the vocabulary
members a code generator needs.

- Conventions.platforms replaces Conventions.figma; PlatformConventions is one
  permissive shape for every platform, and the root of a single
  config/conventions/<id>.yaml so one file validates standalone (073, 078)
- PrimitiveKind and TextBinding/GlyphBinding/ContainerBinding bind text, glyph
  and container to a platform's own component, resolved at emit time (074, 075)
- ContainerBinding.component takes a LayoutMode-keyed map; a platform-level
  stylesProp baselines what each primitive overrides and folds in on
  resolution (076)
- images.component names the image component on a code platform, beside the
  match naming it in Figma (077)
- MetadataConventions narrows a spec's recorded conventions to the producing
  platform (079)
- defaultFillWidth states the container width for a fill-width root (081)

DEFAULT_CONVENTIONS becomes {} — every default it carried belongs inside a
declared platform entry.

Schema package only. specs-from-figma and the CLI follow in that order, and
ADR-078's loader is CLI work not done here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): scope adr.implement to the schema package

The command's steps are types/, schema/, tests and docs, and the constitution it
gates against is the schema package's — but nothing said so, and an ADR whose
Downstream Impact table names the CLI reads as an invitation to follow it there.

States the boundary, and the order consumers are updated in: schema →
specs-from-figma → cli, since the CLI depends on specs-from-figma and cannot be
verified against types the engine has not yet adopted. Records that an ADR with
no schema-package surface is reported as outstanding rather than chased, and
that leaving a consumer uncompilable is expected rather than a reason to widen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(schema): keep ADR-071's defaults rule, and stop duplicating the primitive list

ADR-073's Notes claimed a missing platform entry 'gets no naming at all, which
is the same statement NONE made'. It is not: ADR-071 settled that naming,
slotConstraints and inferNumberProps are defaulted and only convention blocks
are not, and it held because figma was a required key. A platform-keyed map
makes every key optional, so the guarantee had to move rather than lapse.

It moves to resolution, where ADR-071 already put every other default: a
resolver produces a complete entry for any platform it is asked about, declared
or not. ResolvedPlatformConventions requires the three members for that reason.
DEFAULT_CONVENTIONS stays {} because a map has no fixed key to populate — no new
exported constant, and no constitution amendment.

Also:
- ContainerBinding.component drops minProperties on the keyed map. A partial map
  is normal and an empty one is inert; the schema was rejecting what the type
  allowed, which Constitution I calls drift.
- PrimitiveKind is derived from PrimitiveBindings instead of written out beside
  it, so the vocabulary and the block enforcing it are one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cli): read config/conventions/ and resolve primitives at emit time

Schema:
- TextBinding gains a `content` concept — EGDS Text takes its string as a
  `text` prop, not children, and GlyphBinding already had the counterpart.
  No default: absent means children, a name means that prop, null means no
  content channel. ADR-075 and the conventions docs updated to match.

CLI:
- ConfigLoader reads config/conventions/<platform>.yaml (ADR-078). The
  filename is the platform id, so a platform is declared in exactly one file
  and there is no merge rule. A stray config/conventions.yaml is refused with
  the migration it needs, not silently ignored.
- resolveConventions became a per-platform resolver, applying each concept's
  default prop name inside a declared binding and folding the platform-level
  stylesProp into each primitive.
- PlatformConventions.ts gives every call site a value for an undeclared
  platform, restoring the guarantee the required `figma` key used to give.
- The React transformer resolves text/glyph/container elements to the bound
  component, keeping the generated class so existing CSS still applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): record the root constraint on container bindings (ADR-076)

A container binding substitutes a component for a box that holds children, so
the component's root must be the box those children land in. A layout component
that wraps its children in an inner element cannot stand in for one: the
container's gap, padding and alignment land on the outer box while the children
sit a level deeper, and a wrapper carrying `flex: 1 0 0` inside a
`height: fit-content` parent collapses the subtree to zero height.

Neither failure raises an error, and neither is expressible in the schema —
whether a root hosts its children is a fact about that component's generated
markup, not about the conventions naming it. Recorded as a Decision Driver, a
new section under Decision 1, a Consequence, and a caution in the conventions
docs.

ADR-074 Decision 2 is left alone pending a call on whether composition-only
resolution should be restated there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cli): migrate and init write config/conventions/ (ADR-078)

Both commands still produced the single-file layout the loader now refuses, so
`specs init` was scaffolding a workspace the CLI would not read.

- `specs migrate config` writes config/conventions/figma.yaml — everything the
  pre-split file declared was a Figma fact — plus commented stubs for react and
  web-components. A workspace that generates code will want them, and a file of
  pure comments parses to null, so an untouched stub declares nothing. Its
  overwrite guard now also refuses when the conventions directory holds files.
- `migrateConfigV1` returns the Figma entry BODY rather than a `{ figma }`
  wrapper: the filename is the platform id.
- `specs init` scaffolds the same five files.
- The figma template loses its wrapping key and is de-indented to the root.

Tests: 694 passing. Verified end to end — a v1 workspace migrates, the stubs
parse to null, and the loader reads the result without refusing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ADR 074-076, 084-085: primitives promote to component instances at capture (#382)

* docs(adr): draft ADR-084 — Element.$extensions for promotion provenance

Adds `$extensions` to `Element`, mirroring `AnatomyElement`, with
`com.figma.promotedPrimitive` and `com.figma.styles`. These record that a
primitive layer was promoted to a component instance and which styles the
promotion consumed, so the transformation is reversible from the spec alone.

Claims 084 in the ADR index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): rewrite 074/075/076 for capture-time promotion; add ADR-085

074 inverts: primitives promote to component instances during capture, in
composed example content only. A component's own variants stay primitive.
Removes the emit-time PlatformConventions.primitives binding block; PrimitiveKind
survives as a standalone enum.

075 replaces the concept-keyed prop-name map with conventions.primitives — a
component-keyed table of rules from a style source to a partial props object,
keyed literally on token paths or raw scalars. Adds scoring for selection among
candidates sharing a kind. `source` stays out of the schema's validation surface.

076 drops the LayoutMode-keyed component union: a Row/Column/Box trio is three
entries selected by scoring. A promoted container's children are hoisted into a
slotContentExamples entry and referenced as its slot fill. Keeps stylesProp.

085 adds settings.spec.promotePrimitives, defaulting on, so an unpromoted
baseline can be captured without dismantling a workspace's conventions.

Claims 085 in the ADR index; clears stale highlights on the rewritten three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): default promotePrimitives to false

Promotion restructures composed example content, so a workspace elects it
rather than receiving it on upgrade. Matches collapsePrimitiveWrapper, the
capture-time normalization it most resembles. A conventions table is inert
until the setting is turned on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(schema): implement ADR 074-076, 084-085 — capture-time promotion

Types:
- Conventions: remove PrimitiveBindings and the three binding types; add
  root-level `primitives` (component-keyed PrimitiveEntry with kind + map of
  PrimitiveRule). PrimitiveKind declared directly rather than as a keyof.
  stylesProp stays a platform member and survives resolution.
- Element: add $extensions with com.figma.promotedPrimitive and .styles.
- Settings: add spec.promotePrimitives, defaulting false.

Schema: mirrored in conventions, component and settings schemas. PrimitiveRule
constrains exactly one of prop/values; `source` stays an unvalidated string so
the validation surface does not track the Styles key set.

Docs: rewrite the conventions primitives section, add a promote-primitives
settings page, add rows to elements and settings tables.

Type tests updated for the new shapes. tsc, schema validation and test-d all
pass. Version stays 0.31.0 — every removal is of an unreleased member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(schema): describe capture-time promotion in the 0.31.0 changelog

Adds the release-note paragraph for promotion alongside the config split, and
records that stylesProp is now the only styling-prop level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cli): load conventions/primitives.yaml; drop the emit-time binding resolver

primitives.yaml is a reserved basename in config/conventions/ for the
platform-neutral promotion table (ADR-075). It shares the directory with the
per-platform files but is not a platform, so no platform may take that id.

An entry missing kind or map is dropped with a warning rather than failing the
run, so a half-written table promotes what it describes.

Removes resolvePrimitiveBindings and resolveContainerComponent, which resolved
the emit-time per-platform bindings ADR-074 replaces. stylesProp stays a
platform member and is read directly rather than folded into each binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): reserve conventions/primitives.yaml (ADR-078 Decision 5)

The promotion table is not a platform, so Decision 3's "the filename is the
platform id" leaves it with no home. It takes a reserved basename in the same
directory: everything composing into one Conventions stays in one place, and no
platform may take that id.

Rejected putting it at config/primitives.yaml, which would split one artifact
across two locations, and putting a primitives key in every platform file, which
would need the merge rule Decision 3 exists to avoid.

Recorded in ADR-075's Notes and the conventions docs page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(schema): rename kind to elementType; record multipleMatches on a promotion

Teammate feedback on the primitive-composition ADRs.

`PrimitiveEntry.kind` becomes `elementType`. The value is a PrimitiveKind, itself
a strict subset of ElementType — the vocabulary a reader already knows from
anatomy. Naming it for that superset means an entry reads as "promoted from a
text element" using the spec's own word, instead of a second term for one idea.

`FigmaElementExtension.multipleMatches` records that more than one entry resolved
against an element and the highest scorer won. A warning announces ambiguity once
to whoever watched the run; this keeps it on the element, where a reader or a
later lint pass can find it.

Also fixes a latent bug in the primitives loader: a second resolveConventions
call site still passed the pre-primitives shape and threw on an empty workspace.
The CLI suite was never run when that landed.

ADR-075 and ADR-084 updated, with the naming rationale recorded. Schema types,
JSON schema, type tests, docs and changelog follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(cli): one call site for resolveConventions

getDefaultConfig was calling the resolver with an empty input purely to get a
correctly-shaped result back, which is what DEFAULT_CONVENTIONS already is. Spread
rather than shared, matching what the resolver returned, so a consumer mutating
its config cannot reach the exported constant.

The resolver now has one caller: the path that actually reads config/conventions/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(schema): record consumed content in a promotion's provenance

A promoted layer's text string or glyph name moved into propConfigurations and
was deleted from the element, with nothing recording it. Its value survives, but
only under whichever prop the conventions table named — so restoring a raw layer
meant consulting that table, which is what the residue exists to avoid.

FigmaElementExtension gains `content`, beside `styles` rather than within, because
content is not a style. It takes Element.content's own type, so a bound value is
recorded as the binding it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record that a promotion table is written against one token profile

promote-primitives gains a section mapping each spec.tokens profile to what a
values key must be, and calls out two cases that read as working but are not
straightforward: FIGMA_SYNTAX_* falls back to the token path per token, so a
correct table there mixes two vocabularies with nothing marking which is which;
and CUSTOM works only while the mapping keeps a $token member.

tokens.md gets a note after the main content, since the coupling matters when
changing the profile but is not what the page is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: conventional headings on promote-primitives

Noun-phrase headings matching the other settings pages, and the Path section they
all carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: changelog for capture-time primitive promotion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: tighten this release's changelog entries

One scannable line per change across the unreleased sections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant