Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-docs-model-controller-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
61 changes: 40 additions & 21 deletions .claude/skills/mosaic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
name: mosaic
description: >-
Work on Mosaic UI: styling a component with StyleX (`stylex.create`, `--cl-*`
tokens, `themeProps`), or building a flow — authoring a state machine
(`setup`, states/guards/`invoke`, wiring to React with `useMachine`/`useActor`/
`useSelector`), writing the controller (Clerk adapter) or view (rendering) layer,
testing any of those layers, or migrating a legacy / pre-Mosaic component into the
machine / controller / view split. Use when building, styling, debugging, testing, or
migrating anything Mosaic. `references/mosaic-architecture.md` (repo root) holds the
design-system contract; this skill is the how-to layer.
tokens, `themeProps`), or building a flow — writing the model (the Clerk
adapter), the controller (local state, in React state or a state machine:
`setup`, states/guards/`invoke`, wired to React with `useMachine`/`useActor`/
`useSelector`), or the view (rendering), testing any of those layers, 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
contract; this skill is the how-to layer.
---

# Mosaic UI
Expand All @@ -19,42 +20,60 @@ Two things live under Mosaic, and this skill covers the how-to for both:
the styles, `themeProps` emits the part's public identity (the `.cl-<slot>`
class plus `data-<axis>` attrs), and `mergeStyleProps` fuses the two with the
consumer's `className`/`style`.
- **Flows** follow a **machine → controller → view** split that keeps Clerk
resource logic out of visual components and makes behavior testable without a
running Clerk app:
- **Flows** follow a **model → controller → view** split — _where the data comes
from_ → _what the user is doing to it_ → _what that looks like_. What crosses
each boundary is plain data: no Clerk resource reaches the controller, no
machine snapshot reaches the view.

```text
machine Pure flow rules: states, events, guards, async invokes, errors.
No React hooks. No Clerk hooks. No Clerk resource objects.
model Clerk adapter: reads Clerk hooks and resources, resolves the
environment, gates permissions, and answers with plain data plus
plain callbacks under an explicit `status`. The only layer that may
import Clerk hooks or call Clerk resource methods.

controller Clerk/data adapter: reads Clerk hooks/resources, injects async
effects into machine context, gates permissions, derives view props.
The only layer that may import Clerk hooks or call resource methods.
controller Local state: what is open, what is in flight, what the view may do
next — held in React state or a state machine, whichever the
interaction's complexity calls for. Wraps the model's callbacks so
an action can report pending, hold the surface still while it runs,
and close on success. No Clerk imports.

view Rendering only: receives a snapshot plus explicit props, renders UI,
sends events. No Clerk imports. No data-fetching. No mutations.
view Rendering: takes plain props and callbacks, renders UI, calls them
back. No Clerk imports. No data-fetching. No machine snapshot.
```

A machine is **not a fourth layer**, and not a requirement. It is one of the two
ways a controller can hold its state, and picking one is a complexity call:
`useState` for a boolean that never touches async, a machine once the
interaction has an async lifecycle or two values that must change together, and
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/ui/src/mosaic/machine/ADOPTION.md`.

`references/mosaic-architecture.md` (repo root, read by all agents) is the
canonical contract for the whole design system — the `--cl-*` tokens, the
`.cl-<slot>` + `data-<axis>` styling API, the CSS build, and the "Flow and data
architecture" section that defines the split. Read it for the _what_; this skill
is the _how-to_.

`packages/ui/src/mosaic/user-button/` is the fullest worked example of the split
in the repo — model, controller, view, wrapper, types, messages, and a test per
layer. Copy from it.

## Which reference to read

| You are… | Read |
| ---------------------------------------------------------------------- | ------------------------------------------------------ |
| Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` |
| Styling a component (tokens, `stylex.create`, `themeProps`, CSS build) | `references/stylex.md` |
| Building an enter/exit transition, 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 controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` |
| Writing the view (rendering a snapshot, sending events) | `references/views.md` |
| Testing a machine, controller, or view | `references/testing.md` |
| Writing the view (rendering plain props) | `references/views.md` |
| Testing a model, controller, or view | `references/testing.md` |
| Migrating a legacy component into Mosaic (the end-to-end workflow) | `references/migration.md` |
| Running the parity audit that guards a migration | `references/parity-audit.md` |

The migration workflow (`migration.md`) ties the flow references together: it
treats the legacy component as the spec and drives you through the machine,
treats the legacy component as the spec and drives you through the model,
controller, and view layers, then verifies parity with `parity-audit.md`.
149 changes: 108 additions & 41 deletions .claude/skills/mosaic/references/controllers.md
Original file line number Diff line number Diff line change
@@ -1,61 +1,128 @@
# Controllers

The controller is the adapter from Clerk resources into machine context and view
props. It is the **only layer in a Mosaic flow that may import Clerk hooks or
call Clerk resource methods** — the machine (`machines.md`) and view
(`views.md`) stay Clerk-free so they remain testable in plain JS.
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.

See `references/mosaic-architecture.md` → "Controllers" for the canonical
example. This file is the practical checklist.
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 is what lets a controller test run
against a fake object instead of a mocked Clerk.

Worked examples:

- `packages/ui/src/mosaic/user-button/user-button.controller.tsx` — wraps a model
- `packages/ui/src/mosaic/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 |

