Skip to content

Feature: React and Web Components Transformers - #358

Open
nathanacurtis wants to merge 37 commits into
mainfrom
feat/react-from-specs
Open

Feature: React and Web Components Transformers#358
nathanacurtis wants to merge 37 commits into
mainfrom
feat/react-from-specs

Conversation

@nathanacurtis

@nathanacurtis nathanacurtis commented Aug 27, 2026

Copy link
Copy Markdown
Member

Rebuilds the transform work onto the current release branch. The previous
feat/react-from-specs branched from the 0.28.0/0.25.0 release and missed three
cycles, so its schema-side changes had become regressions — it still carried
clipContent, originalName, and a resurrected Config.ts. Those are discarded;
only the transform payload is carried forward. The webcomponents work is
cherry-picked for the same reason rather than merged.

Added

  • cssvars transformer — emits library-level CSS custom properties, resolved
    from the fetched library JSON in the workspace data directory, so generated
    stylesheets stand on their own.
  • Inline effects and gradients in the css transformer — shadows, blurs, and
    gradient fills declared directly on an element now become real box-shadow,
    filter, and background-image declarations. They were previously dropped.
  • webcomponents and webcomponents-stories transformers — experimental.
    A Lit element and a web-components Storybook page, the same shape react and
    stories produce for React. The output shape may change without a
    breaking-change note.

Changed

  • react and stories are consumed from @directededges/react-from-specs;
    webcomponents and webcomponents-stories from
    @directededges/webcomponents-from-specs. Names, options, and output unchanged.

Adaptations this port required

  • ADR-069styleToCSS.ts and Css.mapping.md used clipContent, which the
    engine no longer emits. Renamed to clipsContent so the overflow rule fires.
  • ADR-071 — the transformer context read config.dataDirectory; now
    config.settings.data?.directory. The imported webcomponents.md referred to
    config.processing.states, now the figma.states convention.
  • outputFormat is now required on TransformerContext, so every ported test
    context failed to compile. Supplied it, and replaced two as any casts in
    Cssvars.test.ts with real contexts rather than letting them hide the gap.
  • Css.mapping.md carried a client library namespace in three places; replaced
    with the neutral ds forms used on the release branch.
  • cssvars had a docs page but no nav entry or index row — it was unreachable.

Verification

  • tsc --noEmit: zero new errors. Two remain (GenerateCommand.ts:589, an rfc/
    storybook import) — both confirmed pre-existing on the release branch.
  • vitest packages/cli/tests: 688 passed, 2 skipped.
  • Runtime smoke test: the built CLI resolves all seven transformer names with no
    "unknown transformer" warning; a control run with a bogus name does warn,
    confirming the external packages load at runtime.

Dependencies

Neither @directededges/react-from-specs nor
@directededges/webcomponents-from-specs is published yet; both resolve through
workspace symlinks for development. Both need a first publish before a release
carrying this can ship.

The matching engine work is on feat/react-from-specs in specs-from-figma, which
carries the ADR-071 engine refactor this CLI calls against.

🤖 Generated with Claude Code

nathanacurtis and others added 9 commits August 17, 2026 18:05
…#353)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(adr): draft ADR 071 — separate library conventions from tooling settings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): revise ADR 071 — independent term lists, Figma-scoped conventions, artifact layout, plugin analysis

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — uniform match/exclude shape for name-based conventions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — block-sequence YAML; rekey conventions without adding capability

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — expand states examples from a real catalog declaration

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — focus Decision 1 on the two-way split; move plugin evidence to Downstream Impact

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — explicit detect setting replaces block-presence on-switch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — no switch; scope and backgroundImage classify as conventions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — two root types, no container; avoids PropConfigurations collision

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — output/format swap, figma.naming, settings absorbs workspace members, ResolvedConventions justified

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — settings grouped by concern, paths co-located, pipeline artifact

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — work vs place; sources folds into data; transformers own no paths

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — assets block, analysis derived, targets deferred

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — Pipeline type, supporting type placement, schema ref strategy, author, PanelSettings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — one schema file per artifact, workspace schema retired

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — retire workspace.schema.json for per-artifact schemas

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(schema)!: split Config into Conventions, Settings and Pipeline (ADR-071)

