diff --git a/.changeset/mosaic-docs-model-before-api.md b/.changeset/mosaic-docs-model-before-api.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-docs-model-before-api.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 5cdb17f0017..310ff0d4cf2 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -73,6 +73,8 @@ Most problems a feature hits already have a shared answer. Find the row for your | 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 | +| Any other file picker or drop zone, e.g. 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/`) | diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index f07c2c0cf8e..fa29b017566 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -191,6 +191,56 @@ Context holds the `ErrorDescription`, never copy (see "Errors"). A machine whose surface shows no error still logs it, so a failure is never silent (`features/switcher/switcher.controller.tsx`). +### Multi-step flows + +A flow with several steps on one surface is one machine and one form. No existing +flow follows every rule below yet, so each points at the closest example. + +- **The machine owns the step.** Each step is a state, and the controller derives + the step the view shows from the snapshot, so an async state maps to the screen + that stays up while it runs (`viewStep` in `reverification.controller.ts`). No + `useState` step beside it, and no separate open flag. +- **Guards live in the machine.** What allows a step to continue is a guard on its + event. The controller reads it with `actor.can(event)` rather than writing a + `canContinue` that repeats it. +- **Async work is a named state with `invoke`.** Testing a connection is a + `testing` state, not a `pending` field in context and not a call in a transition + action. Its result lands in context from `onDone`, beside the state that waited + for it, not in a `usePendingAction` flag (`starting` and `submitting` in + `reverification.controller.ts`). +- **One `useForm` holds every step's values.** Its `onSubmit` is the last step's + save, and `canSubmit` covers only that save (`create-organization.controller.ts`). + Values are not copied into machine context with `TYPE` events. +- **Navigation is machine events.** Continue and back send `NEXT` and `BACK` with + the values the guard needs, and the machine chooses the next step. The + controller routes the form's submit by step: a middle step sends `NEXT`, and + only the last step runs `form.handleSubmit`, so Enter never saves early. + +```ts +const [snapshot, send, actor] = useMachine(machine, { context: { testConnection: model.onTestConnection } }); +const form = useForm({ initialValues, onSubmit: values => model.onSave(values) }); +const next = { type: 'NEXT', values: form.values } as const; + +return { + step: viewStep(snapshot.value), + form, + isTesting: snapshot.value === 'testing', + canContinue: actor.can(next), + onSubmit: event => { + if (snapshot.value === 'review') { + form.handleSubmit(event); + return; + } + event.preventDefault(); + send(next); + }, + onBack: () => send({ type: 'BACK' }), +}; +``` + +The view side, one `Flow` and a `SubmitButton` footer, is in `views.md` → +"Moving between screens on one surface". + ## Responsibilities - **Hold the interaction state.** In React state or a machine — see "Which one diff --git a/packages/mosaic/docs/models.md b/packages/mosaic/docs/models.md index 4fca659dae8..a8d02f6ac57 100644 --- a/packages/mosaic/docs/models.md +++ b/packages/mosaic/docs/models.md @@ -135,6 +135,45 @@ drops the fallback instead of holding the space open. Keep the two apart. query and returns to page 1). The controller keeps the interaction around it, such as the input's value and the debounce, and calls that callback. +## Before the API exists + +A feature built from a design before Clerk can back it still starts with its +model. Write the model's return type first, the same shape a real model returns: +a `status`, plain data and callbacks. The model hook comes when the API does. + +- **`Pick` what Clerk already has.** Search `@clerk/shared/types` before writing a + field. A new design often needs types Clerk already has, such as + `OrganizationEnrollmentMode` or `EnterpriseConnectionResource`. +- **Name the rest for the resource it will become.** A type Clerk does not have + yet is named for that future resource (`EnterpriseConnectionTest`), not for the + screen (`AccessData`). When the resource lands, the type becomes a `Pick`. +- **The swingset fixture implements that type.** It builds a ready model and + passes it to the feature's real controller and view + (`CreateOrganizationReadyModel` in + `packages/swingset/src/stories/create-organization.stories.tsx`). +- **Composition stays in Mosaic.** The wrapper, and whatever it renders around + the view (a title, a back action, a footer), lives in the feature. Swingset + supplies data and nothing else, because code there has no feature test and is + thrown away when the model lands. + +```ts +export type SsoConnection = Pick; + +export interface EnterpriseConnectionTest { + success: boolean; + email?: string; +} + +export type SsoModel = + | { status: 'loading' } + | { + status: 'ready'; + connections: SsoConnection[]; + enrollmentMode: OrganizationEnrollmentMode; + onTestConnection: (connectionId: string) => Promise; + }; +``` + ## Time-dependent display data Use the shared `useNow` hook and `MosaicProvider` clock support introduced in diff --git a/packages/mosaic/docs/views.md b/packages/mosaic/docs/views.md index 3025faadcaf..80a30dbd5db 100644 --- a/packages/mosaic/docs/views.md +++ b/packages/mosaic/docs/views.md @@ -73,13 +73,20 @@ return makes the new screen pop in instead. - **The surface sits outside the flow, and a step renders only what goes inside it.** A screen used as a step of another feature cannot bring its own `Card.Root`. `ConfirmShell` owns the card and `Reverification` renders only its contents. -- **Nest a flow when a step has steps of its own.** The outer flow keeps its step - mounted, and `useFlowAutoFocus` reaches the inner active step. +- **One flow per surface.** A step that has steps of its own adds them to the same + flow, so the view renders one `Flow.Root`. - **The controller owns the step.** Going back is direction `-1`. Derive it from the step when each step has one way in, and hold it in the controller when it does not (`reverification.controller.ts`). - **Mark what takes focus with `useFlowAutoFocus`.** Focus moves only for a step that transitions in, so a flow that opens on a step does not take focus. +- **A multi-step flow gets derived props.** The view renders the `step`, `form` + and flags the controller passes and does not work out the step itself. Every + step's `Form` uses `id={form.id}` and the controller's `onSubmit`, and its + footer action is a `SubmitButton` with `form={form.id}`. A middle step sets + `isPending={isTesting}` and `disabled={!canContinue}`, and the last sets + `isPending={form.isSubmitting}` and `disabled={!form.canSubmit}`. The + controller side is in `controllers.md` → "Multi-step flows". A loading or error state of a single screen is not a step, and neither is the skeleton shown before a feature has data. Those can stay conditionals.