Skip to content

Commit 5dd6ce0

Browse files
committed
docs(repo): codify StyleX best practices in the Mosaic skill
Expand references/stylex.md into an evidence-backed do/don't guide from a sweep of the astryx design system: sections on breaking up stylex.create (base + variant maps + state objects) and dynamic styles (write-a-var vs set-property, perf model). Fix marker files to .markers.stylex.ts, add scoped ancestor states, and record the Mosaic convention that stylex.create lives in a co-located .styles.ts, never inline in the .tsx.
1 parent 1f8d89f commit 5dd6ce0

2 files changed

Lines changed: 260 additions & 29 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.claude/skills/mosaic/references/stylex.md

Lines changed: 258 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,53 @@ The public contract is unchanged from the recipe era — consumers still target
1010
`--cl-*` vars, the `.cl-<slot>` class, and `data-<axis>` attrs, never StyleX's
1111
hashed `x…` atoms. What changes is how a component is authored internally.
1212

13-
## File layout & the `.stylex.ts` rule
13+
The guidance below is opinionated and evidence-backed: it codifies patterns
14+
proven out across a full StyleX component library (tokens, ~100 components,
15+
complex cases like Table/Calendar/Popover/Slider). Each is framed as **do X /
16+
don't Y**. Lint enforcement of some of these is still landing, so treat them as
17+
conventions to follow by hand, not guarantees the toolchain makes for you.
1418

15-
| File | Holds |
16-
| ------------------------- | ---------------------------------------------------------- |
17-
| `tokens.stylex.ts` | `defineVars` / `defineConsts` only (the tokens) |
18-
| `<comp>/<comp>.styles.ts` | `stylex.create({...})` — the atoms |
19-
| `<comp>/<comp>.tsx` | component; spreads `stylex.props(...)` via `mergeProps` |
20-
| `props.ts` | `themeProps` (`.cl-<slot>` + `data-<axis>`) + `mergeProps` |
21-
| `styles/index.ts` | isolated-build barrel; derives `*VarName` types |
19+
## File layout & the `.stylex.ts` convention
2220

23-
All 9 `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension`
24-
rule reserves the `.stylex.ts` extension for token files: **a `.stylex.ts` file
25-
may export nothing but `defineVars`/`defineConsts` results.** So:
21+
| File | Holds |
22+
| --------------------------------- | ---------------------------------------------------------- |
23+
| `tokens.stylex.ts` | `defineVars` / `defineConsts` only (the tokens) |
24+
| `<comp>/<comp>.styles.ts` | `stylex.create({...})` — this component's atoms |
25+
| `<family>.styles.ts` | **shared** atoms imported by a whole component family |
26+
| `<comp>/<comp>.markers.stylex.ts` | `stylex.defineMarker()` results for scoped ancestor states |
27+
| `<comp>/<comp>.tsx` | component; spreads `stylex.props(...)` via `mergeProps` |
28+
| `props.ts` | `themeProps` (`.cl-<slot>` + `data-<axis>`) + `mergeProps` |
29+
| `styles/index.ts` | isolated-build barrel; derives `*VarName` types |
30+
31+
The `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension`
32+
rule reserves the `.stylex.ts` extension for StyleX define-primitives: **a
33+
`.stylex.ts` file may export nothing but `defineVars` / `defineConsts` /
34+
`defineMarker` results.** So:
2635

