Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-button-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== changeset files =="
git ls-files .changeset | sort
echoecho"== target changeset =="if [ -f .changeset/mosaic-button-variants.md ];then
cat -n .changeset/mosaic-button-variants.md
elseecho"missing"fiechoecho"== package/ui references =="
git ls-files | rg 'packages/ui|ui/'| head -100
echo
rg -n '"name"\s*:\s*["`@-`]?[^"]*ui|packages/ui|`@clerk/ui`|`@mui/material`|firefox' package.json CHANGELOG.md packages README.md .changeset -S ||true

Repository: clerk/javascript

Length of output: 5459


🏁 Script executed:

#!/bin/bashset -euo_pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 807


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== root package/workspaces =="
cat -n package.json | sed -n '1,220p'echoecho"== packages/ui package metadata =="
cat -n packages/ui/package.json | sed -n '1,220p'echoecho"== related changesets =="while IFS= read -r f;doecho"--- $f"
cat -n "$f"done<<(git ls-files .changeset '*.md'| grep -v config.json | grep -v changelog.js | grep -v README.md | sort)echoecho"== button contract references =="
rg -n "intent|color|variant|Browser\|Firefox|firefox|120|121" packages/ui -S --glob '!**/CHANGELOG.md'| head -200
echoecho"== exports/test files touching Button/Mosaic =="
rg -n "Button|ButtonProps|color|variant|intent" packages/ui/src --glob '*.{ts,tsx}' -S | head -250

Repository: clerk/javascript

Length of output: 18637


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort);doecho"--- $f"
cat -n "$f"doneechoecho"== root workspace configs =="
fd '^pnpm-workspace.yaml$|^turbo.json$|^README.md$' -t f -x sh -c 'echo "--- $1"; cat -n "$1" | sed -n "1,220p"' sh {}
echoecho"== target package references =="
rg -n --no-heading '`@clerk/ui`|packages/ui|Firefox|firefox|12 [01]|121|120|intent|color|variant' .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S | head -300
echoecho"== button files in packages/ui/src =="
rg -n --no-heading 'export .*Button|interface .*ButtonProps|type .*ButtonProps|const .*Button|ButtonProps|intent|color|variant' packages/ui/src --glob '*.{ts,tsx}' -S | head -300

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant .changeset files and contents =="forfin$(git ls-files .changeset '*.md'| sort | grep -v -E '(config\.json|changelog\.js|README\.md)$');doecho"--- $f"
cat -n "$f"doneechoecho"== exact target references =="forpatin'`@clerk/ui`''packages/ui''Firefox''firefox''120''121''intent''color''variant';doecho"--- $pat"
rg -n "$pat" .changeset README.md package.json turbo.json pnpm-workspace.yaml .github packages/ui -S --glob '!**/CHANGELOG.md' --glob '!**/README.md' --glob '!**/*.png' --glob '!**/*.svg'| head -100
doneechoecho"== all Button-related files under packages/ui/src =="
git ls-files 'packages/ui/src/**'| rg 'button|Button|buttons'||trueechoecho"== Button symbol/export definitions under packages/ui/src =="
rg -n --no-heading -i 'button|intent|color|variant|Firefox|120|121' packages/ui/src -S | head -300

Repository: clerk/javascript

Length of output: 50375


Add the @clerk/ui release entry to this changeset.

.changeset/mosaic-button-variants.md is only the header, while @clerk/ui is a public package and this change touches its Mosaic Button contract/Firefox build floor. Add its bump/migration details so the release note and package version are not missed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-button-variants.md around lines 1 - 2, Add the appropriate
`@clerk/ui` release entry to the frontmatter in mosaic-button-variants.md,
including the required version bump and migration details for the Mosaic Button
contract and Firefox build floor. Preserve the existing changeset structure and
header format.

Sources: Coding guidelines, Learnings