BREAKING CHANGE: Config, ResolvedConfig and DEFAULT_CONFIG are removed.
Library facts move to Conventions.figma, run choices to Settings grouped by
concern, and declared work to Pipeline. Metadata.config becomes
Metadata.conventions + Metadata.settings, and workspace.schema.json is
replaced by one schema per authored artifact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): ADR 071 — record the config/ folder-name rationale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* adr(071): accept — Conventions, Settings and Pipeline replace Config

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(site): retarget schema pages to Conventions, Settings and Pipeline (ADR-071)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr): use neutral example identifiers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Ported from the specsplugin-com repo, lost in the site migration.

- Left-aligned doc layout with sidebar and table of contents hidden,
  rather than the centered splash template
- Activate your license section split into individual and team
  subscriptions, drawing the subscription/license distinction
- Seat management consolidated into Manage your subscription
- Card grids replaced with plain link lists; links made site-relative

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli)!: load conventions, settings and pipeline (ADR-071)

ConfigLoader reads a config/ directory of three artifacts and still loads a
legacy specs.config.yaml, migrating it in memory with a one-time warning.
init scaffolds all three files. Commands, bridge and analyzers read the two
halves, with a fallback for specs whose metadata predates the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(site): regroup nav as Configuration with conventions, settings and pipeline

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(settings): retarget configuration pages to the three artifacts (ADR-071)

Each option page names which file it lives in and why, with legacy paths kept
as migration notes. Overview retitled Configuration; folders documents the
per-concern directories; data-sources renames data to fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(cli,guides): retarget to the three-artifact configuration (ADR-071)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): update stale strings in scan manifest and init output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: legacy config is refused, not migrated in place; add migrate command page

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): refuse pre-split config; add specs migrate config

The loader detects a legacy file only to stop — before the fallback path, so a
workspace is never generated from defaults it did not declare. Conversion moves
to a registry-backed migrate command, keyed by subject and source version.

init refuses in a legacy workspace rather than scaffolding defaults over a real
configuration. Every refusal names a remedy that works: the discovered file, an
explicit --source, or moving a user-level config into the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(schema): update types README for the split exports

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(schema): align README diagram border

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(schema): add DEFAULT_CONVENTIONS; amend constitution for the split exports

The constitution named DEFAULT_CONFIG as the only permitted runtime export, so
ADR-071 violated it by shipping two. The rule now permits one defaults constant
per configuration artifact, and conventions gain theirs — removing the literals
the CLI had been hardcoding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: retarget repo docs to the three-artifact configuration (ADR-071)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(schema,cli)!: default the split spec layout to on

One folder per component holding one file per concern is what transform,
analyze and render all read, so the layout was not really a per-consumer
choice — yet splitComponents, splitConcerns and useSubfolders carried no
schema default and both consumers picked false, the one shape nothing
downstream can use.

DEFAULT_SETTINGS now carries all three as true and ResolvedSettings
requires them. The generate flags are renamed for what they do, since
each can now only turn a split off: --combine-as-library,
--combine-concerns and --no-subfolders replace --split-components,
--split-concerns and --use-subfolders. An absent flag defers to the
configured value rather than overriding it.

specs migrate config writes all three out explicitly, including from a
source that configured no output block, so migrating changes where
configuration lives without changing what a workspace emits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(cli,schema): record the configuration split in the changelogs

The cli 0.28.0 section was empty, so the breaking change users hit first
— specs.config.yaml no longer being read — went unrecorded while the
release notes described only the layout default.

