diff --git a/.changeset/mosaic-agent-docs.md b/.changeset/mosaic-agent-docs.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-agent-docs.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-review-guidance.md b/.changeset/mosaic-review-guidance.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-review-guidance.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md index dd3fe28724d..3554edae36f 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -8,7 +8,7 @@ description: >- `useSelector`), or the view (rendering), testing a feature, or migrating a legacy / pre-Mosaic component into the model / controller / view split. Use when building, styling, debugging, testing, or migrating anything - Mosaic. `references/mosaic-architecture.md` (repo root) holds the design-system + Mosaic. `packages/mosaic/ARCHITECTURE.md` holds the design-system contract; this skill is the how-to layer. --- @@ -50,7 +50,7 @@ sometimes both in one controller. Either way the controller returns plain props, so the view cannot tell and neither can its tests. Criteria and worked before/afters: `packages/mosaic/src/machine/ADOPTION.md`. -`references/mosaic-architecture.md` (repo root, read by all agents) is the +`packages/mosaic/ARCHITECTURE.md` is the canonical contract for the whole design system — the `--cl-*` tokens, the `.cl-` + `data-` styling API, the CSS build, and the "Flow and data architecture" section that defines the split. Read it for the _what_; this skill @@ -58,24 +58,21 @@ is the _how-to_. `packages/mosaic/src/features/user-button/` is the fullest worked example of the split in the repo — model, controller, view, wrapper, types, messages, and a feature -test. Copy from it. +test. Copy from it. Its feature test sits in `__tests__/` because it predates +colocating tests (`packages/mosaic/docs/testing.md`). + +`packages/mosaic/AGENTS.md` loads for any work in the package. It maps common +problems (errors, pending state, focus after removal, debouncing, confirm steps) +to the shared helper that already solves them, and says to ask the user before +adding a new pattern. Check it before writing any of those by hand. ## Which reference to read -| You are… | Read | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | -| Building on / authoring a headless primitive (`src/primitives/`) | `references/headless.md` | -| Styling a component (tokens, `stylex.create`, `themeProps`, CSS build) | `references/stylex.md` | -| Building an enter/exit transition, a row that expands and collapses, or any motion that reads as wrong | `references/motion.md` | -| Writing the model (the Clerk adapter, `status`, permissions) | `references/models.md` | -| Writing the controller (local state, pending, action wrapping) | `references/controllers.md` | -| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | -| Writing the view (rendering plain props) | `references/views.md` | -| Testing a feature (feature tests, unit tests) | `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 +Use the "Task guides" table in `packages/mosaic/AGENTS.md`. It is the shared +task index for agents, including those that do not discover this skill. Read +the guide for the layer or behavior you are changing. + +The migration workflow (`packages/mosaic/docs/migration.md`) ties the flow references together: it treats the legacy component as the spec and drives you through the model, controller, and view layers, then verifies parity with `parity-audit.md`. @@ -108,8 +105,6 @@ this axis. Which surface the dialog holds, and the geometry that comes with it. ``` -This is the opposite of the rule for **code**, where a comment earns its place by -explaining what the code cannot say for itself — the cascade fight behind a -`null`, the measured reason a duration is what it is. Keep that reasoning where a -future maintainer will hit it, which is the source file. The trade you rejected -belongs in a code comment or the PR description; the docs get the conclusion. +Follow the root `AGENTS.md` rule for code comments. Put implementation rationale +and rejected alternatives in the PR description. Component docs describe the +resulting API. diff --git a/.claude/skills/mosaic/references/controllers.md b/.claude/skills/mosaic/references/controllers.md deleted file mode 100644 index bc51468a921..00000000000 --- a/.claude/skills/mosaic/references/controllers.md +++ /dev/null @@ -1,173 +0,0 @@ -# Controllers - -The controller sits between the view and the outside world. It holds the **local -state** — what is open, what is in flight, what the view may do next — and wraps -the model's actions so the surface can report and survive them. - -It does **not** touch Clerk. Its effects arrive as injected plain functions: -from a model (`models.md`) when a wrapper composes the two, or as a prop when a -leaf view calls its own controller. That keeps the controller independent of -where its effects come from. - -Worked examples: - -- `packages/mosaic/src/features/user-button/user-button.controller.tsx` — wraps a model -- `packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts` - — takes its one effect as a prop - -See `references/mosaic-architecture.md` → "Controllers" for the layer contract. - -## Which one holds the state - -A controller is not a machine wrapper. It is the layer that owns the -interaction, and it holds that state in whichever tool the interaction's -complexity calls for: - -| The interaction… | Hold it in | -| ------------------------------------------------------------------------ | --------------------------- | -| Is a boolean or a controlled value, no async, nothing else depends on it | `useState` | -| Has an async lifecycle, or two values that must change together | A machine, same file | -| Has a coordinated async core plus some UI-only flags beside it | Both — machine for the core | -| Is one async action from a button, needing only pending and an error | `usePendingAction` | - -`usePendingAction(fn, { errorFallback })` covers any single async action a -button starts: set a default, sign out a device, accept or decline a request. It -ignores clicks while one is in flight, owns the error -(see "Errors") and returns `{ run, isPending, errorMessage, reset }`. `run` -resolves `true` on success, so the caller can close a dialog or move focus. It -is not for: - -- a form → `useForm` -- a confirm step → `Confirmation` / `Destructive` -- an action whose result drives other state → a machine -- per-row pending across a list, or holding pending through a redirect → the - connected and enterprise accounts controllers, until `usePendingAction` takes - a key - -`useUserProfileDeleteSectionController` earns a machine on three counts: the -delete is async, a failure must land back on the previous step with a reason, -and `deleted` is terminal so the dialog must never reopen. `useUserButtonController` -earns one because `open` and `pendingKey` constrain each other and dismissing -must not abandon an in-flight invoke. - -A single `isEditing` boolean earns nothing. A two-state machine with one event -is a boolean spelled long: - -```tsx -export function useSectionController({ onSave }: { onSave: () => void }) { - const [isEditing, setIsEditing] = React.useState(false); - return { - isEditing, - onEdit: () => setIsEditing(true), - onCancel: () => setIsEditing(false), - }; -} -``` - -The choice is invisible from outside — the controller returns plain props either -way, so the view and its tests are unaffected, and swapping one for the other -later is a change to one file. Don't front-load a machine for a flow that has -not earned one; don't leave a coordinated async flow in flag soup because it -started as one boolean. - -`packages/mosaic/src/machine/ADOPTION.md` is the full criteria, including its -"honest boundary" table of what stays `useState` inside a component that does -have a machine. - -## Responsibilities - -- **Hold the interaction state.** In React state or a machine — see "Which one - holds the state" below. -- **Pass the model's `status` through.** The wrapper then branches on one value: - - ```tsx - if (model.status !== 'ready') { - return { status: model.status }; - } - ``` - -- **Wrap actions to drive pending state.** One helper, so every action is wrapped - the same way and only one can be in flight: - - ```tsx - const runAction = (keyFor, fn, closeOnSuccess = false) => - fn - ? (...args) => send({ type: 'RUN', key: keyFor(...args), run: async () => fn(...args), closeOnSuccess }) - : undefined; - ``` - - An absent model callback stays absent, so a capability the instance does not - offer never reaches the view as a dead affordance. - -- **Decide what closes the surface.** Only the controller knows whether an action - ends the interaction. `onSelectOrganization` closes on success; a switch that - leaves the menu useful does not; a navigation closes _before_ it hands off. -- **Hold the surface still while an action runs.** `setActive` swaps the active - organization while its promise is still in flight, so the live model would - rearrange the popup mid-action. Freeze the model the action started from and - render that until it settles: - - ```tsx - const resolvedModel = context.frozenModel ?? model; - ``` - - Freezing also covers the model dropping `ready → loading` during Clerk's - transitive state, which would otherwise flash the fallback. - -- **Derive view props.** `actor.can(...)` results, a `pendingKey` run through - `useSpinDelay`, a `mode` forced by a capability flag — anything the view would - otherwise have to re-derive. - -## Rules - -- **No Clerk imports.** If a controller needs a Clerk fact, the model supplies it - as data. -- **No machine snapshot in the return value.** Return the plain props the view - reads (`open`, `pendingKey`, `isDeleting`, `errorMessage`), never `snapshot` - and `send`. -- Dismissing must not abandon an in-flight effect. Model `open` as context, and - give `OPEN`/`CLOSE` no target in the busy state so they don't leave it: - - ```ts - busy: { - on: { CLOSE: { actions: assign(() => ({ open: false })) } }, - invoke: fromPromise(context => context.run(), { /* … */ }), - } - ``` - -## Errors - -One path, so every error a user sees goes through the `errors.*` catalog: - -- **Owners map, nothing else does.** `toLocalizableError(cause)` from - `utils/errors.ts` is called only by the hooks that own a failure: `useForm`, - `useConfirmationController`, `useDestructiveController` and - `usePendingAction` (inline row actions). Use one of them instead of catching - in a feature. -- **Owners hold an `ErrorDescription`.** It is either a `LocalizableError` - (Clerk gave a `code`) or an `UnlocalizableError` (`{ cause }`, the original - failure). `toLocalizableError` logs the second as - `[Clerk] Could not localize error`. Branch with `isLocalizableError` only - when a feature handles the two differently. -- **Owners resolve copy at render; views get a string.** Owners return - `errorMessage` from `useErrorText()`: the - catalog entry for `code__paramName`, then `code`, then Clerk's message, then - the feature's `errorFallback`, then the generic error. Views never call - `useErrorText` on an error they did not create themselves. -- Not there yet, each with a follow-up: `useForm` resolves when the save fails - and falls back to `form.error` instead of taking an `errorFallback`, and the - profile picture row resolves its controller's error in the view. -- **`errorFallback` is the feature's copy for faults Clerk cannot describe** - (code bugs, non-Clerk errors). An `UnlocalizableError` renders the fallback, - never its cause's `.message`. A network failure has its own catalog entry, - `network_error`. -- In tests, reject with `clerkApiError(code, message)` from - `src/__tests__/clerk-errors.ts` to assert catalog copy. Reject with a plain - `Error` (silence `console.error`) to assert the fallback. - -## Testing - -The feature test covers the controller by default. Hold the -FAPI request an action makes to assert its in-flight state (the spinner, the rows -stood down), then release or fail it to assert what closes the surface. Timing -that is awkward to drive through FAPI can get a smaller test. See `testing.md`. diff --git a/.claude/skills/mosaic/references/headless.md b/.claude/skills/mosaic/references/headless.md deleted file mode 100644 index 08a8a80d2fe..00000000000 --- a/.claude/skills/mosaic/references/headless.md +++ /dev/null @@ -1,306 +0,0 @@ -# Headless primitives - -`packages/mosaic/src/primitives/` is the unstyled, accessible primitive layer -inside Mosaic: Accordion, Autocomplete, Collapsible, Dialog, Drawer, -FileUpload, Menu, OTP, Popover, Select, Tabs, TagInput, Toast, Tooltip. Every part emits **zero -styles** — positioning, keyboard nav, focus management, dismiss, and ARIA are -delegated to `@floating-ui/react`; all appearance is applied externally via -`data-*` selectors and consumer classNames. - -This is a folder inside `@clerk/mosaic`, not a separate package (it used to be -`@clerk/headless`, which was merged into Mosaic). Mosaic's styled -`src/components/*` wrap these primitives via plain relative imports; there is -no subpath export for them, so nothing outside `@clerk/mosaic` can import a -primitive directly today. - -## Read this for the _what_ - -Per-primitive API docs (parts, props, keyboard, data attributes, ARIA) live -**next to the code** and are the source of truth: - -- **`packages/mosaic/src/primitives//README.md`** — one per primitive. -- **`packages/mosaic/src/primitives/README.md`** — primitives overview, the - primitive table, and the full **consuming-from-`@clerk/ui`** guide (the - `makeCustomizable` wrapper, the TS2742 annotation requirement, pass-through - parts, the `render` escape hatch — aspirational: `@clerk/ui` does not - currently import these). - -This file is the _how-to_ for the shared conventions — what every primitive has -in common, so you can author a new one or a new part without re-deriving the -pattern. - -## Consuming a primitive - -Every primitive is a compound component exported as a namespace. From inside -`@clerk/mosaic`, import it by relative path; render `Root` + parts: - -```tsx -import { Select } from '../../primitives/select'; - - - - - - - - - - -; -``` - -- **Each element-rendering part accepts native props for its tag plus a `render` - prop.** Pass-through parts (`Root`, `Portal`) render no element of their own - and have their own APIs instead. Unused parts tree-shake out. -- **Style by className/`data-*`**, never by a slot attribute — the primitives - don't emit one. -- **`render` is the override escape hatch.** It takes a function - (`render={props => }`) **or an element** - (`render={}`) — the element is cloned with the part's computed props - and refs merged in. -- **From `@clerk/ui`**, wrap element-rendering parts with `makeCustomizable` to - get the theme-aware `sx` prop; pass-through parts (`Root`, `Portal`) are used - directly. See `packages/mosaic/src/primitives/README.md`. - -## Authoring a part: the useRender contract - -Every part that renders a DOM element calls **`useRender`** (from -`../../utils`) instead of returning JSX. This is the single mechanism behind -`render` overrides, state→`data-*` mapping, and ref merging. (It replaced the -old `renderElement` helper — `renderElement` no longer exists.) - -```tsx -'use client'; -import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; -import { useSelectContext } from './select-context'; - -export type SelectTriggerProps = ComponentProps<'button'>; - -export const SelectTrigger = React.forwardRef( - function SelectTrigger(props, ref) { - const { render, ...otherProps } = props; - const { open, refs, getReferenceProps } = useSelectContext(); - - const ownProps = { type: 'button' } satisfies DefaultProps<'button'>; - const defaultProps = { ...ownProps, ...getReferenceProps() }; - - return useRender({ - defaultTagName: 'button', - render, - state: { open }, - stateAttributesMapping: { - open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }), - }, - ref: [refs.setReference, ref], - props: mergeProps<'button'>(defaultProps, otherProps), - }); - }, -); -``` - -`useRender` params: - -| Param | Purpose | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | -| `defaultTagName` | Tag rendered when no `render` is given. | -| `render` | Consumer override: a render function **or** a React element (cloned with merged props). | -| `props` | Props to spread onto the element. Pass refs via `ref`, not here. | -| `ref` | A ref or **array** of refs; merged internally (`useMergeRefs`). E.g. `[refs.setX, ref]`. | -| `state` + `stateAttributesMapping` | Maps state values to `data-*` attrs (below). | -| `enabled` | When `false`, returns `null`. Positioners pass `enabled: mounted` to gate the floating DOM. | - -Rules that hold for **every** part: - -- **`'use client';`** at the top of every component file. -- **`React.forwardRef`** for any part that renders an element. -- **`const { render, ...otherProps } = props;`** — pull `render` out, spread the rest. -- **`ownProps satisfies DefaultProps`** for authored defaults. `DefaultProps` - is the tag's props widened to allow `data-*` keys — `satisfies` type-checks - every key against the real element without an `as` cast. -- **`mergeProps(internal, consumer)` — internal first, consumer second.** Event - handlers chain (internal fires, then consumer), `style` shallow-merges, - `className` concatenates, everything else the consumer overwrites. This lets - consumers extend behavior without breaking ARIA/handlers the primitive owns. - -### state → data-attribute mapping - -`stateAttributesMapping` maps each `state` key to a function returning a -`data-*` object or `null` (omit). Return the boolean-off branch as `null` for -presence attrs, or a second attribute for on/off pairs: - -```ts -// presence: attr only when true -selected: v => (v ? { 'data-selected': '' } : null), -disabled: v => (v ? { 'data-disabled': '' } : null), -// pair: data-open vs data-closed -open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }), -``` - -Consumers then style off `[data-selected]`, `[data-open]`, etc. - -## File layout of a primitive - -Every `primitives//` folder follows the same shape: - -| File | Holds | -| ------------------- | ---------------------------------------------------------------------------------------------- | -| `-root.tsx` | Context provider; owns floating/interaction/transition state. Often wraps `FloatingTree`. | -| `-.tsx` | One file per part (`-trigger`, `-popup`, `-positioner`, …), each a `forwardRef` + `useRender`. | -| `-context.ts` | Context type + `createContext` + guard hook (below). | -| `parts.ts` | Re-exports each part under its short alias. | -| `index.ts` | Public entry: namespace + prop-type re-exports. | -| `.test.tsx` | Tests (jsdom, via Mosaic's vitest config). | -| `README.md` | The primitive's API docs. | - -**Context + guard hook** — the pattern that makes "used outside Root" a clear error: - -```ts -export const SelectContext = createContext(null); - -export function useSelectContext() { - const ctx = useContext(SelectContext); - if (!ctx) throw new Error('Select compound components must be used within '); - return ctx; -} -``` - -**`parts.ts`** — alias each part; this is what the namespace spreads: - -```ts -export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger'; -export { type SelectOptionProps, SelectOption as Option } from './select-option'; -// … -``` - -**`index.ts`** — namespace + public prop types (the prop types must be -re-exported here or `@clerk/ui`'s `.d.ts` rollup hits TS2742): - -```ts -export * as Select from './parts'; -export type { SelectProps, SelectTriggerProps, SelectOptionProps /* … */ } from './parts'; -``` - -**`Portal`** parts take an optional `root` and gate on `mounted`: - -```tsx -export function SelectPortal(props: { - children: ReactNode; - root?: HTMLElement | RefObject | null; -}) { - const { mounted } = useSelectContext(); - if (!mounted) return null; - return {props.children}; -} -``` - -## Animation lifecycle (`data-*` driven) - -All enter/exit timing lives in **CSS**; the primitives only toggle `data-*` -attributes and drive unmount off the Web Animations API. Root spreads -`transitionProps` (from `useTransition`) onto the Popup; the lifecycle: - -- **Open** → synchronously `mounted=true`, status `'starting'`: first committed - frame carries `data-open` + `data-starting-style` + inline `transition: none` - (snapshot frame). One rAF later `data-starting-style` clears → CSS transitions - fire toward the resting style. -- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied. - After all animations on the element finish (`useAnimationsFinished`), - `mounted` flips false and the element unmounts. - -Consumer CSS keys off these: `[data-starting-style] { opacity: 0 }`, -`[data-open] { animation: … }`, `[data-ending-style] { opacity: 0 }`. - -### Exiting content must be frozen - -Anything that animates out outlives `open` by the length of its exit, and -whatever closed it has usually already changed the data behind it — picking a -menu item, selecting an option, switching account, a machine returning to `idle`, -a form clearing. The subtree re-renders with the new data and swaps visibly under -the exit, which reads as a flash of the next screen. This applies to **every part -with an exit transition**, not only popups: a sheet, a panel, a step, an inline -region that fades out all have the same window. - -Two ways to hold the old frame, depending on whether the part re-renders for -reasons of its own while closed: - -**`Freeze`** — wrap the children, keep the animating element live: - -```tsx -const { render, children, ...otherProps } = props; -const { open, popupRef, transitionProps } = useMenuContext(); - -const defaultProps = { - ...transitionProps, - children: {children}, -}; -``` - -**A held snapshot** — keep the last open `children` in a ref and render those -while closed. `FlowStep` does this: its `children` prop genuinely changes when the -step value moves on, so there is a correct old value to render rather than a frame -to hold. - -```tsx -if (open) activeChildrenRef.current = children; -// ... -children: open ? children : activeChildrenRef.current, -``` - -Reach for `Freeze` by default; reach for the snapshot when the outgoing content is -a distinct element you still have. - -Two things to get right either way: - -- Gate on **`!open`**, never `!mounted`. `mounted` stays true through the whole - exit — that is precisely the window this covers, so `!mounted` freezes nothing. -- Freeze the **children**, not the animating element. It has to stay live for - `data-closed` / `data-ending-style` to land on it and for the animation to run. - -In place today: Popover, Select, Combobox, Menu, Dialog (`Freeze`), Flow -(snapshot). Not yet: Drawer, Autocomplete, Tooltip, and the Accordion / -Collapsible / Tabs panels. Any new part with an exit transition needs one of the -two. - -**Positioners** gate the floating layer on `mounted` via `useRender`'s -`enabled`, so the positioned DOM doesn't exist until the first frame: - -```tsx -const element = useRender({ defaultTagName: 'div', render, enabled: mounted, ref: [refs.setFloating, ref], props }); -if (!element) return null; -``` - -## Shared hooks (`primitives/hooks`) - -| Hook | Signature (abridged) | Purpose | -| ----------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `useControllableState` | `(controlled, defaultValue, onChange?) => [value, setValue]` | Dual-mode controlled/uncontrolled state; `onChange` fires either way. | -| `useTransition` | `({ open, ref }) => { mounted, transitionStatus, transitionProps }` | Enter/exit lifecycle; spread `transitionProps` onto the animated part. | -| `useTransitionStatus` | `(open) => { mounted, transitionStatus, setMounted }` | Lower-level phase machine (`'starting'` / `'ending'` / `undefined`). | -| `useAnimationsFinished` | `(ref, open) => (cb) => void` | Runs `cb` once all CSS animations finish; aborts on rapid toggles. | -| `useDataTable` | `(opts) => { rows, sorting, pagination, rowSelection, … }` | Table state (sort/filter/paginate/select), controlled or uncontrolled. | - -## Shared utils (`primitives/utils`) - -- **`useRender`, `mergeProps`, `ComponentProps`, `DefaultProps`, `RenderProp`** — the part-authoring primitives (above). -- **`Freeze({ frozen, children })`** — holds its subtree's DOM at the last - committed frame while `frozen` (a suspended boundary whose `display: none` is - undone in an insertion effect). Wrap a transitioning popup's children in it so - they don't swap under the exit animation; see above. -- **`cssVars({ sideOffset? }): Middleware`** — floating-ui middleware setting - `--cl-anchor-width/height`, `--cl-available-width/height`, `--cl-transform-origin` - on the floating element. Place it **after** `arrow()`. -- **`resetLayoutStyles(el): () => void`** — temporarily forces flex/grid - alignment to `initial` for accurate `scrollHeight`/`scrollWidth` measurement; - restores on next rAF. Call the returned cleanup from effect cleanup. - -## Testing - -Tests run under **jsdom**, via Mosaic's own `vitest.config.mts`, and include -`axe` accessibility assertions (`vitest-axe`'s `toHaveNoViolations`, registered -in `src/primitives/test-utils/vitest.setup.ts`). `pnpm --filter @clerk/mosaic -test`. See `testing.md` for the Mosaic flow-layer testing model (a different -concern — that covers models/controllers/views, not these primitives). diff --git a/.claude/skills/mosaic/references/machines.md b/.claude/skills/mosaic/references/machines.md deleted file mode 100644 index 00dafc70a09..00000000000 --- a/.claude/skills/mosaic/references/machines.md +++ /dev/null @@ -1,61 +0,0 @@ -# Machines - -A machine is **not a layer**. It is one of the two ways a controller -(`controllers.md`) can hold its state — the one to reach for when the -interaction has an async lifecycle, an error path back to a previous step, a -terminal state that must never reopen, or two values that must change together. -It is declared in the controller's own file — there is no `*.machine.ts` in a -feature — and the controller is the only thing that sends to it. - -A flow without those properties does not need one: `useState` in the controller -is the right answer for a boolean that never touches async, and a controller can -run a machine for its coordinated core with `useState` flags beside it. The view -cannot tell the difference either way. `machine/ADOPTION.md` is the judgement -call, with real before/after migrations and an "honest boundary" table of what -stays `useState`. - -The machine runtime is documented **next to the code**, and that in-tree doc is -the source of truth (it is updated in the same diff when the runtime changes and -is readable by every tool, not just Claude Code). Read: - -- **`packages/mosaic/src/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/mosaic/src/machine/ADOPTION.md`** — when a flow is worth a - machine and when it isn't. - -Two directories one letter apart: `machine/` is the runtime, `machines/` holds -standalone machines and the shared `__tests__/test-utils.ts`. A feature's own -machine goes in its `*.controller.tsx`, not in either. - -## Injected effects - -A machine never calls Clerk. The effect it invokes arrives through context as a -plain function, seated by the controller — `useMachine` re-seats context via -`useLayoutEffect` every render, so the machine always invokes the latest one: - -```ts -deleting: { - invoke: fromPromise(context => context.deleteAccount(), { - onDone: 'deleted', - onError: { - target: 'confirming', - actions: assign((_, event) => ({ error: toLocalizableError(event.error) })), - }, - }), -} -``` - -A machine whose surface shows no error yet still logs it, so a failure is never -silent: `onError: { target: 'idle', actions: [assign(() => settled), (_, event) => console.error('[Clerk] User button action failed', event.error)] }` -(`user-button.controller.tsx`). - -Context holds the `ErrorDescription`, never copy. The controller turns it into -text at render with `errorText(error, errorFallback)`, so locale changes and -overrides still apply (see `controllers.md` → "Errors"). - -To wire it to Clerk data see `models.md`; to render its state 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/stylex.md b/.claude/skills/mosaic/references/stylex.md deleted file mode 100644 index ee881cc69ae..00000000000 --- a/.claude/skills/mosaic/references/stylex.md +++ /dev/null @@ -1,728 +0,0 @@ -# Styling a component with StyleX - -Mosaic is styled with **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; read it -against the reference component, `packages/mosaic/src/components/button/`. - -Consumers target `--cl-*` vars, the `.cl-` class, and `data-` attrs, -never StyleX's hashed `x…` atoms. That public contract is fixed; everything -below is how a component is authored internally to produce it. - -The guidance below is opinionated and evidence-backed: it codifies patterns -proven out across a full StyleX component library (tokens, ~100 components, -complex cases like Table/Calendar/Popover/Slider). Each is framed as **do X / -don't Y**. Lint enforcement of some of these is still landing, so treat them as -conventions to follow by hand, not guarantees the toolchain makes for you. - -## File layout & the `.stylex.ts` convention - -| File | Holds | -| --------------------------------- | ------------------------------------------------------------------------------------------ | -| `tokens.stylex.ts` | `defineVars` / `defineConsts` only (the tokens) | -| `/.styles.ts` | `stylex.create({...})` — this component's atoms | -| `utils/.styles.ts` | **shared** atoms composed by many components (`reset`, `focusOutline`, `truncationStyles`) | -| `utils/` | everything shared across components — styles and non-style helpers alike | -| `/.markers.stylex.ts` | `stylex.defineMarker()` results for scoped ancestor states | -| `/.tsx` | component; spreads `stylex.props(...)` via `mergeStyleProps` | -| `props.ts` | `themeProps` (`.cl-` + `data-`), `mergeStyleProps`, the `Mosaic*Props` types | -| `styles/index.ts` | isolated-build barrel; derives `*VarName` types | - -The `@stylexjs` eslint rules run on `src/mosaic/**`. The `enforce-extension` -rule reserves the `.stylex.ts` extension for StyleX define-primitives: **a -`.stylex.ts` file may export nothing but `defineVars` / `defineConsts` / -`defineMarker` results.** So: - -- `stylex.create(...)` lives in a plain `.styles.ts` file, never `.stylex.ts`. -- `stylex.defineMarker()` is a define-primitive like the token builders, so it - lives in its own `.markers.stylex.ts`, never a plain `.ts`. -- Derived types (`type ColorVarName = keyof typeof colorVars`) live in - `styles/index.ts`, not in `tokens.stylex.ts` (they'd be a disallowed export). - -**Styles live in their own file, never inline in the `.tsx`.** - -- **DO** put a component's `stylex.create` in a co-located `.styles.ts` and - import the atoms into the component. The `.tsx` stays rendering-only. This is a - deliberate Mosaic convention — a single predictable place for a slot's styles - and a readable component file. It diverges from StyleX libraries that author - `create` inline in the component; we don't. -- **DON'T** inline `stylex.create` in a `.tsx`. No "it's only a few atoms" - exception — small components get a `.styles.ts` too. -- **DO** hoist atoms into a shared `utils/.styles.ts` when several - components render the same visual surface — e.g. inputs (`TextInput`, - `NumberInput`, date fields, `Selector`) sharing one `inputWrapper` / - `inputStatusBorder` / `inputStatusFocusWithin` set instead of redefining the - border/focus treatment five times. Four exist today: `reset.styles.ts`, - `typography.styles.ts`, `focus-outline.styles.ts` and `rtl.styles.ts`. -- **DO** compose `rtl.mirror` from `styles/rtl.styles.ts` onto a direction-aware - icon — a chevron that means "forward"/"back", a pagination arrow, the - log-out arrow leaving its frame — via its - `xstyle`. It flips the glyph with `scaleX(-1)` only under an ancestor carrying - `dir="rtl"`, so the same `chevron-right` reads as "forward" in both directions. - Pick it by meaning, not by shape: a chevron that points at a dropdown or an - external-link arrow stays unmirrored. -- **DON'T** register a mirrored twin (`chevron-forward`) in the icon registry or - swap the name at the call site by direction. The direction lives in CSS. -- **DON'T** put a shared style file under `components/`. That directory holds one - subdirectory per component and nothing else, so a loose file there reads as a - component that lost its folder. -- **DON'T** copy a slot's atoms between siblings. A repeated style object is the - same signal as a repeated conditional: extract the shared concept. - -## 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-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', -} as const; -export const colorVars = stylex.defineVars(colorDefaults); -``` - -- **DO** put light + dark in one token value via `light-dark()`. Never ship a - second `@media (prefers-color-scheme: dark)` copy of a color. -- **DO** reserve `--cl-*`-prefixed keys for the public, overridable contract. -- **DO** derive a gray-backed colour token from `--cl-color-neutral` mixed into - `--cl-color-background` (`neutralMix(80, 98)` in `tokens.stylex.ts`) rather than - restating an oklch value. There is no gray scale: retinting neutral retints the ramp. -- **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-brand-hover-12`. -- **DON'T** mint a per-step derivative token for something a `calc()`/`color-mix()` - can express from an existing token. -- **DON'T** give one value two public names. The focus ring's colour is - `--cl-color-ring` and nothing else — a `--cl-focus-outline-color` alias beside it - would let a consumer override one and not the other, and the ring's appearance - would then depend on which they picked. -- **DO** build a fill that sits _on top of_ an unknown backdrop — a hover or pressed - wash on a transparent `outline`/`ghost` control — from the `--cl-color-neutral-alpha-*` - tokens: `--cl-color-neutral` (black on light, white on dark) at a fixed opacity over - `transparent`, not a percentage of a gray token. Status colors have the same three - steps (`--cl-color-negative-alpha-100` …). - - ```ts - backgroundColor: colorVars['--cl-color-neutral-alpha-300'], - ``` - - A gray token like `--cl-color-foreground` 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) * -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. - -**Reference tokens by bracket string key**, always: `colorVars['--cl-color-brand']`, -`space['2']`. A computed key (`colorVars[name]`) defeats StyleX static analysis -and won't compile. - -## 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']})` ``. - -### Breaking up `stylex.create` - -There is no one big styles object. Split by concern into several small named -objects and compose them at the call site. - -- **DO** split into: a **base** object (structure the slot always has), one or - more **variant maps** keyed by a prop value, and small **state** objects applied - conditionally. - - ```ts - // base: what the slot always is - const styles = stylex.create({ - base: { display: 'inline-flex', borderRadius: radiusVars['--cl-radius-element'] }, - iconOnly: { aspectRatio: '1' }, - }); - // variant map: keyed by the prop value, indexed at the call site - const variants = stylex.create({ - primary: { backgroundColor: colorVars['--cl-color-brand'] }, - secondary: { backgroundColor: colorVars['--cl-color-secondary'] }, - }); - const sizes = stylex.create({ - sm: { height: sizeVars['--cl-size-sm'] }, - md: { - /*…*/ - }, - }); - ``` - -- **DO** compose base + modifier in a **single** `stylex.props(...)` call, adding - conditional atoms with `&&` / ternary. Order matters — later wins, so consumer - `xstyle` goes last: - - ```tsx - stylex.props( - styles.base, - variants[variant], // prop-keyed map lookup - sizes[size], - isIconOnly && styles.iconOnly, - xstyle, // consumer StyleX — always LAST so it wins - ); - ``` - -- **DON'T** put everything in one monolithic object (you lose prop-keyed lookup and - conditional composition), and **don't** swing the other way into one object per - property. Group by concern: base, variant, size, state. -- **DON'T** branch a variant with `variant === 'primary' ? a : b` inside the style. - Encode the axis as a keyed map and look it up (`variants[variant]`). A repeated - ternary is a missing variant map. -- 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 `-`, - 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 - }); - // .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-brand']}, ${colorVars['--cl-color-brand-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-brand'], - ':active': primaryActive, - '@media (hover: hover)': { - // the media block contributes only the pseudo; the top-level `default` still - // paints the rest state. `:not(:active)` is required — 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 --filter @clerk/mosaic`, - then grep `dist/styles.css` for the two selectors and compare their - specificity. - -- **A button that opens something takes the pressed fill while open**, so a - disclosure trigger stays visibly engaged for as long as its surface is. Disclosure - primitives already set `data-open` on the trigger (`popover-trigger.tsx` and - friends), so this is styling-only — no headless change. It needs the _same_ - exclusion as `:active`, or hovering an open trigger lifts it back to the lighter - hover step: - - ```ts - backgroundColor: { - default: 'transparent', - ':enabled:active': colorVars['--cl-color-neutral-alpha-200'], - ':enabled[data-open]': colorVars['--cl-color-neutral-alpha-200'], - '@media (hover: hover)': { - default: null, - ':enabled:hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-100'], - }, - }, - ``` - - Worked example: `button.styles.ts`, applied across every filled/outline/ghost cell - (`link` opts out — it reads as text, not a control). - -Worked example: `packages/mosaic/src/components/button/button.styles.ts`. - -- **DON'T** write a focus ring by hand. Compose `focusOutline` from - `styles/focus-outline.styles.ts` into the element's `stylex.props(...)`: - `focusOutline.visible` for the element's own keyboard focus, `focusOutline.within` - for a **container** that should ring when a child is focused. The ring is one - decision for the whole system — width, style, colour and offset all come from - `--cl-focus-outline-*` and `--cl-color-ring`, so a theme retargets every ring at - once. - - ```tsx - // item.tsx — the ring is an atom in the chain, not a property in the styles file - stylex.props(reset.base, focusOutline.visible, slots.item.base, slots.item[size]); - ``` - - It uses `:focus-visible`, never bare `:focus`, and `:has(:focus-visible)` for the - container form — **not** `:focus-within`, which matches any descendant focus - including a mouse click, so the container ring would flash on click. Reach for - `:focus-within` only when you genuinely want an any-modality reaction (keep an - affordance visible while a descendant is focused), never for a ring. - - The ring is written as **longhands**, not the `outline` shorthand: StyleX ranks a - longhand above a shorthand regardless of argument order, so it survives a - component or consumer style carrying `outline: 'none'`, and a component can - re-colour its ring without restating the width and style. - - `Input` is the deliberate exception — it marks focus with a border and box-shadow - rather than a ring, and keeps an outline only under `forced-colors`. - -- **DO** use `default: null` when a property exists **only** in a pseudo/state - branch, so the atom doesn't emit a base value that would clobber a merged style: - - ```ts - boxShadow: { default: null, ':focus-visible': focusShadow }, - ``` - -- **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. - -- **DON'T** reuse `--cl-ease-default` for something **leaving**. It is an arrival - curve; run backwards it stalls for most of its duration and its overshoot - becomes a wobble past the target. Departures take `easingVars['--cl-ease-exit']` - at a shorter duration. See `motion.md` — enter/exit asymmetry has its own - reference, with the measurements behind these rules. - -- **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-` attrs via `themeProps`, in addition to the atom, so - the state stays overridable in plain consumer CSS. - -**Scoped ancestor states.** When a child's style depends on an **ancestor's** -interaction state (a wrapper `:hover`/`:focus-visible` styling an inner mark), -don't reach for a descendant combinator by hand. Use `stylex.when.ancestor()` -with a **marker** so the selector is scoped to _your_ subtree and can't be -triggered by unrelated ancestors: - -```ts -// checkbox.markers.stylex.ts — a define-primitive module, its own file -export const checkboxScope = stylex.defineMarker(); - -// checkbox.styles.ts — the inner mark reacts to the labelled wrapper's state -borderColor: { - default: colorVars['--cl-color-border'], - [stylex.when.ancestor(':hover', checkboxScope)]: { - '@media (hover: hover)': `color-mix(in oklab, ${colorVars['--cl-color-border']}, ${colorVars['--cl-color-tint']} 20%)`, - }, -}, -``` - -Apply the marker atom to the ancestor, reference the scope in the descendant's -`create`. This replaces the Emotion engine's parent-selector hacks. - -Write the hover guard and these selectors **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. - -## Dynamic styles: dos and don'ts - -A style key can be a **function** of a runtime value: -`const dyn = stylex.create({ width: (w) => ({ width: w }) })`, applied -`dyn.width(value)`. This is the right tool for a **continuous or unbounded** -runtime value that can't be enumerated as a variant map — sizes, transforms, -offsets, indents, counts, delays, computed widths. It is **not** a substitute for -a variant map over a small closed set. - -**When to reach for dynamic (and when not):** - -- **DO** use a dynamic function for a value that is continuous, per-instance, or - formula-derived: an avatar pixel size (`size => ({ width: size, height: size })`, - font size `size * 0.4`), a drag transform - (`t => ({ transform: t })` with `t = \`scale(${zoom}) translate(...)\``), a -tree-row indent `(depth-1) \* step`, a line clamp `n => ({ WebkitLineClamp: n })`, -a staggered `animationDelay`. -- **DON'T** use dynamic for a closed enum (sm/md/lg, primary/secondary) — that's a - variant map. **DON'T** use it for a plain static token — that's a static atom. - -**The important sub-distinction — write a `--var`, or set the property?** - -There are two shapes of dynamic style. Prefer the first whenever the value feeds a -rule that also needs media/pseudo variants, needs to be read by descendants, or -must stay overridable by the consumer: - -- **A. Write a CSS custom property; let a static rule consume it.** Only **one** - atom is generated no matter how many distinct values flow through, and — this is - the subtle win — a `var()` written into a class does **not** beat a consumer's - class-level `@media` override, whereas a raw inline `style` property would. - - ```ts - // set the var dynamically… - const dyn = stylex.create({ - gutter: (digits: number) => ({ '--_cl-gutter': `${digits}ch` }), - tabIndicator: (offset: string) => ({ '--_cl-tab-indicator': offset }), - }); - // …and consume it from a normal static atom / descendant rule - const styles = stylex.create({ row: { gridTemplateColumns: 'var(--_cl-gutter) 1fr' } }); - ``` - - Also the right shape for a value a parent computes and **descendants** read - (`--_cl-content-width`, an avatar-group `--_cl-overlap` a sibling rule consumes): - the var inherits; no per-descendant atom. - -- **B. Set the real property directly** — fine when the value is genuinely - per-instance, only this element uses it, and no rule/descendant needs to read it - (a live drag `transform`, a one-off `width`, `WebkitLineClamp`). Accept that - each distinct value emits its own atom. - -**Perf model to keep in mind:** each _distinct argument value_ emits a new atom / -injected rule. A bounded numeric set (~10–20 sizes) is fine. For an unbounded -value, sub-pattern A collapses it to a single `--var` atom; reach for a raw inline -`style` only when you specifically do **not** want it to be overridable. - -> This supersedes the earlier blanket "Mosaic styling is fully static." Static is -> still the default — most styling is atoms, variant maps, and conditional-value -> objects. Dynamic functions are the deliberate exception for continuous runtime -> values, and even then the first move is usually to write a single `--cl`/`--_cl` -> var rather than a raw inline style. - -**Every condition is a value key, never a top-level object.** A pseudo/at-rule -goes _inside_ the property it modifies (`transitionProperty: { default: …, '@media …': … }`), -not as a bare key on the style object. A top-level `'@media …': { … }` block is -legacy syntax and the `@stylexjs/no-legacy-contextual-styles` + -`@stylexjs/valid-styles` rules reject it (only `::before`/`::after` may sit at the -top level). Reduced-motion is the common case: - -```ts -transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' }, -``` - -### Reacting to `data-*` state (the headless-transition case) - -Headless primitives drive animation off `data-*` attributes — e.g. the popover -popup carries its own `data-starting-style` (entering frame) and -`data-ending-style` (exiting). You can style off these in StyleX; it depends on -_whose_ attribute you're reading: - -- **The element's own attribute → wrap in `:where(...)`.** Conditional keys must - start with `:` or `@`, so a bare `[data-*]` is rejected — but `:where([data-*])` - is a valid pseudo-class string that matches the same element (zero specificity; - StyleX self-doubles the atom class so the conditional still wins): - - ```ts - popup: { - opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 }, - transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.94)' }, - // Reduced motion drops `transform` and keeps the fade — the gate belongs on the - // moving property, not the whole transition. - transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity' }, - // Positional against `transitionProperty`, and branched by direction: the exit is - // shorter and takes the departure curve. See `motion.md`. - transitionDuration: { - default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, - ':where([data-ending-style])': durationVars['--cl-duration-fast'], - }, - transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-default']}`, - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, - }, - }, - ``` - -- **Another element's attribute → `stylex.when.*`.** For relational state use - `stylex.when.ancestor(sel)` / `.descendant(sel)` / `.siblingBefore(sel)` / - `.siblingAfter(sel)` / `.anySibling(sel)`, each taking a `:${string}` or - `[${string}]` selector and returning a valid conditional key - (`:where-ancestor(...)` etc.). Use this when a parent/sibling owns the state - (e.g. a `[data-open]` container theming its children); use `:where([data-*])` - when the element owns it. - -So: `:where(...)` for self-state, `stylex.when.*` for relational state. Both -compile to real attribute selectors in `styles.css`, so animation stays -CSS-native — no JS state plumbing through the component. - -## Public contract & composition (`props.ts`) - -The element carries three things, and nothing else is a contract: - -1. `--cl-*` vars (from tokens), 2. `.cl-` class, 3. `data-` attrs. - -`themeProps('button', { intent, variant })` produces the stable `.cl-` class -plus a kebab-cased `data-` reflection of the visual props, so consumers -target stable data-attribute selectors, not collision-prone class names. - -`mergeStyleProps` fuses its bags left to right — **theme props → StyleX atoms → -the props the part was called with** — concatenating `className`, shallow-merging -`style` with the later bag winning, and letting the later bag overwrite anything else: - -```tsx -function Button({ intent, variant, xstyle, ...rest }: ButtonProps) { - return ( - } title={m.dialogTitle} confirmationValue={m.fieldPlaceholder} - onDelete={onConfirm} - isDeleting={isDeleting} - errorMessage={errorMessage} -/> +/>; ``` +When one dialog serves every row in a list, use the handle form: create a handle +with `Confirmation.createHandle()` (or `Destructive.createHandle`), pass it +to each row's trigger and to the block's `handle` prop, and the clicked row +arrives as the payload of `onConfirm` / `onDelete`. In this form the block runs +its own controller, so pass the effect and `errorFallback` straight to it. +`user-profile-mfa-section.view.tsx` and `api-keys-table.view.tsx` are worked +examples. + ## Pure derivation belongs beside the view, not in it Which affordance lands in which slot, and how a consumer's `order` array @@ -94,32 +106,30 @@ opened the dialog unmounted with the menu, and a dialog mounted at the section rather than inside the menu has no floating-tree ancestor to walk back to. A keyboard user loses their place mid-list and a screen reader announces nothing. -Hand focus to a surviving element. `finalFocus` on `Dialog.Popup` and on the -`Confirmation` block takes a function, resolved when the dialog closes — which is -after the row has gone, so it can pick from what is left: +Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It +records which row was removed and, through `finalFocus`, picks the row that took +its place, the last row when it was the last, or `fallback()` once the list is +empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a +function resolved when the dialog closes, after the row has gone: ```tsx -const triggers = useRef(new Map()); -const removed = useRef(undefined); - -const removeRow = async (row: Row) => { - const index = rows.findIndex(candidate => candidate.id === row.id); - await onRemove(row.id); - // Only once it is really gone: a cancelled or failed attempt keeps its own trigger. - removed.current = index; -}; - -const focusAfterRemove = () => { - const index = removed.current; - removed.current = undefined; - if (index === undefined) { - return null; // null keeps the default — the trigger, which is still there - } - const next = rows[Math.min(index, rows.length - 1)] ?? anchorRow; - return (next && triggers.current.get(next.id)) ?? null; -}; +const removalFocus = useListRemovalFocus({ + ids: methods.map(method => method.id), + onRemove, + fallback: () => section.current?.querySelector('button:not([disabled])') ?? section.current, +}); + +// each row's trigger +triggerRef={removalFocus.registerTrigger(method.id)} + +// the dialog +finalFocus={removalFocus.finalFocus} +onConfirm={method => removalFocus.remove(method.id)} ``` +A cancelled or failed removal keeps its own trigger, so `finalFocus` returns +`null` and the dialog's default (the trigger) applies. + Prefer the row that took the removed one's place, the last row when it was the last, and a control that outlives the list once it is empty. @@ -132,4 +142,4 @@ every successful removal drops focus on the floor. The view's behavior (roles, labels, focus) is covered by the feature test. Its visual states are swingset stories with plain props. See `testing.md`. -See `references/mosaic-architecture.md` → "Views" for the layer contract. +See `packages/mosaic/ARCHITECTURE.md` → "Views" for the layer contract. diff --git a/packages/mosaic/src/machine/ADOPTION.md b/packages/mosaic/src/machine/ADOPTION.md index 833566bbbb5..fbce48027c4 100644 --- a/packages/mosaic/src/machine/ADOPTION.md +++ b/packages/mosaic/src/machine/ADOPTION.md @@ -1,12 +1,11 @@ # Why adopt the Mosaic state machine? The [README](./README.md) explains **how** to use the library. This document -answers **why** — by walking through three real migrations from today's +answers **why** — by walking through five real migrations from today's `@clerk/ui` code, showing what collapses and what the numbers look like. -The README already covers the delete-organization flow (`idle → confirming → -deleting`) and the ConfigureSSO Wizard (see the worked example in the `
` -block and the parity test at +The README already covers the ConfigureSSO Wizard (see the worked example in the `
` +block and the migration test at [`__tests__/wizard-migration.test.tsx`](./__tests__/wizard-migration.test.tsx)). Everything below is a fresh example. @@ -19,7 +18,8 @@ Two patterns appear over and over in `@clerk/ui` flows: 1. **`useLoadingStatus`** — a hand-rolled 3-state machine: ```ts - // hooks/useLoadingStatus.ts + // hooks/useLoadingStatus.ts (abridged: the real setters also take a + // `metadata` value, returned as `loadingMetadata`) type Status = 'idle' | 'loading' | 'error'; export const useLoadingStatus = () => { const [state, setState] = useSafeState({ status: 'idle' as Status }); @@ -115,8 +115,8 @@ Problems: ### After ```ts -import { createMachine } from '@/mosaic/machine/create-machine'; -import { assign } from '@/mosaic/machine/assign'; +import { createMachine } from '../../machine/create-machine'; +import { assign } from '../../machine/assign'; type Context = { error: string | null }; type Event = { type: 'SUBMIT'; emailAddress: string } | { type: 'NAVIGATE_DONE' }; @@ -497,8 +497,8 @@ const handleSubmit = async e => { ``` The component then disables all buttons with `status.isLoading` and shows a -spinner on whichever button was clicked by passing the strategy down as a -separate prop or via a ref. Two sync calls per async entry point, a +spinner on whichever button was clicked through `setLoading(strategy)` and +`loadingMetadata`. Two sync calls per async entry point, a required `finally` in each, and nothing that prevents both handlers from calling `setLoading` simultaneously. @@ -507,7 +507,7 @@ from calling `setLoading` simultaneously. A single `submitting` state with `activeStrategy` in context replaces both hooks: ```ts -import { setup } from '@/mosaic/machine/setup'; +import { setup } from '../../machine/setup'; interface SignInStartContext { activeStrategy: OAuthStrategy | 'email' | null; @@ -568,8 +568,10 @@ In React, `isLocked` replaces `card.setLoading()` and `activeStrategy` replaces the per-button `status.isLoading` check: ```tsx -const [snapshot, send] = useMachine(signInStartMachine, { - context: { signInFn: params => signIn.create(params) }, +const signInFn = params => signIn.create(params); +const machine = useMemo(() => createSignInStartMachine({ signInFn }), []); +const [snapshot, send] = useMachine(machine, { + context: { signInFn }, onDone: () => setActive({ session: signIn.createdSessionId }), }); @@ -781,6 +783,17 @@ collocated with the value that drives it. --- +## In Mosaic today + +The controllers that already made this call are the best reference: + +- `blocks/confirmation/confirmation.controller.ts`: a machine +- `blocks/destructive/destructive.controller.ts`: one `useState` holding a status union +- `features/user-button/user-button.controller.tsx`: a machine +- `hooks/use-pending-action.ts`: keyed `useState` plus a lock + +--- + ## When NOT to reach for a machine A machine earns its keep when **two or more** of these are true: @@ -831,11 +844,11 @@ patterns above show a few natural entry points: ## The proven precedent -The ConfigureSSO Wizard was the most complex existing implicit machine in -`@clerk/ui`. It was a hand-rolled `reduce(state, event, config)` pure reducer +The ConfigureSSO Wizard is the most complex existing implicit machine in +`@clerk/ui`. It is a hand-rolled `reduce(state, event, config)` pure reducer plus a React seam full of `useRef` mirrors and two "adjust-state-during- render" passes. The migration parity test ([`__tests__/wizard-migration.test.tsx`](./__tests__/wizard-migration.test.tsx)) -confirms the machine version reproduces every behavior while discarding the -seam entirely. The comparison table at the bottom of the README's worked +ports the reducer's test cases to a machine version that drops the seam +entirely. The wizard itself has not moved yet. The comparison table at the bottom of the README's worked example is worth reading if you're considering a similar migration. diff --git a/packages/mosaic/src/machine/README.md b/packages/mosaic/src/machine/README.md index 344e145dc2a..d4951947791 100644 --- a/packages/mosaic/src/machine/README.md +++ b/packages/mosaic/src/machine/README.md @@ -62,8 +62,8 @@ run it. ## Your first machine ```ts -import { createMachine } from '@/mosaic/machine/create-machine'; -import { assign } from '@/mosaic/machine/assign'; +import { createMachine } from '../../machine/create-machine'; +import { assign } from '../../machine/assign'; const loader = createMachine({ initial: 'idle', // where it starts @@ -112,7 +112,7 @@ assign>((_, e) => ({ factory functions that don't require repeating them: ```ts -import { setup } from '@/mosaic/machine/setup'; +import { setup } from '../../machine/setup'; const { createMachine, assign } = setup(); @@ -164,7 +164,7 @@ A machine on its own does nothing — it's just a description. To run it you wra it in an **actor** (the running instance): ```ts -import { createActor } from '@/mosaic/machine/create-actor'; +import { createActor } from '../../machine/create-actor'; const actor = createActor(loader); @@ -185,7 +185,7 @@ time. `value` is the state name; `context` is the riding data. `useMachine` does the create + start + subscribe for you: ```tsx -import { useMachine } from '@/mosaic/machine/use-machine'; +import { useMachine } from '../../machine/use-machine'; function Loader() { const [snapshot, send] = useMachine(loader); @@ -223,15 +223,11 @@ You've already seen `states`, `on`, `context`, `assign`, and `invoke`. The rest: ### API at a glance -| Export | What it is | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `createMachine(config)` | Build the definition. A plain, inert, inspectable object. | -| `assign(updater)` | A `(context, event) => Partial` context update, used inside `actions`. | -| `createActor(machine, options?)` | The running instance: `.start()`, `.stop()`, `.send(event)`, `.getSnapshot()`, `.subscribe(fn)`, `.can(event)`, `.recheck()`. | -| `mockActor(machine, { value, context })` | An actor teleported straight to any step — render a transient/unreachable state for docs and snapshots. | -| `useMachine(machine, options?)` | React: own an actor for the component's life → `[snapshot, send]`. Accepts `onDone` callback fired when the machine finishes. | -| `useActor(actor)` | React: bind to a **shared** actor → `[snapshot, send]`. | -| `useSelector(actor, selector, equals?)` | React: subscribe to one **slice** — re-renders only when that slice changes. | +The exports live in `create-machine.ts`, `setup.ts`, `assign.ts`, `create-actor.ts` and `use-machine.ts`; their JSDoc is the reference. Which React hook to reach for: + +- `useMachine(machine)` when the component owns the actor for its lifetime. +- `useActor(actor)` when the actor is shared or passed in, including a `mockActor` that renders a step directly. +- `useSelector(actor, selector)` when a component needs one slice and should re-render only when that slice changes. ### Two behaviors worth knowing @@ -259,6 +255,9 @@ const actor = mockActor(loader, { value: 'failure', context: { error: 'Network e render(); // snapshot a state you'd otherwise have to provoke ``` +A component that takes an actor reads it with `useActor(actor)` instead of +`useMachine`. + ---
@@ -269,7 +268,7 @@ a pure reducer (`reducer.ts`) plus a React seam (`useWizardMachine.ts`). It's th strongest existing proof that this abstraction earns its keep, and it maps onto the library one-to-one. The live migration test — [`__tests__/wizard-migration.test.tsx`](./__tests__/wizard-migration.test.tsx) — -asserts the two are behaviorally identical. +ports the reducer's test cases to the machine. ### What the wizard does diff --git a/packages/mosaic/src/primitives/README.md b/packages/mosaic/src/primitives/README.md index 675f601c4c7..d5f7868a6c5 100644 --- a/packages/mosaic/src/primitives/README.md +++ b/packages/mosaic/src/primitives/README.md @@ -6,31 +6,18 @@ This is an internal folder (`src/primitives/`) inside `@clerk/mosaic`, not a sep ## Primitives -| Primitive | Location | Description | -| ------------ | ----------------------------- | ------------------------------------------------------------- | -| Accordion | `src/primitives/accordion` | Expandable content sections with single/multiple mode | -| Autocomplete | `src/primitives/autocomplete` | Combobox input with filterable option list | -| Dialog | `src/primitives/dialog` | Modal dialog with focus trapping and scroll lock | -| FileUpload | `src/primitives/file-upload` | File picker + drag-and-drop upload with image previews | -| Menu | `src/primitives/menu` | Dropdown and nested context menus with safe hover zones | -| OTP | `src/primitives/otp` | One-time-password / PIN input split into per-character slots | -| Popover | `src/primitives/popover` | Non-modal floating content triggered by click | -| Select | `src/primitives/select` | Dropdown select with typeahead and keyboard navigation | -| Tabs | `src/primitives/tabs` | Tab navigation with animated indicator | -| TagInput | `src/primitives/tag-input` | Text field that turns entries into removable tags | -| Toast | `src/primitives/toast` | Stacked notifications with timers, a queue, and announcements | -| Tooltip | `src/primitives/tooltip` | Hover/focus tooltip with configurable delay and group support | +One folder per primitive under `src/primitives/` (Dialog, Menu, Select, …); the folders are the list. Shared utilities live at `src/primitives/utils` (includes `useRender` and `mergeProps`). -Each primitive has its own README in `src/primitives//` with full API docs, props tables, keyboard navigation, and data attributes. +Most primitives have their own README in `src/primitives//` with full API docs, props tables, keyboard navigation, and data attributes. ## Usage ```tsx import { Select } from '../../primitives/select'; -; +; ``` All primitives follow the same compound component pattern. They emit zero styles — all visual styling is applied externally via `data-*` attribute selectors. @@ -151,4 +138,4 @@ pnpm --filter @clerk/mosaic build # production build pnpm --filter @clerk/mosaic test # run tests ``` -Tests run under jsdom, via Mosaic's `vitest.config.mts`. +Tests run under happy-dom, in the `primitives` project of Mosaic's `vitest.config.mts`. diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 5f51e1fae97..8e64f297ebb 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -377,11 +377,12 @@ export const durationVars = stylex.defineVars(durationDefaults); // // It belongs on properties that MOVE — transform, translate, scale, insets — where // the overshoot is what makes motion read as physical rather than mechanical, which -// in practice means the `slow`/`slower` end of the duration scale. Color and opacity -// take plain `linear` instead: 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 no gain. That is a rule about the property, -// not the duration — a transform at `fast` still wants this curve. +// in practice means the `slow`/`slower` end of the duration scale. A color or opacity +// change on an element that stays put takes plain `linear` instead: 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 no gain. A fade that brings a +// surface in or out takes `--cl-ease-enter` / `--cl-ease-exit`. That is a rule about the +// property, not the duration — a transform at `fast` still wants this curve. // // `--cl-ease-exit` is its counterpart for things LEAVING, In Quad // (https://www.easing.dev/in-quad). Swift Out run backwards spends 90% of its travel @@ -397,8 +398,8 @@ export const durationVars = stylex.defineVars(durationDefaults); // Use it where the overshoot is read as a correction rather than as physicality — a large // surface, or one whose arrival is already carried by a companion signal such as a scrim. // `Dialog` takes it for that reason; a small element moving a short distance still wants -// `--cl-ease-default`, where the settle is the whole point. Same property rule applies: it -// belongs on things that MOVE, and opacity still takes `linear`. +// `--cl-ease-default`, where the settle is the whole point. It is also the curve for an +// entrance fade, since opacity has nothing past `1` to overshoot into. // // `--cl-ease-in-out` is In Out Cubic (https://www.easing.dev/in-out-cubic), for a layout // change that departs from rest and arrives at rest with nothing entering or leaving — a