2736
- `stylex.create(...)` lives in a plain `.styles.ts` file, never `.stylex.ts`.
37+
- `stylex.defineMarker()` is a define-primitive like the token builders, so it
38+
lives in its own `<comp>.markers.stylex.ts`, never a plain `.ts`.
2839
- Derived types (`type ColorVarName = keyof typeof colorVars`) live in
2940
`styles/index.ts`, not in `tokens.stylex.ts` (they'd be a disallowed export).
3041

42+
**Styles live in their own file, never inline in the `.tsx`.**
43+
44+
- **DO** put a component's `stylex.create` in a co-located `<comp>.styles.ts` and
45+
import the atoms into the component. The `.tsx` stays rendering-only. This is a
46+
deliberate Mosaic convention — a single predictable place for a slot's styles
47+
and a readable component file. It diverges from StyleX libraries that author
48+
`create` inline in the component; we don't.
49+
- **DON'T** inline `stylex.create` in a `.tsx`. No "it's only a few atoms"
50+
exception — small components get a `.styles.ts` too.
51+
- **DO** hoist atoms into a shared `<family>.styles.ts` when several siblings
52+
render the same visual surface — e.g. inputs (`TextInput`, `NumberInput`, date
53+
fields, `Selector`) sharing one `inputWrapper` / `inputStatusBorder` /
54+
`inputStatusFocusWithin` set instead of redefining the border/focus treatment
55+
five times. (The shared-surface pattern to reach for when it appears; no shared
56+
styles file exists in Mosaic yet.)
57+
- **DON'T** copy a slot's atoms between siblings. A repeated style object is the
58+
same signal as a repeated conditional: extract the shared concept.
59+
3160
## Tokens (`tokens.stylex.ts`)
3261

3362
**`defineVars` keys that start with `--` emit verbatim** as real custom
@@ -43,6 +72,18 @@ const colorDefaults = {
4372
export const colorVars = stylex.defineVars(colorDefaults);
4473
```
4574

75+
- **DO** put light + dark in one token value via `light-dark()`. Never ship a
76+
second `@media (prefers-color-scheme: dark)` copy of a color.
77+
- **DO** reserve `--cl-*`-prefixed keys for the public, overridable contract.
78+
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
79+
parent writes for a child to read). They still emit verbatim but the `_` marks
80+
them "not a contract, don't override."
81+
- **DO** compute tints at the call site with `color-mix()`, not as their own
82+
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
83+
`--cl-color-primary-hover-12`.
84+
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
85+
can express from an existing token.
86+
4687
**Spacing is one exposed var plus a `defineConsts` scale.** Only `--cl-spacing`
4788
is a custom property; every step is inlined at build as `calc(var(--cl-spacing) *
4889
n)` and carries no var of its own:
@@ -62,6 +103,10 @@ imported file"). `defineConsts` is StyleX's shareable inlined-value primitive, s
62103
it's the only way to get a scale that is both shared across components and free
63104
of per-step vars.
64105

106+
**Reference tokens by bracket string key**, always: `colorVars['--cl-color-primary']`,
107+
`space['2']`. A computed key (`colorVars[name]`) defeats StyleX static analysis
108+
and won't compile.
109+
65110
## Atoms (`<comp>.styles.ts`)
66111

67112
`stylex.create` values must be statically resolvable. Allowed: literals,
@@ -70,9 +115,60 @@ of per-step vars.
70115
arithmetic, inline a `calc()` template literal with a token:
71116
`` `calc(-1 * ${space['2']})` ``.
72117

73-
**Conditions.** Use StyleX's conditional-value objects (a `default` plus
74-
pseudo / at-rule keys), and nest to guard hover so it never sticks on touch —
75-
this is the StyleX form of the old `_hover` condition:
118+
### Breaking up `stylex.create`
119+
120+
There is no one big styles object. Split by concern into several small named
121+
objects and compose them at the call site.
122+
123+
- **DO** split into: a **base** object (structure the slot always has), one or
124+
more **variant maps** keyed by a prop value, and small **state** objects applied
125+
conditionally.
126+
127+
```ts
128+
// base: what the slot always is
129+
const styles = stylex.create({
130+
base: { display: 'inline-flex', borderRadius: radiusVars['--cl-radius-element'] },
131+
iconOnly: { aspectRatio: '1' },
132+
});
133+
// variant map: keyed by the prop value, indexed at the call site
134+
const variants = stylex.create({
135+
primary: { backgroundColor: colorVars['--cl-color-primary'] },
136+
secondary: { backgroundColor: colorVars['--cl-color-secondary'] },
137+
});
138+
const sizes = stylex.create({
139+
sm: { height: sizeVars['--cl-size-sm'] },
140+
md: {
141+
/**/
142+
},
143+
});
144+
```
145+
146+
- **DO** compose base + modifier in a **single** `stylex.props(...)` call, adding
147+
conditional atoms with `&&` / ternary. Order matters — later wins, so consumer
148+
`xstyle` goes last:
149+
150+
```tsx
151+
stylex.props(
152+
styles.base,
153+
variants[variant], // prop-keyed map lookup
154+
sizes[size],
155+
isIconOnly && styles.iconOnly,
156+
xstyle, // consumer StyleX — always LAST so it wins
157+
);
158+
```
159+
160+
- **DON'T** put everything in one monolithic object (you lose prop-keyed lookup and
161+
conditional composition), and **don't** swing the other way into one object per
162+
property. Group by concern: base, variant, size, state.
163+
- **DON'T** branch a variant with `variant === 'primary' ? a : b` inside the style.
164+
Encode the axis as a keyed map and look it up (`variants[variant]`). A repeated
165+
ternary is a missing variant map.
166+
- A helper that assembles a slot may **return an array** of atoms
167+
(`[base, direction[x], gap[n]]`) for the caller to spread into `stylex.props`.
168+
169+
### Conditions & state
170+
171+
Use StyleX's conditional-value objects (a `default` plus pseudo / at-rule keys).
76172

77173
```ts
78174
backgroundColor: {
@@ -85,29 +181,153 @@ backgroundColor: {
85181
},
86182
```
87183

88-
Guard `:hover` behind `@media (hover: hover)`; leave `:active`, `:focus-visible`,
89-
`:disabled` unguarded. Runtime conditions the component owns (disabled, etc.) are
90-
also reflected as `data-<axis>` attrs via `themeProps` so they stay overridable.
184+
- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
185+
leave `:active`, `:focus-visible`, `:disabled` unguarded.
186+
- **DO** use `:focus-visible` for focus rings (never bare `:focus`), and
187+
`:focus-within` on wrapping containers.
188+
- **DO** use `default: null` when a property exists **only** in a pseudo/state
189+
branch, so the atom doesn't emit a base value that would clobber a merged style:
190+
191+
```ts
192+
outline: { default: null, ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}` },
193+
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
194+
```
195+
196+
- **DO** gate every transition/animation on reduced motion, in the same object:
197+
198+
```ts
199+
transitionDuration: {
200+
default: durationVars['--cl-duration-fast'],
201+
'@media (prefers-reduced-motion: reduce)': '0.01ms',
202+
},
203+
```
204+
205+
- **DO** reflect runtime conditions the component owns (disabled, selected,
206+
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
207+
the state stays overridable in plain consumer CSS.
208+
209+
**Scoped ancestor states.** When a child's style depends on an **ancestor's**
210+
interaction state (a wrapper `:hover`/`:focus-visible` styling an inner mark),
211+
don't reach for a descendant combinator by hand. Use `stylex.when.ancestor()`
212+
with a **marker** so the selector is scoped to _your_ subtree and can't be
213+
triggered by unrelated ancestors:
214+
215+
```ts
216+
// checkbox.markers.stylex.ts — a define-primitive module, its own file
217+
export const checkboxScope = stylex.defineMarker();
218+
219+
// checkbox.styles.ts — the inner mark reacts to the labelled wrapper's state
220+
borderColor: {
221+
default: colorVars['--cl-color-border'],
222+
[stylex.when.ancestor(':hover', checkboxScope)]: {
223+
'@media (hover: hover)': `color-mix(in oklab, ${colorVars['--cl-color-border']}, ${colorVars['--cl-color-tint']} 20%)`,
224+
},
225+
},
226+
```
227+
228+
Apply the marker atom to the ancestor, reference the scope in the descendant's
229+
`create`. This replaces the Emotion engine's parent-selector hacks.
230+
231+
Write the hover guard and these selectors **raw**, as above — there is no
232+
`hover()` helper to import, because StyleX can't inline a cross-module helper into
233+
`create` (the Emotion engine's `hover()`/`motionSafe()` utils don't translate).
234+
`*.styles.ts` files are therefore exempted from the repo's `no-restricted-syntax`
235+
media-query rule (`eslint.config.mjs`); the `@stylexjs/*` rules still apply.
236+
237+
## Dynamic styles: dos and don'ts
238+
239+
A style key can be a **function** of a runtime value:
240+
`const dyn = stylex.create({ width: (w) => ({ width: w }) })`, applied
241+
`dyn.width(value)`. This is the right tool for a **continuous or unbounded**
242+
runtime value that can't be enumerated as a variant map — sizes, transforms,
243+
offsets, indents, counts, delays, computed widths. It is **not** a substitute for
244+
a variant map over a small closed set.
245+
246+
**When to reach for dynamic (and when not):**
91247

92-
Write the guard **raw**, as above — there is no `hover()` helper to import,
93-
because StyleX can't inline a cross-module helper into `create` (the Emotion
94-
engine's `hover()`/`motionSafe()` utils don't translate). `*.styles.ts` files are
95-
therefore exempted from the repo's `no-restricted-syntax` media-query rule
96-
(`eslint.config.mjs`); the `@stylexjs/*` rules still apply.
248+
- **DO** use a dynamic function for a value that is continuous, per-instance, or
249+
formula-derived: an avatar pixel size (`size => ({ width: size, height: size })`,
250+
font size `size * 0.4`), a drag transform
251+
(`t => ({ transform: t })` with `t = \`scale(${zoom}) translate(...)\``), a
252+
tree-row indent `(depth-1) \* step`, a line clamp `n => ({ WebkitLineClamp: n })`,
253+
a staggered `animationDelay`.
254+
- **DON'T** use dynamic for a closed enum (sm/md/lg, primary/secondary) — that's a
255+
variant map. **DON'T** use it for a plain static token — that's a static atom.
97256

98-
## Public contract (`props.ts`)
257+
**The important sub-distinction — write a `--var`, or set the property?**
258+
259+
There are two shapes of dynamic style. Prefer the first whenever the value feeds a
260+
rule that also needs media/pseudo variants, needs to be read by descendants, or
261+
must stay overridable by the consumer:
262+
263+
- **A. Write a CSS custom property; let a static rule consume it.** Only **one**
264+
atom is generated no matter how many distinct values flow through, and — this is
265+
the subtle win — a `var()` written into a class does **not** beat a consumer's
266+
class-level `@media` override, whereas a raw inline `style` property would.
267+
268+
```ts
269+
// set the var dynamically…
270+
const dyn = stylex.create({
271+
gutter: (digits: number) => ({ '--_cl-gutter': `${digits}ch` }),
272+
tabIndicator: (offset: string) => ({ '--_cl-tab-indicator': offset }),
273+
});
274+
// …and consume it from a normal static atom / descendant rule
275+
const styles = stylex.create({ row: { gridTemplateColumns: 'var(--_cl-gutter) 1fr' } });
276+
```
277+
278+
Also the right shape for a value a parent computes and **descendants** read
279+
(`--_cl-content-width`, an avatar-group `--_cl-overlap` a sibling rule consumes):
280+
the var inherits; no per-descendant atom.
281+
282+
- **B. Set the real property directly** — fine when the value is genuinely
283+
per-instance, only this element uses it, and no rule/descendant needs to read it
284+
(a live drag `transform`, a one-off `width`, `WebkitLineClamp`). Accept that
285+
each distinct value emits its own atom.
286+
287+
**Perf model to keep in mind:** each _distinct argument value_ emits a new atom /
288+
injected rule. A bounded numeric set (~10–20 sizes) is fine. For an unbounded
289+
value, sub-pattern A collapses it to a single `--var` atom; reach for a raw inline
290+
`style` only when you specifically do **not** want it to be overridable.
291+
292+
> This supersedes the earlier blanket "Mosaic styling is fully static." Static is
293+
> still the default — most styling is atoms, variant maps, and conditional-value
294+
> objects. Dynamic functions are the deliberate exception for continuous runtime
295+
> values, and even then the first move is usually to write a single `--cl`/`--_cl`
296+
> var rather than a raw inline style.
297+
298+
## Public contract & composition (`props.ts`)
99299

100300
The element carries three things, and nothing else is a contract:
101301

102302
1. `--cl-*` vars (from tokens), 2. `.cl-<slot>` class, 3. `data-<axis>` attrs.
103303

104-
`mergeProps` fuses them in precedence order — **theme props → StyleX atoms →
105-
consumer `className`/`style`** — so the consumer always wins:
304+
`themeProps('button', { intent, variant })` produces the stable `.cl-<slot>` class
305+
plus a kebab-cased `data-<axis>` reflection of the visual props, so consumers
306+
target stable data-attribute selectors, not collision-prone class names.
307+
308+
`mergeProps` fuses everything in precedence order — **theme props → StyleX atoms →
309+
consumer `className`/`style`** — so the consumer always wins. It concatenates
310+
className left-to-right and merges `style` with the consumer object spread last:
106311

107312
```tsx
108-
<button {...mergeProps(themeProps('button', { intent, variant }), stylex.props(styles.base, ...), className, style)} />
313+
<button
314+
{...mergeProps(
315+
themeProps('button', { intent, variant }),
316+
stylex.props(styles.base, variants[variant], xstyle),
317+
className,
318+
style,
319+
)}
320+
/>
109321
```
110322

323+
- **DO** put consumer `xstyle` **last** inside `stylex.props(...)` (so their atoms
324+
win the cascade) and consumer `className`/`style` last inside `mergeProps` (so
325+
their raw CSS wins).
326+
- **DON'T** forward `xstyle` down to internal slot elements — it targets the slot
327+
the consumer named, not your private structure.
328+
- **DON'T** call `stylex.props` twice on one element or spread `{...props}` after
329+
the merge result — fuse everything through the one `mergeProps` call.
330+
111331
## Build & CSS delivery (two contexts, same babel)
112332

113333
- **Published** (`build:mosaic``@stylexjs/rollup-plugin`): compiles the
@@ -128,9 +348,18 @@ token colors aren't down-leveled into an invalid polyfill.
128348
## CSS features we lean on / caveats
129349

130350
- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
131-
`:hover`/`:active`/`:focus-visible`/`:disabled`, `::before`/`::after`.
351+
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
352+
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
353+
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
354+
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
355+
`(pointer: coarse)`.
132356
- Prefer CSS-native solutions over JS workarounds for anything StyleX supports.
133357
- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering;
134358
`@property` compiles but emits invalid output).
135-
- We do not use functions-in-`create` (dynamic runtime values); Mosaic styling is
136-
fully static.
359+
- No need for `stylex.firstThatWorks` or `stylex.attrs` — a proven full library
360+
ships without either; reach for conditional-value objects and `mergeProps`
361+
instead.
362+
- Dynamic functions-in-`create` are allowed but exceptional — see "Dynamic styles"
363+
above. Default to static atoms, variant maps, and conditional-value objects; use
364+
a dynamic function only for a continuous runtime value, and prefer writing a
365+
single `--cl`/`--_cl` var over setting a property raw.

0 commit comments

Comments
 (0)