Skip to content

Commit bc42936

Browse files
authored
feat(ui): remap Mosaic Button onto color, variant, and size (#9282)
1 parent 8d214d2 commit bc42936

23 files changed

Lines changed: 1002 additions & 89 deletions
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: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
7878
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
7979
parent writes for a child to read). They still emit verbatim but the `_` marks
8080
them "not a contract, don't override."
81+
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
82+
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
83+
control itself (button, avatar square), `--cl-radius-container` for anything
84+
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
8185
- **DO** compute tints at the call site with `color-mix()`, not as their own
8286
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
8387
`--cl-color-primary-hover-12`.
8488
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
8589
can express from an existing token.
90+
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
91+
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
92+
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.
93+
94+
```ts
95+
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
96+
```
97+
98+
A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
99+
it lands lighter than the percentage of black — and by an amount that shifts with
100+
whatever the control sits on, so the step numbers stop describing what they render.
101+
The scrim composites, so one ramp reads consistently on every surface. This applies
102+
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
103+
from that color toward its own on-fill.
86104

87105
**Spacing is one exposed var plus a `defineConsts` scale.** Only `--cl-spacing`
88106
is a custom property; every step is inlined at build as `calc(var(--cl-spacing) *
@@ -166,23 +184,119 @@ objects and compose them at the call site.
166184
- A helper that assembles a slot may **return an array** of atoms
167185
(`[base, direction[x], gap[n]]`) for the caller to spread into `stylex.props`.
168186

187+
### Multiplying axes: flatten, don't plumb vars
188+
189+
When two visual axes multiply (`color` × `variant`), the tempting move is to have
190+
one axis write custom properties and the other read them — 3 + 4 declarations
191+
instead of 12, with each axis staying independent. **Don't.**
192+
193+
- **DON'T** declare custom properties inside a component's `stylex.create` to
194+
decouple that component's own axes. They emit into the stylesheet and appear on
195+
every instance in devtools, which reads as public API nobody agreed to support.
196+
A `--cl-*` name is worse still — that prefix is reserved for the overridable
197+
contract (see "Tokens").
198+
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
199+
hashed instead of `--cl-*`, but they still land in the inspector on every element.
200+
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
201+
indexed directly. A template-literal key makes an unhandled pair a compile error,
202+
with no lookup map to maintain:
203+
204+
```ts
205+
const variants = stylex.create({
206+
'filled-primary': {
207+
/**/
208+
},
209+
'filled-negative': {
210+
/**/
211+
},
212+
'outline-primary': {
213+
/**/
214+
},
215+
// …one entry per cell
216+
});
217+
// <comp>.tsx — TS resolves the template against the literal keys
218+
variants[`${variant}-${color}`];
219+
```
220+
221+
Each entry is self-contained, so a cell can be read and tuned straight against a
222+
design matrix — which is usually the form these specs arrive in.
223+
224+
- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
225+
inlines it at build, so the duplication leaves the source without emitting a var:
226+
227+
```ts
228+
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
229+
```
230+
231+
Same-file is required — an imported one fails static evaluation ("Atoms" above).
232+
233+
**Where a var is still right.** This rule is about a component plumbing values to
234+
itself. A custom property remains the correct tool when a value must cross an
235+
element boundary (a parent computes, a descendant reads) or collapse an unbounded
236+
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
237+
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
238+
own axes does.
239+
240+
Flattening does scale multiplicatively, so for a genuinely large product reach for
241+
role atoms instead — each color exports the same role names (`solid`, `ink`,
242+
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
243+
indirection the flattened map doesn't have.
244+
169245
### Conditions & state
170246

171247
Use StyleX's conditional-value objects (a `default` plus pseudo / at-rule keys).
172248

173249
```ts
174250
backgroundColor: {
175251
default: colorVars['--cl-color-primary'],
176-
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
252+
':active': primaryActive,
177253
'@media (hover: hover)': {
178-
// only the top-level value needs `default`; the nested block just adds `:hover`
179-
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
254+
// the media block contributes only the pseudo; the top-level `default` still
255+
// paints the rest state. `:not(:active)` is load-bearing — see below.
256+
default: null,
257+
':hover:not(:active)': primaryHover,
180258
},
181259
},
182260
```
183261

184262
- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
185263
leave `:active`, `:focus-visible`, `:disabled` unguarded.
264+
265+
#### A media-wrapped `:hover` outranks a bare `:active`
266+
267+
StyleX encodes precedence by **repeating the class name**, and an at-rule adds
268+
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
269+
a bare `:active` stays single — and the hover wins while the button is pressed:
270+
271+
```css
272+
.x1ozrsgg:active {
273+
} /* 0,1,1 — loses */
274+
@media (hover: hover) {
275+
.x1f1bnkq.x1f1bnkq:hover {
276+
} /* 0,3,0 — wins during the press */
277+
}
278+
```
279+
280+
The symptom is a hovered button that never shows its pressed colour on a mouse
281+
device, while touch devices look correct.
282+
283+
- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
284+
**matching** during a press, so the fix rests on selector semantics rather than on
285+
how StyleX happens to order equal-specificity rules.
286+
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
287+
state a no-hover device ever sees, since such a device never matches
288+
`(hover: hover)`.
289+
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
290+
work — both selectors end up doubled and StyleX emits `:active` after `:hover`
291+
but it depends on StyleX's emission order for the tiebreak and duplicates the
292+
value in every cell.
293+
- Applies to any state pair where one side is inside an at-rule and the other is
294+
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
295+
then grep `dist-mosaic/styles.css` for the two selectors and compare their
296+
specificity.
297+
298+
Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.
299+
186300
- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
187301
**container** that should ring when a child is focused, use
188302
`:has(:focus-visible)`**not** `:focus-within`. `:focus-within` matches any
@@ -208,15 +322,59 @@ backgroundColor: {
208322
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
209323
```
210324