167 changes: 163 additions & 4 deletions .claude/skills/mosaic/references/stylex.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,11 +78,29 @@ export const colorVars = stylex.defineVars(colorDefaults);
- **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a
parent writes for a child to read). They still emit verbatim but the `_` marks
them "not a contract, don't override."
- **DO** name a radius by **what the surface is**, not by size, so the steps nest:
`--cl-radius-inner` for a mark inside a control, `--cl-radius-control` for the
control itself (button, avatar square), `--cl-radius-container` for anything
wrapping controls. A role name survives a value change; `--cl-radius-md` doesn't.
- **DO** compute tints at the call site with `color-mix()`, not as their own
tokens. `color-mix(in oklab, ${primary}, ${fg} 12%)` beats minting
`--cl-color-primary-hover-12`.
- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()`
can express from an existing token.
- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed
wash on a transparent `outline`/`ghost` control — as a **scrim**: an opacity of
black-on-light / white-on-dark over `transparent`, not a percentage of a gray token.

```ts
const step = `color-mix(in oklab, light-dark(oklch(0 0 0), oklch(1 0 0)) 12%, transparent)`;
```

A gray token like `--cl-color-neutral` is a 900, not black, so the same percentage of
it lands lighter than the percentage of black — and by an amount that shifts with
whatever the control sits on, so the step numbers stop describing what they render.
The scrim composites, so one ramp reads consistently on every surface. This applies
to overlay fills only: a fill with a color of its own (`filled-primary`) still blends
from that color toward its own on-fill.

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

### Multiplying axes: flatten, don't plumb vars

When two visual axes multiply (`color` × `variant`), the tempting move is to have
one axis write custom properties and the other read them — 3 + 4 declarations
instead of 12, with each axis staying independent. **Don't.**

- **DON'T** declare custom properties inside a component's `stylex.create` to
decouple that component's own axes. They emit into the stylesheet and appear on
every instance in devtools, which reads as public API nobody agreed to support.
A `--cl-*` name is worse still — that prefix is reserved for the overridable
contract (see "Tokens").
- **DON'T** reach for `defineVars` + `createTheme` to dodge that. The names get
hashed instead of `--cl-*`, but they still land in the inspector on every element.
- **DO** flatten the cross product into one variant map keyed `<axis-a>-<axis-b>`,
indexed directly. A template-literal key makes an unhandled pair a compile error,
with no lookup map to maintain:

```ts
const variants = stylex.create({
'filled-primary': {
/*…*/
},
'filled-negative': {
/*…*/
},
'outline-primary': {
/*…*/
},
// …one entry per cell
});
// <comp>.tsx — TS resolves the template against the literal keys
variants[`${variant}-${color}`];
```

Each entry is self-contained, so a cell can be read and tuned straight against a
design matrix — which is usually the form these specs arrive in.

- **DO** hoist a value shared across cells to a **same-file** `const`. StyleX
inlines it at build, so the duplication leaves the source without emitting a var:

```ts
const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`;
```

Same-file is required — an imported one fails static evaluation ("Atoms" above).

**Where a var is still right.** This rule is about a component plumbing values to
itself. A custom property remains the correct tool when a value must cross an
element boundary (a parent computes, a descendant reads) or collapse an unbounded
dynamic value into a single atom — the `--_cl-*` cases in "Tokens" and sub-pattern
A in "Dynamic styles". Those have no var-free equivalent; decoupling one element's
own axes does.

Flattening does scale multiplicatively, so for a genuinely large product reach for
role atoms instead — each color exports the same role names (`solid`, `ink`,
`tint`) and the variant picks which to apply. Still var-free, at the cost of an
indirection the flattened map doesn't have.

### Conditions & state

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

```ts
backgroundColor: {
default: colorVars['--cl-color-primary'],
':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`,
':active': primaryActive,
'@media (hover: hover)': {
// only the top-level value needs `default`; the nested block just adds `:hover`
':hover': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 12%)`,
// the media block contributes only the pseudo; the top-level `default` still
// paints the rest state. `:not(:active)` is load-bearing — see below.
default: null,
':hover:not(:active)': primaryHover,
},
},
```

- **DO** guard `:hover` behind `@media (hover: hover)` so it never sticks on touch;
leave `:active`, `:focus-visible`, `:disabled` unguarded.

#### A media-wrapped `:hover` outranks a bare `:active`

StyleX encodes precedence by **repeating the class name**, and an at-rule adds
priority. So a `@media (hover: hover)` `:hover` compiles to a doubled selector while
a bare `:active` stays single — and the hover wins while the button is pressed:

```css
.x1ozrsgg:active {
} /* 0,1,1 — loses */
@media (hover: hover) {
.x1f1bnkq.x1f1bnkq:hover {
} /* 0,3,0 — wins during the press */
}
```

The symptom is a hovered button that never shows its pressed colour on a mouse
device, while touch devices look correct.

