@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.
- package/README.md +78 -1
- package/dist/components/Accordion/Accordion.js +4 -3
- package/dist/components/Backdrop/Backdrop.js +7 -8
- package/dist/components/BottomSheet/BottomSheet.js +12 -14
- package/dist/components/Drawer/Drawer.js +12 -14
- package/dist/components/Progress/Progress.js +5 -4
- package/dist/components/Skeleton/Skeleton.js +11 -13
- package/dist/components/SpeedDial/SpeedDial.js +3 -5
- package/dist/components/Spinner/Spinner.js +6 -5
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/useBreakpoint.d.ts +22 -0
- package/dist/hooks/useBreakpoint.js +45 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +23 -2
- package/dist/theme/ThemeContext.d.ts +12 -1
- package/dist/theme/ThemeContext.js +5 -2
- package/dist/theme/applyCastTheme.d.ts +32 -2
- package/dist/theme/applyCastTheme.js +72 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.js +3 -1
- package/dist/theme/useMotion.d.ts +32 -0
- package/dist/theme/useMotion.js +55 -0
- package/dist/tokens/breakpoints.d.ts +57 -0
- package/dist/tokens/breakpoints.js +92 -0
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.js +17 -1
- package/dist/tokens/motion.d.ts +196 -0
- package/dist/tokens/motion.js +175 -0
- package/package.json +2 -1
- package/skills/cast-ui-component/SKILL.md +834 -0
- package/skills/cast-ui-docs-site/SKILL.md +114 -0
- package/skills/cast-ui-usage/SKILL.md +587 -0
|
@@ -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`).
|