211-
- **DO** gate every transition/animation on reduced motion, in the same object:
325+
- **DO** take durations from `durationVars` (`--cl-duration-instant` / `-fast` /
326+
`-base` / `-slow` / `-slower`) rather than a literal, and vary them by state
327+
where the feedback should read as more or less direct:
212328

213329
```ts
214330
transitionDuration: {
215331
default: durationVars['--cl-duration-fast'],
332+
':active': durationVars['--cl-duration-instant'],
333+
},
334+
```
335+
336+
- **DO** pick the timing function by **what the property does**, not by how long
337+
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
338+
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
339+
sells the motion. Color and opacity take plain `linear`: their interpolation is
340+
already perceptually non-uniform, so an ease on top only makes the midpoint
341+
drag, and an overshoot extrapolates past the target color for nothing. A
342+
transform at `--cl-duration-fast` still wants the curve.
343+
344+
- **DO** gate transitions/animations of **motion-bearing** properties on reduced
345+
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
346+
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
347+
and opacity transitions do **not** need it:
348+
349+
```ts
350+
transitionDuration: {
351+
default: durationVars['--cl-duration-base'],
216352
'@media (prefers-reduced-motion: reduce)': '0.01ms',
217353
},
218354
```
219355

356+
- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
357+
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
358+
in charge otherwise, and give it its own atom rather than writing it into every
359+
size — the floor is one physical constant, and a part that isn't really a control
360+
(a `link` variant, which reads as text) opts out by not receiving the atom:
361+
362+
```ts
363+
touchTarget: {
364+
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
365+
},
366+
```
367+
368+
Square controls need the floor on **both** axes, or the target grows tall and narrow.
369+
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
370+
rescaling density must not shrink a touch target with it.
371+
372+
- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
373+
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
374+
renders, and a wrapping tooltip never gets the pointer to explain the disabled
375+
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
376+
match a disabled element, so every one of them needs the gate.
377+
220378
- **DO** reflect runtime conditions the component owns (disabled, selected,
221379
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
222380
the state stays overridable in plain consumer CSS.
@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.
364522

365523
- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
366524
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
525+
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
367526
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
368527
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
369528
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /

packages/swingset/src/stories/button.mdx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
3535
storyModule={ButtonStories}
3636
/>
3737

38+
`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
39+
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
40+
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
41+
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
42+
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
43+
it applies at `@media (pointer: coarse)`.
44+
45+
### Variants
46+
47+
<Story
48+
name='Variants'
49+
storyModule={ButtonStories}
50+
/>
51+
52+
### Colors
53+
54+
`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
55+
it to show. Every combination below is the same two props.
56+
57+
Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
58+
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
59+
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
60+
the same for every cell.
61+
62+
<Story
63+
name='Colors'
64+
storyModule={ButtonStories}
65+
/>
66+
3867
### Shapes
3968

4069
<Story
4170
name='Shapes'
4271
storyModule={ButtonStories}
4372
/>
4473

74+
### Icons
75+
76+
An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
77+
which the button selects on to tighten the padding on that edge — pass icons on either side, or
78+
both, with nothing to declare on the button itself.
79+
80+
<Story
81+
name='Icons'
82+
storyModule={ButtonStories}
83+
/>
84+
85+
The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
86+
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
87+
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
88+
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
89+
The middle button in each row below carries no icon, for comparison.
90+
91+
<Story
92+
name='IconSizes'
93+
storyModule={ButtonStories}
94+
/>
95+
96+
### Truncation
97+
98+
A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
99+
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
100+
width — the label can't wrap to cope, because `size` fixes the height and a second line would
101+
grow out of the button. It stays on one line and ends in an ellipsis instead.
102+
103+
Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
104+
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
105+
children pass through as direct flex items, so an icon keeps its size and its padding while the
106+
label gives way around it.
107+
108+
<Story
109+
name='Truncation'
110+
storyModule={ButtonStories}
111+
/>
112+
45113
### Disabled
46114

47115
<Story
48116
name='Disabled'
49117
storyModule={ButtonStories}
50118
/>
119+
120+
A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
121+
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
122+
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
123+
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
124+
That tooltip is worth adding — a disabled control with no explanation is a dead end.

0 commit comments

Comments
 (0)