- **DO** write the hover branch as `':hover:not(:active)'`. It stops the hover rule
**matching** during a press, so the fix rests on selector semantics rather than on
how StyleX happens to order equal-specificity rules.
- **DO** keep the bare `':active'` outside the media query. It is the only pressed
state a no-hover device ever sees, since such a device never matches
`(hover: hover)`.
- **DON'T** fix it by re-declaring `':active'` _inside_ the `@media` block. It does
work — both selectors end up doubled and StyleX emits `:active` after `:hover` —
but it depends on StyleX's emission order for the tiebreak and duplicates the
value in every cell.
- Applies to any state pair where one side is inside an at-rule and the other is
not. Confirm the output rather than trusting it: `pnpm build:mosaic --filter @clerk/ui`,
then grep `dist-mosaic/styles.css` for the two selectors and compare their
specificity.

Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`.

- **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a
**container** that should ring when a child is focused, use
`:has(:focus-visible)` — **not** `:focus-within`. `:focus-within` matches any
Expand All@@ -208,15 +322,59 @@ backgroundColor: {
outlineOffset: { default: null, ':focus-visible': space['0.5'] },
```

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

```ts
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':active': durationVars['--cl-duration-instant'],
},
```

- **DO** pick the timing function by **what the property does**, not by how long
it runs. Properties that move — `transform`, `translate`, `scale`, `rotate`,
insets — take `easingVars['--cl-ease-default']`, whose slight overshoot is what
sells the motion. Color and opacity take plain `linear`: their interpolation is
already perceptually non-uniform, so an ease on top only makes the midpoint
drag, and an overshoot extrapolates past the target color for nothing. A
transform at `--cl-duration-fast` still wants the curve.

- **DO** gate transitions/animations of **motion-bearing** properties on reduced
motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the
same object. `prefers-reduced-motion` is a vestibular-safety signal, so color
and opacity transitions do **not** need it:

```ts
transitionDuration: {
default: durationVars['--cl-duration-base'],
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
```

- **DO** floor an interactive control's hit area at `targetVars['--cl-target-coarse']`
under `@media (pointer: coarse)`. Use `minHeight`/`minWidth` so the size axis stays
in charge otherwise, and give it its own atom rather than writing it into every
size — the floor is one physical constant, and a part that isn't really a control
(a `link` variant, which reads as text) opts out by not receiving the atom:

```ts
touchTarget: {
minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] },
},
```

Square controls need the floor on **both** axes, or the target grows tall and narrow.
`--cl-target-coarse` is deliberately off the `--cl-spacing` scale: a consumer
rescaling density must not shrink a touch target with it.

- **DON'T** suppress interaction with `pointer-events: none` on a disabled control.
An element that isn't hit-tested supplies no cursor, so `cursor: not-allowed` never
renders, and a wrapping tooltip never gets the pointer to explain the disabled
state. Gate the interactive states on `:enabled` instead — `:hover`/`:active` still
match a disabled element, so every one of them needs the gate.

- **DO** reflect runtime conditions the component owns (disabled, selected,
invalid) as `data-<axis>` attrs via `themeProps`, in addition to the atom, so
the state stays overridable in plain consumer CSS.
Expand DownExpand Up@@ -364,6 +522,7 @@ token colors aren't down-leveled into an invalid polyfill.

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:focus-within`/`:disabled`, `:has()`,
`:not()` and compound pseudo keys (`':hover:not(:active)'` compiles and lints),
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,16 +35,90 @@ Button is the primary action element in Mosaic, used for form submissions and di
storyModule={ButtonStories}
/>

`size` sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
`--cl-target-coarse` rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
`link` opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at `@media (pointer: coarse)`.

### Variants

<Story
name='Variants'
storyModule={ButtonStories}
/>

### Colors

`color` and `variant` are independent: `color` picks the palette, `variant` decides how much of
it to show. Every combination below is the same two props.

Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled`
shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill,
except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is
the same for every cell.

<Story
name='Colors'
storyModule={ButtonStories}
/>

### Shapes

<Story
name='Shapes'
storyModule={ButtonStories}
/>

### Icons

An icon is a child, not a prop. Give it a `placement` and `Icon` reflects that as `data-icon`,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.

<Story
name='Icons'
storyModule={ButtonStories}
/>

The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.

<Story
name='IconSizes'
storyModule={ButtonStories}
/>

### Truncation

A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width _is_ constrained — `fullWidth` in a narrow container, or an explicit
width — the label can't wrap to cope, because `size` fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.

Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis
against, since a bare text node is laid out in an anonymous box no selector can reach. Element
children pass through as direct flex items, so an icon keeps its size and its padding while the
label gives way around it.

<Story
name='Truncation'
storyModule={ButtonStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={ButtonStories}
/>

A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Loading
Loading