diff --git a/.changeset/mosaic-stylex-migration.md b/.changeset/mosaic-stylex-migration.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-stylex-migration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md index 24910ee9e6f..69770c695fb 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -42,15 +42,16 @@ this skill is the _how-to_. ## Which reference to read -| You are… | Read | -| ------------------------------------------------------------------ | ------------------------------------------------------ | -| Styling a component (slot recipes, `useRecipe`, variants, slots) | `references/styling.md` | -| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | -| Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` | -| Writing the view (rendering a snapshot, sending events) | `references/views.md` | -| Testing a machine, controller, or view | `references/testing.md` | -| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` | -| Running the parity audit that guards a migration | `references/parity-audit.md` | +| You are… | Read | +| -------------------------------------------------------------------- | ------------------------------------------------------ | +| Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` | +| Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` | +| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | +| Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` | +| Writing the view (rendering a snapshot, sending events) | `references/views.md` | +| Testing a machine, controller, or view | `references/testing.md` | +| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` | +| Running the parity audit that guards a migration | `references/parity-audit.md` | The migration workflow (`migration.md`) ties the flow references together: it treats the legacy component as the spec and drives you through the machine, diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md new file mode 100644 index 00000000000..d4f817aa277 --- /dev/null +++ b/.claude/skills/mosaic/references/stylex.md @@ -0,0 +1,136 @@ +# Styling a component with StyleX + +Mosaic styling is migrating off the Emotion slot-recipe engine (`styling.md`) +onto **StyleX** (`@stylexjs/stylex` 0.19). StyleX is compile-time atomic CSS: the +style objects become hashed atom classes plus one static stylesheet, with zero +runtime. This file is the authoring model for the StyleX layer; read it against +the pilot, `packages/ui/src/mosaic/components/button/`. + +The public contract is unchanged from the recipe era — consumers still target +`--cl-*` vars, the `.cl-` class, and `data-` attrs, never StyleX's +hashed `x…` atoms. What changes is how a component is authored internally. + +## File layout & the `.stylex.ts` rule + +| File | Holds | +| ------------------------- | ---------------------------------------------------------- | +| `tokens.stylex.ts` | `defineVars` / `defineConsts` only (the tokens) | +| `/.styles.ts` | `stylex.create({...})` — the atoms | +| `/.tsx` | component; spreads `stylex.props(...)` via `mergeProps` | +| `props.ts` | `themeProps` (`.cl-` + `data-`) + `mergeProps` | +| `styles/index.ts` | isolated-build barrel; derives `*VarName` types | + +All 9 `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension` +rule reserves the `.stylex.ts` extension for token files: **a `.stylex.ts` file +may export nothing but `defineVars`/`defineConsts` results.** So: + +- `stylex.create(...)` lives in a plain `.styles.ts` file, never `.stylex.ts`. +- Derived types (`type ColorVarName = keyof typeof colorVars`) live in + `styles/index.ts`, not in `tokens.stylex.ts` (they'd be a disallowed export). + +## Tokens (`tokens.stylex.ts`) + +**`defineVars` keys that start with `--` emit verbatim** as real custom +properties; other keys get hashed. That verbatim behavior is the whole point — +it gives consumers stable `--cl-*` vars to override in plain CSS: + +```ts +const colorDefaults = { + // one value carries light + dark; resolves against the in-scope `color-scheme`, + // so dark mode lives in the token, no `@media (prefers-color-scheme)` copy. + '--cl-color-primary': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', +} as const; +export const colorVars = stylex.defineVars(colorDefaults); +``` + +**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) * +n)` and carries no var of its own: + +```ts +export const spacingVars = stylex.defineVars({ '--cl-spacing': '0.25rem' }); + +const step = (multiple: number): string => `calc(var(--cl-spacing) * ${multiple})`; +export const space = stylex.defineConsts({ '0': '0px', '2': step(2), '8': step(8) /* … */ }); +// usage: gap: space['2'] +``` + +Why `defineConsts` and not a `space(n)` helper: StyleX evaluates +`stylex.create` **statically at build time** and cannot inline a helper imported +from a non-`.stylex` module (it errors "Could not resolve the path to the +imported file"). `defineConsts` is StyleX's shareable inlined-value primitive, so +it's the only way to get a scale that is both shared across components and free +of per-step vars. + +## Atoms (`.styles.ts`) + +`stylex.create` values must be statically resolvable. Allowed: literals, +**same-file** locals, and references to `.stylex` tokens (`colorVars[...]`, +`space['2']`). Not allowed: a value from a helper imported across modules. For +arithmetic, inline a `calc()` template literal with a token: +`` `calc(-1 * ${space['2']})` ``. + +**Conditions.** Use StyleX's conditional-value objects (a `default` plus +pseudo / at-rule keys), and nest to guard hover so it never sticks on touch — +this is the StyleX form of the old `_hover` condition: + +```ts +backgroundColor: { + default: colorVars['--cl-color-primary'], + ':active': `color-mix(in oklab, ${colorVars['--cl-color-primary']}, ${colorVars['--cl-color-primary-foreground']} 24%)`, + '@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%)`, + }, +}, +``` + +Guard `:hover` behind `@media (hover: hover)`; leave `:active`, `:focus-visible`, +`:disabled` unguarded. Runtime conditions the component owns (disabled, etc.) are +also reflected as `data-` attrs via `themeProps` so they stay overridable. + +Write the guard **raw**, as above — there is no `hover()` helper to import, +because StyleX can't inline a cross-module helper into `create` (the Emotion +engine's `hover()`/`motionSafe()` utils don't translate). `*.styles.ts` files are +therefore exempted from the repo's `no-restricted-syntax` media-query rule +(`eslint.config.mjs`); the `@stylexjs/*` rules still apply. + +## Public contract (`props.ts`) + +The element carries three things, and nothing else is a contract: + +1. `--cl-*` vars (from tokens), 2. `.cl-` class, 3. `data-` attrs. + +`mergeProps` fuses them in precedence order — **theme props → StyleX atoms → +consumer `className`/`style`** — so the consumer always wins: + +```tsx +