Skip to content

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) vs selectedValue (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:

  1. 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.
  2. A props section in component.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.
  3. 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-scaffold is 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 drift report in component-patterns.json. Since 2026-10-07, scripts/generate-pattern-schema.js checks this vocabulary instead of requiring one callback name system-wide: every on<X>Change needs an x prop, every default<X> needs both x and on<X>Change, and selection components never carry a selected* 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 + onSearchChange onSearchValueChange Migrated 2026-10-07
    DropdownMenu selectedValue; onClose; rendered conditionally by the consumer value; open + onOpenChange (controlled only: the consumer owns the trigger, and the menu only ever requests false) Migrated 2026-10-07
    TextField size: default | large size: md | lg (40px/48px, the same steps as Button) Migrated 2026-10-07; Figma Size renamed in the alignment pass (amendment below)
    Chip selected (renders aria-pressed) pressed + onPressedChange, controlled only; filter rows are a labelled role=group Migrated 2026-10-07, Figma included: state=Selected split into Pressed (default false) and State (interaction)
    Card, CardHorizontal, Badge variant values default, outline No rename. Badge’s outline|filled names the look; default is 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 children
    AppHeader NavItem.active (the current page) NavItem.current (“Current item in navigation” row above). active now means the momentary press (ADR-025 amendment 2026-10-10) Migrated 2026-10-10 (amendment below)
    Accordion metadata states collapsed / expanded closed / open, the values of the open prop Migrated 2026-10-10 (amendment below)
  • Out of scope: forwarding ref to 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 ↔ Style confirmed. Button and Select already used Style; CardHorizontal’s Variant axis is renamed to Style.
  • Chip: state=Active|Hover|Selected split into Pressed (false/true) and State (Default/Hover); dropdown renamed Has dropdown (Figma-only, table above). All 30 instances kept their state.
  • Accordion list item: isExpanded=on|off|isExpanded3 → Open (true/false, default false) × State (Default/Hover). The third variant was the collapsed hover state.
  • Button: Size values Small/Default/Large → sm/md/lg. The default variant is now md + Neutral, matching code. Trailing icon (default true) toggled the leading icon; it is renamed Has icon, defaults to false, and the instance swap is renamed Icon. The 7 instances that showed the icon through the old default were pinned to true, so nothing changed visually.
  • CardHorizontal: Progress → Has progress. Button arrow: State=Active → Default (Active reads as the :active pseudo-class). CardVertical: State values recased to Default/Hover.
  • Component set names match code: Accordion list item → Accordion; the former Accordion composite → Accordion list, with its State=Expanded|Collapsed axis renamed Show more (default false), since State is reserved for interaction; Button arrow → ButtonArrow; Image placeholder → Image; Option menu → DropdownMenu; Option item → DropdownMenu/Item (its layers inside DropdownMenu → Item).
  • Checkbox (92:8772, formerly Checkbox with label): State=on|off|State3 → Checked (false/true, the native-input name) × State (Default/Hover). State3 became the Hover preview. A Label text 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. State gained Disabled for both Checked values (border border/disabled, label text/disabled, checked fill background/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 code TextField (label + input + error) and takes its name. It gains Label and Error text properties and is now a set (2879:8549) with a Has error variant. Its two variants differ only in the nested input’s Has error, so one switch shows the red border and the message; the Input swap property was removed to make that possible (rule above). This was tested on throwaway copies first. The error layer uses font/size/body-small and text/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:
    • Size is md/lg (40/48px).
    • State=Empty|Filled|Hover is split into Has value × State (Default/Hover/Focus/Disabled) plus Has 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=Outlined variants were deleted, and the axis went with them; code has one look.
    • Trailing icon (default true, but it toggled the leading icon) is now Has icon (default false) + Icon.
    • Variables follow the CSS: fill background/input, borders border/input/{default,hover}, border/selected, border/disabled, border/feedback/error, text text/default or text/disabled.
    • All 44 instances kept their variant, icon and text.
  • Select follows the TextField structure:
    • The former Select set (92:8030) is the private part _Select/Trigger. Its Style=Outlined|Filled axis 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 a Value text property. It is bound per the CSS: placeholder text/subtle, chevron icon/default, or icon/disabled when disabled.
    • A new Select set (2879:8375) holds Label, a Trigger and Error, with a Has error parent variant (rule above).
    • The 8 TextFields that wrapped a Select (all “Language” pickers) are now Select instances with the same label, value and width. This closes the Select-in-TextField gap.
  • Figma’s clone() drops componentPropertyReferences. 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 Style values, the CardVertical halo (two drop shadows bound to color/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’s outline|filled (Default had the border/default stroke only; highlight had the background/overlay/subtlest fill).
  • A new Text text property drives the label layer: the recorded children ↔ Text mapping, as on Button and Chip.
  • The Filled variant’s visible border/default stroke is now a 1px transparent stroke, matching the CSS border: 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.

  • shape was never a gap. The icon-only Button icon set (54:1192) already had Shape (Square / Round), so code shape maps 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, and icon → Icon.
  • trailingIcon: every one of the Button set’s (57:1265) 36 variants gains a TrailingIcon layer after the label, sized like that variant’s leading icon (16px at sm, 24px otherwise). The layer is driven by Has trailing icon (default false) and Trailing icon (an instance swap, default chevron-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 icon instances have no before-state to compare against. The 61 that do were unchanged, and all 136 have valid Size values after the rename.
  • Button icon Disabled and default, fixed the same day. The set gains State=Disabled for every size, shape and variant (18 variants), colored per the CSS: Accent fill background/button/disabled with an icon/inverted/disabled glyph; Neutral border border/disabled with a text/disabled glyph; Transparent a text/disabled glyph. Each Disabled variant is cloned from its Default and re-wired to the Icon swap. The set is re-gridded (rows Size × Shape, columns Variant × State), so the default is now Size=md, Shape=Square, Variant=Neutral, State=Default; code’s shape has no default. All 136 instances were compared before and after, with no difference.
  • Button Neutral Disabled matches the CSS. Its three variants (one per size) used border/default and the raw primitive neutral/neutral-8 on both glyphs. They now use border/disabled and text/disabled, as .neutral:disabled does; the label was already text/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. active marked the link for the current page and rendered aria-current="page". Since the ADR-025 amendment of 2026-10-10, active is 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. current names 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-generation grammar table.

  • Accordion metadata states: collapsed / expanded → closed / open. The vocabulary bans expanded for shown/hidden, and the component’s prop is already open, so the state names now match the prop and the Figma Open property. aria-expanded keeps 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 + menuitem for actions, listbox + option with aria-selected for a choice from value). It changes what assistive technology announces, not the look, so it is not a variant. It is not named role because that is the native HTML attribute; the props type omits role so the component sets it on the root itself.
    AppHeader logoSrcDark The Image row’s part prefix (logoSrc) plus a theme suffix. The header renders both images and CSS shows one per data-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) and Button icon
  • Checkbox (92:8772), for both Checked values
  • TextField and Select, alongside Has value and Has error
  • Any other set whose State has a Disabled value; Chip and ButtonArrow accept disabled in 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. Its Progress parts 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 no completion left.
  • Checkbox Checked=true, State=Hover (2934:8022) added to 92:8772: brand fill with an overlay/hover layer 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.