diff --git a/.changeset/mosaic-docs-one-home.md b/.changeset/mosaic-docs-one-home.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-docs-one-home.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 5f19c6d836b..b6f35fe9f10 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -48,5 +48,4 @@ relevant. | 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). | | `verify-clerk-expo` | Proving a `@clerk/expo` change on an iOS simulator or Android emulator, local or borrowed on a CI runner, with a video or screenshots as evidence. | diff --git a/AGENTS.md b/AGENTS.md index 71f5385ea0a..3cd4953c8cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,6 @@ The skill is `.claude/skills/verify-clerk-expo/`, and the tests and the CLI that ## References -- For the Mosaic design system (tokens, StyleX authoring, `MosaicProvider`, the model/controller/view split, migration from the existing system), see `packages/mosaic/ARCHITECTURE.md`. Work under `packages/mosaic` also follows `packages/mosaic/AGENTS.md`. +- For the Mosaic design system (tokens, StyleX authoring, `MosaicProvider`, the model/controller/view split, migration from the existing system), start at `packages/mosaic/AGENTS.md`, which routes each task to its guide in `packages/mosaic/docs/`. - For dev setup, testing, JSDoc/Typedoc, publishing, changesets, and commit conventions, see `docs/CONTRIBUTING.md`. - For working in the repo day to day (setup ordering and footguns, the package map, dev-loop recipes, and the breaking-change checklist), the `clerk-monorepo` Claude Code skill in `.claude/skills/clerk-monorepo/` restates these rules in actionable form. diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 6e0243492e7..04fbe3de9e7 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -2,91 +2,103 @@ Mosaic is Clerk's next-generation component library. These rules apply to everything under `packages/mosaic`, on top of the repo-root `AGENTS.md`. -- `ARCHITECTURE.md` (this folder) is the contract: tokens, the `.cl-` + `data-` styling API, the CSS build, and the model → controller → view split. -- `docs/` is the how-to, one file per task: StyleX authoring, motion, headless primitives, each flow layer, testing, and migration. +This file holds the rules every change follows. `docs/` holds one guide per task; each opens with a "Read this when" line that matches its line below. + +Every line under a `## Rules` heading is a requirement. To break one, ask first ([When nothing fits](#when-nothing-fits-ask-first)). "Prefer" marks a default you may leave with a reason in the PR. + +## Principles + +1. **Clerk stops at the model.** Everything past it is plain data and plain functions, so controllers and views are tested without Clerk. +2. **Reuse, then ask.** A shared answer beats a local one, and a missing one is a question for the user, not a one-off. +3. **One owner per concern.** Busy state, error copy, strings, tokens and each rule in these docs have one home; everything else derives from it or links to it. +4. **Test what the user sees.** A flow is tested from FAPI in to the screen out, once, at the boundary that owns the behavior. + +## Scope of a change + +The docs describe the Mosaic we are building toward. Code that differs from them is out of date, not precedent, so do not copy it. + +- The rules apply to everything your change adds or rewrites. +- Code you touch but do not rewrite may predate a rule. Fix it when the fix is small, and name what you left in the PR. +- If code or a test contradicts a rule in these docs, stop and ask which one is right, and fix the loser in the same PR. ## Task guides -Read the guide for the task you are doing. Paths below are relative to this package. - -| Task | Read | -| ----------------------------------------- | --------------------------------------------------------------------------------- | -| Author a headless primitive | [Headless primitives](docs/headless.md) | -| Style a component or change the CSS build | [StyleX](docs/stylex.md) | -| Add or debug motion | [Motion](docs/motion.md) | -| Build a loading skeleton | [Skeletons](docs/skeletons.md) | -| Write a model | [Models](docs/models.md) | -| Write a controller | [Controllers](docs/controllers.md) | -| Author or debug a state machine | [Controllers](docs/controllers.md), then [Machine runtime](src/machine/README.md) | -| Write a view | [Views](docs/views.md) | -| Test a feature | [Testing](docs/testing.md) | -| Migrate legacy behavior | [Migration](docs/migration.md) | -| Review migration parity | [Parity audit](docs/parity-audit.md) | -| Add a public prop that shows or hides UI | [Public visibility props](docs/public-props.md) | -| Document a component in Swingset | [Documenting components](docs/documenting-components.md) | -| Prove a change in a browser | [verify-mosaic](.claude/skills/verify-mosaic/SKILL.md) | +Read every line that matches your task. Paths below are relative to this package. + +- Add a feature, or a file to one → [Features](docs/features.md), then the guide for each layer below. If the repo's `packages/ui` has a legacy version, [Migration](docs/migration.md) first +- Write or change a model, or add a Clerk read, mutation or permission check → [Models](docs/models.md) +- Build a feature from a design before Clerk's API can back it → [Models → Before the API exists](docs/models.md#before-the-api-exists) +- Write or change a controller, or how a feature handles pending state, errors, dialogs or removal focus → [Controllers](docs/controllers.md) +- Write or debug a state machine → [Controllers → Machines](docs/controllers.md#machines), then [Machine runtime](src/machine/README.md) +- Build a flow with several steps on one surface → [Controllers → Multi-step flows](docs/controllers.md#multi-step-flows), then [Views → Moving between screens](docs/views.md#moving-between-screens-on-one-surface) +- Write or change a view, a dialog or step it renders, or a feature's copy → [Views](docs/views.md) +- Add a styled component, or change a token, a `.cl-` class or `data-` attribute, `MosaicProvider`, or the CSS build → [Theming](docs/theming.md) +- Write or change a `*.styles.ts` or `*.stylex.ts` file → [StyleX](docs/stylex.md) +- Add or change a transition, a hover or press color change, or reduced-motion handling → [Motion](docs/motion.md) +- Add or change a loading skeleton → [Skeletons](docs/skeletons.md) +- Write or change a headless primitive under `src/primitives/` → [Headless primitives](docs/headless.md) +- Add or rename a public prop that shows, hides or configures part of a component → [Public visibility props](docs/public-props.md) +- Write, move or review a test → [Testing](docs/testing.md) +- Fix or silence a lint error → [Lint](docs/lint.md) +- Move a legacy component's logic or styles into Mosaic → [Migration](docs/migration.md) +- Check that a migration kept every legacy behavior → [Parity audit](docs/parity-audit.md) +- Add or change a component's Swingset page → [Documenting components](docs/documenting-components.md) +- Prove a change in a browser → [verify-mosaic](.claude/skills/verify-mosaic/SKILL.md) +- Remove or rename a public export, prop, `--cl-*` token or styling hook, or change a public type or default → [Breaking changes](docs/breaking-changes.md) +- Review a Mosaic change → [Reuse before you write](#reuse-before-you-write), [Shaping a fix](#shaping-a-fix), and the lines for what the change touches +- Change these docs, `.claude/rules/` or the verify-mosaic skill → [Writing docs](docs/writing-docs.md) ## Before editing a file Before you change a file that matches a pattern below, read its guide, even for a one-line change. -| File | Read | -| ------------------------------------------------------------------ | ---------------------------------- | -| `src/**/*.styles.ts`, `src/**/*.stylex.ts` | [StyleX](docs/stylex.md) | -| `src/**/*.model.ts(x)` | [Models](docs/models.md) | -| `src/**/*.controller.ts(x)` | [Controllers](docs/controllers.md) | -| `src/**/*.view.tsx` | [Views](docs/views.md) | -| `src/**/__tests__/**`, `src/**/*.test.ts(x)`, `src/**/*.test-d.ts` | [Testing](docs/testing.md) | +- `src/**/*.styles.ts`, `src/**/*.stylex.ts` → [StyleX](docs/stylex.md) +- `src/**/*.model.ts(x)` → [Models](docs/models.md) +- `src/**/*.controller.ts(x)` → [Controllers](docs/controllers.md) +- `src/**/*.view.tsx` → [Views](docs/views.md) +- `src/**/__tests__/**`, `src/**/*.test.ts(x)`, `src/**/*.test-d.ts` → [Testing](docs/testing.md) ## Layers in one paragraph -A flow lives in `src/features//`. The **model** (`*.model.ts(x)`) is the only file that reads Clerk hooks or calls Clerk resources, and answers with plain data, plain callbacks and a `status`. The **controller** (`*.controller.ts(x)`) holds local state and wraps the model's callbacks for pending, errors and closing. The **view** (`*.view.tsx`) renders plain props. No Clerk resource reaches the controller and no machine snapshot reaches the view. +A flow lives in `src/features//`. The **model** (`*.model.ts(x)`) is the only file that reads Clerk hooks or calls Clerk resources, and answers with plain data, plain callbacks and a `status`. The **controller** (`*.controller.ts(x)`) holds local state and wraps the model's callbacks for pending, errors and closing. The **view** (`*.view.tsx`) renders plain props. No Clerk resource reaches the controller and no machine snapshot reaches the view. The file shape and how a wrapper composes the layers are in [Features](docs/features.md). ## Reuse before you write -Most problems a feature hits already have a shared answer. Find the row for your problem and use it. Do not write a local version, even a small one. - -| Problem | Use | -| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) | -| A form: values, validation, submit, field errors, success UI | `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI | -| A `
` element, without the browser's validation popup | `Form` (`src/primitives/form/`) | -| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` | -| A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) | -| A delete that needs the user to type a phrase | `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) | -| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`) in the model, passed to `useConfirmController` | -| A direct action that needs step-up verification | `useReverificationFlow` in the model, with `ReverificationDialog` (`src/blocks/reverification/`) in the view | -| Showing any error to the user | One of the owners above. They call `toLocalizableError` (or `toLocalizableErrors` for all errors) and resolve copy with `useErrorText` | -| Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) | -| A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | -| A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) | -| A placeholder for content that is still loading | `Skeleton` (`src/components/skeleton/`), see [Skeletons](docs/skeletons.md) | -| A date shown to the user | `formatDate` (`src/utils/format-date.ts`) | -| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) | -| Any current time read during render | `useNow` (`src/hooks/`), with `until` to stop ticking at a deadline | -| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | -| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` | -| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) | -| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | -| An organization permission checked with `checkAuthorization` | `ORGANIZATION_PERMISSIONS` (`src/utils/permissions.ts`) | -| A verification code's length or resend cooldown | `CODE_LENGTH`, `CODE_RESEND_SECONDS`, `resendUnlockTime`, `isResendUnlocked` (`src/utils/verification.ts`) | -| The seconds left on a resend cooldown, for display | `useResendSeconds` (`src/hooks/`), from a `resendAvailableAt` time kept in the machine | -| Backup codes saved to a file or printed | `downloadBackupCodes`, `printBackupCodes` (`src/utils/backup-codes.ts`) | -| An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) | -| An avatar or logo row: picker, drop target, Upload button or manage menu | `AvatarUpload` (`src/blocks/avatar-upload/`), laid out by the caller | -| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | -| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) | -| Arrow-key navigation with one tab stop across a group of items | `useComposite`, with `useCompositeItems` and `useCompositeItem` (`src/primitives/hooks/`) | -| A panel that animates open and closed to its measured size | `usePanelSize` (`src/primitives/hooks/`) | -| One screen replacing another on the same card or dialog | `Flow` (`src/components/flow/`), see [Views](docs/views.md) | -| A form on a card or dialog step | `useForm` in the controller, passed to the view as `form`, see [Views](docs/views.md) | -| Deciding which part renders `Card.Root` | Only the surface, never a screen or `*Steps`, see [Views](docs/views.md) | -| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) | -| Styles several components share | `src/styles/*.styles.ts` | +Most problems a feature hits already have a shared answer. Find the line for your problem and use it. Do not write a local version, even a small one. A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates this list, and any doc that names it, in the same PR. + +- An async action from a button or row: pending, an error, one lock → `usePendingAction` (`src/hooks/`) +- A form: values, validation, submit, field errors, success UI → `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI +- A `` element, without the browser's validation popup → `Form` (`src/primitives/form/`) +- A model save whose Clerk errors should land on form fields → `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` +- A confirm step before an action → `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) +- A delete that needs the user to type a phrase → `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) +- An action that may need step-up verification → `useReverificationFlow` (`src/features/reverification/`) in the model, passed to `useConfirmController` +- A direct action that needs step-up verification → `useReverificationFlow` in the model, with `ReverificationDialog` (`src/blocks/reverification/`) in the view +- Showing any error to the user → One of the owners above. They call `toLocalizableError` (or `toLocalizableErrors` for all errors) and resolve copy with `useErrorText` +- Moving focus after a list row is removed → `useListRemovalFocus` (`src/hooks/`) +- A debounced, abortable async check as the user types → `useDebouncedAsync` (`src/hooks/`) +- A spinner that should not flash or flicker → `useSpinDelay` (`src/hooks/`) +- A placeholder for content that is still loading → `Skeleton` (`src/components/skeleton/`), see [Skeletons](docs/skeletons.md) +- A date shown to the user → `formatDate` (`src/utils/format-date.ts`) +- Any current time read during render → `useNow` (`src/hooks/`), with `until` to stop ticking at a deadline +- An inline message row; height is undefined until measured → `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` +- Data read from a Clerk resource that should be cached across mounts → A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) +- The seconds left on a resend cooldown, for display → `useResendSeconds` (`src/hooks/`), from a `resendAvailableAt` time kept in the machine +- An avatar or logo row: picker, drop target, Upload button or manage menu → `AvatarUpload` (`src/blocks/avatar-upload/`), laid out by the caller +- Any other file picker or drop zone, such as a metadata or key file → `FileUpload` (`src/primitives/file-upload/`), see its `README.md` +- An inline notice inside content: info, warning, error or success → `Banner` (`src/components/banner/`); a form-level error on a card uses `Card.Banner` +- Siblings that slide when an item is added or removed → `useLayoutAnimation` (`src/primitives/hooks/`) +- An input that reformats as the user types without moving the caret → `useFormattedInput` (`src/primitives/hooks/`) +- Arrow-key navigation with one tab stop across a group of items → `useComposite`, with `useCompositeItems` and `useCompositeItem` (`src/primitives/hooks/`) +- A panel that animates open and closed to its measured size → `usePanelSize` (`src/primitives/hooks/`) +- One screen replacing another on the same card or dialog → `Flow` (`src/components/flow/`), see [Views](docs/views.md) +- An interaction with an async lifecycle or two values that move together → A machine in the controller file, via `setup()` (`src/machine/`) +- Styles several components share → `src/styles/*.styles.ts` +- Anything else small: a permission, verification codes, backup codes, avatar files, a list patch → List `src/utils/`, `src/hooks/` and `src/primitives/hooks/` first; most of these already have a helper ## Signs you are about to invent a pattern -Stop and check the table if you are writing any of these in a feature: +Stop and check the list above if you are writing any of these in a feature: - `try`/`catch` around a Clerk call in a controller or view - `useState` for a pending flag, or for "which row is busy" @@ -100,42 +112,9 @@ Stop and check the table if you are writing any of these in a feature: The form rule applies when you add or rewrite a form. Some existing forms predate it. -## Shaping a fix - -A bug in a feature is often a gap in a shared helper. These rules apply to `fix` PRs as much as to features. - -- **Fix the owner, don't wrap it.** When a shared helper (`src/hooks/`, `src/blocks/`, `src/components/`, `src/machine/`, or `@clerk/shared`) behaves wrongly, change the helper and its tests, then delete the workarounds. Do not spread a helper's return value to swap one method, copy a `||` that undoes one of its fields, or add a second hook beside it for one caller. -- **A changed shared signature is a new pattern.** A new parameter, a return type that goes from `void` to a promise, or a returned field whose meaning changes all go through "When nothing fits: ask first". -- **One source of truth for busy.** When a machine, `usePendingAction` or `useForm` already knows an action is running, derive the flag from it: `isDisabled: actions.isPending || remove.isPending`. Do not mirror it into a second lock, or turn machine state into a promise to feed one. -- **Handle staleness through lifecycle.** A new identity remounts through a React `key`. A busy state ignores close (Controllers → Rules). Do not thread run ids or `AbortSignal`s through the model, controller and machine. -- **Extend a contract with an options object.** Make a field required when every caller has it: `connect(strategy, { walletName, onTarget })`, not `connect(strategy, walletName?, onTarget?)`. -- **Pass shared results through whole.** The view gets `form`, not an object that renames `form.id`, `form.isSubmitting` and `form.handleSubmit`. -- **Keep controller state off model types.** Pending and error flags the controller sets go on a row type the controller builds, not as optional fields on the type the model returns. -- **Narrow by mounting.** A hook that needs loaded data takes it non-null. Mount its component once the model is ready instead of checking for `null` inside (Controllers → Composition and dialog lifetime). -- **Machines:** `assign` stays pure. An effect the machine starts runs in `invoke`. The runtime has no spawned actors, so effects that run side by side, like `useForm`'s per-field `validateAsync`, run in the hook and report back by event, and the machine drops stale results. A phase of an action the user can see (queued, pending, failed) is a named state, not a context field plus a predicate. Screens that can sit on top of any state, like reverification's method picker and help, may be a stack in context, because the runtime has no parallel or history states. - -If code or a test contradicts a rule in these docs, do not copy the code. Stop and ask which one is right, and fix the loser in the same PR. - -## Nested radii - -A part inset from a rounded surface gets its radius from that surface: `max(0px, calc(outer - border - padding))`. Do not pick a fixed step, or it stops matching when `--cl-radius-*` or `--cl-spacing` is overridden. This applies to small insets. When the inset is about as large as the outer radius or larger (Card content, TagInput tags), the part keeps its own radius. - -- **Same component:** declare the outer radius, border and padding as local consts, and use them in both the surface and the calc, so changing any of them updates the inner radius (`components/select/select.styles.ts`). -- **Different component:** the parent computes the radius from the same consts and sets a `--_cl-*-radius` var, and the child reads it with a fallback (`--_cl-button-radius` in `components/action-bar/action-bar.styles.ts` and `components/button/button.styles.ts`). - -Do not add a shared `concentric()` helper or a global surface-radius var. A global var inherits into nested surfaces and gives them the wrong value. - -## Readability before complexity scores - -Do not extract a helper or introduce a machine only to reduce a lint score. -Avoid turning a component into a hook solely to work around React's children -typing. An abstraction must clarify a responsibility or hide useful complexity. When complexity is inherent, keep the relevant logic -together and explain the tradeoff in review. Follow repository rules for any lint -exception; this guidance does not authorize a suppression. - ## When nothing fits: ask first -If no row fits, or a shared helper almost fits but would need a change, **stop and ask the user before writing code**. Say: +If no line fits, or a shared helper almost fits but would need a change, **stop and ask the user before writing code**. Say: 1. the problem, in one sentence 2. which shared helpers you checked and why each does not fit @@ -143,44 +122,15 @@ If no row fits, or a shared helper almost fits but would need a change, **stop a Do not solve it locally and mention it at the end. A one-off that duplicates a shared helper is the thing this rule exists to prevent. -## Keep the docs true - -Docs say what the code cannot: rules, reasons, and which tool fits which problem. For facts the code already holds (lists of exports, tokens, builders or files, and signatures), name the file and give one example. Do not copy the list; a copy goes stale and agents trust it. - -A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates the table above, and any doc that names it, in the same PR. - -## Breaking changes - -While `@clerk/mosaic` is on 0.x, a breaking change is a `minor` changeset. Start its body with `**Breaking:**`, then give an old-to-new table and a search that finds affected usage, so an agent can migrate from the changelog alone. The changelog ships in the npm package at `node_modules/@clerk/mosaic/CHANGELOG.md`. - -```md ---- -'@clerk/mosaic': minor ---- - -**Breaking:** Rename a `UserButton` prop. Values and defaults are unchanged; TypeScript flags the old name. - -| Before | After | -| -------------------- | ------------------ | -| `renderTriggerLabel` | `showTriggerLabel` | - -Find usages with `git grep -n renderTriggerLabel`. -``` - -## Lint - -Mosaic uses Oxlint and runs stricter lint rules than the rest of the repo. Lint with `pnpm lint` from `packages/mosaic`. - -Every disable directive in `src` needs a `-- reason`. The reason is not a code comment, so the root no-comments rule does not apply. Write why the lint rule does not apply here, not what the code does; it is what a reviewer judges. - -### When `sonarjs/cognitive-complexity` fires - -The limit flags code worth a second look. It is not a score to game. +## Shaping a fix -1. Simplify in place first: return early, replace branch chains with a lookup table, remove nesting. -2. Extract a helper only when it names a real concept a reader would look for. Do not split a function just to lower the score. Logic spread across helpers that only make sense together is harder to follow than one longer function. -3. If the complexity is inherent and the function reads best as one unit, disable the rule on that function with a reason: +A bug in a feature is often a gap in a shared helper. These rules apply to `fix` PRs as much as to features. - ```ts - // eslint-disable-next-line sonarjs/cognitive-complexity -- One pass over the keyboard event; each branch handles a separate key. - ``` +- **Fix the owner, don't wrap it.** When a shared helper (`src/hooks/`, `src/blocks/`, `src/components/`, `src/machine/`, or `@clerk/shared`) behaves wrongly, change the helper and its tests, then delete the workarounds. Do not spread a helper's return value to swap one method, copy a `||` that undoes one of its fields, or add a second hook beside it for one caller. +- **A changed shared signature is a new pattern.** A new parameter, a return type that goes from `void` to a promise, or a returned field whose meaning changes all go through "When nothing fits: ask first". +- **One source of truth for busy.** When a machine, `usePendingAction` or `useForm` already knows an action is running, derive the flag from it: `isDisabled: actions.isPending || remove.isPending`. Do not mirror it into a second lock, or turn machine state into a promise to feed one. +- **Handle staleness through lifecycle.** A new identity remounts through a React `key`. A busy action keeps running when its surface closes (Controllers → Rules). Do not thread run ids or `AbortSignal`s through the model, controller and machine. +- **Extend a contract with an options object.** Make a field required when every caller has it: `connect(strategy, { walletName, onTarget })`, not `connect(strategy, walletName?, onTarget?)`. +- **Pass shared results through whole.** The view gets `form`, not an object that renames `form.id`, `form.isSubmitting` and `form.handleSubmit`. +- **Keep controller state off model types.** Pending and error flags the controller sets go on a row type the controller builds, not as optional fields on the type the model returns. +- **Narrowing and machines** follow [Controllers → Composition](docs/controllers.md#composition-and-dialog-lifetime) and [Controllers → Machines](docs/controllers.md#machines). diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md deleted file mode 100644 index a743247fff0..00000000000 --- a/packages/mosaic/ARCHITECTURE.md +++ /dev/null @@ -1,592 +0,0 @@ -# Mosaic Design System Architecture - -## Overview - -Mosaic is the next-generation design system for Clerk's UI components, replacing the existing styled system. Both systems coexist during migration — Mosaic ships as `@clerk/mosaic` from `packages/mosaic`, independently of the Emotion-based `@clerk/ui` package. - -Mosaic components are authored with **StyleX** (`stylex.create` plus the `themeProps` / `mergeStyleProps` helpers in `props.ts`). StyleX compiles the styles out to a static stylesheet at build time, so Mosaic ships no CSS-in-JS runtime and does not use Emotion. (Swingset's dev server is the one exception: it enables StyleX's `runtimeInjection` so edits hot-reload.) - -The public styling contract is a **stable per-slot class** plus **`data-` attributes**: a part emits `class="cl-"` for its identity (`.cl-button`, `.cl-item`) and `data-=""` / presence `data-` for its variants and state. Consumers target those, never StyleX's hashed atoms; tokens ship as overridable `--cl-*` custom properties. - -Once migration is complete, the old system is removed and Mosaic becomes the sole design system. - -## Token architecture - -Tokens are CSS custom properties declared through `stylex.defineVars` in `tokens.stylex.ts`. Each var is named explicitly (`'--cl-color-brand'`, not a generated hash) so it is a stable, documented handle a consumer can override: - -```ts -// packages/mosaic/src/tokens.stylex.ts -export const colorVars = stylex.defineVars({ - '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', - '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', - // … -}); -``` - -Each exported `*Vars` const in that file is one group (`colorVars`, `radiusVars`, …); the file is the list. `space` is also declared with `defineVars`, but its keys are not `--cl-*` names, so its steps compile to hashed custom properties that are internal, not part of the contract. - -There is no gray scale. Every achromatic token (`--cl-color-foreground`, `-border`, `-surface-subtle`, …) is `--cl-color-neutral` (black on light, white on dark) mixed into `--cl-color-surface` at a fixed percentage per mode, so overriding neutral to a warm gray retints the whole ramp. The exception is the borders: `--cl-color-border` (controls) and `--cl-color-border-subtle` (containers and dividers) are neutral washes, 12 and 8 percent in both modes, so they keep the same step over the surface and the faded surface alike. The `--cl--alpha-100|200|300` tokens in `colorVars` are washes (6, 8, and 12 percent of `--cl-color-neutral`, `-negative`, `-positive`, and `-warning` over `transparent`); overriding the solid color retints its washes. - -The seeds are `--cl-color-page`, `--cl-color-neutral`, `--cl-color-brand`, `--cl-color-negative`, `--cl-color-positive` and `--cl-color-warning`. Brand and the three statuses share one shape: the seed, `-foreground` (white in light mode; in dark mode the surface color for brand, positive and warning, where the fills are light, and white for negative), `-subtle` (the 8 percent wash, 12 in dark mode) and `-border` (the 12 percent wash), so both work over the page, the surface or a faded fill and `-alpha-100|200|300` washes. Neutral has the same slots under its own names: `--cl-color-surface-subtle`, `--cl-color-neutral-subtle` (the same wash as the accent `-subtle`, for chips such as the neutral badge) and `--cl-color-border`. Only the seeds and the `-foreground` pairs are set by hand, so retinting a seed retints its companions. - -Three tiers carry the layering. `--cl-color-page` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.16 0 0)` in dark mode, white in light mode). `--cl-color-surface` is everything raised on it (cards, sections, menus, popovers, dialogs, controls) and the base the gray ramp mixes into. `--cl-color-surface-subtle` is the faded step on a surface, for footers, tiles and striped rows. The surface is derived, not set: in dark mode it is `--cl-color-neutral` mixed 5 percent into `--cl-color-page`, and in light mode it is white mixed 60 percent into `--cl-color-page`, so it is exactly white over a white page and still reads as near white over a gray one. So retinting `--cl-color-page` retints the shell, the surface and the whole ramp together; override `--cl-color-surface` directly only to change how far cards sit above the shell. All three are solid, never alpha, so they look the same over any backdrop. - -Light and dark come from CSS `light-dark()` on the default values, so there is no theme object and no re-render on theme change — the browser resolves it. - -`@stylexjs/enforce-extension` requires a `.stylex.ts` file to export nothing but StyleX define-primitives (`defineVars`, `defineConsts`, `defineMarker`), so the derived token-name unions (`ColorVarName`, `RadiusVarName`, …) live in `styles/index.ts` as `keyof typeof colorVars`. - -## Public styling API - -Every Mosaic part carries a **stable class** and reflects its variants and state as **data attributes** — no hashed classnames or registry keys to learn. `themeProps(slot, variants)` (in `props.ts`) emits: - -- `class="cl-"` — the slot identity (`.cl-button`, `.cl-item`, `.cl-item-label`) -- `data-=""` — the resolved variant (`data-variant="outline"`, `data-size="sm"`) -- `data-` — boolean state or boolean variant, presence-only (`data-interactive=""`); omitted when the value is false/null - -Consumers target the class and its attributes, never StyleX's hashed `x…` atoms. - -A theme styles a part from CSS, against that class and its attributes: - -```css -.cl-button { - border-radius: 8px; -} -.cl-button[data-size='sm'] { - border-radius: 4px; -} -.cl-item[data-interactive] { - background-color: var(--cl-color-surface); -} -``` - -A part exposes no `className` or `style` prop. Inside `@clerk/mosaic`, code that renders a part and needs to nudge it passes StyleX atoms through `xstyle`, declared in its own `*.styles.ts` file and composed last so they win: - -```ts -// reverification.styles.ts -export const styles = stylex.create({ helpText: { textAlign: 'center' } }); -``` - -```tsx -import { styles } from './reverification.styles'; - - - {helpText} -; -``` - -Tokens are a second, independent lever: every `--cl-*` custom property (`--cl-color-*`, `--cl-radius-*`, `--cl-font-family-sans`, `--cl-spacing`) can be overridden in plain CSS at `:root` or any scope to re-theme without touching a component. - -State styling uses real class + attribute-selector specificity — no `&&` boost, no data-attr-vs-class ambiguity. - -## MosaicProvider - -Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for four things: per-name icon glyph overrides, localization, the reading direction keyboard navigation follows (it mounts `DirectionProvider`), and the toast region (it mounts `ToastProvider`). - -```tsx -import { MosaicProvider } from './mosaic-provider'; - - }}>{children}; -``` - -`` looks the override up by the glyph's `iconName`, renders it in place of the built-in glyph and applies Mosaic's own sizing props to it, so a swapped glyph stays visually consistent with the rest. Outside a provider, `useMosaicIcons()` returns `{}` and every icon renders the glyph it was passed. Callers import each glyph directly so the bundle carries only the glyphs in use; `iconRegistry` holds every glyph for catalogs and tests and must not be imported from shipped code. - -## Component authoring pattern - -Styles are declared once per component with `stylex.create`, keyed off the same axes the component exposes as props, then fused with `themeProps` output and the props the component was called with: - -Declare the styles in `src/components/button/button.styles.ts`: - -```ts -export const styles = stylex.create({ - base: { display: 'inline-flex', borderRadius: radiusVars['--cl-radius-md'] }, -}); - -// `color` and `variant` are independent props, but every pair resolves to one style, so they are -// keyed compositely rather than merged at render time. -export const variants = stylex.create({ - 'filled-primary': { backgroundColor: colorVars['--cl-color-brand'], borderColor: 'transparent' }, - 'filled-negative': { backgroundColor: colorVars['--cl-color-negative'], borderColor: 'transparent' }, - 'outline-primary': { backgroundColor: 'transparent', borderColor: colorVars['--cl-color-brand'] }, - 'outline-negative': { backgroundColor: 'transparent', borderColor: colorVars['--cl-color-negative'] }, -}); -``` - -Import them in `src/components/button/button.tsx`: - -```tsx -import { styles, variants } from './button.styles'; - -export const Button = React.forwardRef(function Button( - { color = 'primary', variant = 'filled', size = 'md', disabled, xstyle, ...rest }, - ref, -) { - const props = mergeStyleProps( - themeProps('button', { color, variant, size, disabled }), - stylex.props(reset.base, styles.base, variants[`${variant}-${color}`], xstyle), - rest, - ); - return ( - - - {messages.save} - - - - ); -} -``` +The controller owns the form with `useForm`, including what `canSubmit` allows, and passes `form` to the view whole. The view does not check values, call `preventDefault` or create the form's id. Worked example: `organization-profile-edit-name.dialog.tsx`. -- **Errors:** a form-level error goes in `Card.Banner`, and a field error in - `Field.Feedback`. -- **Focus:** `initialFocus` on `Dialog.Popup` covers the step a dialog opens on, and - `useFlowAutoFocus` covers each step entered after it. A field that can be either uses - both. -- **Closing while pending:** the controller's `onOpenChange` ignores the close, and the - view does not guard it again. -- **No values to submit:** a step with no values does not need `useForm` - (`CopyKeyStep` uses `Form` only so Enter copies the key). +- **Wiring:** `Card.Content render={}` holds the fields, and `Card.Footer` holds the cancel `Button` and a `SubmitButton form={form.id}` with `isPending={form.isSubmitting}`, `disabled={!form.canSubmit}` and `focusableWhenDisabled`. +- **Fields:** `Field.Root` takes `disabled={form.isSubmitting}` and `invalid` from the field's feedback, and the input spreads `form.register(name)`. +- **Errors:** a form-level error goes in `Card.Banner role='alert'`, and a field error in `Field.Feedback`. +- **Focus:** `initialFocus` on `Dialog.Popup` covers the step a dialog opens on, and `useFlowAutoFocus` covers each step entered after it. A field that can be either uses both. +- **Closing while pending:** the controller's `onOpenChange` ignores the close, and the view does not guard it again. +- **No values to submit:** a step with no values does not need `useForm` (`CopyKeyStep` uses `Form` only so Enter copies the key). ## Where the strings live -Every string a view renders comes from the feature's `*.messages.ts`, shaped the -way `@clerk/i18n` takes a base definition. A plural message is its forms; a -parameterized one is its template. +Each feature's strings are one `Messages` object declared `as const` in its `*.messages.ts`, shaped the way `@clerk/i18n` takes a base definition: a plural message is its forms, a parameterized one is its template. To add copy for a new section, step or dialog: -1. Put the strings in the `*.messages.ts` next to the view that renders them. Name - each key for what the string is (`label`, `resend`, `confirm`), not where it - sits in the markup. -2. Mount the object in `src/localization/registry.ts` under the public component - the user sees it in: a section of `UserProfile` goes at `userProfile.
`, - and a dialog inside it at `userProfile.
.addDialog` or `.removeDialog`. - Copy two views share goes under the component that renders it, never under a - new `common` root. -3. Read it with `useMessages` and the root, in the view or controller that - renders it: +1. Put the strings in the `*.messages.ts` next to the view that renders them. Name each key for what the string is (`label`, `resend`, `confirm`), not where it sits in the markup. +2. Mount the object in `src/localization/registry.ts` under the public component the user sees it in (see the key tree below). +3. Read it inside the component that renders it, with `useMessages` and the root: `const m = useMessages('userProfile').mfa;`. A module-level helper takes the messages as a parameter rather than importing them. -```tsx -const m = useMessages('userProfile').mfa; -``` +### Message keys are public + +`MosaicCatalog` is derived from the registry, so a consumer's overrides type-check against the real keys, as a dot path (`'userButton.popup.label'`) or a nested object. Renaming or moving a key breaks every override that names it; treat it like renaming a prop. -The rules behind these steps are in [ARCHITECTURE.md](../ARCHITECTURE.md), and -`src/localization/__tests__/registry.test.ts` fails on a key that is not camelCase. +The key tree follows what the user sees, so a key can be found from the screen: -Never import a `*.messages.ts` directly. A direct import skips the customer's -locale and overrides, so the copy can't be localized. +- A root is a public component (`userProfile`) or a shared block (`form`, `errors`), never a file name. There is no `common` root: the same English in two components is two keys, because a translation may need to differ by context. +- Below the root, keys nest by section, then by the dialog or step inside it: `userProfile.email.addDialog`, `userProfile.email.removeDialog`. +- Copy that several views render through one shared component belongs to that component (`userProfile.contact` for the row email and phone share). +- Every segment is camelCase. A map keyed by data ids (`errors`, `roles`, `userProfile.mfa.methods`) keeps the ids as its keys. `src/localization/__tests__/registry.test.ts` lists those maps and checks the rest. + +### Resolving a string + +`MosaicProvider` takes `localization={{ locale, messages, overrides }}`: `messages` is the catalog for `locale`, `overrides` is the app's own changes on top, and English sits under both. `locale` feeds `Intl.PluralRules` and `Intl.NumberFormat` in the string helpers, and `useLocale()` exposes it to feature code that formats dates and relative times (`user-profile-active-devices-section.model.ts`). + +Resolve strings with the helpers in `src/localization/messages.ts`, never with `.replace` or `.split`: + +- `fill(m.trigger.open, { name })` substitutes `{name}` placeholders. +- `plural(m.members, count, locale)` picks a form with `Intl.PluralRules` for the `locale` from `useLocale()` and fills `{count}`. +- `rich(m.description, { values, components })` renders `{#strong}…{/strong}` markup through the component given for that tag, so the sentence stays in the messages file. Markup is for inline emphasis a translator may keep or move. A wrapper the view needs for its own purposes, such as a countdown span, goes in `values` as an element, so the string only sees `{seconds}`. + +Each helper is typed from the string: `fill` accepts exactly its placeholders, `rich` exactly its tags, and `plural` whatever the forms name besides `count`. ## Blocks -A **block** is a view fragment that owns one piece of state nothing outside it can -use, and takes the rest as props. There are three: +A block is a view fragment that owns one piece of state nothing outside it can use, and takes the rest as props: -- `blocks/destructive` (`Destructive`) holds the half-typed confirmation phrase - and compares it. +- `blocks/destructive` (`Destructive`) holds the half-typed confirmation phrase and compares it. - `blocks/confirmation` (`Confirmation`) is a confirm step with no typed phrase. -- `blocks/avatar-upload` (`AvatarUpload`) is an avatar or logo picker. Its parts - share the file input, so the caller places them in its own layout. +- `blocks/avatar-upload` (`AvatarUpload`) is an avatar or logo picker. Its parts share the file input, so the caller places them in its own layout. -Both take a `controller` from `useConfirmController` (`blocks/confirm/`), which -decides whether the dialog closes or explains itself. The hook runs in the -wrapper or controller, never in the view; the view receives the controller as a -prop. A dialog next to its button takes the button as `trigger`: +`Destructive` and `Confirmation` take a `controller` from `useConfirmController` (`blocks/confirm/`), which decides whether the dialog closes or explains itself. The hook runs in the wrapper or controller, never in the view. A dialog next to its button takes the button as `trigger`: ```tsx ``` -When one dialog serves every row in a list, the controller carries the row as -its payload. Mount the block once after the list, open it from a row with -`controller.open(row)`, and derive the copy from the payload. `onConfirm` in the -wrapper receives the same row. See "Removing a row takes focus with it" in -`controllers.md` for the focus hand-off that comes with a removal: +When one dialog serves every row in a list, the controller carries the row as its payload. Mount the block once after the list, open it from a row with `controller.open(row)`, and derive the copy from the payload; `onConfirm` in the wrapper receives the same row: ```tsx remove.open(row) }]} /> @@ -250,23 +138,8 @@ wrapper receives the same row. See "Removing a row takes focus with it" in /> ``` -## Pure derivation belongs beside the view, not in it - -Which affordance lands in which slot, and how a consumer's `order` array -rearranges a list, are decisions with no React in them. They live in -`*.layout.ts` / `*.utils.ts` and get their own tests — the view calls the result. - -## Removing a row takes focus with it - -When a confirmed action removes the row its trigger sits on, focus has to be -handed to a surviving element. The bookkeeping lives in the controller, with -`useListRemovalFocus`; see `controllers.md`. The view's part is to register each -row's trigger with the `registerTrigger` it is given and pass `finalFocus` to the -block. +When the confirmed action removes the row its trigger sits on, the view registers each row's trigger with the `registerTrigger` it is given and passes `finalFocus` to the block. The rest is in [Controllers → Removing a row takes focus with it](controllers.md#removing-a-row-takes-focus-with-it). ## Testing -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 `packages/mosaic/ARCHITECTURE.md` → "Views" for the layer contract. +The feature test covers the view's behavior (roles, labels, focus). Its visual states are Swingset stories with plain props ([Testing](testing.md)). diff --git a/packages/mosaic/docs/writing-docs.md b/packages/mosaic/docs/writing-docs.md new file mode 100644 index 00000000000..0f46e3b73b7 --- /dev/null +++ b/packages/mosaic/docs/writing-docs.md @@ -0,0 +1,14 @@ +# Writing docs + +Read this when you change `AGENTS.md`, a guide in `docs/`, a path rule in `.claude/rules/`, or the verify-mosaic skill. It covers where a rule lives and how to check a docs change. + +Worked example: [Motion](motion.md), which opens with its "Read this when" line, names its worked examples, lists its rules, then gives the how-to. + +## Rules + +- **One home per rule.** `AGENTS.md` holds the rules every change follows, and one guide in `docs/` holds each task. Edit the owning section and link to it from elsewhere; do not restate it. +- **Docs say what the code cannot:** rules, reasons, and which tool fits which problem. For facts the code already holds (lists of exports, tokens, builders or files, and signatures), name the file and give one example. Do not copy the list; a copy goes stale and agents trust it. +- **Keep each guide's shape:** a `Read this when …` line that names the files and tasks it covers, a worked example, `## Rules`, then the how-to. When the line changes, change its line in [Task guides](../AGENTS.md#task-guides) to match. +- **Check every link, anchor and source path you touched**, and that each path rule in `.claude/rules/` still names a guide that exists. +- **Compare the removed text with where it went**, so no rule is lost in a move. +- **Walk two unlike real tasks through the new text**, such as a styling fix and a new feature section, and check each reaches every rule it needs.