ADR-026 — Component prop vocabulary and API proposal step
Date: 2026-10-07
Status: accepted
Amended: 2026-10-12
Context
ADR-023, ADR-024 and ADR-025 each set naming rules for the problem in front of them: part names are Figma layer names, variants name an absolute weight, lifecycle state is one status enum, booleans are bare adjectives that default to false. ADR-023 deferred the rest: “The system-wide property-naming audit is a separate follow-up.” This ADR is that follow-up.
The rules exist, but nothing lists the concepts. Nothing says that “the selected value” is always value, or that “show/hide” is always open. So the same concept already has different names across the fixed set. scripts/generate-pattern-schema.js reports this in .claude/component-patterns.json → drift, but only /layout-generation reads that file (ADR-013), so a scaffold never sees it:
- Change callbacks:
onOpenChange(Accordion),onPressedChange(CardVertical.Favorite),onValueChange(Select),onSelect/onClose(DropdownMenu). - Selected value:
value(Select) vsselectedValue(DropdownMenu). - Size scales:
sm|md|lg(Button, Avatar),sm|lg(CardVertical),sm|md(Icon),default|large(TextField). - A controlled pair whose names don’t match:
searchValue+onSearchChange(AppHeader).
The ds101 references the system is built on (Component property naming, Component API design, Component usability) ask for three things: name one concept one way across components; use the same names, options and defaults in Figma and code; agree on an API (anatomy plus a props list marked Figma-only, code-only or both) before building. The first two are only partly written down. The third has no step anywhere: /component-scaffold goes straight from Figma context to four files.
The options were:
- An API template document in
docs/that every new component fills in. It would duplicate the metadata and the TypeScript props type, go stale like every doc page, and nobody would read it at the moment of naming a prop. - A
propssection incomponent.schema.json. It would make prop names machine-checkable from metadata. But it is a breaking change to the ADR-001 contract, it duplicates the TypeScript types (which are already the source of truth for props), and every one of 27 metadata files would need a migration. - A vocabulary table in the component rules, plus an API proposal checkpoint in
/component-scaffold. The vocabulary lives where it is loaded when touching component code. The checkpoint is the “agree before building” moment, and it lives in the command that produces the API.
Decision
Option 3. The vocabulary is a table in packages/components/AGENTS.md → “Prop vocabulary”. The proposal step is in /component-scaffold.
Vocabulary
One concept gets one name across the fixed set. A new component reuses these names; a prop name not in the vocabulary is a new term and needs a reason in the API proposal.
| Concept | Name | Notes |
|---|---|---|
| Visual weight or style | variant |
One per component. Values name the look or weight, not a rank (ADR-024). Never type, kind, mode, style, tone, appearance. default is allowed as the baseline value of a surface or context axis whose other values are departures from it (Card default|elevated|transparent), never on an emphasis axis (ADR-024’s Button problem). |
| Size | size |
T-shirt scale sm / md / lg, using only the steps the component has. Typography components (Text, Heading, TextLink) are the exception: their size takes type-scale names. |
| Corner shape | shape |
square / round. |
| Layout direction | orientation |
horizontal / vertical. direction is reserved for what a glyph points at (ButtonArrow). |
| Lifecycle | status |
One enum; derived from data when possible (ADR-025). |
| Selected or entered value | value / defaultValue / onValueChange |
For custom widgets. A component that spreads onto a native input keeps native names (value/checked + onChange). |
| Shown / hidden | open / defaultOpen / onOpenChange |
Disclosure, popovers and menus. Never expanded, visible or onClose alone. |
| Toggle | pressed / defaultPressed / onPressedChange |
For aria-pressed, including filter chips: each chip toggles independently, and the page owns the set. default* is omitted when the caller must always own the state. |
| Selection in a set | value on the container |
selected is never an item prop. When a group owns the choice (tabs, listbox options), the container holds value / onValueChange and items derive aria-selected from it. |
| Current item in navigation | current on the item |
Boolean, renders aria-current (page for a nav link). An item prop, unlike selected: nothing in the component chooses it and there is no change event; the page sets it from its route (AppHeader NavItem). Breadcrumb derives it from position and takes no prop. |
| Any other controlled state | <x> / default<X> / on<X>Change |
The callback name comes from the state prop’s name: searchValue → onSearchValueChange. |
| Discrete event | on<Verb>, or on<Part><Verb> for an event from a nested part |
onSelect(value) for picking an item; onUserMenuSelect. Never handle*. |
| Boolean | Bare adjective or participle, default false |
disabled, required, certified, wrap, fullWidth. Never is*, has*, show*. Exception: hideLabel. The label still renders for assistive technology, so the default-false form is “hide”. |
| Accessible name | label |
Visible label text when the component shows one; otherwise the aria-label (Icon, Breadcrumb). |
| Error | error (message string) |
Presence means invalid; there is no separate invalid boolean (ADR-025 content state). |
| Main content | children |
For wrappers and for single free-form content. A component that places several pieces of text uses one named prop per piece (title, subtitle, author; ADR-023 amendment 2026-10-02). |
| Image | src / alt |
On a component that is the image (Image, Avatar, CardVertical.Media). A composite prefixes the part: thumbnailSrc, logoSrc. |
| Collection | items |
<part>Items when a composite has more than one (navItems, userMenuItems). Form selection inputs keep the native options. |
| Icon | icon (leading) / trailingIcon |
Icon itself takes name. |
| Root element / inner heading | as / headingLevel |
as changes the component’s root element; headingLevel sets a heading the component renders inside itself (Accordion). |
| Link | href |
Figma names
Code and Figma share names, values and defaults. Casing is the only routine difference: trailingIcon in code is Trailing icon in Figma, and variant is Variant. A slot property takes its code prop’s name: the one default slot is Children; other slots take their part’s name (Body, Meta). Any other difference is a recorded mapping: it goes in the table below and is called out in the component’s API proposal. It is never an unrecorded divergence. Known mappings:
| Code | Figma | Why |
|---|---|---|
| Optional prop or optional part omitted | Has <x> boolean, default false |
An instance-swap or text layer has no “empty” option (ADR-023). An optional part left out of a composition (<CardVertical.Progress> on Root) is the same case (amendment 2026-10-12). |
Pseudo-classes (:hover, :focus-visible, :active) |
State variant |
Preview only; State holds interaction only (ADR-025). :disabled is the Disabled boolean, a casing-only match with disabled. |
children as a label string |
Text text property |
A text property, not a slot: the label layer’s content (Button, Chip). Children names only a slot property. |
Native value / placeholder (TextField) |
Has value variant + Value text property |
Figma can’t show the placeholder and a value at once. Has value=false previews the placeholder style; Value holds whichever text is shown. |
progress (CardVertical) |
Status variant (Not started / In progress / Completed) |
Preview of the lifecycle the code derives from progress (ADR-025). Figma can’t derive, so the axis is explicit there and has no code prop. |
value (ProgressBar, number 0–100) |
Value variant (0/10/20/50/80/100) |
A variant can’t hold a continuous number, so Figma previews a few steps. Same name; the values are a sample. |
aria-selected on a menu or listbox item (Derived from the container’s value) |
Selected boolean on the item, built at State=Default only |
Preview only. Figma can’t derive the selection from a container, and the item takes no selected prop in code (vocabulary: selection in a set). |
Code-only props (event handlers, default*, className, id) have no Figma property. Figma-only properties (State, Has <x>, design-only interaction states such as Menu Expanded) are listed as such.
A state that changes both the parent and a nested part is a variant on the parent (amendment 2026-10-07, TextField Has error):
- Figma can’t drive a nested instance’s variant from a parent boolean. So the parent becomes a set whose variants differ only in that state, each with the nested part preset to match. Figma keeps a designer’s nested overrides on a parent flip only for properties the two presets agree on.
- The nested instance must not be bound to an instance-swap property. A swap value is shared by every variant, so per-variant presets collapse into one.
- All other part properties stay on the part, exposed through nested instance properties. Add a parent axis only when a state really spans parent and part; multiplying axes is how configuration collapse starts (ds101, Nathan Curtis).
Figma-only properties with no code counterpart, recorded 2026-10-07:
| Component set | Property | Why it stays |
|---|---|---|
| Chip | Has dropdown |
Legacy dropdown-arrow look. Out of scope in code (Chip metadata “do not use”); kept because placed instances use it. |
| Accordion list | The whole set, Show more = false / true |
A stack of Accordion instances plus a Show more link, for page mock-ups. Code composes Accordions directly and has no list component. |
| Input Group + button | The whole component | Search input plus icon button, for page mock-ups. Code composes TextField and Button (AppHeader search). |
| Image | Size = Small / Medium / Large |
Placeholder sizes for mock-ups; code sizes by aspectRatio and its container. |
| AppHeader | Device = Desktop / Tablet / Mobile |
One preview per device breakpoint. Code reflows with device tokens and media queries in its CSS Module, and has no prop. |
Known gaps (code props with no Figma property): TextField hideLabel (Figma can’t invert a boolean onto a layer’s visibility; hide the Label layer by hand).
API proposal step
Before writing any file, /component-scaffold produces an API proposal and stops for the developer’s approval:
- Anatomy: the named parts and layers. These names become Figma layer names and, if the component has parts,
composition.parts(ADR-023). - Props table: one row per prop, with its vocabulary concept (or “new term” with a reason), kind (variant axis, lifecycle, content, controlled state, event, native), type, default, and Figma property, marked both, code-only or Figma-only.
- Divergences: any Figma property that differs from code by more than casing, and any value that departs from the vocabulary.
The developer approves or edits the table; only then are the four files generated. Inside /add-component this is part of Stage 1, so the loop gains a checkpoint before code exists, in addition to the visual checkpoint after it.
Consequences
-
One reference answers “what do I call this prop?”. It is loaded with the component rules, so it is in front of the scaffold and the reviewer at the moment a name is chosen. The ADR-025 naming bullet folds into it.
-
Naming arguments happen once, on a table, before code and Figma exist. That is cheaper than renaming afterwards, which ADR-024 showed costs a migration in code, metadata, stories and Figma.
-
/component-scaffoldis no longer one-shot: it waits for a reply. That is the point of the step, and it matches the developer-present nature of the moment. -
No new gate yet. Detection is the
driftreport incomponent-patterns.json. Since 2026-10-07,scripts/generate-pattern-schema.jschecks this vocabulary instead of requiring one callback name system-wide: everyon<X>Changeneeds anxprop, everydefault<X>needs bothxandon<X>Change, and selection components never carry aselected*prop. A blocking check (adding banned boolean prefixes) is still a follow-up. -
Existing drift is migrated in a separate PR. These are breaking renames, each touching code, metadata, stories and Figma, so they ship apart from this decision:
Component Before After State AppHeader searchValue+onSearchChangeonSearchValueChangeMigrated 2026-10-07 DropdownMenu selectedValue;onClose; rendered conditionally by the consumervalue;open+onOpenChange(controlled only: the consumer owns the trigger, and the menu only ever requestsfalse)Migrated 2026-10-07 TextField size: default | largesize: md | lg(40px/48px, the same steps as Button)Migrated 2026-10-07; Figma Sizerenamed in the alignment pass (amendment below)Chip selected(rendersaria-pressed)pressed+onPressedChange, controlled only; filter rows are a labelledrole=groupMigrated 2026-10-07, Figma included: state=Selectedsplit intoPressed(defaultfalse) andState(interaction)Card, CardHorizontal, Badge variantvaluesdefault,outlineNo rename. Badge’s outline|fillednames the look;defaultis a baseline value (vocabulary row above)Closed 2026-10-07 Badge label(the badge’s text)children(single free-form content, vocabulary row above)Migrated 2026-10-10, Figma included (amendment below). Found 2026-10-08 by the harness-ablation pilot, where every arm named it childrenAppHeader NavItem.active(the current page)NavItem.current(“Current item in navigation” row above).activenow means the momentary press (ADR-025 amendment 2026-10-10)Migrated 2026-10-10 (amendment below) Accordion metadata statescollapsed/expandedclosed/open, the values of theopenpropMigrated 2026-10-10 (amendment below) -
Out of scope: forwarding
refto the root element (ds101 usability checklist, question 9). No component forwards one today. That is a behaviour gap, not a naming one, tracked in issue #114.
Amendment (2026-10-07) — Figma alignment pass
The file was brought in line with the vocabulary by renaming properties in place, so placed instances keep their state. No components were created or deleted.
variant↔Styleconfirmed. Button and Select already usedStyle; CardHorizontal’sVariantaxis is renamed toStyle.- Chip:
state=Active|Hover|Selectedsplit intoPressed(false/true) andState(Default/Hover);dropdownrenamedHas dropdown(Figma-only, table above). All 30 instances kept their state. - Accordion list item:
isExpanded=on|off|isExpanded3→Open(true/false, defaultfalse) ×State(Default/Hover). The third variant was the collapsed hover state. - Button:
SizevaluesSmall/Default/Large→sm/md/lg. The default variant is nowmd+Neutral, matching code.Trailing icon(defaulttrue) toggled the leading icon; it is renamedHas icon, defaults tofalse, and the instance swap is renamedIcon. The 7 instances that showed the icon through the old default were pinned totrue, so nothing changed visually. - CardHorizontal:
Progress→Has progress. Button arrow:State=Active→Default(Activereads as the:activepseudo-class). CardVertical:Statevalues recased toDefault/Hover. - Component set names match code:
Accordion list item→Accordion; the formerAccordioncomposite →Accordion list, with itsState=Expanded|Collapsedaxis renamedShow more(defaultfalse), sinceStateis reserved for interaction;Button arrow→ButtonArrow;Image placeholder→Image;Option menu→DropdownMenu;Option item→DropdownMenu/Item(its layers inside DropdownMenu →Item). - Checkbox (
92:8772, formerlyCheckbox with label):State=on|off|State3→Checked(false/true, the native-input name) ×State(Default/Hover).State3became the Hover preview. ALabeltext property now drives the label layer. The box, check and label are rebound to the variables the code reads (background/input,background/brand,border/input/{default,hover}at 1.5px,text/inverted/default,text/default). All 12 instances kept their checked state and labels.StategainedDisabledfor bothCheckedvalues (borderborder/disabled, labeltext/disabled, checked fillbackground/disabled), so every metadata state has a preview. The box’s main component (92:8764), which had been removed from the canvas while its instances stayed, is back on the Checkbox page as_Checkbox/Box(private, unpublished), defaulting to the unchecked look; variants override fill, stroke and check per state. - TextField: the Figma
Input Group(71:1189) is the codeTextField(label + input + error) and takes its name. It gainsLabelandErrortext properties and is now a set (2879:8549) with aHas errorvariant. Its two variants differ only in the nested input’sHas error, so one switch shows the red border and the message; theInputswap property was removed to make that possible (rule above). This was tested on throwaway copies first. The error layer usesfont/size/body-smallandtext/feedback/error. Its nested input’s properties are exposed on the parent, and the input fills the field’s width as in code.Input field(92:8005) becomes the private part_TextField/Input:Sizeismd/lg(40/48px).State=Empty|Filled|Hoveris split intoHas value×State(Default/Hover/Focus/Disabled) plusHas error(with an empty+error row for “required field left empty”), covering every metadata state. The new Focus, Disabled and error rows are duplicates of existing variants.- The 12 unused
Style=Outlinedvariants were deleted, and the axis went with them; code has one look. Trailing icon(defaulttrue, but it toggled the leading icon) is nowHas icon(defaultfalse) +Icon.- Variables follow the CSS: fill
background/input, bordersborder/input/{default,hover},border/selected,border/disabled,border/feedback/error, texttext/defaultortext/disabled. - All 44 instances kept their variant, icon and text.
- Select follows the TextField structure:
- The former
Selectset (92:8030) is the private part_Select/Trigger. ItsStyle=Outlined|Filledaxis is gone: code has one look, and the single Outlined use was moved to Filled. - Its properties are now
Has value×State(Default/Hover/Focus/Disabled) ×Has error(with empty+error) and aValuetext property. It is bound per the CSS: placeholdertext/subtle, chevronicon/default, oricon/disabledwhen disabled. - A new
Selectset (2879:8375) holdsLabel, aTriggerandError, with aHas errorparent variant (rule above). - The 8 TextFields that wrapped a Select (all “Language” pickers) are now
Selectinstances with the same label, value and width. This closes the Select-in-TextField gap.
- The former
- Figma’s
clone()dropscomponentPropertyReferences. A duplicated variant must be re-wired to the set’s properties, or its layers stop responding to them. - Figma picks a set’s default variant by canvas position (top-left), not layer order, so defaults were set by moving variants.
- ADR-024’s Figma follow-ups were already done: the Button
Stylevalues, the CardVertical halo (two drop shadows bound tocolor/icon/on-media/halo), and the variables.
Amendment (2026-10-08) — Badge label is drift
The harness-ablation pilot rebuilt Badge three times and every run named its text children, which is what the Content row prescribes for single free-form content. Badge’s shipped label predates the vocabulary. It joins the migration table as open drift; the rename is a separate breaking PR like the others.
Amendment (2026-10-10) — Variant in Figma, Children for a default slot
variant ↔ Style is no longer a recorded mapping. Its rationale was that a property named Variant is ambiguous in the Figma UI. Figma’s own Simple Design System (figma/sds, Button.figma.ts reads getEnum("Variant", …)) names it Variant, and Style collides with Figma’s color and text styles. Every other mapping in the table exists because Figma can’t express something; this one was a preference. appearance (Atlassian, Fluent) was considered and rejected: variant is the majority name (MUI, Radix, Chakra, shadcn, Primer, Polaris, React Spectrum, SDS), and agents default to it, so the code name stays and Figma follows it with a casing-only difference.
Slot properties are named after their code prop. A part or component whose one free-form region maps to children in code names that slot Children in Figma (Nathan Curtis, “Implementing Slots in a Figma Library”: name the default slot children or items). A component with several regions names each slot after its part (Body). A label string is a Text text property, not a slot.
Renamed in place in the Figma file; all 326 placed instances kept their values (read back before and after):
| Node | Set | Before | After | Instances |
|---|---|---|---|---|
57:1265 |
Button | Style |
Variant |
54 |
54:1192 |
Button icon | Style |
Variant |
136 |
130:7289 |
CardHorizontal | Style |
Variant |
91 |
143:4417 |
brand-logo (Figma-only) | Style |
Variant |
37 |
2871:7505 |
CardVertical.Meta | slot Meta |
slot Children |
8 |
CardVertical.Root’s Body slot keeps its name: Root has several regions, and Body is the part’s name. No Figma property is named Style any more.
Amendment (2026-10-10) — Badge label → children migrated
Badge now takes its text as children: ReactNode, like Chip. All call sites moved: Badge and CardVertical stories, and the showcase’s CourseOverview, Dashboard, PipelineDag and PipelineDetailPanel. The Figma Badge set (28:1134, previously recorded as having no Figma node) is aligned to the same API, in place, with all 13 instances keeping their variant and text:
type=Default|highlight→Variant=Outline|Filled. The values match code’soutline|filled(Default had theborder/defaultstroke only; highlight had thebackground/overlay/subtlestfill).- A new
Texttext property drives the label layer: the recordedchildren↔Textmapping, as on Button and Chip. - The Filled variant’s visible
border/defaultstroke is now a 1px transparent stroke, matching the CSSborder: 1px solid transparent. Code is the source of truth (ADR-002).
Amendment (2026-10-10) — Button trailingIcon and shape in Figma
Two of the three known gaps are closed. TextField hideLabel remains.
shapewas never a gap. The icon-onlyButton iconset (54:1192) already hadShape(Square/Round), so codeshapemaps to it with recased values. That set’s own drift is fixed in place, the same way as the Button alignment pass:size=Small|Medium|Large→Size=sm|md|lg, andicon→Icon.trailingIcon: every one of theButtonset’s (57:1265) 36 variants gains aTrailingIconlayer after the label, sized like that variant’s leading icon (16px atsm, 24px otherwise). The layer is driven byHas trailing icon(defaultfalse) andTrailing icon(an instance swap, defaultchevron-down): the recorded “optional prop omitted ↔Has <x>” mapping. All 54 placed instances keep the layer hidden, with variant, text, leading icon and width unchanged.- Verification gap: the before-snapshot was cut off by figma-cli’s 20,000-character output limit, so 75 of the 136
Button iconinstances have no before-state to compare against. The 61 that do were unchanged, and all 136 have validSizevalues after the rename. Button iconDisabled and default, fixed the same day. The set gainsState=Disabledfor every size, shape and variant (18 variants), colored per the CSS: Accent fillbackground/button/disabledwith anicon/inverted/disabledglyph; Neutral borderborder/disabledwith atext/disabledglyph; Transparent atext/disabledglyph. Each Disabled variant is cloned from its Default and re-wired to theIconswap. The set is re-gridded (rows Size × Shape, columns Variant × State), so the default is nowSize=md, Shape=Square, Variant=Neutral, State=Default; code’sshapehas no default. All 136 instances were compared before and after, with no difference.ButtonNeutral Disabled matches the CSS. Its three variants (one per size) usedborder/defaultand the raw primitiveneutral/neutral-8on both glyphs. They now useborder/disabledandtext/disabled, as.neutral:disableddoes; the label was alreadytext/disabled. All 54 instances were compared before and after, with no difference.
Amendment (2026-10-10) — Component contract migration, Tier 1
The first tier of the contract migration (vocabulary drift left after the earlier rows) ships the breaking renames and records the remaining props that are outside the vocabulary.
-
AppHeader
NavItem.active→current.activemarked the link for the current page and renderedaria-current="page". Since the ADR-025 amendment of 2026-10-10,activeis the momentary press on every interactive component, and the nav link gets that state in Tier 2, so the two meanings would have met on one element.currentnames the ARIA attribute it drives and is now a vocabulary row. The CSS class follows (.navLinkCurrent). Call sites moved: AppHeader and Layout stories, the showcase’s Homepage and CourseOverview, and the/layout-generationgrammar table. -
Accordion metadata
states:collapsed/expanded→closed/open. The vocabulary bansexpandedfor shown/hidden, and the component’s prop is alreadyopen, so the state names now match the prop and the FigmaOpenproperty.aria-expandedkeeps its name: it is the ARIA attribute, not a prop. -
Recorded new terms (outside the vocabulary, each with its reason):
Component Prop Why it is not a vocabulary name DropdownMenu listRole: 'menu' | 'listbox'Picks the ARIA pattern ( menu+menuitemfor actions,listbox+optionwitharia-selectedfor a choice fromvalue). It changes what assistive technology announces, not the look, so it is not avariant. It is not namedrolebecause that is the native HTML attribute; the props type omitsroleso the component sets it on the root itself.AppHeader logoSrcDarkThe Image row’s part prefix ( logoSrc) plus a theme suffix. The header renders both images and CSS shows one perdata-theme, because a logo is an asset, not a token, and can’t follow the theme through a variable. Optional: without it the light logo shows in both themes.
Amendment (2026-10-11) — Disabled leaves State
ADR-025 (amendment 2026-10-11) makes disabled a configured flag. In Figma it becomes a Disabled boolean (default false, ordered first), a casing-only match with code, so it is no longer covered by the pseudo-classes mapping. State keeps only Default/Hover/Focus/Active. The dated entries above that add State=Disabled record what was true then.
Sets to migrate via /figma-cli. Each splits State=Disabled into Disabled=true, State=Default, builds disabled only at State=Default, and keeps every instance’s look (compared before and after):
Button(57:1265) andButton iconCheckbox(92:8772), for bothCheckedvaluesTextFieldandSelect, alongsideHas valueandHas error- Any other set whose
Statehas aDisabledvalue;ChipandButtonArrowacceptdisabledin code, so check their sets first.
Until a set is migrated, its State=Disabled stands as a pending exception.
Amendment (2026-10-11) — Component contract migration, Tier 3 (Figma alignment)
Tier 3 aligns the Figma file with the contract (ADR-025 state groups, this ADR’s names), interactively through /figma-cli. Every rename is made in place on the existing variants. Nothing is cloned or moved, so every placed instance keeps its main component. Each set’s instances (main component id and size) were snapshotted before the change and compared after, and none changed.
Disabled leaves State in every set. State=Disabled becomes Disabled=true, State=Default, and every other variant becomes Disabled=false. Disabled is the first property, and each set’s default variant is unchanged. That closes the pending list in the amendment above, and no State in the file has a Disabled value any more.
| Node | Set | Disabled variants | Instances |
|---|---|---|---|
57:1265 |
Button | 12 of 48 | 54 |
54:1192 |
Button icon | 18 of 72 | 136 |
92:8772 |
Checkbox | 2 of 5 | 12 |
61:1228 |
ButtonArrow | 2 of 6 | 34 |
92:8005 |
_TextField/Input |
4 of 28 | 45 |
92:8030 |
_Select/Trigger |
1 of 7 | 8 |
The outer TextField (2879:8549) and Select (2879:8375) sets carry only Has error. Their Has value × State axes live on the nested parts above (the parent-and-part rule). Chip has no disabled preview.
State=Active previews (values from the ADR-025 Tier 2 table): ButtonArrow ×2 (overlay/active fill), DropdownMenu/Item, Accordion Open=false (overlay/active alone, because the closed trigger is transparent in code), and Checkbox for both Checked values (overlay/active layered over the container fill). The Accordion Open=false, State=Hover variant filled with container/elevated; it now uses overlay/hover, as the CSS does. Two of its text layers used a missing font family and now use the open variant’s text style.
Linked and renamed sets. Each of these sets now matches the code vocabulary. Four components that had no Figma node now point to one (figmaNodeId).
| Node | Before | After | Instances |
|---|---|---|---|
95:16289 |
Avatar Size=Small/Medium/Large (default Small) |
Size=sm/md/lg, default md like code |
37 |
54:1259 |
Progress bar, completion |
ProgressBar, Value (recorded mapping) |
219 |
82:2242 |
Header main, headerLayout |
AppHeader, Device (Figma-only) |
16 |
86:3211 |
Breadcrumb (single component) | unchanged, now linked | 16 |
130:8323 |
breadcrumb link, State=Active/hover/Current (Active was the resting look) |
_Breadcrumb/Link, Current × State=Default/Hover/Active. The current label now uses text/default like .labelCurrent, which darkens the 17 current-page instances. |
51 |
130:8676 |
Nav item, Property 1=Default/Variant2 |
_AppHeader/NavItem, Current × State=Default/Hover/Active. Variant2 had no instances and became Current=true with text/brand, like .navLinkCurrent. |
22 |
86:3505 |
Show more link, state=collapsed/expanded/state3/state4 × Property |
Open × State=Default/Hover (part of the Figma-only Accordion list) |
8 |
155:6236 |
DropdownMenu/Item, State |
Selected × State; Selected=true uses text/brand at weight 600, like .itemSelected |
24 |
130:7289 |
CardHorizontal Variant=default/inverted |
Variant=Default/Inverted, the same casing as Badge’s Outline/Filled |
91 |
figmaNodeId is a node id or none. The field held free text (N/A — …, note: …, ""). scripts/sense-component.js treated that text as a node id, and scripts/airtable-sync.js only strips values starting with none. component.schema.json now enforces ^([0-9]+:[0-9]+|none)$. none covers layout primitives, text-style components (Text, Heading, VideoFrame) and components with no set (Card, Divider, TextLink). Icon’s old id pointed to a section of 32 single-glyph components, not a set, so Icon is none too.
Variable push: color/background/on-media/active (Theme, both modes → color/black/alpha-6), the Tier 2 token. It was the only variable missing.
Left for Tier 4: CardVertical.Root still exposes its progress part as completion, and Checkbox has no Checked=true, State=Hover cell.
Amendment (2026-10-11) — Component contract migration, Tier 4 (close-out)
The two items Tier 3 left open are closed:
- CardVertical.Root progress already reads
Value. ItsProgressparts are instances of the ProgressBar set, so the Tier 3 rename (completion→Value) carried through. A search of every set, component and instance in the file finds nocompletionleft. - Checkbox
Checked=true, State=Hover(2934:8022) added to92:8772: brand fill with anoverlay/hoverlayer and the brand border, matching the code fix recorded in ADR-025 (amendment “Checkbox checked hover”). The set now has 8 variants; its default variant is unchanged.
The metadata composedOf lists of AppHeader, Badge and Breadcrumb now match their JSX, so component-patterns.json reports no drift.
Amendment (2026-10-12) — Has <x> covers optional parts
The Has <x> recorded mapping was worded for an optional prop. On a Parent.Root, what a consumer leaves out is an optional part, not a prop: CardVertical.Root previews <CardVertical.Progress> with Has progress. That is the same mapping, so the row now reads “optional prop or optional part omitted”. It isn’t a Figma-only property with no code counterpart: the counterpart is rendering the part or not. Surfaced by the answer-key read-back for the Figma fidelity measurement (.claude/handoff/2026-10-12-figma-fidelity-measurements.handoff.md); see ADR-023 amendment 2026-10-12 for the fixed-part rule.