ADR-027 — Component spec file, split from metadata by reader
Date: 2026-10-07
Status: accepted
Amended: 2026-10-12
Context
The ds101 page on component specs asks a spec for things <Name>.metadata.json does not hold:
- Anatomy: every named part, with its role and what activating it does. ADR-001 left
anatomyout on token-cost grounds. Only ADR-023 compound parts are recorded (composition.parts), so Button’s icon, label and trailing icon are not recorded anywhere. - The whole API: every prop with its type and default, including non-enum and code-only props.
variantsonly lists enum axes. - Prohibited combinations as data. Today they are prose. “Requires the icon prop and an aria-label” sits inside
variants.shape.purpose. - A token for each styled value.
tokensis a flat list per category. It cannot say which part uses a token, or in which state. - Decisions design files cannot show: Figma property mappings, divergences and the sets a component spans. These live in ADR-026 prose and in memory notes.
ds101 also says “a description informs, a contract arbitrates”: a spec is only useful when something checks it.
Two facts about this repo shape the answer:
- Metadata has two kinds of reader.
/layout-generationreads all 27 metadata files on every run, forusage,variantsandcomposition: how to choose a component and put it together with others. The builder moments (/component-scaffold,/review-component,a11y-coverage.js) read one component at a time, forstates,tokensandaccessibility: what to build. Every gap above is builder information. Adding it to metadata would make layout generation pay for it 27 times on every run. - ADR-026 already has the “agree before building” step, but nothing keeps its output.
/component-scaffoldproduces an API proposal (anatomy, a props table with Figma surface, divergences) and stops for approval. The approved table lives only in the chat. ADR-026 rejected apropssection in the metadata schema for two reasons: it would break the ADR-001 contract, and it would duplicate the TypeScript types.
Writing a pilot spec for Button found five problems that nothing reported:
metadata.tokensleaves out two tokens the CSS Module reads:font.line-height.noneandborder-radius.full.- The icon-only rule (
shaperequiresiconandaria-label) is not enforced by the types. The components rules ask for type-enforced anti-patterns. - Button has no focus style and no focus token; the browser’s default ring applies.
- ADR-026 lists Button
shapeas a code prop with no Figma property. In fact icon-only buttons are a separate Figma set,Button icon(54:1192). - The
1pxborder width is a literal in 15 CSS Modules. No border-width token exists.
The options were:
- Extend
component.schema.json. This breaks the ADR-001 contract and grows the context/layout-generationreads on every run. - A Markdown spec page per component. ADR-001 already rejected Markdown for agent context. Nothing could validate it, and it would go stale like any doc page.
- A separate
<Name>.spec.jsonfor the builder reader, checked against the code.
Decision
Option 3, as a pilot.
The file
<Name>.spec.json sits next to the component and is validated against packages/components/component.spec.schema.json. It has these sections:
| Section | Holds |
|---|---|
anatomy |
Every named part, internal elements included: element, builtOn, role, action, required, when. Names follow the ADR-023 naming contract. |
props |
The approved ADR-026 API proposal, kept: kind, type, options, default, vocabulary concept, and figma (surface, property, set). |
constraints |
Combinations a component requires or forbids (if / require / ignore / forbid), each with an enforcedBy list (type, test, lint). Where a component is placed stays in usage.antiPatterns. |
states |
Every state with its ADR-025 kind (interaction, appData, lifecycle, content) and its trigger. |
styles |
Part → ordered rules of when (prop values and state) → CSS property → value. A value is a token path, a { literal, reason } or a { sameAs }. A raw value can still appear, but it has to say why. |
figma |
The component sets it spans, and recorded divergences with the decision on each. |
Who reads it
/component-scaffoldwrites it from the approved API proposal before any code exists. This makes it a spec of intent, not a description of what was built./review-componentreads it to review the code against the spec./layout-generationnever reads it, andgenerate-pattern-schema.jsdoes not aggregate it. The ADR-013 rule still holds: each reader gets the narrowest context.
Why the duplication is acceptable
TypeScript stays the source of truth for what was built. The spec is the agreed intent. npm run spec:validate (scripts/validate-spec.js, in components-check.yml) fails when the two disagree:
- Props: every prop the component declares is in the spec, and every spec prop exists. Variant-axis options match the TypeScript union and the metadata axis. Destructuring defaults match.
- Styles: the token paths in the spec equal the
--ds-*properties the CSS Module reads, and each one is defined in the built token CSS. - Cross-references: anatomy, states, constraints and
whenkeys resolve, and every metadata state appears in the spec. - Unenforced constraints (an empty
enforcedBy) are listed as backlog items, not failures. This follows the shrinking-ledger convention ofa11y-backlog.json.
This answers ADR-026’s objection. The metadata schema is untouched. When the same fact lives in two places and a check fails on disagreement, that is a contract, not drift.
Pilot and exit condition
The pilot covers Button (an atom) and CardVertical (a compound component with ADR-023 parts). During the pilot:
- Specs are optional. The validator checks only components that have one.
- Metadata is unchanged.
tokens,statesandaccessibilitystay where they are, so the pilot specs repeat them.
Accept when both of these hold:
- The pattern-accuracy harness, with a third arm (metadata + spec), shows component scaffolding does not get worse.
- CardVertical’s parts fit the schema without new top-level keys.
On acceptance:
- Move
tokens,statesandaccessibilityfrom metadata into the spec. This is a breaking change to the ADR-001 contract and needs an ADR-001 amendment, which also reverses theanatomyexclusion for the spec file. Migratea11y-coverage.js,validate-metadata.jstoken resolution,sense-component.jsand Airtable sync. - Change
/component-scaffoldstep 3 to write<Name>.spec.json. - Make a spec required for every component. Generate
propsandstyleswith a script from the props types and CSS Modules; the agent writes only anatomy roles and constraints. - Correct ADR-026’s known-gaps line about Button
shape.
Reject if the harness shows scaffolding gets worse, or if keeping specs up to date costs more than the drift it catches. In that case delete the spec files and the validator; metadata is untouched.
The harness plan (Arm C, two tasks, the spec:conformance gate, and what “not worse” means) is in .claude/handoff/archive/2026-10-07-spec-harness-arm.handoff.md.
Consequences
- Each kind of reader has its own file. Layout generation’s context does not grow, and gets smaller once the migration moves three sections out.
- Facts that design files cannot carry (roles, banned combinations, code-only props, Figma set mappings) now have a field that a schema checks.
- A raw value in
stylesmust state its reason. The validator catches a token that the CSS reads but the spec leaves out, which is the gapmetadata.tokenshas had. - Each component gets one more file to keep in sync. The validator makes that cost visible instead of letting drift pass silently. The real burden can only be judged after the CardVertical pilot.
spec:validateneeds built tokens. In CI it runs aftertokens:build.- The five problems the Button pilot found are reported but not fixed in this change. Two are already visible in the spec: the unenforced icon-only constraint (validator backlog) and the missing focus style (the
focus-visiblestate notes).
Amendment (2026-10-07) — CardVertical pilot
CardVertical.spec.json is written and passes spec:validate. The second exit condition is met: the parts fit without new top-level keys. They did need four additions inside existing sections, which this amendment records as part of the schema:
anatomy[].parentrecords nesting. A compound component has 22 named elements in four levels, and a flat list can’t show whichIconbelongs to which part.anatomy[].builtOnPropsrecords the fixed props a part passes to its child component (Title → Headingsize=headline-serif, Body → Stackgap=sm). This is where most of a compound component’s look comes from, and it never appears in its own CSS Module.- A
templatestyle value embeds{token.path}references in a composite CSS value: the halodrop-shadow(...)andcalc(100% + {space.stack.xs}). The validator extracts the token paths from it. - An
elementprop kind foras. ADR-026’s kind list had no row for it.
Part props are keyed <Part>.<prop> inside props. The validator reads each <Name><Part>Props type and checks completeness, options and defaults per part. It also checks that every metadata composition.parts entry appears in anatomy with the same builtOn.
Size. The CardVertical spec is 18 KB and its metadata 21 KB; for Button the two are about 9 KB each. Until the migration moves tokens, states and accessibility out of metadata, a builder moment reading both files roughly doubles its per-component context. The harness arm has to weigh that against any accuracy gain.
What the pilot found:
Rootsetsmin-width: 220px/160px. These are raw px values in a CSS Module, which the component rules forbid; the tokens-author convention is a size primitive plus a device alias.metadata.tokenslists six tokens the CSS Module never reads (color.text.*,font.family.headline-serif,font.weight.medium,font.line-height.tight,space.inline.sm). They reach the card throughHeading,TextandInline. The spec states this throughbuiltOnPropsrather than listing other components’ tokens. This is part of #117.- No opacity or z-index token scale exists. The thumbnail hover
0.8and the overlay stacking (1,100) are literals with reasons. - The one prop constraint, controlled
pressedwins overdefaultPressed, is already enforced by a behavioural test. CardVertical’s other rules are about composition (what a part accepts) and belong in metadatacomposition.parts.
Amendment (2026-10-08) — Harness arm result: accepted
The first exit condition is met. The pattern-accuracy harness ran Arm A (brief + API table + dependency metadata + schema) against Arm C (A + the target’s <Name>.spec.json), N = 3 runs per task, on 2026-10-08. The accept bar was registered before any run: Arm C’s median headline total ≤ Arm A’s on each task.
| Task | Arm A median (range) | Arm C median (range) | Spec conformance A → C | Prompt chars A → C |
|---|---|---|---|---|
component-button |
35 (33–35) | 0 (0–2) | 38 → 26 | 16,302 → 25,209 (+55%) |
component-cardvertical-parts |
9 (7–12) | 4 (4–6) | 46 → 39 | 57,276 → 75,152 (+31%) |
Arm C is better on both tasks, so the bar is met. The developer’s prediction (“better, especially in prop and accessibility compliance”) holds for props: spec conformance improves on both tasks. It shows nothing for accessibility: lint and a11y:coverage scored the same in both arms, and this harness never runs axe.
Why the size of the win is overstated:
- Arm A had no token catalogue. 32–34 of Button Arm A’s violations are invented token names in its metadata (
color.action.accent.background,spacing.sm). Its CSS invents unprefixed custom properties too, which this harness’s headline doesn’t count. The spec gave Arm C the real paths. The real/component-scaffoldcan read the token files, so most of the Button gap measures “no token names vs. token names”, not the spec’s other sections. The harness-ablation eval’sunknown-tokentrap measures this inside a real workspace. - A scorer false positive costs both arms 2 on CardVertical. All six runs export the right type names as
export interface, and the task’s required pattern accepts onlyexport type. Corrected medians are 7 vs. 2, so the verdict doesn’t change. The task file was locked before the run and was left as it is, per the honest-outcome rule.
What acceptance changes now:
- Specs stay optional, and only Button and CardVertical have one, until the migration below lands.
spec:validatestays incomponents-check.yml. - The “On acceptance” steps above are now the backlog, not part of this change: move
tokens,statesandaccessibilityinto the spec (with the ADR-001 amendment), switch/component-scaffoldstep 3 to write the spec, require a spec for every component with script-generatedpropsandstyles, and correct ADR-026’s Buttonshapeline. Each is its own PR. - The extra context is a real cost. Until the migration removes the duplicated sections, a builder moment that reads both files carries +31% to +55% more prompt for these two components.
- The harness-ablation eval’s Arm 1 context includes the specs.
prepare.jskeeps and strips them like metadata, including redacting the target’s own spec.
Amendment (2026-10-11) — Migration step 1: accessibility joins the spec, builder sections leave metadata for spec components
The first “On acceptance” step is done for the two components that have a spec, and made transitional for the rest. The metadata side is recorded in the ADR-001 amendment of the same date.
accessibilityis a new required spec section. It has the shape metadata had (role,notes,ariaAttributes,keyboardInteractions). The spec already hadstates(with ADR-025 kinds, richer than the metadata{ state, description }pairs) andstyles, so the move needed no other new section.- No
tokenssection in the spec. A list next tostyleswould repeat it. The token list is derived fromstylesbyspecTokensinscripts/lib.js(same five categories), andspec:validatealready checksstylesagainst the CSS Module and the built tokens. - Button and CardVertical lose
states,tokensandaccessibilityfrom metadata.spec:validateno longer checks metadata states against the spec; there are none. - The move cannot be all-or-nothing yet. Moving the sections out of all 27 metadata files needs 27 specs, which is the backfill (step 4) and needs the generator (step 3) first. Until then the validator requires the three sections in metadata when a component has no spec, and forbids them when it has one.
- Pattern output is unchanged.
npm run patterns:generateproduces the samecomponent-patterns.jsonbefore and after, because the accessibility strings it quotes now come from the spec through the same reader.
Still open from the acceptance list: /component-scaffold step 3 writing the spec (step 2), the props/styles generator (step 3), the backfill and making the spec required (step 4), and the ADR-026 Button shape line (step 5). The harness-ablation tooling (scripts/harness-ablation, scripts/pattern-accuracy-harness) still passes metadata files as context and strips specs for the target; it needs no change for this step, but a re-run after the backfill should confirm the smaller metadata does not hurt scaffolding.
Amendment (2026-10-11) — Migration step 3: spec:generate drafts a spec from the code
The acceptance list called for a script that generates props and styles so only anatomy roles and constraints are authored. It is npm run spec:generate -- <Name> (scripts/generate-spec.js). The props reader that spec:validate already used moved to scripts/spec-props.js so both scripts read the TypeScript the same way.
What it is for. The backfill of the 25 components that already exist. It describes code that is there. It does not replace /component-scaffold writing the spec before the code for a new component; run afterwards on a component that has a spec it only refreshes derived prop fields.
What it derives, and what it leaves as TODO.
| Derived from the code | Left as TODO for a person or agent |
|---|---|
| Props the component and its parts declare: type, options (in declaration order), literal defaults, a kind guess | The ADR-026 vocabulary concept of a prop it has no rule for; the Figma property and set; native props the contract depends on but the component does not declare |
Parts, from metadata composition.parts and the classes the JSX uses, with element and builtOn |
Each part’s role and action; parts with no class of their own (a label span); parent |
States, from pseudo-classes and metadata states (kind by name) |
The ADR-025 kind and trigger of any state it cannot place |
styles: token paths, option classes, [data-x] attributes, pseudo-class states, --_x custom properties resolved to the property that reads them (a second reader becomes sameAs), border shorthands split |
Why a raw value is raw; every selector it cannot place (:has(), svg descendants, ancestor state, aria-* attributes, :: elements) |
accessibility, copied from metadata |
Constraints; Figma sets and divergences |
spec:validate now fails on any string or key that starts with TODO, so a draft cannot pass until it is finished. Writing the real spec file also removes states, tokens and accessibility from the component’s metadata, because metadata:validate forbids them once a spec exists. An existing spec is only merged: its authored fields, parts, states and style rules are kept, and options/default are refreshed from the code. Drift in parts or states is reported.
Measured against the two pilot specs (run with --fresh into a scratch file, compared field by field):
| Button | CardVertical | |
|---|---|---|
| Own props found | 7 of 7 (the 3 missing are inherited disabled, type, onClick) |
25 of 25 |
| Props with kind, options and default equal | 7 of 7 | 23 of 25 (progress and Progress.value are lifecycle props, guessed as content) |
Style (property, value) pairs found |
37 of 37 | 20 of 20 |
| Style declarations on the same part and condition | 41 of 43 | 11 of 26 |
Selectors left as TODO |
0 | 5 of 17 |
For a single-element component like Button the draft is nearly the finished spec. For a compound component the values are right but placement is not: a class shared by two parts (.onMedia), ancestor state (.card:hover .thumbnail) and aria-pressed styling have to be placed by hand. The pilots’ judgment calls (builtOnProps, Menu (design-only) Figma notes, constraints) are not derivable and are not attempted.
Not tried. Running it on a component with no spec in the repo. A scratch test on Badge (generate, fill 7 TODOs, spec:validate with --components-dir) passed. Step 4 is the real test.
Amendment (2026-10-11) — Migration step 4: every component has a spec, and the spec is required
All 27 components now have a <Name>.spec.json. spec:validate fails for a component without one, and metadata:validate rejects the sections that moved (ADR-001 amendment of this date). The --components-dir mode, used by the harness runs, still checks only the specs it finds.
How the 25 were backfilled. spec:generate drafted each one, then a person completed it. Of the 25:
- 18 needed only
TODOfills (part roles, vocabulary concepts, reasons for raw values): Avatar, Badge, Box, Card, Chip, Divider, Heading, Icon, Image, Inline, ProgressBar, ScrollArea, Stack, Text, TextLink, VideoFrame, and, after the corrections below, ButtonArrow and Breadcrumb. - 7 needed their structure rewritten by hand: Accordion, AppHeader, Checkbox, CardHorizontal, DropdownMenu, Select, TextField. The generator’s guess was wrong in the same ways each time: metadata’s
accessibility.roledescribes the inner control (the Accordion trigger, the Checkbox input), not the element the generator calledRoot; classes applied conditionally (isOpen && styles.panelOuterOpen) are state, not parts; and combination states in metadata (checked-hover,disabled-unchecked) are three independent conditions that the rulewhenexpresses directly. - Box’s padding had to be written out:
data-pxis computed frompaddingX ?? padding, so each size is three rules (padding,paddingX,paddingY), in that order.
Bugs the backfill found in the generator, now fixed. The worst was silent: .panelOuterOpen .panel was merged into the always-on Panel rule, overwriting opacity: 0 with 1. Specs for Breadcrumb and ButtonArrow had also invented parts (LabelCurrent, IconLeft) from ternary classes and passed validation; an audit run found them and both were regenerated. spec:validate cannot catch either kind of error, because both leave props and token sets intact. The generator now treats && and ternary classes as conditions, attaches a modifier-only selector to the element that carries it, and leaves a TODO for state it cannot name.
Validator changes.
- A spec may have no props (Divider).
- The TypeScript options comparison applies only when the prop’s type is a literal union. Image’s
aspectRatioisstringin TypeScript and an enumerated axis in metadata. - A token that only
index.tsxreads through a template counts as read (Avatar). - A prop typed with a shared union of more than 12 literals (
IconName) iscontent, not avariantAxis(TextFieldicon, as Button’s already was).
What the backfilled specs do not contain.
- Constraints. Only Button and CardVertical have any (2 in all). Code cannot say which prop combinations are forbidden, and the other components’ rules still sit in
usage.antiPatterns. This is a gap, not a finding that none exist. - Figma read-back. Figma property names follow the ADR-026 casing rule. They were not read back from the Figma sets, except where the contract-migration handoff recorded them (
Has author,Has error,Disabled,Checked,Has progress). - Figma sets and divergences. The
figmasection is empty for the 25.
Findings the specs make visible. Six raw values in CSS Modules have an exact token and should be migrated: font-weight: 600 is font.weight.semibold (AppHeader, Breadcrumb, DropdownMenu), 4px is size.050 (ProgressBar), 3rem is size.600 (VideoFrame, twice). Each is recorded as a literal with that reason, not changed here. The other 116 literals of 122 carry a reason that holds. Also visible: the AppHeader hamburger has no click handler, Checkbox draws a 1.5px border where every other module uses 1px, and the token set has no shadow, z-index, opacity, border-width or aspect-ratio scale.
Still open from the acceptance list: the ADR-026 known-gaps line about Button shape (step 5).
Amendment (2026-10-11) — Migration step 5: what happened to the pilot’s findings
The acceptance list ended with correcting ADR-026 and deciding the findings the pilots reported. Each was checked against the repo today.
| Finding (pilot, 2026-10-07) | State | Evidence |
|---|---|---|
ADR-026 lists Button shape as a code prop with no Figma property |
Already closed | The ADR-026 amendment of 2026-10-10 (“shape was never a gap”) maps it to the Button icon set (54:1192); the known-gaps line now lists only TextField hideLabel. No edit needed. |
metadata.tokens leaves out tokens the CSS reads |
Closed by construction | The token list is derived from the spec’s styles for all 27 components (ADR-001 amendment, step 4). |
| The icon-only rule is not enforced by the types | Closed | ButtonProps is a union whose shape branch requires icon and aria-label and forbids children and trailingIcon; Button.a11y.test.tsx has @ts-expect-error cases for both; the spec constraint says enforcedBy: ["type", "test"]. |
| Button has no focus style or token | Closed | ADR-028: one token-bound :focus-visible ring in reset.css. The Button spec’s focus-visible state points to it. |
CardVertical Root sets raw min-width 220px and 160px |
Closed | size.card.min.lg and size.card.min.sm. |
CardVertical metadata.tokens lists tokens the CSS never reads |
Closed | As above. |
The 1px border width is a literal in 15 CSS Modules |
Open: needs a decision | 13 modules, 17 declarations, plus Checkbox at 1.5px and 2px. The specs record each as a literal with a reason. |
| No opacity or z-index token scale | Decided: no scale | z-index is 100 on three popovers (AppHeader, CardVertical, Select) and 1 once; opacity is 0/1 for a fade and 0.8 once. Too few distinct values to justify a scale. They stay literals with a reason. Revisit if a fourth distinct value appears. |
The six raw values with an exact token are migrated. font-weight: 600 became var(--ds-font-weight-semibold) (AppHeader, Breadcrumb, DropdownMenu), height: 4px became var(--ds-size-050) (ProgressBar), and 3rem became var(--ds-size-600) (VideoFrame, width and height). Their spec literals are now token paths. size.050 is 0.25rem, so the progress track now follows the root font size where 4px did not; at the default size it is identical. A same-machine before/after screenshot of the five components’ default stories, light and dark (10 shots), differed in 0 pixels. The committed ADR-019 baselines were not used, because they come from a different OS.
Decision for the owner: a border-width token. Recommended: add one, because 17 declarations repeat the same value and the spec now records them all as unexplained literals. It is a token-scale change (a new primitive and device alias, a Figma variable, an Airtable row), so it goes through /tokens-author as its own PR, not through this migration. Checkbox’s 1.5px and 2px should be decided in the same change: either align the box to the 1px default or add a second step.
Amendment (2026-10-12) — The Arm C exit test is frozen
The pattern-accuracy harness’s Arm C (the target’s spec added to the prompt), which this ADR used as its exit test, is frozen with the rest of the harness at 044d28f. It scored a model’s emitted metadata against a schema that still had tokens, so it can’t be re-run on the current schema. Rationale and the guard: ADR-029 amendment 2026-10-12. The accept bar for the spec rollout (scaffolding does not regress with the smaller metadata) is now checked by the first real spec-first scaffold, not by re-running Arm C.