@castui/cast-ui 4.8.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,834 @@
1
+ ---
2
+ name: cast-ui-component
3
+ description: The finalized playbook for adding or extending a component in @castui/cast-ui end to end. Design tokens, Figma component, React Native code, and the design↔code association (Figma component-description text, with Code Connect as an optional plan-gated upgrade). So design and production stay 1:1 and the cast-sync Figma plugin keeps working. Use whenever building/extending a component (Accordion, Heading, Progress, Spinner, Tabs, or any new one), creating or altering design tokens, adding icons, wiring a component into Figma via MCP, reconciling Figma ↔ code, or documenting the design↔code mapping. Covers token requirements, the Icon/slot architecture, density themes, cast-sync colour contract, where colours/sizes must live (sub-component vs component), the Figma description template, and the code package conventions.
4
+ ---
5
+
6
+ # Cast UI Component Architecture Skill
7
+
8
+ Master reference for shipping a component into `@castui/cast-ui` without breaking
9
+ the design-token contract or the **cast-sync** Figma plugin. Work the phases in
10
+ order: **tokens → Figma → code → design↔code association**. That ordering is the
11
+ **build sequence** for a new component, not a hierarchy of truth: once built, the
12
+ Figma component and the code component are **co-equal, bidirectional mirrors**
13
+ kept in sync both ways (§5).
14
+
15
+ **What Cast UI is:** a cross-platform React Native library (iOS / Android / Web
16
+ via react-native-web), **zero runtime dependencies**, a 3-layer design-token
17
+ system exported from Figma, and runtime **density** theming. Reference component:
18
+ `src/components/Button/Button.tsx`. Copy its shape when unsure. Figma file key:
19
+ `JGtlpxLPJMZcwvQ3UZ9ZUl` (`cast-ui-kit`). Full node map: Appendix A.
20
+
21
+ State of the package: **37 components, all built and exported** (v4.10.0),
22
+ plus the motion token system (§M). There are no scaffolded-empty components.
23
+ The docs site (site/, deployed to GitHub Pages) documents every component
24
+ from a registry; a new component is not done until its registry entry exists
25
+ (see the cast-ui-docs-site skill).
26
+
27
+ ---
28
+
29
+ ## 0. Non-negotiable contracts
30
+
31
+ Every decision downstream is constrained by these.
32
+
33
+ 1. **Three token layers, each aliasing the one below:** Component → Semantic →
34
+ Primitive. **Bind to the right layer per category:** *colour* component
35
+ bindings always go through the `semantic` intent layer; *typography* through
36
+ the semantic type scales; *shared spacing* (gap/padding/border-radius) aliases
37
+ the `semantic` `control-*` family **where one matches**; but a
38
+ **component-specific *dimension*** with no shared semantic (track-height,
39
+ dot-size, indicator-size) aliases a **primitive `size/`·`space/`·`radius/`
40
+ token directly**. The real, verified file convention (Badge `dot-size`→
41
+ `size/*`, Toggle `track-height`→`size/*`, Progress `track-height`→`size/*`,
42
+ Tabs `indicator-height`→`size/*`). Never bind a raw literal. (§1)
43
+ 2. **Density changes spacing only (~25% of tokens).** `compact | default |
44
+ comfortable` vary padding / gap / spacing. Colours, border-radius, focus
45
+ rings, icon sizes, and typography are **constant** across densities. (§1)
46
+ 3. **Colours stay in `intent → prominence → state → {bg, fg, border}`.** This is
47
+ the exact shape cast-sync exports and `ThemeProvider` consumes. Anything
48
+ outside it is silently dropped by the plugin. (§2)
49
+ 4. **Icons are the slot-based `<Icon>` component, rendering Material Symbols
50
+ Outlined.** Code renders by font ligature; Figma uses a slot. (§3)
51
+ 5. **Fonts: Inter / JetBrains Mono / Noto Serif via `Platform.select`.**
52
+ Typography is constant across densities. (§4)
53
+ 6. **Props mirror Figma properties 1:1**. Same names, same values, both sides.
54
+ (§5)
55
+ 7. **Colours/sizes live at the lowest source**. In the sub-component if one
56
+ exists, else in the component's own variants; **never as style overrides on a
57
+ parent**. Fix at base-component level so instances inherit. (§6)
58
+ 8. **Zero runtime dependencies.** Only `react` + `react-native` peer deps.
59
+
60
+ ---
61
+
62
+ ## Writing style (all documentation)
63
+
64
+ Everything this skill writes uses plain, simple language. This covers Figma
65
+ component descriptions, code header comments, story text, PR notes, and this
66
+ skill itself.
67
+
68
+ Rules:
69
+
70
+ 1. No em dashes. Use a full stop, a comma, or a colon instead.
71
+ 2. Short, direct sentences. One idea per sentence.
72
+ 3. Plain words. Say what a thing does, not how clever it is.
73
+ 4. No AI writing tells. Avoid "not just X but Y", "it's worth noting",
74
+ "in order to" (write "to"), filler, and rule-of-three flourishes.
75
+ 5. Dev, MCP, and agent notes may use the exact technical term when it is needed,
76
+ and no more.
77
+ 6. If a word can be cut and the meaning stays, cut it.
78
+ 7. Refer to a component with angle brackets: <Button>, <Tabs>. Write a private
79
+ sub-component with its underscore: _<Tab>.
80
+
81
+ ## 1. Design tokens (build FIRST, via Figma MCP)
82
+
83
+ A component is only correct when its spacing resolves through the semantic layer.
84
+ Never hardcode padding/gap or invent numbers.
85
+
86
+ ### 1.1 The three layers
87
+
88
+ ```
89
+ Component design-tokens/component/component-{compact,default,comfortable}.tokens.json
90
+ ↓ aliases (com.figma.aliasData → targetVariableName in collection "semantic")
91
+ Semantic design-tokens/semantic.tokens.json
92
+ ↓ aliases
93
+ Primitive design-tokens/primitive.tokens.json (raw values: colour, space, size, radius, opacity, type)
94
+ ```
95
+
96
+ DTCG format with Figma extensions. The JSON files are the Figma **sync target**;
97
+ the lean runtime copies are `src/tokens/` (constants) + `src/theme/themes.ts`
98
+ (density spacing). When Figma tokens change, update the TS to match.
99
+
100
+ **Variable collections** (verify ids live via `get_variable_defs`):
101
+ `primitive`, `component` (3 modes: compact/default/comfortable), `semantic`
102
+ (2 modes: semantic-light / semantic-dark), and `motion` (1 mode — durations,
103
+ cycles, easing beziers as x1/y1/x2/y2 floats, springs, and semantic
104
+ transition/feedback/loop roles aliasing them). Motion is constant across
105
+ density and colour mode. Committed mirror: `design-tokens/motion.tokens.json`.
106
+
107
+ ### 1.2 Reuse semantic tokens. Don't mint primitives
108
+
109
+ Component spacing aliases the shared **`control`** family in `semantic`, e.g.
110
+ Button `large/gap` → `control-lg/gap/default`, `border-radius` →
111
+ `control-lg/border-radius`, focus ring → `control/focus-ring-width`. The
112
+ `semantic` collection exposes: `control-sm`, `control-lg`, `control`, `intent`,
113
+ `overlay`, `display`, `heading`, `title`, `body`, `label`, `caption`, `text`,
114
+ `surface`, `neutral`.
115
+
116
+ **Rule:** for shared control *spacing* (gap, padding, border-radius) alias the
117
+ existing `control-*` semantic family **where one matches**. For a
118
+ **component-specific dimension** (track-height, dot-size, indicator-size) bind
119
+ the **primitive `size/`/`space/` scale directly**. Matching the verified
120
+ Badge/Toggle convention; do **not** mint a new semantic for a one-component
121
+ dimension. Mint a new semantic token only for a genuinely *shared, reusable* new
122
+ concept, and that semantic must itself alias a primitive, never a literal.
123
+ Primitive scale (for reference): `size/2`·`space/2`=2, `size/3`·`space/3`=4,
124
+ `4`=6, `5`=8, `6`=10, `7`=12, `8`=14, `9`=16, `10`=20, `11`=24, `12`=32, `13`=40;
125
+ pill radius = `radius/full` (9999).
126
+
127
+ **Spacing. Verify before you reuse.** "Alias `control-*` where one matches" means
128
+ *resolve the control-* values across all three density modes and compare*. If the
129
+ component's spacing is bespoke (tighter or looser than the standard control), the
130
+ control-* family will **not** match and the **primitive `space/N` binding is the
131
+ correct fallback**, not a violation. *Worked example. Tabs:* a tab runs tighter
132
+ than a button, so `tabs/{size}/{gap,padding-x,padding-y}` legitimately bind
133
+ `space/N` directly (e.g. `tabs/default/padding-x` = 6/7/9 ≠ `control/padding/x` =
134
+ 6/8/10). Don't force a control-* alias that doesn't resolve to the same numbers.
135
+
136
+ *Worked example. Progress:* `progress/{small,default,large}/track-height` →
137
+ `size/{3,5,7}` (4/8/12), `progress/border-radius` → `radius/full`, identical
138
+ across all three density modes (track thickness is keyed by `size`, constant
139
+ across density). *Worked example. Tabs:* `tabs/{size}/indicator-height` →
140
+ `size/{2,2,3}` (2/2/4), `tabs/indicator-radius` → `radius/full`, both constant
141
+ across density; `tabs/list-gap` → `space/{5,9,11}` (8/16/24, density-varying).
142
+
143
+ ### 1.3 Density rule
144
+
145
+ Only `gap` / `padding*` / spacing may differ across the three density blocks.
146
+ If a colour, radius, focus ring, or icon size differs by density, it's wrong.
147
+ Those are constant and live at the top level of the theme tokens, not inside the
148
+ per-size/per-density blocks.
149
+
150
+ ### 1.4 Create the tokens as Figma variables (MANDATORY. Not optional)
151
+
152
+ **Writing the runtime TS (§1.5) is NOT a substitute for this step.** The runtime
153
+ copies and the Figma variables are two separate artifacts; a component is only
154
+ "tokenised" when the variables exist in the `cast-ui-kit` file **and** mirror the
155
+ TS. Skipping the Figma side leaves design with nothing to bind and silently
156
+ breaks the cast-sync round-trip. Create the variables via the Figma MCP
157
+ (`use_figma`, after loading the `figma-use` skill):
158
+
159
+ 1. **Inspect first.** `get_variable_defs` on the closest existing analog
160
+ (Progress↔Toggle/Badge for a track; reuse its exact naming + binding pattern).
161
+ Collections: `primitive` (1 mode), `component` (3 density modes:
162
+ compact/default/comfortable), `semantic` (semantic-light / semantic-dark).
163
+ 2. **Create by token category. Bind to the right layer:**
164
+ - **Spacing (gap/padding):** `component` var `{name}/{size}/{prop}` → alias the
165
+ `control-*` semantic if its values match, else primitive `space/N`. Scope
166
+ `GAP` (the GAP scope covers both itemSpacing and padding).
167
+ - **Component dimension (track-height, indicator-size, dot-size):** `component`
168
+ var → alias primitive `size/N` **directly** (Badge/Toggle/Tabs convention).
169
+ Scope `WIDTH_HEIGHT`.
170
+ - **Border-radius:** `component` var `{name}/border-radius` (or `{name}/
171
+ indicator-radius` etc. where clearer) → primitive `radius/N` (pill =
172
+ `radius/full`). Scope `CORNER_RADIUS`.
173
+ - **Colour. Three cases, pick the right one:**
174
+ - *Intent-driven* fills/text (a button bg, a progress **fill**, a badge, a
175
+ selected tab's label + indicator) reuse the existing
176
+ `intent/{intent}/{prominence}/{state}/{bg|fg|border}` semantics. Never mint
177
+ a per-component variable for these. (A selected-tab label/indicator is a
178
+ *text/line* colour → bind the **`fg`**, e.g.
179
+ `intent/{intent}/default/default/fg`, not a `bg`.)
180
+ - *A bespoke non-intent control surface* (a progress **track**, a toggle
181
+ off-state, a **tabs baseline track**) gets its **own dedicated semantic**
182
+ in the `control/{component}/...` namespace, aliasing a primitive grey.
183
+ This is the verified convention (`control/toggle/off/bg`,
184
+ `control/progress/track/bg`, `control/tabs/track/bg`, all → cool-grey/200
185
+ light · cool-grey/700 dark). Do **not** reuse `surface/subtle` for this;
186
+ mirror it in code as a `{component}Colors` slice on the scheme
187
+ (`scheme.progress.track`, `scheme.tabs.track`, `scheme.toggle.track.off`).
188
+ - *Generic* page/overlay/description colours use `surface/*`, `text/*` (a
189
+ tab's unselected label = `text/muted`; hovered = `text/primary`).
190
+ Extend the intent axis itself only for a genuinely new colour concept (§2).
191
+
192
+ **Only bind the colours the component actually has.** A *fill-only* element
193
+ (a progress bar, a track, a divider) has **no `fg`**. Don't invent one. A
194
+ tab binds the intent `fg` (text + indicator line) and has no `bg`. And bind
195
+ only the intent **states** the component can enter.
196
+ - **Text:** apply the kit Text Styles (already bound to `semantic` typography
197
+ vars); don't mint per-component type tokens.
198
+ 3. **Set `scopes` explicitly** on every new variable (`GAP`, `WIDTH_HEIGHT`,
199
+ `CORNER_RADIUS`, …). Never leave `ALL_SCOPES`.
200
+ 4. **Provide all three density modes**; a constant takes the same alias in each.
201
+ 5. **Verify** each created variable by re-resolving `valuesByMode` to the target
202
+ name. Then **re-export** the three `component-*.tokens.json` via cast-sync so
203
+ `design-tokens/component/` picks up the new `com.figma.aliasData`. Hand-editing
204
+ those export JSONs is discouraged. Regenerate from Figma instead.
205
+
206
+ #### Worked recipe. Create aliased component variables via `use_figma`
207
+
208
+ ```js
209
+ const cols = await figma.variables.getLocalVariableCollectionsAsync();
210
+ const comp = cols.find(c => c.name === 'component');
211
+ const modeIds = comp.modes.map(m => m.modeId); // compact, default, comfortable
212
+
213
+ // 1. Idempotency guard. Re-running double-creates otherwise.
214
+ for (const id of comp.variableIds) {
215
+ const v = await figma.variables.getVariableByIdAsync(id);
216
+ if (v.name.startsWith('progress/')) return { aborted: 'progress vars exist' };
217
+ }
218
+
219
+ // 2. Fetch the primitive target (size/5 = 8) by its id.
220
+ const sizePrim = await figma.variables.getVariableByIdAsync('VariableID:183:51');
221
+
222
+ // 3. Create the FLOAT var, set EXPLICIT scopes, bind the SAME alias to every mode.
223
+ const v = figma.variables.createVariable('progress/default/track-height', comp, 'FLOAT');
224
+ v.scopes = ['WIDTH_HEIGHT']; // never leave ALL_SCOPES
225
+ const alias = figma.variables.createVariableAlias(sizePrim); // takes the Variable OBJECT
226
+ for (const m of modeIds) v.setValueForMode(m, alias); // constant ⇒ same alias per mode
227
+
228
+ // 4. Verify by re-resolving valuesByMode → target id/name before returning.
229
+ const chk = await figma.variables.getVariableByIdAsync(v.id);
230
+ return { id: v.id, ok: Object.values(chk.valuesByMode).every(x => x.id === sizePrim.id) };
231
+ ```
232
+
233
+ `createVariable(name, collectionObject, 'FLOAT')` accepts the collection object;
234
+ `createVariableAlias(targetVar)` takes the **Variable object** (not an id);
235
+ a *constant-across-density* token binds the **same** alias in all three modes.
236
+ **Always re-resolve and verify** in the same call. Keep each creation script to
237
+ one logical batch (≤10 vars) per `use_figma` call.
238
+
239
+ ### 1.5 Mirror into runtime TS
240
+
241
+ 1. `src/theme/types.ts`. Add `{Component}SizeTokens` + `{Component}ThemeTokens`,
242
+ add the field to `ComponentTokens`. Density-varying spacing goes inside the
243
+ size variants; constants (radius, focus ring, icon size, indicator-height) at
244
+ the top level or keyed by size.
245
+ 2. `src/theme/themes.ts`. Resolved values for **all three densities**.
246
+ 3. `design-tokens/token-reference.json`. Add the verified values (agent map).
247
+ 4. Export new token types from `src/theme/index.ts` and `src/index.ts`.
248
+
249
+ ---
250
+
251
+ ## M. Motion contract
252
+
253
+ Motion is tokenized like colour. The layers:
254
+
255
+ - **Code:** `src/tokens/motion.ts` — primitives (`duration`, `cycle`,
256
+ `easingBezier`, `spring`) and semantic roles (`transition`, `feedback`,
257
+ `loop`), built by `resolveMotion(overrides?)`. `src/theme/useMotion.ts` is
258
+ the only access point: it adds `reduceMotion` (live OS setting),
259
+ `useNativeDriver` (false on web), and `scale(ms)` (0 under reduce-motion).
260
+ - **Figma:** the `motion` variable collection mirrors the same names
261
+ (`duration/base`, `easing/standard/x1..y2`, `transition/standard/duration`
262
+ as an alias, easing role names as STRING variables). The kit's Motion page
263
+ carries keyframed spec cards using the exact beziers.
264
+ - **Theming:** `ThemeProvider` takes a `motion` prop of primitive overrides;
265
+ `applyCastTheme` maps a cast-theme.json `motion` block onto it; cast-sync
266
+ v4 exports the collection.
267
+
268
+ Rules when animating a component:
269
+
270
+ 1. Never hardcode a duration, easing, or spring. Read a **semantic role**
271
+ through `useMotion()` (e.g. `motion.transition.standard`,
272
+ `motion.loop.spin`). If no role fits, extend the roles in
273
+ `src/tokens/motion.ts` AND the Figma collection together.
274
+ 2. Wrap every duration in `motion.scale()`, pass
275
+ `motion.transition.*.easing` as the easing, and use
276
+ `motion.useNativeDriver`. For loops, check `motion.reduceMotion` and skip
277
+ starting the loop.
278
+ 3. Add a `Motion:` line to the Figma component description naming the role.
279
+ 4. Update the role table on the kit's Motion page and the site's Motion page
280
+ if roles change.
281
+
282
+ ## 2. cast-sync colour contract
283
+
284
+ `cast-sync/` (Figma plugin) reads the `semantic` collection (and, since v4,
285
+ the `motion` collection) and emits `cast-theme.json`, whose `colors.light` /
286
+ `colors.dark` match `ThemeProvider`'s `colors` prop **exactly**:
287
+
288
+ ```
289
+ intent ∈ {neutral, brand, danger}
290
+ → prominence ∈ {default, bold, subtle}
291
+ → state ∈ {default, hover, active}
292
+ → { bg, fg, border } // "#RRGGBB" | "#RRGGBBAA" | literal "transparent"
293
+ ```
294
+
295
+ - **Consume colour only via `useTheme().colors[intent][prominence][state]`** in
296
+ code; never read raw hex except the shared disabled set (`disabledColors` in
297
+ `src/tokens/colors.ts`).
298
+ - **Bind the right channel.** A solid filled control (button bg, progress fill)
299
+ reads the **`bg`**; a *text + thin line* element (a selected tab's label and
300
+ underline indicator) reads the **`fg`**. They share hex in light mode but
301
+ diverge in dark, so the channel matters. Match whatever the Figma layer binds.
302
+ - **Don't add a new colour axis** unless you also extend the Figma `semantic`
303
+ collection *and* the resolver in `cast-sync/code.ts`.
304
+ - **Non-intent colours** come from a generic `surface/*` / `text/*` / `neutral/*`
305
+ semantic, OR a dedicated **`control/{component}/...`** semantic for a
306
+ component's *own* bespoke control surface (toggle off-track, progress track,
307
+ tabs baseline track), aliasing a primitive grey and mirrored as a
308
+ `{component}Colors` slice on the scheme.
309
+ - After adding colour usage, run cast-sync in Figma and confirm light + dark
310
+ render with no missing-variable warnings.
311
+
312
+ ---
313
+
314
+ ## 3. Icons (the slot architecture)
315
+
316
+ This is the most nuanced area; read it fully before adding any icon.
317
+
318
+ ### 3.1 The `<Icon>` component
319
+
320
+ - **Figma:** the public `<Icon>` **component set** is `774:2875` (page `<Icon>`).
321
+ It is **slot-based**: each size variant is a box containing a Figma *slot*; the
322
+ designer drops any Material Symbol into the slot via the Material Symbols
323
+ plugin. A deprecated standalone `<Icon>` (`182:8`) still exists. **never point
324
+ new instances at it**; always use the set `774:2875`.
325
+ - **Code:** `src/components/Icon/Icon.tsx` renders the glyph by **font ligature**
326
+ (the `name` string, e.g. `chevron_right`). Zero SVG packages.
327
+ - **Glyph coverage, axes, and styles:** the **entire** Material Symbols glyph set
328
+ is available. **FILL, weight, grade, and optical-size are supported** via the
329
+ Icon's props. Only the **Outlined style font** is loaded. Rounded/Sharp are
330
+ separate fonts and a known gap.
331
+ - **Design↔code seam (incl. axes):** the slot holds a *vector* named after the
332
+ symbol; code renders by *name*. A pulled design yields a vector, not
333
+ `<Icon name="…">`, and the designer's **fill / weight choices live on the
334
+ vector, not in a prop**. The design↔code bridge (§8) must cover both: slot
335
+ layer name → `name`, AND the plugin's FILL / weight → the `fill` / `weight`
336
+ props. Encoded in the component description (and a published Code Connect
337
+ record, where a Dev seat exists). Otherwise Figma shows a filled glyph and
338
+ code renders the outlined default.
339
+
340
+ ### 3.2 Size scale + tokens
341
+
342
+ | Variant | Token | px | Primitive |
343
+ |---|---|---|---|
344
+ | `xs` | `icon/xs/size` | 12 | `size/7` |
345
+ | `small` | `icon/small/size` | 16 | `size/9` |
346
+ | `default` | `icon/default/size` | 20 | `size/10` |
347
+ | `large` | `icon/large/size` | 24 | `size/11` |
348
+
349
+ Code mirror: `src/tokens/icon.ts` exports `type IconSize = 'xs'|'small'|'default'|'large'`
350
+ and `iconSize: Record<IconSize, number>` (12/16/20/24), re-exported from
351
+ `src/index.ts`.
352
+
353
+ ### 3.3 Sizing icons inside components
354
+
355
+ Never hardcode `const ICON_SIZE = 16`. Two equivalent host patterns:
356
+
357
+ - **Named-scale hosts** pass the host `size` straight through:
358
+ `<Icon size={size} … />`. Use for Button, Input, Badge, Select trigger/option,
359
+ **<Tab>** (a tab's `leadingIcon` follows the tab `size`).
360
+ - **Density-token hosts** read a per-size `iconSize` from the theme. Use for List,
361
+ Card, Alert, Toast, Dialog, Chip, Checkbox, Avatar.
362
+
363
+ In Figma, every embedded icon is an instance of `774:2875` pinned to the matching
364
+ `size=` variant.
365
+
366
+ **Control glyphs can be sized to their container.** A Material Symbols glyph
367
+ carries ~30 % intrinsic padding, so sizing a checkbox tick to the indicator size
368
+ still leaves breathing room.
369
+
370
+ **The scale gap (important):** the `<Icon>` set covers 12/16/20/24 only. Decide a
371
+ component's icon sizes up front; if it needs an out-of-scale size, add a variant +
372
+ token or size that glyph by the host.
373
+
374
+ ### 3.4 Colour binding
375
+
376
+ There is **no dedicated icon-colour token**. An icon's glyph fill binds to the
377
+ **same `intent/{intent}/{prominence}/{state}/fg` variable as its nearest label**,
378
+ so colour tracks the host automatically. In code: `<Icon name="…" color={fg} />`.
379
+ Never hardcode an icon hex. (Tab's `leadingIcon` is passed the same resolved `fg`
380
+ the label uses. Selected→intent fg, unselected→text muted, hover→text primary,
381
+ disabled→disabled fg.)
382
+
383
+ ### 3.5 Code `<Icon>` API
384
+
385
+ ```tsx
386
+ type IconProps = {
387
+ name: string; // Material Symbols name (ligature)
388
+ size?: IconSize | number; // named scale OR explicit px (default 20)
389
+ color?: string; // pass the host's resolved fg
390
+ fill?: boolean; weight?: 100..700; grade?: number; opticalSize?: number;
391
+ };
392
+ ```
393
+ Icons are accessibility-hidden. Don't double-label.
394
+
395
+ ### 3.6 Placing / migrating glyphs in Figma
396
+
397
+ Clone a reference `<Icon>` that already has the glyph in its slot, `insertChild`,
398
+ `setProperties({size})` by width, scale the glyph GROUP to the box, bind every
399
+ glyph VECTOR fill to the host fg variable, remove legacy nodes. **Always fix at
400
+ base-component level** so nested instances inherit.
401
+
402
+ ### 3.7 Icon gotchas
403
+
404
+ - **Slot content doesn't auto-scale on variant change**. Fix with
405
+ `stretchChildOnInsert=true` + glyph group `FILL` (applied to `774:2875`).
406
+ Instances sized before that fix keep a stale slot override. `resetOverrides()`.
407
+ - **`resetOverrides()` reverts the glyph colour to `text/primary`**. Re-bind the
408
+ fg after any reset.
409
+
410
+ ---
411
+
412
+ ## 4. Fonts & typography
413
+
414
+ - Import families from `src/tokens/typography.ts`: `fontFamily.sans` (Inter),
415
+ `.mono` (JetBrains Mono), `.serif` (Noto Serif). Each is a `Platform.select`.
416
+ - Use the **typography scales**, never raw sizes: `label`, `title`, `body`,
417
+ `heading`, `display` (each `sm/md/lg`) + `caption`. Map `size → scale` (Tabs
418
+ uses `LABEL_TYPE`: small→`label-sm`, default→`label-md`, large→`label-lg`,
419
+ rendered through the shared `<Text>` component so it inherits the ramp).
420
+ - Per-family weights: label & title = medium (500); heading = semibold (600);
421
+ body, caption, display = regular (400).
422
+ - Typography is **constant across densities**.
423
+ - Fonts are consumer-loaded. Document this in the component header comment.
424
+
425
+ ### Font strategy. Current state + target (direction, not yet built)
426
+
427
+ Today the three families are hardcoded in `src/tokens/typography.ts`. The
428
+ **target** is a themeable custom-font system: fonts become a `ThemeProvider`
429
+ concern (a `fonts` slice), pulled from Figma via cast-sync v2's
430
+ `typography[*].fontFamily`, with a dev-only `console.warn` fallback when a
431
+ referenced family isn't registered.
432
+
433
+ ---
434
+
435
+ ## 5. Props ⇄ Figma. A 2-way mirror
436
+
437
+ Figma component properties and code props are **co-equal, bidirectional
438
+ mirrors**. Design→code flows through the MCP (inferred from the naming/description
439
+ mirror, or a published Code Connect record); code→design flows through the
440
+ generate / sync path. Keep them in lockstep **both directions** with identical
441
+ names and values.
442
+
443
+ Standard vocabulary (use exactly when applicable):
444
+
445
+ - `intent`: `'neutral' | 'brand' | 'danger'`. Colour scheme.
446
+ - `prominence`: `'default' | 'bold' | 'subtle'`. Weight.
447
+ - `size`: `'small' | 'default' | 'large'`. Spacing + typography scale.
448
+ - `state`: interaction/selection variant (e.g. Tab `default | hover | selected |
449
+ disabled`). Note: some `state` values are *runtime* in code (hover via
450
+ `onHoverIn`/`onHoverOut`), not props. See §8.3.
451
+ - `disabled`: boolean. Shared muted styling.
452
+
453
+ Component-specifics (`Tabs.value/onValueChange`, `Tab.value`, `Progress.value`,
454
+ `Spinner.size`) sit on top but still match the Figma property names. Type text
455
+ `children` narrowly (`string`, not `ReactNode`).
456
+
457
+ ### Variant props vs. content props. A global rule
458
+
459
+ - **Variant props** describe **style / structure**. `type`, `intent`,
460
+ `prominence`, `size`, `state`. Figma VARIANT properties; code string-union props.
461
+ - **Content / value props** describe what a consumer puts **into** an instance.
462
+ Text, a numeric `value`, an icon slot, a child element. **Not** variants.
463
+
464
+ For every content / value prop:
465
+
466
+ 1. **Name it once, identically on both sides.** Text content is `children` (never
467
+ `text`/`label`), a slot is `leadingIcon`, a numeric input is `value`.
468
+ 2. **Code = single source of truth.**
469
+ 3. **Figma = one shared property bound across *every* variant**, neutral
470
+ placeholder default (e.g. `"Tab"`), bound via `componentPropertyReferences`.
471
+
472
+ **Never bake content into individual variants.** *Verified clean. `_<Tab>`:* the
473
+ label is a single shared `children` text property (placeholder `"Tab"`) bound
474
+ across all 18 variants, text fill bound to a variable, not a literal.
475
+
476
+ ### Lean variant matrices. Don't enumerate redundant cross-products
477
+
478
+ When a variant axis only affects *some* states, model it leanly rather than
479
+ forcing a full cross-product of identical-looking variants. *Verified. `_<Tab>`:*
480
+ `intent` only changes the **selected** tab's colour, so intent carries the three
481
+ values on `state=selected` (9 variants) while `default`/`hover`/`disabled` are
482
+ intent-agnostic (`neutral`, 9 variants) = 18, **not** 3 intents × 4 states × 3
483
+ sizes = 36. Every variant still has a real intent value (no `"-"` sentinel), and
484
+ the lean model is documented so the code `intent` prop is understood as a no-op
485
+ unless selected. Use a real placeholder value (`neutral`), never a `"-"` sentinel,
486
+ for axes that don't apply in a given state.
487
+
488
+ ---
489
+
490
+ ## 6. Build in Figma. And where colours/sizes live
491
+
492
+ 1. **Component set** named exactly as the code component (`Tabs`, `Spinner`, …;
493
+ a private sub-component keeps its `_<X>` name, e.g. `_<Tab>`).
494
+ 2. **Variant properties** matching §5 props. Names and values char-for-char.
495
+ 3. **Bind every spacing value** to a component variable (§1). No detached numbers.
496
+ 4. **Bind every colour** to `intent/...` semantic variables (or `surface`/`text`/
497
+ `neutral`/`control/{component}` for non-intent).
498
+ 5. **Icons:** instances of `774:2875` at the right `size=` variant, glyph fill
499
+ bound to the host fg (§3.4).
500
+ 6. **Text:** apply the kit Text Styles.
501
+ 7. **All density modes** for any new spacing vars.
502
+ 8. **Write the `descriptionMarkdown`** for the set (and sub-component) using the
503
+ Overview-first template (§8.1): Overview, Do's and Don'ts (✅/❌), Useful
504
+ links, then MCP context. This text drives the design↔code association.
505
+ 9. **Publish** so consumers get the variables/styles.
506
+
507
+ ### Where colours and sizes must live (avoid style overrides)
508
+
509
+ - **If the component has a private sub-component** (`<Select>` composes
510
+ `_<SelectOptions>`; `<Tabs>` composes `_<Tab>`), the icon/label colour + size
511
+ lives **in the sub-component's own variants**, and the parent inherits with
512
+ **zero fill / colour overrides**. *Verified clean: `<Tabs>` has 0 colour
513
+ overrides on its `_<Tab>` instances. Only content/layout overrides
514
+ (text, size, width/height).*
515
+ - **If the component holds the icon directly** (Alert, Toast, Card, Avatar), the
516
+ colour binding lives on the icon **within the component's intent variants**.
517
+ - **Never paint colour as an instance override on a parent.** To audit: walk a
518
+ set's nested instances, check `instance.overrides[*].overriddenFields` for
519
+ `fills`/`boundVariables` on an icon/label node. There should be none.
520
+ - **Legitimate per-instance overrides** are *content*: text labels, the slot
521
+ glyph, per-row `size`, `width`/`height`.
522
+ - **Renaming variant option values keeps instances mapped.** Figma tracks
523
+ instances by the underlying component node, so renaming a variant value (e.g.
524
+ `state=active` → `state=default`, or killing an `intent="-"` option by renaming
525
+ it to `neutral`) automatically remaps existing instances. Verify after, but no
526
+ orphaning. When you add variants outside the set's current bounds (a new state
527
+ row), **resize the component-set frame** to contain them or they render clipped.
528
+ - **Always make changes at the base-component level** so all instances inherit.
529
+
530
+ ---
531
+
532
+ ## 7. Code the component
533
+
534
+ Create `src/components/{Name}/` with `{Name}.tsx`, `{Name}.stories.tsx`,
535
+ `index.ts`. Follow Button:
536
+
537
+ - **`Pressable`** for interactives. `pressed` via render prop.
538
+ - **Hover** via `onHoverIn`/`onHoverOut` + `useState`. **Focus** via
539
+ `onFocus`/`onBlur` + `useState`.
540
+ - **State priority `disabled > pressed > hovered > default`** (Tab resolves
541
+ `disabled → selected → hovered → default` for its label/indicator fg).
542
+ - **Compound components** (Tabs/Tab) share selection through React context: the
543
+ parent owns `value`/`onValueChange`/`intent`/`size`; the child derives
544
+ `selected` from context and throws if rendered outside the parent.
545
+ - **Tokens:** spacing from `useTheme().components.{name}[size]`; colours from
546
+ `useTheme().colors[intent][prominence][state]` (a selected tab reads
547
+ `.default.default.fg`) and `scheme.*` for non-intent (track, muted/primary
548
+ text, disabled); typography from the `<Text>` scales; icon sizes from
549
+ `iconSize`/host tokens; `controlTokens.borderWidth`.
550
+ - **Accessibility:** `accessibilityRole` (`tablist` on the list, `tab` on each
551
+ tab, `progressbar`, `header`, …), `accessibilityState`
552
+ (`disabled`/`selected`/`busy`), label fallback to text.
553
+ - **Header doc comment** mapping the component to its Figma node + variant→prop
554
+ table.
555
+
556
+ ### Stories (required)
557
+
558
+ `{Name}.stories.tsx`: a **Playground** (`argTypes` for every prop), **variant
559
+ showcases**, a **density comparison** (each density via `ThemeProvider`), and a
560
+ **full matrix**. Chromatic snapshots every story.
561
+
562
+ ### Verify the component live in Storybook (the live-example gate)
563
+
564
+ 1. `npm run storybook`.
565
+ 2. Open **Components/{Name}**.
566
+ 3. Confirm: Playground drives every prop; variant/size showcases render the full
567
+ matrix; density comparison differs only in spacing; animated/interactive
568
+ states actually respond; nothing clipped/mis-coloured in light mode.
569
+ 4. Fix anything off **at the token or component source**, not with a per-story
570
+ patch.
571
+
572
+ ### Export
573
+
574
+ `src/index.ts`: `export { {Name}, type {Name}Props, … } from './components/{Name}';`
575
+ (compound components export both: `Tabs, Tab, type TabsProps, type TabProps, …`).
576
+
577
+ ---
578
+
579
+ ## 8. Design ↔ code association (naming/descriptions first; Code Connect optional)
580
+
581
+ The goal: an MCP design inspection (`get_design_context`) on a Cast UI node yields
582
+ the **real** Cast UI component (`<Tabs intent=… size=… />`), not generic markup.
583
+ Two layers get you there. The first is mandatory, the second a plan-gated upgrade.
584
+
585
+ ### 8.1 The association is primarily the architectural mirror (always do this)
586
+
587
+ The Figma MCP and Code Connect's own auto-suggestion key off the **identity of
588
+ names and the text in descriptions**. Because §5 keeps props and Figma properties
589
+ 1:1, the association is mostly *earned* by building both sides to match:
590
+
591
+ - **Component name char-for-char**. The Figma set is named exactly as the code
592
+ component (`Tabs`; `_<Tab>` ↔ exported `Tab`). A private sub-component keeps its
593
+ `_<X>` name and maps to the exported code name.
594
+ - **Variant property names + values char-for-char**. `intent`/`size`/`state` and
595
+ their options match the code union types exactly. This is what lets the matcher
596
+ line a Figma variant up with a code prop value without a hand-written rule.
597
+ - **Descriptions carry the bridge**. Write the component description (and variant
598
+ descriptions where they disambiguate) to state the code path and the property
599
+ mapping, e.g. *"Maps to `src/components/Tabs/Tabs.tsx` (`Tab`). intent→intent,
600
+ size→size, state=selected→selected tab, state=disabled→disabled, state=hover→
601
+ runtime onHoverIn (not a prop), text→children, leadingIcon slot→leadingIcon."*
602
+ The MCP reads these. They are how the agent (and Figma's suggestion engine)
603
+ infers the right component when no published Code Connect record exists.
604
+
605
+ #### The Figma description template
606
+
607
+ Because the description text *is* the association on this plan, give every
608
+ component set (and its sub-component) a structured description. Write it to the
609
+ node's **`descriptionMarkdown`** (not plain `description`) so the formatting
610
+ renders in Figma.
611
+
612
+ Order the sections for the reader who opens it most, the designer, first:
613
+
614
+ 1. **Overview** (plain language, no jargon): what the component is and what a
615
+ designer keeps in sync. Where colour and states live, which part to edit to
616
+ change a look, what a slot holds.
617
+ 2. **Do's and Don'ts**: a short bulleted list. Lead each line with ✅ or ❌.
618
+ 3. **Useful links**: the component in Storybook and the source file on GitHub,
619
+ as markdown links.
620
+ 4. **MCP context** (for the agent/dev): the code path, the prop list, and how
621
+ each variant/property maps to a code prop. Flag any seam where a Figma variant
622
+ is a *runtime* behaviour in code, not a prop. Technical only where needed.
623
+
624
+ Figma `descriptionMarkdown` supports **bold**, *italic*, links, and bulleted
625
+ lists only. No headings and no inline code, so use **bold** for section titles
626
+ and for component names. Keep every line plain and short (see Writing style).
627
+ Mapping arrows (`→`) are fine.
628
+
629
+ Example. `_<Tab>` (the `<Tabs>` container mirrors it):
630
+
631
+ ```
632
+ **Overview**
633
+
634
+ One tab. All colours and states live here, not on **<Tabs>**.
635
+
636
+ **Do's and Don'ts**
637
+
638
+ - ✅ Edit a variant here to change how a state looks (hover, disabled, selected).
639
+ - ✅ Set the label text and the leading icon per instance. They are slots.
640
+ - ✅ Choose **intent** and **size** on the parent **<Tabs>**, not here.
641
+ - ❌ Don't restyle a tab's colour on a **<Tabs>** instance. Fix it here on the base.
642
+ - ❌ Don't expect **intent** to colour unselected tabs. Only the selected tab uses it.
643
+
644
+ **Useful links**
645
+
646
+ - [Tabs in Storybook](https://<storybook-host>/?path=/docs/components-tabs--docs)
647
+ - [Tabs.tsx on GitHub](https://github.com/<org>/cast-ui/blob/main/src/components/Tabs/Tabs.tsx)
648
+
649
+ **MCP context**
650
+
651
+ Code: src/components/Tabs/Tabs.tsx, exported as **<Tab>**. Props: value, children,
652
+ leadingIcon, disabled. Variant map: intent→intent (selected tab only), size→size,
653
+ state=selected→selected (the tab whose value === Tabs.value),
654
+ state=disabled→disabled, state=hover→runtime onHoverIn (not a prop). Label
655
+ text→children. leadingIcon slot→leadingIcon (Material Symbols name).
656
+ ```
657
+
658
+ With the mirror clean and descriptions written, an agent inspecting the node has
659
+ everything it needs to emit correct Cast UI code **even without a published Code
660
+ Connect mapping**. This is the inferred/heuristic path. Reliable when the mirror
661
+ is exact, but not contractual. Keep names + descriptions in lockstep with code
662
+ whenever either side changes.
663
+
664
+ ### 8.2 Code Connect. The deterministic upgrade (requires a Developer seat)
665
+
666
+ Code Connect turns the inferred association into a **published, deterministic**
667
+ one: `get_design_context` returns your exact snippet with prop mapping every time.
668
+ It is **gated**. The MCP Code Connect tools (`get_code_connect_map`,
669
+ `add_code_connect_map`, `send_code_connect_mappings`,
670
+ `get_context_for_code_connect`) and the `figma connect publish` CLI all require a
671
+ **Developer seat on an Organization or Enterprise plan**. On a Starter/Pro file
672
+ they return *"You need a Developer seat in an Organization or Enterprise plan to
673
+ access Code Connect."*. That is expected, not a bug; fall back to §8.1.
674
+
675
+ When a seat is available, two equivalent paths:
676
+
677
+ 1. **MCP path**. `add_code_connect_map` / `send_code_connect_mappings` map the
678
+ node → `src/components/{Name}/{Name}.tsx`, then verify with
679
+ `get_code_connect_map` / `get_context_for_code_connect`.
680
+ 2. **File path**. Author `src/components/{Name}/{Name}.figma.ts` with
681
+ `@figma/code-connect` (`figma.connect(Component, url, { props, example })`) and
682
+ `figma connect publish`. This artifact can live in the repo and be published
683
+ later, the day the seat exists. (Note: it imports `@figma/code-connect`, so add
684
+ it as a devDependency and keep `*.figma.ts` out of `tsconfig.build.json`'s
685
+ compiled set if the package isn't installed yet.)
686
+
687
+ ### 8.3 Property mapping (the 1:1 both layers express)
688
+
689
+ ```
690
+ Figma intent → prop intent (neutral|brand|danger)
691
+ Figma size → prop size (small|default|large)
692
+ Figma state=selected → selected tab (Tabs.value === Tab.value)
693
+ Figma state=disabled → prop disabled={true}
694
+ Figma state=hover → runtime onHoverIn/onHoverOut (NOT a code prop)
695
+ Figma text layer → children
696
+ Figma icon slot → leadingIcon (Material Symbols name)
697
+ ```
698
+
699
+ Note the asymmetry naming alone can't encode: a Figma **variant** like
700
+ `state=hover` is a *runtime* interaction in code, not a prop. The agent must know
701
+ (from the description / this skill) to render it via `onHoverIn`/`onHoverOut`.
702
+ Document those seams in the component description so the inference stays correct.
703
+
704
+ ---
705
+
706
+ ## 9. Ship checklist
707
+
708
+ 1. `git checkout -b feature/{component-name}`.
709
+ 2. Tokens in `design-tokens/` (3 density files) + `src/theme/types.ts` +
710
+ `src/theme/themes.ts` (all densities) + `token-reference.json`.
711
+ 3. Component built per Button, with full stories; **verified live in Storybook**
712
+ (§7).
713
+ 4. Exported from `src/index.ts` (+ token types from `src/theme/index.ts`).
714
+ 5. Type-check: `node_modules/.bin/tsc --project tsconfig.build.json --noEmit`.
715
+ 6. Build + smoke: `npm run build && node scripts/smoke-test.js`.
716
+ 7. cast-sync round-trip verified in light + dark (no missing-variable warnings).
717
+ 8. **Design↔code association in place:** Figma component name + variant values +
718
+ component/variant descriptions mirror the code (§8.1, the mandatory layer);
719
+ Code Connect published + verified **only if** a Developer seat is available
720
+ (§8.2). A blocked Code Connect call on a non-Enterprise plan is expected. The
721
+ §8.1 mirror is the association.
722
+ 9. **Docs site registry entry** — add the component to
723
+ `site/src/data/registry/` (props, live examples, dos/don'ts, motion role
724
+ if any) and run `npm run smoke` in site/ (renders every page and
725
+ evaluates every example). The cast-ui-docs-site skill covers this.
726
+ 10. PR → pass the **blocking** Chromatic review → merge.
727
+ 11. Release: bump `version` in `package.json` (**minor** for a new component) +
728
+ `CHANGELOG.md` section; CI auto-publishes on merge to `main`.
729
+
730
+ ### Environment gotchas
731
+ - Use `node_modules/.bin/tsc` directly. `npx tsc` resolves the wrong package.
732
+ - npm cache perms: `--cache /tmp/npm-cache-cast-ui`.
733
+ - **A fresh sandbox has no `node_modules`**. `npm install --cache
734
+ /tmp/npm-cache-cast-ui --no-audit --no-fund` first.
735
+ - **Don't verify the build with a bare `node -e "require('./dist/index.js')"`**.
736
+ Use `node scripts/smoke-test.js` or a static `grep` over `dist/`.
737
+ - File deletes/writes in this workspace can be gated. An `EPERM`/`Operation not
738
+ permitted` from the file tools is sandboxing, not a code issue; use the shell
739
+ (bash on the mounted path) to read/edit repo files when the file tools are
740
+ blocked.
741
+ - Security: keep `deepMerge`'s prototype-pollution guards; render icon names via
742
+ `<Text>`; type text `children` as `string`; no `eval` /
743
+ `dangerouslySetInnerHTML` / dynamic code.
744
+
745
+ ---
746
+
747
+ ## Appendix A. Figma traversal map
748
+
749
+ File `JGtlpxLPJMZcwvQ3UZ9ZUl`. Link form:
750
+ `https://www.figma.com/design/JGtlpxLPJMZcwvQ3UZ9ZUl/cast-ui-kit?node-id={id-with-dash}`.
751
+ Pages: Components `163:892`, `<Icon>` `774:3112`.
752
+
753
+ | Component | id | Component | id |
754
+ |---|---|---|---|
755
+ | Button | `168:398` | Chip | `305:2967` |
756
+ | Input | `182:21` | Divider | `305:3931` |
757
+ | Select | `206:541` | Avatar | `305:4524` |
758
+ | Checkbox | `247:2575` | Tooltip | `306:3566` |
759
+ | Radio | `250:3322` | Dialog | `306:3327` |
760
+ | Toggle | `251:2792` | Popover | `306:3703` |
761
+ | Badge | `261:2839` | Skeleton | `306:3805` |
762
+ | Alert | `273:2814` | Text | `769:2710` |
763
+ | Toast | `277:2958` | Icon (set) | `774:2875` |
764
+ | Card | `301:2979` | Tabs | `891:4507` |
765
+ | Table | `1330:220` | SpeedDial | `1384:8770` |
766
+ | ToggleButtonGroup | `1275:7098` | AppBar | `1371:8169` |
767
+ | Menu | `1374:8493` | Autocomplete | `1391:9456` |
768
+ | Breadcrumbs | `1344:7484` | Accordion | `972:5116` |
769
+ | Drawer | `1366:13544` | CodeBlock | `1360:220` |
770
+ | Spinner | `914:52884` | Slider | `1345:7806` |
771
+ | Link | `1331:8288` | Backdrop | `1376:7975` |
772
+ | BottomSheet | `983:6519` | List | `452:3323` |
773
+ | Progress | `877:4491` | | |
774
+
775
+ Private sub-components: `_<SelectOptions>` `199:92`, `_<SelectGroupLabel>`
776
+ `203:99`, `_<SelectTag>` `205:34`, `_<ChoiceLabel>` `244:2530`, `_<ListItem>`
777
+ `452:3275`, `_<Tab>` `890:4446`. Deprecated standalone Icon: `182:8` (do not use).
778
+
779
+ ---
780
+
781
+ ## Appendix B. Architectural lessons (gotcha catalogue)
782
+
783
+ - **Slot content doesn't auto-scale on variant change**. `stretchChildOnInsert=true`
784
+ + glyph group `FILL`.
785
+ - **`resetOverrides()` clears colour → `text/primary`**. Re-bind fg after.
786
+ - **Fix at the base component, not instances**. Instance edits create overrides.
787
+ - **Colour belongs at the source**. Sub-component variants if one exists (Tabs →
788
+ `_<Tab>`), else the component's intent variants. Parent fill overrides = smell.
789
+ - **Content overrides are fine**. Text, slot glyph, per-row size, width/height.
790
+ - **Material Symbols have intrinsic padding**. Don't over-shrink control glyphs.
791
+ - **The Icon scale (12/16/20/24) is not exhaustive**. Confirm sizes up front.
792
+ - **Renaming variant options remaps instances automatically** (Figma tracks the
793
+ underlying node). But adding variant rows outside the set bounds clips them;
794
+ resize the component-set frame.
795
+ - **Kill `"-"` sentinels on variant axes**. Use a real placeholder value
796
+ (`neutral`) for an axis that doesn't apply in a given state, and model the
797
+ matrix leanly (§5) rather than enumerating identical cross-products.
798
+ - **Bind the colour *channel* that matches the layer**. `bg` for a solid fill,
799
+ `fg` for text + a thin line (selected-tab label/indicator). Same hex in light,
800
+ divergent in dark.
801
+ - **Audit overrides** via `instance.overrides[*].overriddenFields`: `fills` /
802
+ `boundVariables` on an icon/label = style (avoid on parents); `componentProperties`
803
+ / `visible` / `width`/`height` = content/structure (expected).
804
+ - **Code Connect is plan-gated**. `"You need a Developer seat…"` is expected on
805
+ non-Enterprise plans; the §8.1 naming/description mirror carries the association.
806
+
807
+ ---
808
+
809
+ ## Appendix C. Build history notes
810
+
811
+ Every component in `src/components/` is built, exported, and tokenised.
812
+ Progress and Tabs remain the reference builds; their worked notes encode the
813
+ verified conventions (lean variant matrices, `control/{component}` semantics,
814
+ primitive `size/N` bindings).
815
+
816
+ **Progress (code-first reference).** Tokens:
817
+ `progress/{small,default,large}/track-height` → `size/{3,5,7}` (4/8/12) and
818
+ `progress/border-radius` → `radius/full`, constant across density. Fill reuses
819
+ `intent/{intent}/bold/default/bg`; the track is `control/progress/track/bg`,
820
+ mirrored as `scheme.progress.track`.
821
+
822
+ **Tabs (Figma + code mirror reference).** `<Tabs>` (`891:4507`) owns
823
+ `value`/`onValueChange`/`intent`/`size` via context; `_<Tab>` (`890:4446`,
824
+ exported as `Tab`) carries all colour. Lean matrix: 18 variants, not 36.
825
+ `state=hover` is runtime `onHoverIn`, not a prop. Tokens
826
+ `tabs/{size}/{gap,padding-x,padding-y}` bind primitive `space/N` (bespoke,
827
+ tighter than `control-*`); `indicator-height` → `size/{2,2,3}`;
828
+ `tabs/list-gap` → `space/{5,9,11}`.
829
+
830
+ **Motion-consuming components** (all read roles through `useMotion()`):
831
+ Drawer + BottomSheet (`transition/standard` scrim + `spring/overlay` slide),
832
+ Backdrop (`transition/standard`), Spinner (`loop/spin`), Skeleton
833
+ (`loop/pulse`), Progress (`loop/indeterminate`), SpeedDial
834
+ (`transition/enter`/`exit`), Accordion (`transition/expand`).