diff --git a/.changeset/mighty-jars-sell.md b/.changeset/mighty-jars-sell.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mighty-jars-sell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/README.md b/.claude/skills/README.md index ca1d7892cf7..0031ac9bc9c 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -41,6 +41,7 @@ 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. | +| 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). | diff --git a/.claude/skills/mosaic-machine/SKILL.md b/.claude/skills/mosaic-machine/SKILL.md deleted file mode 100644 index 2022e47a578..00000000000 --- a/.claude/skills/mosaic-machine/SKILL.md +++ /dev/null @@ -1,218 +0,0 @@ ---- -name: mosaic-machine -description: > - Author and use Mosaic state machines. Use when the user is writing a state machine - with createMachine, modelling a multi-step flow, wiring a machine to React with - useMachine/useActor/useSelector, debugging a machine transition, or migrating from - useState booleans to a machine. ---- - -# Mosaic Machine - -> **XState-first rule:** Before designing any library feature or changing any API, look up how XState v5 handles the same pattern and align to it. Never invent new API shapes. - -Core imports live in `packages/ui/src/mosaic/machine/`. - -```ts -import { setup } from './setup'; // primary: pre-binds TContext + TEvent -import { createActor, mockActor } from './createActor'; -import { useMachine, useActor, useSelector } from './useMachine'; - -// Lower-level (only when not using setup): -import { createMachine } from './createMachine'; -import { assign } from './assign'; -``` - -`setup()` returns `{ createMachine, assign, fromPromise }`. Use `fromPromise` for promise-based `invoke` configurations — it carries the resolved type to `e.output` in `onDone.actions`. - ---- - -## Anatomy - -Use `setup()` at the top of each machine file. It pre-binds -both type parameters, returning a typed `createMachine` and `assign` so you -never have to restate them at call sites. - -```ts -import { setup } from './setup'; - -// 1. Define context type — flat object, null defaults for optional fields. -interface MyContext { - data: string | null; - error: string | null; -} - -// 2. Define the event union — SCREAMING_SNAKE_CASE types. -type MyEvent = { type: 'FETCH' } | { type: 'RETRY' } | { type: 'RESET' }; - -// 3. Pre-bind types once for the file. -const { createMachine, assign, fromPromise } = setup(); - -// 4. Factory when async deps are needed; plain createMachine() when not. -export function createMyMachine(fetchData: () => Promise) { - return createMachine({ - // no needed - id: 'my', - initial: 'idle', - context: { data: null, error: null }, - states: { - idle: { - on: { FETCH: 'loading' }, - }, - loading: { - // fromPromise carries the resolved type to e.output in onDone.actions. - // A raw src function also works — e.output is `any` in that case. - invoke: fromPromise(() => fetchData(), { - onDone: { - target: 'success', - // e.output: string — typed from fetchData's return type, no cast needed - actions: assign((_, e) => ({ data: e.output, error: null })), - }, - onError: { - target: 'failure', - // e: ErrorInvokeEvent — inferred, no import needed - actions: assign((_, e) => ({ error: String(e.error) })), - }, - }), - }, - success: { type: 'final' }, - failure: { - on: { RETRY: 'loading', RESET: 'idle' }, - }, - }, - }); -} -``` - -`assign`'s second type parameter is inferred from its position: - -- Inside `on['SOME_EVENT']` → narrowed to that event member (e.g. `e.value` is safe) -- Inside `fromPromise(...).onDone` → `DoneInvokeEvent` where `TOutput` is the src return type -- Inside `onError` → `ErrorInvokeEvent` -- Inside `after[delay]` → `AfterEvent` - -You do **not** need to import or write `DoneInvokeEvent`, `ErrorInvokeEvent`, `AfterEvent`, -or `Extract` in machine files. - ---- - -## Do's - -**Model states, not booleans.** Replace `isOpen + isDeleting + isError` with explicit states — `idle → confirming → deleting → deleted`. Impossible combinations become unrepresentable. - -**Define machines at module level or in a factory function.** They're static descriptions; creating inside a component recreates the object on every render (harmless for `useMachine` due to its `useRef` guard, but confusing and wasteful). - -**Inject async deps via a factory, not module-level closure.** - -```ts -// ✓ factory — testable, no import-time side effects -export const createDeleteOrgMachine = (destroyFn: () => Promise) => createMachine({ ... }); - -// ✗ module-level capture — hard to test, couples to module load order -const machine = createMachine({ states: { deleting: { invoke: { src: () => someGlobal.destroy() } } } }); -``` - -**Use `assign` for context updates.** It's a pure `(context, event) => Partial` — the runtime merges the patch. - -**Use `invoke` for async work.** Actions are synchronous side effects only; promises in actions are invisible to the machine. - -**Gate navigation with state-node `guard`.** Every transition targeting the state checks it automatically — no per-transition boilerplate. - -```ts -states: { - step2: { - guard: (ctx) => ctx.step1Complete, // blocks all entry to step2 - on: { NEXT: 'step3', PREV: 'step1' }, - }, -} -``` - -**Test in plain JS.** Drive `createActor → start → send` with no React. Reach unreachable/transient states with `mockActor`: - -```ts -const actor = mockActor(machine, { value: 'deleting', context: { error: null } }); -expect(actor.getSnapshot().value).toBe('deleting'); -``` - -**Use `actor.recheck()` when external data a guard reads changes.** It re-seats to the derived initial if the current state's guard no longer holds, or fires any pending `always` transition. - ---- - -## Don'ts - -**Don't do async work in `actions`.** Promises returned from an action function are dropped — the machine never sees the resolved value. - -**Don't mutate context directly in actions.** Side effects only; use `assign` to update context. - -**Don't track "impossible" state in context.** If you find yourself checking `isDeleting && isOpen`, add a state instead of adding a guard on a context flag. - -**Don't pass an async function captured at module definition time.** It can't be stubbed in tests, and it breaks the pattern of injecting live props. - ---- - -## React patterns - -### `useMachine` — own a flow for the component's lifetime - -```tsx -function DeleteOrganization({ organization }: { organization: Org }) { - const [snapshot, send] = useMachine(deleteOrgMachine, { - // `context` is kept current via useLayoutEffect — safe to pass live props/functions. - context: { destroyFn: () => organization.destroy() }, - // `onDone` fires once when the machine reaches a `type: 'final'` state. - onDone: () => router.navigate('/dashboard'), - }); - - return ( - send({ type: isOpen ? 'OPEN' : 'CANCEL' })} - isDeleting={snapshot.value === 'deleting'} - onConfirm={() => send({ type: 'CONFIRM' })} - error={snapshot.context.error} - /> - ); -} -``` - -Branch on `snapshot.value` for UI, not on `snapshot.context` booleans. - -`onDone` always calls the latest prop — no stale-closure risk. Do not replace it with a `useEffect` watching `snapshot.status`. - -### `useActor` — bind to a shared actor - -Use when the actor's lifecycle is owned by a parent or context provider. - -```tsx -function StepIndicator({ actor }: { actor: WizardActor }) { - const [snapshot] = useActor(actor); - return ; -} -``` - -### `useSelector` — subscribe to a slice - -Re-renders only when the selected value changes (by `Object.is`). Primary way to consume a shared actor without full-snapshot coupling. - -```tsx -const error = useSelector(actor, snap => snap.context.error); -const isDeleting = useSelector(actor, snap => snap.value === 'deleting'); -``` - -### Injecting live props - -`useMachine` calls `actor.setContext(options.context)` via `useLayoutEffect` after every render. Pass functions from props without recreating the machine: - -```tsx -// The machine reads `ctx.onSuccess` — always the latest prop. -const [snapshot, send] = useMachine(machine, { context: { onSuccess: props.onSuccess } }); -``` - -### Debug logging (remove before shipping) - -```tsx -import { useMachineLogger } from './useMachine'; - -const [snapshot, send] = useMachine(machine); -useMachineLogger('myFlow', snapshot); // logs: [myFlow] idle → loading { data: null } -``` diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md new file mode 100644 index 00000000000..24910ee9e6f --- /dev/null +++ b/.claude/skills/mosaic/SKILL.md @@ -0,0 +1,57 @@ +--- +name: mosaic +description: >- + Work on Mosaic UI: styling a component with slot recipes (`defineSlotRecipe` / + `useRecipe` / slots / variants), or building a flow — authoring a state machine + (`setup`, states/guards/`invoke`, wiring to React with `useMachine`/`useActor`/ + `useSelector`), writing the controller (Clerk adapter) or view (rendering) layer, + testing any of those layers, or migrating a legacy / pre-Mosaic component into the + machine / controller / view split. Use when building, styling, debugging, testing, or + migrating anything Mosaic. `references/mosaic-architecture.md` (repo root) holds the + design-system contract; this skill is the how-to layer. +--- + +# Mosaic UI + +Two things live under Mosaic, and this skill covers the how-to for both: + +- **Styled components** are authored with **slot recipes** — one recipe owns a + part's slot identity (`data-cl-slot`), variants, state, and appearance + cascade; `useRecipe` resolves it and hands back per-slot props to spread. +- **Flows** follow a **machine → controller → view** split that keeps Clerk + resource logic out of visual components and makes behavior testable without a + running Clerk app: + +```text +machine Pure flow rules: states, events, guards, async invokes, errors. + No React hooks. No Clerk hooks. No Clerk resource objects. + +controller Clerk/data adapter: reads Clerk hooks/resources, injects async + effects into machine context, gates permissions, derives view props. + The only layer that may import Clerk hooks or call resource methods. + +view Rendering only: receives a snapshot plus explicit props, renders UI, + sends events. No Clerk imports. No data-fetching. No mutations. +``` + +`references/mosaic-architecture.md` (repo root, read by all agents) is the +canonical contract for the whole design system — tokens, theme delivery, the +`data-cl-*` styling API, slot recipes, appearance/cascade/scope, and the "Flow +and data architecture" section that defines the split. Read it for the _what_; +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` | + +The migration workflow (`migration.md`) ties the flow references together: it +treats the legacy component as the spec and drives you through the machine, +controller, and view layers, then verifies parity with `parity-audit.md`. diff --git a/.claude/skills/mosaic/references/controllers.md b/.claude/skills/mosaic/references/controllers.md new file mode 100644 index 00000000000..75dd3166696 --- /dev/null +++ b/.claude/skills/mosaic/references/controllers.md @@ -0,0 +1,61 @@ +# Controllers + +The controller is the adapter from Clerk resources into machine context and view +props. It is the **only layer in a Mosaic flow that may import Clerk hooks or +call Clerk resource methods** — the machine (`machines.md`) and view +(`views.md`) stay Clerk-free so they remain testable in plain JS. + +See `references/mosaic-architecture.md` → "Controllers" for the canonical +example. This file is the practical checklist. + +## Responsibilities + +- **Read Clerk state.** Call hooks like `useOrganization()`, `useUser()`, + `useSession()`. This is the only layer allowed to. +- **Inject async effects + live props into machine context.** Pass plain + functions that close over live resources; `useMachine` re-seats context via + `useLayoutEffect` every render, so the machine always reads the latest prop. + + ```tsx + const [snapshot, send, actor] = useMachine(deleteOrgMachine, { + context: { + organizationName: organization?.name ?? '', + destroyOrganization: () => organization?.destroy() ?? Promise.resolve(), + }, + }); + ``` + +- **Gate permissions and visibility.** Resolve `session.checkAuthorization(...)` + and collapse loading/permission/empty into an explicit status the wrapper + branches on — not scattered booleans: + + ```tsx + if (!canRead || !settings.enabled || !organization) return { status: 'hidden' as const }; + if (!firstPageLoaded) return { status: 'loading' as const }; + return { status: 'ready' as const, snapshot, send, canSubmit: actor.can({ type: 'CONFIRM' }) }; + ``` + +- **Own revalidate timing.** Call `data.revalidate()` / `.reload()` after + mutations. Deciding _when_ (fire-and-forget vs awaited) is controller logic, + not view logic. +- **Handle first-page-load empty-state.** Wait for the first page before + deciding to hide a section, so read-only users don't see a hide-then-show + flicker. +- **Derive view props.** Expose `actor.can(...)` results (e.g. `canSubmit`) so + the view never re-implements a machine guard. + +## Rules + +- Pass **plain data and plain functions** into machines and views. Do **not** + pass Clerk resource objects through to the view. +- Branch the wrapper on the controller's `status`, not on raw `isLoaded` flags + sprinkled through the tree. + +## Testing + +Test the controller against a **mocked Clerk** for the gating / `hidden` / +empty-state logic. This is the **highest-risk, least-covered layer**: it holds +the Clerk resource semantics that the pure machine tests can't reach. When a +migration loses behavior, it is usually a controller responsibility (revalidate +timing, a permission gate, an empty-state rule) that quietly went missing — +concentrate scrutiny here. diff --git a/.claude/skills/mosaic/references/machines.md b/.claude/skills/mosaic/references/machines.md new file mode 100644 index 00000000000..dcb7d125d9f --- /dev/null +++ b/.claude/skills/mosaic/references/machines.md @@ -0,0 +1,18 @@ +# Machines + +The machine runtime is documented **next to the code**, and that in-tree doc is +the source of truth (it's updated in the same diff when the runtime changes and +is readable by every tool, not just Claude Code). Read: + +- **`packages/ui/src/mosaic/machine/README.md`** — the mental model (state / + event / context / transition), your first machine, `setup` to drop the type + boilerplate, running it with `createActor` / `useMachine`, and the API at a + glance (`assign`, `invoke`, `guard`, `always`, `entry`/`exit`, `final`, + `mockActor`, `useActor`, `useSelector`, `recheck()`). +- **`packages/ui/src/mosaic/machine/ADOPTION.md`** — when a flow is worth a + machine and when it isn't, with real before/after migrations. + +The machine is the pure flow layer: states, events, guards, async `invoke`, +errors — no React hooks, no Clerk. To wire it to Clerk data see `controllers.md`; +to render its snapshot see `views.md`; to test it see `testing.md`; to migrate a +legacy component into this pattern see `migration.md`. diff --git a/.claude/skills/mosaic/references/migration.md b/.claude/skills/mosaic/references/migration.md new file mode 100644 index 00000000000..4339b05c6cb --- /dev/null +++ b/.claude/skills/mosaic/references/migration.md @@ -0,0 +1,95 @@ +# Migrating a component into Mosaic + +Migrating a legacy component means taking logic that was fused into one file and +pulling it apart into the machine / controller / view layers (see the skill +overview and `references/mosaic-architecture.md` → "Flow and data architecture"). + +**The core risk this workflow exists to manage:** a legacy component fuses +rendering, data-fetching, and flow logic into one blob. Splitting it three ways +silently drops behavior that was only ever _implicit_ — a per-field error map, a +step-up reverification, an empty-state gate, a derived callout. None of it +surfaces as a failing test or a type error. It only surfaces when someone diffs +old against new. So the spine of this workflow is **treat the legacy component +as the spec, and prove the new layers cover every line of it.** + +Do not write the machine first and then ask "did I get everything?" — that means +proving a negative. Invert it: enumerate the legacy behavior first, then make +each layer account for a specific row. + +--- + +## Phase 1 — Inventory the legacy behavior (the spec) + +Locate the legacy files (usually under `packages/ui/src/components//`). +Then grep them for the primitives that carry hidden, load-bearing logic. Every +hit is a row you must consciously place or drop later: + +| Primitive | The behavior it usually hides | +| --------------------------------- | -------------------------------------------------- | +| `useEffect` | sync-on-load, reset-on-close, derived side effects | +| `handleError` / `card.setError` | per-field error mapping | +| `useReverification` | step-up reverification before a mutation | +| `revalidate` / `.reload()` | cache invalidation timing after a mutation | +| `/ +``` + +Turn the hits into a **behavior inventory** — one row per: effect, guard, error +path, empty/loading state, permission gate, revalidate, reset-on-close, derived +label. This list is finite and is the contract the migration must satisfy. + +## Phase 2 — Design the split (map every row to a layer) + +Assign each inventory row to exactly one layer. A row with no home is a behavior +you are about to drop. + +- Flow rules (states, events, guards, async `invoke`, error transitions) → + **machine** (`machines.md`). +- Clerk reads, mutations, permission gating, revalidate timing, first-page-load + empty-state → **controller** (`controllers.md`). +- Rendering, labels, derived booleans → **view** (`views.md`). + +## Phase 3 — Implement and test per layer + +File shape: `.machine.ts` · `.controller.tsx` · +`.view.tsx` · `.tsx` (thin composition wrapper). + +Each layer is testable in isolation — that isolation is what makes the migration +verifiable. Follow the testing recipe in each layer's reference: machine via +`createActor`/`mockActor` (no React, no Clerk), view via a fake snapshot + fake +`send`, controller against a mocked Clerk. The controller is the highest-risk, +least-covered layer — concentrate scrutiny there. + +## Phase 4 — Verify parity (the confidence step) + +Machine and view tests only cover branches you remembered to write. To catch the +ones you didn't, run an automated diff of legacy against new. + +Launch an **Explore subagent** with the prompt in `parity-audit.md`. Give it the +legacy file paths and the new machine/controller/view paths. It returns a table +classifying every legacy behavior as: + +- **Migrated** — points at a specific state / transition / context field. +- **Deliberately changed** — names the new behavior and why (e.g. infinite + scroll → "Load more" button). +- **Deferred** — a real tracked ticket, **not** a `// TODO` buried in a machine + file. A buried TODO is invisible at review time; that is exactly how the + domains-section migration shipped three regressions. + +Every inventory row from Phase 1 must land in exactly one bucket. The table is +**ephemeral**: it drives the work and the PR discussion, then is discarded. It is +not committed. + +## Phase 5 — Ship + +Tests green, then a changeset and a conventional commit. See the `clerk-monorepo` +skill for the dev loop and the hard rules. In short: `pnpm changeset` describing +the user-facing change, scope `ui`, and remember non-major `packages/ui` changes +load into older SDKs in the wild, so keep the public surface backwards +compatible. diff --git a/.claude/skills/mosaic/references/parity-audit.md b/.claude/skills/mosaic/references/parity-audit.md new file mode 100644 index 00000000000..3949584bd0e --- /dev/null +++ b/.claude/skills/mosaic/references/parity-audit.md @@ -0,0 +1,74 @@ +# Parity audit — Phase 4 reference + +The parity audit is the confidence step of a Mosaic migration. It diffs the +legacy component against the new machine/controller/view and classifies every +legacy behavior, so behavior that lived implicitly in the old blob can't be +silently dropped. + +Run it as an **Explore subagent** (read-only, returns a conclusion, not file +dumps). It is **ephemeral** — the table guides the work and the PR discussion, +then is discarded. Nothing is committed. + +## The subagent prompt + +Fill in the two file lists and hand this to an Explore agent: + +``` +In the repo at , audit a Mosaic migration for behavioral parity. + +LEGACY component files (the spec — behavior must be preserved or consciously changed): + + +NEW Mosaic files (machine / controller / view / wrapper): + + +Enumerate EVERY behavior in the legacy files — each: effect, guard, error path, +empty/loading state, permission gate, revalidate/reload call, reset-on-close, +derived label/count, pagination trigger. For each one, find where it lives in +the new files and classify it: + + - Migrated → points at a specific new state / transition / context field / prop + - Deliberately changed → the new behavior differs on purpose; name the new behavior and why + - Deferred → not implemented in the new code; note whether a tracked ticket exists + or it is only a buried `// TODO` + +Quote ONLY the relevant branch from each file (the conditional, the effect, the +guard). Never dump whole files. Return the result as the table below, most +severe gaps first, then a short list of any Deferred items that are tracked only +by a `// TODO` (these are the regressions at risk of shipping). +``` + +## Output table format + +| Legacy behavior | Layer it should live in | Status | Evidence (legacy → new) | +| ------------------------------------------------------- | ----------------------- | --------------------------- | --------------------------------------------------------------------------------- | +| Per-field error mapping via `handleError(err, [field])` | machine/view | Deferred (only a `// TODO`) | `AddDomainForm.tsx` handleError → `*-add-verify.machine.ts` single `errorMessage` | + +`Status` is one of: **Migrated** · **Deliberately changed** · **Deferred**. + +## Grep list (shared with Phase 1) + +```bash +rg -n 'useEffect|handleError|card\.setError|useReverification|revalidate|/ +``` + +## Worked example — `OrganizationProfileDomainsSection` + +The audit run against the finished domains migration surfaced three behaviors +that the machine/controller/view split dropped, each tracked only by a buried +`// TODO` (i.e. invisible at review time): + +1. **Per-field error mapping.** Legacy `handleError(err, [field])` mapped errors + to the specific field; the new add/verify machine collapses every failure to + one `errorMessage` string in context. +2. **Step-up reverification on domain delete.** Legacy `RemoveDomainForm` left a + TODO to wrap `domain.delete()` in `useReverification`; the new remove machine + omits the step-up entirely. +3. **Pending-invitations callout.** Legacy `useCalloutLabel` computed a count + from `totalPendingInvitations + totalPendingSuggestions`; the new view + receives the counts but never renders the callout. + +None of these failed a test or a typecheck. They are exactly the class of +regression this audit exists to catch — surface them as tracked Deferred items, +not `// TODO`s. diff --git a/.claude/skills/mosaic/references/styling.md b/.claude/skills/mosaic/references/styling.md new file mode 100644 index 00000000000..76f575a2b6f --- /dev/null +++ b/.claude/skills/mosaic/references/styling.md @@ -0,0 +1,190 @@ +# Styling a component with slot recipes + +A styled Mosaic component is authored with one **slot recipe**. The recipe owns +everything about how the part looks and is targeted: its slot identity +(`data-cl-slot`), base styles, variants, and the appearance cascade. +`useRecipe(recipe, opts)` resolves it against the active theme + appearance and +hands back **per-slot props** — `css` already merged and every `data-cl-*` +attribute attached — that you spread onto the element. You never hand-thread +`css={[...]}`. + +`references/mosaic-architecture.md` (repo root) is the full contract: the +appearance cascade + scope, the token architecture (`theme.spacing`/`alpha`/ +`mix`/`text`), the condition vocabulary (`_hover`, `_disabled`, …), and the +`data-cl-*` public styling API. This file is the authoring how-to; read it +alongside two real components: + +- `packages/ui/src/mosaic/components/button.tsx` — single-slot, full variants. +- `packages/ui/src/mosaic/components/tabs.tsx` — multi-slot, bridged onto a + headless primitive. + +--- + +## Single-slot (the common case) + +`slot: 'button'` is shorthand for one implicit `root` slot. Define `base`, +`variants`, `compoundVariants`, and `defaultVariants`; register the slot id; +infer the props from the recipe; then destructure variants + state and spread the +resolved `root` props. + +```tsx +import React from 'react'; +import { defineSlotRecipe, useRecipe, type RecipeVariantProps } from '../slot-recipe'; + +export const buttonRecipe = defineSlotRecipe(theme => ({ + slot: 'button', + base: { + display: 'inline-flex', + borderRadius: theme.rounded.md, + ...theme.text('sm'), + _focusVisible: { outline: `2px solid ${theme.alpha('primary', 50)}` }, + _disabled: { opacity: 0.5, cursor: 'not-allowed', pointerEvents: 'none' }, + }, + variants: { + intent: { primary: {}, destructive: {} }, + variant: { filled: {}, outline: {}, ghost: {} }, + size: { sm: { ...theme.text('xs') }, md: { ...theme.text('sm') } }, + fullWidth: { true: { width: '100%' }, false: {} }, + }, + compoundVariants: [ + { + intent: 'primary', + variant: 'filled', + css: { + backgroundColor: theme.color.primary, + color: theme.color.primaryForeground, + _hover: { backgroundColor: theme.mix('primary', 'primaryForeground', 12) }, + }, + }, + ], + defaultVariants: { intent: 'primary', variant: 'filled', size: 'md', fullWidth: false }, +})); + +// Register the slot id — this is what makes `button` autocomplete in appearance.elements. +declare module '../registry' { + interface MosaicSlotRegistry { + button: true; + } +} + +// Infer variant props (+ sx) from the recipe — don't re-declare them by hand. +export type ButtonProps = React.ComponentPropsWithRef<'button'> & RecipeVariantProps; + +export const Button = React.forwardRef(function MosaicButton(props, ref) { + const { intent, variant, size, fullWidth, disabled, sx, children, ...rest } = props; + const { root } = useRecipe(buttonRecipe, { + variants: { intent, variant, size, fullWidth }, + state: { disabled: !!disabled }, + sx, + }); + return ( + + ); +}); +``` + +Key moves: + +- **`variants` vs `state`.** A visual axis chosen by the caller (`intent`, + `size`) is a **variant**. A runtime condition (`disabled`, `selected`, + `invalid`) is **state** — pass it under `state:` so it emits `data-cl-disabled` + and is styled via the `_disabled` condition, which keeps it overridable through + `appearance.elements`. Don't model `disabled` as a boolean variant. +- **`compoundVariants`** style a combination (`intent: 'primary'` **and** + `variant: 'filled'`) that no single axis owns. +- **`sx`** is the per-instance escape hatch; it merges last, before appearance. +- **`RecipeVariantProps`** derives `{ intent?, variant?, size?, +fullWidth?, sx? }` — the recipe is the single source of the prop types. + +## Multi-slot + +One recipe owns several parts under `slots`, each mapping a key to a public +`data-cl-slot` id (kebab-case). `base` is keyed by slot. Each rendered part calls +`useRecipe(recipe)` and spreads its own slot. `tabs.tsx` bridges the resolved +slots onto headless primitives, so slot identity lives in this styled layer: + +```tsx +export const tabsRecipe = defineSlotRecipe(theme => ({ + slots: { + list: { slot: 'tabs-list' }, + tab: { slot: 'tabs-tab' }, + panel: { slot: 'tabs-panel' }, + indicator: { slot: 'tabs-indicator' }, + }, + base: { + list: { display: 'flex', gap: theme.spacing(4), borderBottom: `1px solid ${theme.alpha('primary', 10)}` }, + tab: { + ...theme.text('sm'), + color: theme.color.mutedForeground, + '&[data-cl-selected]': { color: theme.color.primary }, + '&[data-cl-disabled]': { opacity: 0.5, cursor: 'not-allowed' }, + }, + panel: { '&[data-cl-hidden]': { display: 'none' } }, + indicator: { height: '2px', backgroundColor: theme.color.primary }, + }, +})); + +declare module '../registry' { + interface MosaicSlotRegistry { + 'tabs-list': true; + 'tabs-tab': true; + 'tabs-panel': true; + 'tabs-indicator': true; + } +} + +const List = React.forwardRef>( + function TabsList(props, ref) { + const { list } = useRecipe(tabsRecipe); + return ( + + ); + }, +); +// …Tab / Panel / Indicator each read their own slot from useRecipe(tabsRecipe)… +``` + +Note how `tabs` targets state with **raw attribute selectors** +(`'&[data-cl-selected]'`) because those states come from the headless primitive +rather than a `useRecipe({ state })` call. When your own component owns the +state, prefer `state: { … }` + the condition keys (`_disabled`, `_hover`) so it +stays overridable through `appearance.elements`; drop to raw `&[data-cl-*]` +selectors only for states applied by something else. + +## Lighter sugar: `useSlot` / `slot` + +Not every part needs variants. `slot-recipe.ts` is the heavy end of one spectrum +(`useSlot.ts`): + +- **`useSlot('avatarBox', { state })`** — themeable + targetable + appearance-aware, + but no recipe. `css` is just `sx` + appearance. +- **`slot('badgeText')`** — the barest: returns only `{ 'data-cl-slot': … }` to + make an element targetable. No hook, no css. + +Reach for a full recipe only when you have variants; otherwise use `useSlot` or +`slot`. See `references/mosaic-architecture.md` → "Sugar — useSlot / slot" for +the details. + +## Checklist + +- Recipe with `defineSlotRecipe(theme => …)`; `slot:` for one part, `slots:` for + many. +- `declare module '../registry'` block registering every slot id (co-located + with the recipe) so it autocompletes in `appearance.elements`. +- Caller-chosen axes → `variants`; runtime conditions → `state` + condition keys. +- `RecipeVariantProps` for the prop type; destructure variants + + state, spread the resolved slot(s). +- No `css={t => …}` function form in Mosaic (Emotion would type `t` as + `InternalTheme`); use the recipe's `theme => config` or `useMosaicTheme()`. diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md new file mode 100644 index 00000000000..e7ef866fff2 --- /dev/null +++ b/.claude/skills/mosaic/references/testing.md @@ -0,0 +1,190 @@ +# Testing a Mosaic flow + +A flow is three layers (`machines.md` · `controllers.md` · `views.md`), and each +is tested in isolation. That isolation is the point: the machine needs no React, +the view needs no Clerk. Tests are Vitest + React Testing Library, co-located in +`__tests__/` next to the feature, named `.{machine,controller,view}.test.*`. + +Canonical templates to copy from: + +- `packages/ui/src/mosaic/sections/__tests__/delete-organization.machine.test.ts` +- `packages/ui/src/mosaic/sections/__tests__/delete-organization.controller.test.tsx` +- `packages/ui/src/mosaic/sections/__tests__/delete-organization.view.test.tsx` + +Shared helpers live in +`packages/ui/src/mosaic/machines/__tests__/test-utils.ts`: `deferred()` (a +promise whose `resolve`/`reject` are captured so you can assert an in-flight +state before settling it), `tick()` (flush microtasks so an `invoke`'s +`onDone`/`onError` runs), and `noop` (a no-op async dep). + +Run one file with `pnpm --filter @clerk/ui test `. + +--- + +## Machine — plain JS, no React + +Drive the actor directly. Inject async deps through `context` as `vi.fn()`s, send +events, assert `getSnapshot().value` / `.context`. Await `tick()` when an +`invoke` needs to settle. + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { createActor } from '../../machine/createActor'; +import { deleteOrgMachine } from '../delete-organization.machine'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +it('invokes the injected delete function after a valid confirmation', async () => { + const destroyOrganization = vi.fn(() => Promise.resolve()); + const actor = createActor(deleteOrgMachine, { + context: { organizationName: 'Acme Inc', destroyOrganization }, + }); + + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE_CONFIRMATION', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM' }); + + expect(actor.getSnapshot().value).toBe('deleting'); + expect(destroyOrganization).toHaveBeenCalledTimes(1); + + await tick(); + expect(actor.getSnapshot().value).toBe('deleted'); +}); +``` + +For a transient/unreachable state you can't easily send your way to (mid-mutation, +a guard-gated wizard step), teleport in with `mockActor(machine, { value, +context })` — see `machine/README.md` → "Testing & docs". + +## Controller — mock Clerk, assert `status` + +The controller is the only layer that touches Clerk, so this is the only test +that mocks it. Mock `@clerk/shared/react` with mutable module-level vars reset in +`beforeEach`, render a tiny harness that surfaces `controller.status` (and the +snapshot once ready), and assert the loading / hidden / ready gating. Use +`deferred()` + `act()` to hold an async effect open and observe the in-flight +state. + +```tsx +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deferred } from '../../machines/__tests__/test-utils'; +import { useDeleteOrganizationController } from '../delete-organization.controller'; + +let destroy: ReturnType; +let revalidate: ReturnType; +let checkAuthorization: ReturnType; +let isLoaded: boolean; +let organization: { id: string; name: string; destroy: () => Promise; adminDeleteEnabled: boolean } | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded, organization, membership: null }), + useOrganizationList: () => ({ userMemberships: { revalidate } }), + useSession: () => ({ isLoaded: true, session: { id: 'sess_1', checkAuthorization } }), + }; +}); + +beforeEach(() => { + destroy = vi.fn(); + revalidate = vi.fn().mockResolvedValue(undefined); + checkAuthorization = vi.fn().mockReturnValue(true); + isLoaded = true; + organization = { id: 'org_1', name: 'Acme Inc', destroy, adminDeleteEnabled: true }; +}); +afterEach(() => vi.clearAllMocks()); + +function Harness() { + const controller = useDeleteOrganizationController(); + if (controller.status !== 'ready') return {controller.status}; + return ( +
+ {controller.snapshot.value} + +
+ ); +} + +it('is hidden when the user lacks the delete permission', () => { + checkAuthorization.mockReturnValue(false); + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); +}); + +it('drives CONFIRM → deleting → resolve → deleted', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + render(); + // …open + type + confirm… + await act(async () => gate.resolve()); + expect(revalidate).toHaveBeenCalledTimes(1); +}); +``` + +Assert controller responsibilities here that the machine can't see: the `hidden` +gate from `checkAuthorization`, `loading` until Clerk is loaded, and that +`revalidate` fires (only) on success. + +## View — fake snapshot, no Clerk + +Build a plain `snapshot` object and pass a `vi.fn()` `send`. Assert what renders +per `snapshot.value` and that interactions send the right event. **Wrap the view +in ``** — it's not a Clerk provider; it supplies the theme +context that `useRecipe`/`useSlot` read. No Clerk providers or fixtures. + +```tsx +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Snapshot } from '../../machine/types'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { DeleteOrgContext } from '../delete-organization.machine'; +import { DeleteOrganizationView } from '../delete-organization.view'; + +function snapshot(overrides: Partial> = {}): Snapshot { + return { + value: 'confirming', + status: 'active', + context: { organizationName: 'Acme Inc', confirmationValue: '', destroyOrganization: async () => {}, error: null }, + ...overrides, + }; +} + +function renderView(snap: Snapshot, send = vi.fn(), canSubmit = false) { + render( + + + , + ); + return { send }; +} + +it('emits a typed confirmation event from the input', () => { + const { send } = renderView(snapshot()); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Acme Inc' } }); + expect(send).toHaveBeenCalledWith({ type: 'TYPE_CONFIRMATION', value: 'Acme Inc' }); +}); + +it('shows machine errors without Clerk fixtures', () => { + renderView( + snapshot({ + context: { + organizationName: 'Acme Inc', + confirmationValue: 'Acme Inc', + destroyOrganization: async () => {}, + error: 'Delete failed', + }, + }), + ); + expect(screen.getByText('Delete failed')).toBeInTheDocument(); +}); +``` + +Pass derived booleans (`canSubmit`, from `actor.can(...)`) as props — the view +never re-implements a machine guard, so a view test never needs the machine. diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md new file mode 100644 index 00000000000..6cde265d985 --- /dev/null +++ b/.claude/skills/mosaic/references/views.md @@ -0,0 +1,32 @@ +# Views + +The view renders a snapshot and emits events. Nothing else. + +- **No Clerk imports.** No data-fetching hooks. No mutation calls. Everything the + view needs arrives as explicit props from the controller (`controllers.md`). +- **Branch on `snapshot.value`, not on context booleans.** The machine models + the states; the view reads them. +- **Take derived booleans from the controller.** `actor.can(...)` results (e.g. + `canSubmit`) are passed in — the view never re-implements a machine guard. + +```tsx + send({ type: 'TYPE_CONFIRMATION', value })} + onDelete={() => send({ type: 'CONFIRM' })} + canSubmit={canSubmit} + isDeleting={snapshot.value === 'deleting'} + error={snapshot.context.error} +/> +``` + +## Testing + +Render the view directly with a **fake snapshot and a fake `send`**. No Clerk +providers, no Clerk fixtures. Because the view is pure rendering, a test can +assert "state X renders element Y and clicking Z sends event W" for every branch +of `snapshot.value` without any of the flow or data machinery. + +See `references/mosaic-architecture.md` → "Views" for the layer contract. diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 9e9f10e62d2..5b10ce25e51 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -527,6 +527,13 @@ To migrate a component from the old system to Mosaic: 6. Update token references — e.g. `theme.colors.$primary500` → `theme.color.primary`. 7. Ensure the component is inside a `MosaicProvider` in the tree. +The steps above cover the **styling** migration (recipes + tokens). For **flow** +components — where the legacy component also fuses data-fetching and flow logic — +splitting that logic into the machine/controller/view layers and verifying no +implicit behavior is dropped is its own end-to-end workflow. See the `mosaic` +Claude Code skill (`.claude/skills/mosaic/`), in particular its +`references/migration.md`. + ## Files | File | Purpose |