diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 0031ac9bc9c..dbebb0e3f54 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -41,7 +41,8 @@ Skills are Claude Code specific. Cursor does not read this directory; it uses `. ## Skills in this repo -| Skill | Use it for | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. | -| `mosaic` | Mosaic flow UI: authoring machines, controllers, and views, and migrating a legacy component into the split (with parity verification). | +| Skill | Use it for | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. | +| `mosaic` | Mosaic flow UI: authoring machines, controllers, and views, and migrating a legacy component into the split (with parity verification). | +| `mosaic-stylex` | Authoring StyleX for Mosaic: `stylex.create`/`props`, `--cl-*` tokens, the stable class + cascade-layer override contract, markers, build, and migrating off Emotion/`useRecipe`. | diff --git a/.claude/skills/mosaic-stylex/SKILL.md b/.claude/skills/mosaic-stylex/SKILL.md new file mode 100644 index 00000000000..5227c3ec3b6 --- /dev/null +++ b/.claude/skills/mosaic-stylex/SKILL.md @@ -0,0 +1,130 @@ +--- +name: mosaic-stylex +description: >- + Author and organize StyleX styles for Mosaic components. Use when writing a + component's styles with `stylex.create` / `stylex.props`, defining or consuming + design tokens (`defineVars` / `defineConsts` / `createTheme`), exposing the + stable public styling contract (stable `.cl-*` classes, `--cl-*` CSS variables, + `data-*` attributes, cascade layers), setting up relational/state styling + (markers + `stylex.when`), wiring the build (rollup/tsdown plugin, layers, + ESLint), or migrating a Mosaic component off Emotion/`useRecipe` onto StyleX. + The direction: compile-time atomic CSS, a shipped static stylesheet, and + consumer overrides through their own CSS instead of the `appearance` prop. +--- + +# StyleX for Mosaic + +Mosaic is pivoting its styling engine from **Emotion** (runtime CSS-in-JS, +`useRecipe`/`appearance`) to **StyleX** (compile-time atomic CSS, a shipped +static stylesheet). This skill is the how-to for authoring StyleX the Mosaic +way. It is grounded in two sources: + +- The **astryx** design system (`../research/astryx`, ~350 `.stylex.ts` files) — + a mature, production StyleX design system. Its patterns are the reference. +- The Clerk **mosaic-x POC** (`packages/ui/src/mosaic-x/`, PR #9189) and its + writeup `references/mosaic-stylex-migration.md` — the proven direction for + _this_ repo. + +## The contract we are building toward + +Four public-surface goals drive every decision. Consumers style Mosaic from +**their own CSS**, never from an `appearance` prop: + +1. **Stable CSS variables** — `--cl-foreground`, `--cl-primary`, … default in + our sheet and a consumer overrides them with plain `:root { --cl-foreground: red }`. +2. **Stable class names** — every button carries `.cl-button`; a consumer targets + `.cl-button { … }` in their CSS. +3. **Stable data attributes** — variant/state reflected as `data-variant`, + `data-size`, etc. for scoped targeting (`.cl-button[data-variant='outline']`). +4. **A shipped static stylesheet** (`mosaic.css`) imported once into a cascade + layer the consumer controls. + +StyleX gives us goals 1, 3, 4 natively. Goal 2 (stable classes) needs a specific +technique — you attach your own plain `.cl-*` class alongside StyleX's atomic +classes. See `references/overrides.md`. + +## The core authoring shape + +Every styled part follows the same three moves. Read this, then the references. + +```tsx +import * as stylex from '@stylexjs/stylex'; +import { mergeProps, themeProps } from '../shared/naming'; // cl-* stable class + data-attr helpers +import { colors, radius, space } from './tokens.stylex'; + +// 1. stylex.create — base + each variant VALUE as its own style object. +// Variants are COMPOSED conditionally, not resolved by a recipe engine. +const styles = stylex.create({ + base: { + display: 'inline-flex', + borderRadius: radius['--cl-radius-md'], + // states live inside property values as { default, ':hover', … } — never as top-level keys + outline: { default: 'none', ':focus-visible': `2px solid ${colors['--cl-primary']}` }, + }, + filledPrimary: { backgroundColor: colors['--cl-primary'], color: colors['--cl-primary-foreground'] }, + outline: { backgroundColor: 'transparent', borderColor: colors['--cl-border'] }, + sizeSm: { paddingBlock: space['--cl-spacing'], fontSize: '0.75rem' }, + disabled: { opacity: 0.5, cursor: 'not-allowed', pointerEvents: 'none' }, +}); + +// 2. Infer props; provide variant defaults. +export interface ButtonProps extends React.ComponentPropsWithRef<'button'> { + variant?: 'filled' | 'outline'; + size?: 'sm' | 'md'; +} + +// 3. Compose in stylex.props(...); merge the stable class + data-attrs first, +// consumer className/style last (they win). +export const Button = ({ variant = 'filled', size = 'md', disabled, className, style, ...rest }: ButtonProps) => ( + +); +``` + +Three ideas make this different from the Emotion/`useRecipe` model you may know: + +- **Variants are conditional composition, not a recipe engine.** There is no + `defineSlotRecipe`, no `variants` map resolved at runtime, no `compoundVariants` + DSL. You write `cond && styles.x` in `stylex.props(...)`. Last wins. +- **State is `{ default, ':hover', … }` inside a property value**, compiled into + the sheet — not a `_hover` condition key resolved at runtime. (Consumers still + override state through their own `.cl-*` CSS.) +- **The stable `.cl-*` class + `data-*` attrs are the public API**, emitted by + `themeProps` and fused with StyleX's atomic classes by `mergeProps`. Consumers + never see the atomic `x…` hashes. + +## Which reference to read + +| You are… | Read | +| --------------------------------------------------------------------------------- | -------------------------------------- | +| Writing a component's styles (create/props, variants, states, composition) | `references/authoring.md` | +| Defining or consuming tokens (`--cl-*` vars, `defineVars`/`defineConsts`, themes) | `references/tokens-and-theming.md` | +| Exposing/overriding the public contract (stable classes, cascade layers) | `references/overrides.md` | +| Naming files, splitting styles, exports, the `naming`/`mergeProps` helpers | `references/organization.md` | +| Parent/sibling/state scoping (markers + `stylex.when`), dynamic, keyframes | `references/relational-and-dynamic.md` | +| Setting up the build, ESLint, and distribution | `references/build.md` | +| Migrating a component off Emotion/`useRecipe` | `references/migration.md` | + +## Hard rules (apply everywhere) + +- **Reference tokens, never hardcode** colors, spacing, radius, or type. `colors['--cl-foreground']`, not `'#000'`. Hardcoding breaks theming. +- **No top-level pseudo-classes or media queries.** Nest them inside property values: `{ default, ':hover', '@media …' }`. +- **No `style` or `className` alongside a raw `stylex.props()` spread on the same element** — route both through `mergeProps` so they merge correctly. +- **`defineVars`/`defineConsts` live only in `*.stylex.ts` files**, named exports only, no other exports in the file. +- **Longhand over shorthand** (`paddingBlock`/`paddingInline`, not `padding`); `null` unsets a property. +- **StyleX styles the element they're on — no child/descendant selectors.** Style each part on its own element; drive cross-element state by composing style objects or using markers (`references/relational-and-dynamic.md`). +- **Never document or let consumers target the atomic `x…` hashes** — they change every build. The stable surface is `.cl-*`, `--cl-*`, and `data-*` only. diff --git a/.claude/skills/mosaic-stylex/references/authoring.md b/.claude/skills/mosaic-stylex/references/authoring.md new file mode 100644 index 00000000000..cc4c4d97e82 --- /dev/null +++ b/.claude/skills/mosaic-stylex/references/authoring.md @@ -0,0 +1,303 @@ +# Authoring component styles with StyleX + +The StyleX authoring API is small. This file is the distilled version plus the +patterns astryx uses at scale. Full upstream reference: the StyleX LLM authoring +guide at https://stylexjs.com/docs/llm-resources. + +## `stylex.create` — define styles as named namespaces + +```tsx +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + base: { display: 'inline-flex', alignItems: 'center', paddingBlock: 8 }, + title: { fontSize: 24, fontWeight: 'bold' }, +}); +``` + +- **Longhand + single-value shorthands only.** `paddingBlock`/`paddingInline`, + `marginInlineStart`, `borderColor` — not `padding`, `margin`, `border`. Multi-value + shorthands (`border: '1px solid red'` used as a single value where you'd also set + `borderColor` elsewhere) fight the atomic model. `borderWidth`/`borderStyle`/`borderColor` + as three keys is fine. +- **Length values are pixels by default** (`paddingBlock: 8` → `8px`). +- **`null` unsets a property** — useful in a "reset" namespace or a `default` branch. + +## `stylex.props` — turn namespaces into `{ className, style }` + +```tsx +
+``` + +Multiple namespaces merge; **last wins** on conflict. Falsy entries are ignored, +which is the whole variant mechanism: + +```tsx +stylex.props( + styles.base, + isActive && styles.active, // boolean && namespace + variant === 'primary' ? styles.primary : styles.secondary, + xstyle, // caller override, always last +); +``` + +In Mosaic you rarely call `stylex.props` bare on a public element — wrap it with +`mergeProps` so the stable `.cl-*` class, `data-*` attrs, and consumer +`className`/`style` fuse in one place (see `organization.md`). `stylex.props` +bare is fine on **internal** elements that carry no public contract. + +## Variants = conditional composition (no recipe engine) + +There is no `defineSlotRecipe`/`variants` map. A variant is one style namespace +per value, selected by indexed lookup or a boolean condition. Two idioms, both +from astryx: + +**Indexed lookup** — the variant prop is the key: + +```tsx +const variants = stylex.create({ + primary: { backgroundColor: colors['--cl-primary'], color: colors['--cl-primary-foreground'] }, + secondary: { backgroundColor: colors['--cl-secondary'] }, + ghost: { backgroundColor: 'transparent' }, +}); + +const sizeStyles = stylex.create({ + sm: { height: sizes['--cl-size-sm'], paddingInline: space['--cl-spacing'] }, + md: { height: sizes['--cl-size-md'] }, +}); + +// apply +stylex.props(styles.base, variants[variant], sizeStyles[size], xstyle); +``` + +Type the axis from the map so props stay in sync: + +```tsx +export type ButtonSize = keyof typeof sizeStyles; // 'sm' | 'md' +``` + +**Boolean / compound conditions** — for multi-axis and compound cases, just +`&&`-compose. This replaces `compoundVariants`: + +```tsx +stylex.props( + styles.base, + variant === 'filled' && intent === 'primary' && styles.filledPrimary, + variant === 'filled' && intent === 'destructive' && styles.filledDestructive, + variant === 'outline' && styles.outline, + disabled && styles.disabled, + xstyle, +); +``` + +**Repeated indexed scales** (spacing/gap) — build the map once, key by the step: + +```tsx +const gapStyles = stylex.create({ + 0: { columnGap: space['--cl-spacing-0'], rowGap: space['--cl-spacing-0'] }, + 1: { columnGap: space['--cl-spacing-1'], rowGap: space['--cl-spacing-1'] }, + 2: { columnGap: space['--cl-spacing-2'], rowGap: space['--cl-spacing-2'] }, +}); +export type SpacingStep = keyof typeof gapStyles; +``` + +## State styling: nest inside property values + +Pseudo-classes and media queries are **values keyed by their selector**, never +top-level namespace keys. `default` is required when any conditional key is present. + +```tsx +const styles = stylex.create({ + button: { + backgroundColor: { + default: colors['--cl-primary'], + ':hover': `color-mix(in oklab, ${colors['--cl-primary']}, ${colors['--cl-primary-foreground']} 12%)`, + ':active': `color-mix(in oklab, ${colors['--cl-primary']}, ${colors['--cl-primary-foreground']} 24%)`, + ':disabled': colors['--cl-muted'], + }, + cursor: { default: 'pointer', ':disabled': 'not-allowed' }, + // media as a value; nest a media query under a pseudo when both apply + outline: { + default: 'none', + ':focus-visible': `2px solid ${colors['--cl-primary']}`, + }, + transitionDuration: { + default: motion['--cl-duration-fast'], + '@media (prefers-reduced-motion: reduce)': '0s', + }, + }, +}); +``` + +Common recommended states: `:hover`, `:active`, `:focus`, `:focus-visible`, +`:focus-within`, `:disabled`. Gate `:hover` behind `@media (hover: hover)` when +you don't want it firing on touch (astryx does this consistently): + +```tsx +backgroundColor: { + default: colors['--cl-primary'], + ':hover': { '@media (hover: hover)': colors['--cl-primary-hover'] }, +}, +``` + +That nested `':hover' → '@media (hover: hover)'` is the StyleX equivalent of +Emotion Mosaic's `_hover: ['@media (hover: hover)', '&:hover']` condition +(`packages/ui/src/mosaic/conditions.ts`). There is **no runtime condition map** in +StyleX — you don't register `_hover` and expand it later; you write the gate +inline per property. The compiled selector (`@media (hover: hover) { &:hover }`) +is identical. Key order doesn't affect output, but pseudo-outer / media-inner is +the convention. What you give up: the gate isn't applied automatically, and it's +overridden through consumer CSS (`.cl-button:hover { … }`), not a merged `_hover` +key. + +Keep the gate DRY by hoisting the media string to a `defineConsts` (its values +are valid as **nested keys**, and it inlines at compile time — no runtime var): + +```tsx +// media.stylex.ts +export const media = stylex.defineConsts({ hover: '@media (hover: hover)' }); +``` + +```tsx +import { media } from './media.stylex'; +backgroundColor: { + default: colors['--cl-primary'], + ':hover': { [media.hover]: colors['--cl-primary-hover'] }, +}, +``` + +You **cannot** wrap this in a runtime helper (`_hover(value)`) called inside +`stylex.create` — the compiler statically evaluates the `create` argument, so the +nested object literal (optionally with the `defineConsts` key) is the ceiling for +reuse. The auto-applied, appearance-overridable `_hover` was the Emotion model; +the StyleX trade is explicit, compiled gates. + +`color-mix(...)` and `calc(...)` work inside values (verified in the POC), which +is how you derive hover/active shades without a runtime `theme.mix()` helper. + +**Prefer JS/prop changes over `:first-child`/`:nth-child`** and **prefer real +elements over `::before`/`::after`** — both bloat the atomic sheet and hurt a11y. + +## Pseudo-elements + +Top-level keys within a namespace (unlike pseudo-classes, which are value-nested): + +```tsx +const styles = stylex.create({ + input: { + color: colors['--cl-foreground'], + '::placeholder': { color: colors['--cl-muted-foreground'] }, + '::selection': { backgroundColor: colors['--cl-primary'] }, + }, +}); +``` + +## CSS reset (box-sizing, margin/padding, borders) + +Mosaic is an **embedded widget**, not a whole-app framework, so it must **not** +ship a global reset (`*`, `html`, `body`, `h1`, `button`, …) the way astryx's +`reset.css` does — that would restyle the host app's entire DOM. Two scoped moves +replace it: + +**1. Per-component base reset in StyleX (primary).** StyleX styles only the +element it's on, and Mosaic components are self-contained, so put the reset in each +component's `base`. Factor the repeated bits into one shared `reset` object and +compose it: + +```tsx +const reset = stylex.create({ + box: { boxSizing: 'border-box', margin: 0, padding: 0 }, +}); + +const styles = stylex.create({ + base: { display: 'inline-flex', borderRadius: radius['--cl-radius-md'] /* … */ }, +}); + +stylex.props(reset.box, styles.base /* variants */); // reset first, so real styles win +``` + +This is what the POC's `button.tsx` already does implicitly — it sets +`borderWidth`/`borderStyle`/`borderColor` explicitly rather than leaning on a +global reset. + +**2. A _scoped_ reset stylesheet for the genuinely universal bits.** `box-sizing` +and the border reset are things you don't want to hand-repeat on every element. +StyleX can't express a `*`/descendant selector, so ship this as **plain hand-written +CSS**, scoped to the Mosaic subtree and zero-specificity via `:where()`: + +```css +@layer cl-reset { + :where([data-cl-scope] *, [data-cl-scope] *::before, [data-cl-scope] *::after) { + box-sizing: border-box; + border-width: 0; + border-style: solid; /* now a component adds a border with just border-width + border-color */ + } +} +``` + +The Mosaic root carries `data-cl-scope` (the same root that sets `color-scheme` + +`container-type`). `:where()` keeps specificity 0 so component `base` styles and +consumer overrides always win; the `@layer` keeps it below everything else. + +Two notes carried from astryx: the **border reset** (`border-width: 0; border-style: solid`) +is a real ergonomic win — borders become `borderWidth` + `borderColor` only. And +for layout-critical components, **also** assert `boxSizing: 'border-box'` in the +StyleX `base` (belt-and-suspenders) so they're correct even if a consumer strips +the reset sheet. Never reach for a global `*`/`body` reset. + +## Accepting style overrides from a parent + +Type a passthrough prop as `StyleXStyles` and place it **last** so it overrides: + +```tsx +import type { StyleXStyles } from '@stylexjs/stylex'; + +type Props = { xstyle?: StyleXStyles }; +stylex.props(styles.base, variants[variant], xstyle); // xstyle wins +``` + +Constrain or exclude properties when a slot must not be re-laid-out: + +```tsx +import type { StyleXStyles, StyleXStylesWithout } from '@stylexjs/stylex'; +type ColorOnly = StyleXStyles<{ color?: string; backgroundColor?: string }>; +type NoLayout = StyleXStylesWithout<{ margin: unknown; padding: unknown; width: unknown }>; +``` + +Note: an `xstyle` typed as `StyleXStyles` accepts **StyleX styles only** — it is +not the free-form `sx` object from Emotion Mosaic. Arbitrary runtime styles are +gone; see `migration.md`. + +## Composition across parts + +Compound components layer styles per element. A layout helper returns an array of +namespaces that the caller spreads — astryx's `stack()`/`container()` pattern: + +```tsx +export function stack({ direction, gap }: StackOptions) { + return [baseStyles.stack, directionStyles[direction], gap != null && gapStyles[gap]] as const; +} + +// caller +stylex.props(...stack({ direction, gap }), xstyle); +``` + +Icon sizing and other sub-parts get their own indexed map, applied on the child +element: + +```tsx +const iconSizeStyles = stylex.create({ sm: { width: 16, height: 16 }, md: { width: 20, height: 20 } }); +{icon}; +``` + +## Antipatterns (StyleX will fight you or silently misbehave) + +- **Top-level pseudo/media keys** in a namespace — invalid. Nest inside a value. +- **Importing non-StyleX constants** into a style value (`padding: MY_CONST`) — + use a token (`space['--cl-spacing']`) or a `defineConsts` value. +- **`className`/`style` alongside a bare `stylex.props()` spread** — route through + `mergeProps`. +- **Child/descendant/self-attribute selectors** (`& > div`, `&[data-selected]`) — + unsupported. Compose a variant style object driven by the prop instead, or use a + marker for genuine ancestor/sibling relationships (`relational-and-dynamic.md`). +- **Multi-value shorthands** where atomic merging matters — prefer longhands. diff --git a/.claude/skills/mosaic-stylex/references/build.md b/.claude/skills/mosaic-stylex/references/build.md new file mode 100644 index 00000000000..c4129b305af --- /dev/null +++ b/.claude/skills/mosaic-stylex/references/build.md @@ -0,0 +1,152 @@ +# Build, ESLint & distribution + +StyleX is a compiler. It transforms `stylex.*` calls into atomic classes + +`defineVars` into custom properties and extracts everything to one static `.css`. +The only runtime is a tiny `stylex.props()` class-joiner. + +## Build wiring (the POC, verified) + +The Clerk POC compiles `src/mosaic-x` with the StyleX **rollup** plugin inside the +existing `tsdown` (rolldown) build, isolated from the Emotion build so nothing +else is touched: + +```ts +// tsdown.mosaic-x.config.mts +import stylexPlugin from '@stylexjs/rollup-plugin'; +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/mosaic-x/index.ts'], + outDir: './dist-mosaic-x', + format: ['esm'], + dts: true, + clean: true, + platform: 'browser', + tsconfig: './tsconfig.mosaic-x.json', // jsxImportSource back to 'react' (Emotion-free) + external: ['react', 'react-dom', '@stylexjs/stylex'], + plugins: [stylexPlugin({ fileName: 'mosaic.css', useCSSLayers: true })], +}); +``` + +- The rollup plugin bundles its own TS/JSX Babel syntax plugins — no + `@babel/preset-*` needed on this path. The oxc/tsdown pass strips types and + lowers JSX; the StyleX plugin transforms the `stylex.*` calls. +- Output: `dist-mosaic-x/{index.js, index.d.ts, mosaic.css}`. +- Deps: `@stylexjs/stylex` (dependency), `@stylexjs/rollup-plugin` (devDependency). +- **`jsxImportSource` matters** — the `@clerk/ui` package defaults JSX to + `@emotion/react`. mosaic-x overrides it back to `react` in its own tsconfig so + the output is genuinely Emotion-free. + +Run it: `cd packages/ui && pnpm build:mosaic-x`, then open +`src/mosaic-x/demo.html` for the override proof. + +## Key StyleX compiler options (astryx's babel config) + +If a Babel-based pipeline is used instead of the rollup plugin, these are the +options that matter (from astryx `packages/core/babel.config.json`): + +```jsonc +[ + "@stylexjs/babel-plugin", + { + "dev": false, // production: no dev runtime / readable-name overhead + "runtimeInjection": false, // styles extracted to CSS at build, not injected at runtime + "genConditionalClasses": true, // REQUIRED for stylex.when.* (markers / relational) + "treeshakeCompensation": true, // re-adds styles a tree-shake would wrongly drop + "unstable_moduleResolution": { "type": "commonJS", "rootDir": "." }, + }, +] +``` + +- `genConditionalClasses: true` — without it, markers/`stylex.when` don't compile. +- `treeshakeCompensation: true` — pair with `sideEffects` in package.json. +- `dev: true` in local dev gives readable, debuggable class names. + +## Cascade layers + +Keep **`useCSSLayers: true`**. StyleX wraps its atomic rules in +`@layer priority1, priority2, …` for correct intra-StyleX precedence. Those nest +under whatever outer layer the consumer imports our sheet into — see +`overrides.md`. We do **not** engineer consumer override precedence; layers make +it the consumer's job (`@import '@clerk/ui/mosaic.css' layer(components)`). + +For a global reset, mirror astryx's layer order: `reset` → `