ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

ADR 073-079, 081: Conventions for primitive composition - #363

Open
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition
Open

ADR 073-079, 081: Conventions for primitive composition#363
nathanacurtis wants to merge 31 commits into
release/schema-0.31.0+cli-0.28.0from
adr/primitive-composition

Conversation

@nathanacurtis

@nathanacurtisnathanacurtis commented Aug 30, 2026

Copy link
Copy Markdown
Member

Conventions become platform-keyed. figma stops being the only namespace and becomes one implementation key among react, web-components and swiftui — because this pipeline reads Figma to produce specs and writes specs to produce Figma, so Figma is a peer rather than a special case. On top of that axis, the shape gains the vocabulary a code generator needs: which of a platform's own components means text, glyph, or container.

The ADRs

#Decision
073conventions.platforms replaces conventions.figma; keys name implementations, not platform families, and stay flat
074Primitives resolve to components at emit time, not in the spec — one spec serves every implementation, and PrimitiveKind is the bindable subset of ElementType
075props is a closed, concept-keyed map onto a component's props; unmapped styling routes to stylesProp
076A container binds to one component or a LayoutMode-keyed map; only stylesProp hoists to the platform
077images.component names the image component in code, beside the match naming it in Figma — an image is an attribute, not a node kind, so it stays out of the primitive vocabulary
078One conventions file per platform in config/conventions/; the filename is the platform id, so there is no merge rule and no single-file form
079metadata.conventions records only the producing platform, not every platform the workspace configures
081defaultFillWidth — the container width a platform gives a root that resizes to fill its parent

What is implemented here

packages/schema only. The order for this ecosystem is schema → specs-from-figma → CLI, since the CLI depends on specs-from-figma; this PR is the first step.

  • types/Conventions.tsConventions.platforms, PlatformConventions, PrimitiveKind, TextBinding / GlyphBinding / ContainerBinding and their resolved forms, MetadataConventions, stylesProp, defaultFillWidth. DEFAULT_CONVENTIONS becomes {}: every default it carried belongs inside a declared platform entry, and no platform being declared is a statement no default can supply.
  • types/Metadata.tsconventions retyped to MetadataConventions.
  • schema/conventions.schema.json — rewritten. PlatformConventions doubles as the standalone per-file root ADR-078 needs, so a single config/conventions/<id>.yaml validates on its own and the two forms cannot drift. MetadataConventions is the same definition under maxProperties: 1.
  • schema/component.schema.jsonmetadata.conventions now references MetadataConventions.
  • Type tests for the platform map, the closed props vocabularies, the LayoutMode-keyed container, the metadata narrowing, and defaultFillWidth.
  • Docs: schema/conventions.md restructured around platforms, primitives, stylesProp and defaultFillWidth; 30 pages updated to drop the figma: YAML wrapper (the filename carries the id now) and repoint paths and anchors.
  • CHANGELOG.md — unreleased 0.31.0 entries amended where the reshape invalidated them, rather than contradicted by new ones.

Gates: tsc -p tsconfig.build.json clean, validate-schema.sh 7/7, all 14 .test-d.ts compile, packages/schema tests 13/13.

Version: stays 0.31.0. Conventions is @since 0.31.0 and unpublished — npm's latest is 0.30.0 — so reshaping it inside the release that introduces it breaks no published contract. That reasoning expires the moment 0.31.0 ships.

Not done here

  • ADR-078's loader. Its Type changes table says (none) — the directory discovery, per-platform resolution and the refusal of a single config/conventions.yaml are all CLI work. The schema-side half (the standalone per-file definition) is done.
  • specs-from-figma and the CLI. Both read conventions.figma and will not compile against these types until updated, in that order.
  • ADR-078's schema table names workspace.schema.json, which ADR-071 already removed. Stale row; nothing to change.
  • Some settings pages still write figma.images-style prose headings. They read correctly as platform-qualified concept names, but the figma. prefix no longer appears in the file itself.

🤖 Generated with Claude Code

nathanacurtisand others added 7 commits August 27, 2026 14:55
…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>
`--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>
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>
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md Outdated
Comment threadadr/073-platform-conventions-namespace.md
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>
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
Comment threadadr/075-primitive-style-prop-mapping.md Outdated
nathanacurtisand others added 9 commits August 30, 2026 16:42
…ills 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>
…tive
- `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>
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>
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>
… (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>
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>
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>
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>
…nces
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>
@nathanacurtisnathanacurtis changed the title docs(adr): conventions for primitive composition (ADR-073–077)ADR: Conventions for primitive composition (ADR-073–077)Aug 31, 2026
@nathanacurtisnathanacurtis changed the title ADR: Conventions for primitive composition (ADR-073–077)ADR 073-078: Conventions for primitive compositionAug 31, 2026
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
nathanacurtis added a commit that referenced this pull request Aug 31, 2026
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>
Brings in ADR-080: PropConfigurationValue and InstanceExample.propConfigurations
admit null, so a configuration can state that a nullable prop is unset.
nathanacurtisand others added 2 commits August 31, 2026 15:19
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>
- 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>
@nathanacurtisnathanacurtis changed the title ADR 073-078: Conventions for primitive compositionADR 073-079, 081: Conventions for primitive compositionAug 31, 2026
nathanacurtisand others added 2 commits August 31, 2026 15:40
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>
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>
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>
nathanacurtisand others added 8 commits August 31, 2026 16:47
…rimitive 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>
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.
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>
Brings the closed transform packages into the branch. Rendering now lives
only in specs-from-figma's packages, so the CLI's own React and Stories
transformers are deleted rather than kept in step:
- packages/cli/src/transforms/React.ts — deleted (react-from-specs)
- packages/cli/src/transforms/Stories.ts — deleted (react-from-specs)
- packages/cli/src/transforms/react/primitives.ts — deleted, my duplicate of
the resolver that belongs in react-from-specs
The registry now resolves react, stories, webcomponents, webcomponents-stories
and cssvars from the closed packages, which is what the eg workspace's
pipeline.yaml has been asking for.
This also fixes two things seen in Storybook, both of which were the branch
mismatch rather than defects:
- `Dot.contract.ts` exported `EgdsPagingCarouselDotDefaults` while the scaffold
imported `DotDefaults`. The release branch's Contract.ts prefixed subcomponent
symbols with the parent; feat/react-from-specs already emits the bare name.
- Story ids moved because the public Stories transformer titled generated
stories `Components/…` where the closed one titles them `React/…`.
Types/Transformer.ts keeps both sides: dataDirectory and scoped from the feat
branch, platform and platformId from this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Sign up for freeto 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

@nathanacurtis