`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/ui/src/mosaic/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

- **Read Clerk state.** Call hooks like `useOrganization()`, `useUser()`,
`useSession()`. This is the only layer allowed to.
- **Inject async effects + live props into machine context.** Pass plain
functions that close over live resources; `useMachine` re-seats context via
`useLayoutEffect` every render, so the machine always reads the latest prop.
- **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
const [snapshot, send, actor] = useMachine(deleteOrgMachine, {
context: {
organizationName: organization?.name ?? '',
destroyOrganization: () => organization?.destroy() ?? Promise.resolve(),
},
});
if (model.status !== 'ready') {
return { status: model.status };
}
```

- **Gate permissions and visibility.** Resolve `session.checkAuthorization(...)`
and collapse loading/permission/empty into an explicit status the wrapper
branches on — not scattered booleans:
- **Wrap actions to drive pending state.** One helper, so every action is wrapped
the same way and only one can be in flight:

```tsx
if (!canRead || !settings.enabled || !organization) return { status: 'hidden' as const };
if (!firstPageLoaded) return { status: 'loading' as const };
return { status: 'ready' as const, snapshot, send, canSubmit: actor.can({ type: 'CONFIRM' }) };
const runAction = (keyFor, fn, closeOnSuccess = false) =>
fn
? (...args) => send({ type: 'RUN', key: keyFor(...args), run: async () => fn(...args), closeOnSuccess })
: undefined;
```

- **Own revalidate timing.** Call `data.revalidate()` / `.reload()` after
mutations. Deciding _when_ (fire-and-forget vs awaited) is controller logic,
not view logic.
- **Handle first-page-load empty-state.** Wait for the first page before
deciding to hide a section, so read-only users don't see a hide-then-show
flicker.
- **Derive view props.** Expose `actor.can(...)` results (e.g. `canSubmit`) so
the view never re-implements a machine guard.
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

- Pass **plain data and plain functions** into machines and views. Do **not**
pass Clerk resource objects through to the view.
- Branch the wrapper on the controller's `status`, not on raw `isLoaded` flags
sprinkled through the tree.
- **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(), { /* … */ }),
}
```

## Testing

Test the controller against a **mocked Clerk** for the gating / `hidden` /
empty-state logic. This is the **highest-risk, least-covered layer**: it holds
the Clerk resource semantics that the pure machine tests can't reach. When a
migration loses behavior, it is usually a controller responsibility (revalidate
timing, a permission gate, an empty-state rule) that quietly went missing —
concentrate scrutiny here.
Feed the controller a **fake model object** — a plain literal with
`status: 'ready'` and `vi.fn()` callbacks — and render a tiny harness that
surfaces what it returns. No Clerk mocking. Assert the pending key, what closes
the surface, and that an absent model callback stays absent. See `testing.md`.
2 changes: 1 addition & 1 deletion .claude/skills/mosaic/references/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,4 @@ if (!element) return null;
Tests run in **real Chromium** (vitest browser mode), not jsdom, and include
`axe` accessibility assertions. `pnpm test` in `packages/headless`. See
`testing.md` for the Mosaic flow-layer testing model (a different concern — that
covers machines/controllers/views, not these primitives).
covers models/controllers/views, not these primitives).
49 changes: 43 additions & 6 deletions .claude/skills/mosaic/references/machines.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
# 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's updated in the same diff when the runtime changes and
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/ui/src/mosaic/machine/README.md`** — the mental model (state /
Expand All @@ -10,9 +24,32 @@ is readable by every tool, not just Claude Code). Read:
glance (`assign`, `invoke`, `guard`, `always`, `entry`/`exit`, `final`,
`mockActor`, `useActor`, `useSelector`, `recheck()`).
- **`packages/ui/src/mosaic/machine/ADOPTION.md`** — when a flow is worth a
machine and when it isn't, with real before/after migrations.
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) => ({
errorMessage: event.error instanceof Error ? event.error.message : 'Something went wrong.',
})),
},
}),
}
```

The machine is the pure flow layer: states, events, guards, async `invoke`,
errors — no React hooks, no Clerk. To wire it to Clerk data see `controllers.md`;
to render its snapshot see `views.md`; to test it see `testing.md`; to migrate a
legacy component into this pattern see `migration.md`.
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`.
Loading
Loading