Adds a Breaking section leading the release, writes up specs migrate as
the Added feature it is, and covers config/ discovery, --config taking a
directory, init refusing over a legacy workspace, the vocabulary
renames, and the removal of user-level ~/.specs/config.yaml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…2) (#357)

Figma has no numeric variant, so a count axis is authored as a VARIANT whose
options are the strings "1", "2", "3". Read back, that became an EnumProp — a
string type over a numeric domain — and consumers typed the prop "1" | "2" | "3".

NumberProp could carry the numeric type but left the range open; EnumProp could
carry the closed set but fixed type: string. A numeric variant needs both at
once, and neither type provided it. `enum?: number[]` closes that gap, so the
option set survives alongside the numeric type — the render direction rebuilds
the variant axis from it positionally, so losing it would make the round trip
lossy.

Additive and optional: a NumberProp without `enum` keeps its present meaning of
an open numeric range.

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

Rebuilds the transform work onto the current release. Three adaptations the
original branch predates: clipsContent (ADR-069), configuration read from
settings.data.directory (ADR-071), and outputFormat now required on
TransformerContext.

- cssvars emits library-level CSS custom properties from the fetched library JSON
- css expresses inline shadows, blurs and gradient fills that were dropped before
- react and stories are consumed from @directededges/react-from-specs

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

Cherry-picks the webcomponents wiring onto the current release rather than
merging feat/webcomponents-from-specs, whose base predates ADR-069 and ADR-071
and would have reinstated clipContent and the removed Config type.

- webcomponents and webcomponents-stories come from
  @directededges/webcomponents-from-specs, consumed like react-from-specs
- both are marked experimental in the docs and changelog; the output shape is
  not yet stable
- the imported webcomponents page referred to config.processing.states, which
  ADR-071 replaced with the figma.states convention
- cssvars had a docs page but no nav entry or index row, so it was unreachable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nathanacurtis nathanacurtis changed the title feat(cli): cssvars transformer, inline effects in css, react/stories to their own package feat(cli): cssvars + inline effects, react/stories and experimental webcomponents transformers Aug 28, 2026
nathanacurtis and others added 4 commits August 28, 2026 14:29
`--components` narrows the per-component pass, but cssvars produces all of its
output in finalize() over the whole data directory, so a single-component run
still paid for the full library stylesheet.

TransformerContext now carries `scoped`, set when --components is given, and
the cssvars transform returns early rather than rebuilding output a scoped run
cannot have invalidated. Re-running it unscoped stays the way to pick up token
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Figma frame dimensions include padding; the CSS default excludes it, so every
element carrying both a fixed dimension and padding rendered larger than the
spec by exactly its padding — a 24px frame with 4px padding measured 32px.
Generated stylesheets now set border-box on the block and its descendants.

False variant values emitted `[data-x="false"]`, but scaffolds write booleans as
presence: the attribute is set to "" when true and omitted when false, never
written as "false". Those rules matched nothing, so every false-valued variant's
styling was dead. They now emit `:not([data-x])`.

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

Cursor is an affordance Figma has no concept of. It is now inferred from what
the stylesheet actually styles: a component that styles a pressed state is a
press target and gets `pointer`; one that styles a disabled state gets
`not-allowed`. Keying off emitted rules rather than declared states matters —
a spec can declare a state whose variant produces no styling at all, which is
not evidence of interactivity, and that gave text inputs a pointer.

An opacity VARIABLE is authored on a percentage scale (36) while an unbound
opacity arrives as the ratio Figma stores (0.36). Emitting the variable bare
produced `opacity: 36`, which clamps to 1 and silently discarded the state.
Token references are now multiplied into a CSS percentage; raw values are
untouched, so the two authoring paths both round-trip.

A classified boolean's FALSE value has no concept of its own — it is the
negation of the true concept. Those variants were dropped entirely as base/rest
state, so an unselected variant and every hover/pressed pairing with it emitted
no rule. They now emit `:not([aria-selected="true"])`, with multi-part concepts
negated as an AND of nots.

A variant layout that exactly reverses a flex parent's children now emits
`flex-direction: row-reverse`/`column-reverse` under that variant's selector.
This is a visual swap, so DOM order — and reading and tab order — stays as
authored. Partial reorders are not expressible this way and are relocated by
the emitters instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A negative itemSpacing means children OVERLAP, which `gap` cannot express. The
branch that recognised it returned early with a note that child margins were
"deferred" — so overlap was never emitted, and every layout declaration after
that point was silently dropped for the element too, including FILL
translation. Stylesheets now emit a negative margin on each child after the
first, along the parent's main axis, and the negative case only skips `gap`.

`--get-images` picked the file to query by a hard-coded preference for the
`library` alias, so generating from a different source asked the wrong file for
its image URLs. A hash present only in the source file came back missing and
was reported as Figma failing to return it. The alias now comes from the
manifest being generated, which already names its source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nathanacurtis nathanacurtis changed the title feat(cli): cssvars + inline effects, react/stories and experimental webcomponents transformers Feature: React and Web Components Transformers Aug 31, 2026
nathanacurtis and others added 6 commits August 31, 2026 08:04
…ows (#362) (#364)

`scan` collapsed every dev status other than READY_FOR_DEV to NONE, so a
COMPLETED component was indistinguishable from an untouched one. The manifest
parser then accepted only those two values and silently discarded any other row.

Dev statuses are now carried through verbatim, an unrecognized status leaves the
row parsed but unselected, and both scan and generate warn on a row they cannot
read. Selection is unchanged: generate still builds only READY_FOR_DEV defaults.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ADRs drafted in PR #363 never had their index rows cherry-picked onto
main, so the index topped out at 070 and the next author would have claimed
073 again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(adr): draft ADR-080 — null as a prop configuration value

Widens PropConfigurationValue with a null arm so a configuration can state
that a nullable prop is unset, instead of pairing a content value with a
visibility boolean that is not a prop of the component being configured.

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

* feat(schema): null as a prop configuration value (ADR-080)

A configuration can now state that a nullable prop is unset. null is a value,
not an absence: an absent key inherits from the layer beneath, a null key
overrides an inherited value with no value.

Without it, a content prop paired with a visibility boolean could only be
expressed as both at once — a bound value beside a false flag naming a prop
that pairing had already removed from the component's props.

InstanceExample.propConfigurations declares its own inline union so it can
exclude PropBinding, so it is widened separately. It gains null and keeps that
exclusion.

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

* docs(adr): accept ADR-080

All gates pass: tsc build, schema validation 7/7, type tests compile.
Moves the index row from Draft to Accepted.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
A component's rules are now written three ways from one pass. The class form is
unchanged. The host form targets `:host`, with the root's qualifiers inside the
functional form — `:host([data-ratio="1:1"])` — so a Web Component can be the
root itself and a caller can size it by styling the element. The light-DOM form
carries the box-sizing reset for content composed into a custom element, which
nothing inside a shadow root can reach: `:host *` stops at the boundary and
`::slotted()` reaches only the top level. Without it every composed element
computed as content-box and each padded one came out larger than the spec says.

Root selectors all resolve through one helper, so the forms cannot drift. Name
warnings are suppressed on the second pass — the same names, reported once.
nathanacurtis and others added 4 commits September 1, 2026 19:16
Reserves the number for the slot-wrapper collapse ADR drafted on
083-slot-wrapper-collapse, so concurrent ADR authors do not collide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(adr): draft ADR-083 — collapsing a slot-only wrapper

Extends ADR-058's primitive wrapper collapse to a root whose sole anatomy
child is a slot, governed by the existing collapsePrimitiveWrapper setting.
Context and prior analysis in #377.

Claims 083 in the index (082 is taken by origin/082-component-description).

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

* docs(adr): drop the style eligibility test from ADR-083

Root and slot are both container nodes with the same style signature, so
merging them discards nothing and no style needs testing. ADR-058 needs an
eligibility list because it merges a container into a `text` or `glyph`
leaf, where container styles have nowhere to go — that reason does not carry
over.

- Eligibility is structural only: a container root with exactly one anatomy
  child, of type slot, all-or-nothing across variants
- The merge keeps the slot's value on any shared key; the slot is the box the
  children sit in, and the wrapper's layout is an artifact of holding one child
- Expansion splits by what a style acts on: parent layout to both boxes, child
  layout to the slot, everything else to the root

`padding` is called out as the one parent-layout key that is not a no-op on a
single-child wrapper — applied to both boxes it insets twice.

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

* docs(adr): correct the padding note in ADR-083

Merge keeps the slot's value on a shared key; values are never summed. A
root with padding 8 and a slot with padding 12 collapses to 12.

The earlier note framed padding as an exception to the split rule. It is
not: a slot carrying padding is an authoring mistake, since padding belongs
to the box with the surface and the slot has none.

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

* feat(schema): collapse a slot-only wrapper (ADR-083)

collapsePrimitiveWrapper now covers a root container whose only child is a
container bound to the component's sole slot property. Eligibility is the
binding, not the anatomy label, and it must hold in every variant.

No member is added, removed or retyped — the setting's documented meaning
widens and the behaviour belongs to the consuming packages, so this is a
PATCH within the unreleased 0.31.0.

- types/Settings.ts — Settings.spec and ResolvedSettings.spec doc comments
  state both shapes and why they are tested differently
- schema/settings.schema.json — description widened to match
- site docs — the settings page gains the slot shape, its eligibility and a
  collapsed example; the schema table row is updated
- CHANGELOG — one Changed entry under 0.31.0

The collapsed element keeps the slot layer's $extensions['com.figma'], so the
source layer stays traceable, matching what ADR-058 already does for a leaf.

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

* docs(adr): accept ADR-083

All three gates pass: tsc build, schema validation (7/7), and the type
tests. Status flipped to ACCEPTED and the INDEX row moved from Draft to
Accepted.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The number was claimed on main and the release branch while the ADR branch
was in flight, so merging release brought that draft row back alongside the
accepted one — the duplicate the accept skill warns about. Same ADR, two
tables, two titles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nathanacurtis and others added 3 commits September 3, 2026 16:05
* 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>
A boxed admonition gave the primitives story more weight than it warrants on a
page about token profiles. One See Also line carries the same fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the anatomy-roles work onto the branch where Config has been split into
Conventions, Settings and Pipeline (ADR-071), re-homing the config surface
rather than resurrecting Config.

- Renumbers the role ADRs. 066 was already claimed and merged as lossless key
  formatting, so 066 becomes 067, 067 becomes 068, and 068 becomes 086 —
  the next number unclaimed across every branch.
- Adds `AnatomyElement.role` and the `RoleConceptName` open alias.
- Homes `propRoles`, `roleValidation` and `PropRoleName` in Conventions beside
  `states`, which is where ADR-067 argues they belong.
- Adds `Settings.spec.roles` (default false) as the feature on-switch: when
  false, annotations are not read and generation behaves exactly as today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nathanacurtis and others added 11 commits September 3, 2026 22:23
The port checked whole files out of feat/anatomy-roles, which reverted content
that branch predated. Restores the target versions and re-grafts only the role
additions onto them:

- `component.schema.json` had lost ADR-080 null support in PropConfigurationValue
  and InstanceExample.propConfigurations, which the schema tests caught.
- `types/Anatomy.ts` had lost `FigmaAnatomyElementExtension.name` — the lossless
  key-formatting field, unrelated to roles and belonging to the other ADR 066.

Also wires the role config through the CLI, which resolves platform conventions
from an explicit allowlist rather than passing unknown keys through:

- `ConfigLoader.resolvePlatform` reads `propRoles` and `roleValidation`.
- `TransformerContext` carries `propRoles`, handed from the figma platform the
  way `processingStates` already is, because it names props the spec declares
  rather than anything about the target platform.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swapping a `div` for a `<button>` inherits the UA's own border, background,
font and padding, which the spec's declarations were authored without. The
visible symptom is a size change between states: the state block that sets
`border-width` suppresses the UA border, the state that sets none keeps it,
so the control changes width when it changes state. Seen on a togglebutton
whose selected state grew by the UA border's width; `button` was silently
affected the same way.

The fix belongs in CSS rather than the scaffold, and the css transform can
now see the role. `Css.ts` reads `anatomy.<element>.role` and, when the root
carries a role that emits a UA-styled element, writes a reset block *before*
any spec declaration — so the spec still fully describes the appearance and
two states cannot differ in size for a reason the design never expressed.
`link` also clears text-decoration, since an anchor carries decoration rather
than a border. Applies to the shadow-DOM stylesheet too.

Also fixes a state divergence the roles feature introduced: a wired
togglebutton held its pressed state internally while variant-conditional
content kept reading the original prop, so clicking moved the control's
background but not its indicator. The role now assigns the live value back to
the bound prop, making it the single source of truth for everything below.

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

Two independent reviews of the generated output surfaced defects the roles work
introduced or newly exposed.

Dead selectors. `CONCEPT_TABLE` pairs `:disabled` with `[aria-disabled="true"]`
because either may carry the state, but emitting both everywhere produces rules
that can never match: a role emitting a native control sets the real `disabled`
property and never the ARIA string, and a shadow host cannot match `:disabled`
at all without form association. The selector is now resolved per target and per
root role, across the state rules, the hover/active guards, and the cursor block.
DE Button's stylesheet loses 17 unmatchable selectors.

Inner semantic element for Web Components. A root interactive role now emits a
real `<button>` inside the shadow root, with `delegatesFocus`, rather than
stamping `role`/`tabindex` onto the host. It is reset to nothing and takes no
box, so the host keeps the root's layout and appearance. See ADR-083 for the
architectural tension this sits in — that decision collapses a wrapper so the
light DOM owns root styles, and this adds a semantic element inside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Variant option labels arrived in the source vocabulary — `Filled`, `Medium`,
`20x20` — and fossilised into the public API, the markup and the stylesheet.
HTML attribute selectors are case-sensitive, so an author writing the natural
`appearance="filled"` got a silently unstyled component, and after adoption
every consumer callsite and override would encode the design tool's casing.

**The spec is not modified.** It keeps the source vocabulary, so the
lossless-formatting promise across transformation boundaries holds and
provenance is unaffected. This is a one-way normalization in the emitters only;
reversing it would need value-level provenance the spec does not yet carry.

**Lowercasing is the only change.** Characters are never stripped or replaced:
`20x20` keeps its digits, `Very small` becomes `very small` rather than
`very-small`. Removing characters would change what a value denotes and surface
as a user-facing defect.

Applied at every site that turns a spec enum string into output, because a
mismatch between any two means a component silently loses its variant styling:
the contract's union and defaults, and the CSS data-attribute selectors.

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

Two independent reviews reached the same finding from opposite targets: the
shared contract exports `Slots`, `SlotVisibility` and `SlotRules`, which no
scaffold imports and neither a React nor a custom-element consumer can act on.
One called it dead weight, the other transform machinery in the consumer's API
surface.

They move to a sibling `{Component}.metadata.ts`, emitted only where slots
exist, so the contract is the props a consumer writes and nothing else.

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

Wraps every generated stylesheet in `@layer specs`, so an unlayered consumer rule
wins regardless of specificity. Without it, retheming a state meant matching
selectors like `.x[data-appearance="outline"]:hover:not(:disabled) .x__label`
exactly, for every state — a specificity race the generator won by accident of
how many guards it emitted.

Also corrects the disabled selector for `link`. An anchor has no `disabled`
property: a disabled link drops its `href` and announces `aria-disabled`, so
`:disabled` can never match and only the ARIA selector belongs there.

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

A role answers what an element *is*; some signals a designer needs to record are
what activating it *does*. A dismiss affordance is a button — it announces as
one, takes the same element, and its semantics are identical — so it cannot be a
second role without breaking the one-role-per-element rule that part resolution
depends on, and making it a control concept would grow the vocabulary by
behavior × control rather than by control kind.

Adds `AnatomyElement.event` and the open `EventConceptName` alias, read from a
second annotation key through the grammar that already parses arbitrary
`key:value` lines. The boundary is stated: does it change how the control is
announced? A togglebutton announces its pressed state, so it is a role; a dismiss
announces as an ordinary button, so it is an event.

ADR 087 records the naming exploration. `event` is chosen for this iteration;
`action` is more accurate — the event is the click, and dismiss is the response —
and `command` has web-platform precedent in the Invoker Commands API. Both are
written up so the choice can be revisited while no customer file carries the
annotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`event` named the wrong half of the interaction — the event is the click, and
`dismiss` is the response to it — and it invited values that genuinely are events
(`hover`, `focus`), which belong to state classification. `action` is accurate,
has counterparts on every target platform, and is unambiguously a verb. ADR 087
records `event`, `command` and two others as rejected alternatives, with the
Invoker Commands API noted as the reason to revisit `command` if the web
transform ever emits that native attribute.

`actions` is an array of objects rather than a scalar string. An element may
reasonably perform more than one behavior, and each will want properties of its
own — where focus moves after a dismissal, what a navigation targets. Only `type`
is defined today; a bare string would have to become an array later, breaking
specs that already carry it.

Also restores the Roles sidebar group, which the port to this branch dropped, and
adds Actions beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The released blocks lead with a bolded present-tense claim and speak to the
person running the commands; the unreleased ones had drifted into terse notes
that name mechanism and trail their detail behind an em-dash.

Each entry now bolds through the verb, so the bolded span is a sentence a reader
can act on, and carries its flags and qualifiers in the same clause. Detail a
reader can get from --help or by opening the file is gone.

The release leads open with a bolded claim and drop the caveats — what is
preserved, what is never touched — which belong to the entries below rather than
to the framing. The CLI block gains the lead paragraph it was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comparing where the decisions stand meant opening the adr/ directory and reading
status lines one file at a time. The changelog already frames the release, so it
carries the list: accepted, updated drafts and new drafts, each linking the ADR
number beside its current title.

States are read from the files rather than from adr/INDEX.md, which goes stale
and currently lists 083 under two states at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen drafts sit here rather than on the release branch — the roles and
behavior concepts, the platform conventions namespace, and the promotion chain
from capture through to the setting that switches it on.

Titles are taken from each file's own heading, so a title that carries no
backticks in the file carries none here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from release/schema-0.31.0+cli-0.28.0 to main September 4, 2026 20:01
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