Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/mosaic-item-button-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Fix clickable rows, such as the method picker in `<UserProfile />`'s Add 2-step verification dialog, picking up the browser's default button background and border in apps without a CSS reset.
5 changes: 5 additions & 0 deletions .changeset/mosaic-profile-dialog-shadow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Fix `<UserProfile />` and `<OrganizationProfile />` opened in a dialog losing their shadow and edge, which the dialog was clipping on every side.
2 changes: 2 additions & 0 deletions .changeset/swingset-studio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
14 changes: 9 additions & 5 deletions .claude/skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ relevant.

## Skills in this repo

| Skill | Use it for |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. |
| `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. |
| 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). |
| `studio` | The dev-only swingset video stage: authoring showcases, framing popover components by their expanded state, and the stage's grid and motion. |
| `studio-ideas` | Paging through suggested showcases drawn from merged Mosaic work, and claiming or dismissing them. |
| `studio-ideas-refresh` | Updating the showcase ideas list from merged PRs on GitHub. |
| `studio-promote` | Turning a studio recording into an X-ready mp4, poster, and post copy in the "Studio — Ready to post" Notion database. |
| `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. |
59 changes: 59 additions & 0 deletions .claude/skills/studio-ideas-refresh/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
name: studio-ideas-refresh
description: >-
Bring the swingset studio showcase ideas list (`packages/swingset/src/studio/ideas.json`) up to
date from merged Mosaic PRs on GitHub. Use when someone asks to refresh, update, or regenerate the
showcase or demo suggestions, or when `studio-ideas` reports the list is stale.
---

# Refresh studio ideas

Turn merged PRs into demo ideas for the studio. The file format is in the `studio-ideas` skill.

## 1. Collect PRs

Read `mergedAfter` from `ideas.json`, then:

```sh
gh pr list --repo clerk/javascript --state merged --limit 200 \
--search "merged:>=<mergedAfter date> -label:dependencies" \
--json number,title,mergedAt,body,files,labels
```

If it returns 200, the window is truncated: rerun with a `merged:<start>..<end>` range per week until
each returns fewer. Keep PRs that merged after `mergedAfter` and touched `packages/mosaic/` or `packages/ui/`. Drop
`chore`, `ci`, `docs`, `test`, and `Version packages` PRs. Skip any PR already listed in an idea,
including `built` and `dismissed` ones.

## 2. Judge each PR

The question is whether a 10–30 second silent video of the change would make a front-end developer
stop scrolling. Use the title and body first, then `gh pr diff <n>` when you can't tell.

- **Strong**: a new component or flow, visible motion, a gesture or interaction, a before/after that
reads at a glance (a layout no longer breaking, a flicker gone).
- **Good**: a visual polish pass, a new variant or prop with a visible effect, a mobile or dark-mode
improvement.
- **Maybe**: a real improvement that needs a caption to land (contrast, spacing, error copy).
- **Skip**: refs, types, routing, state machines, test or build changes, anything with no pixels.

Group PRs that tell one story into one idea (the Table primitive and the styled Table rebuilt on it
are one demo). If a new PR extends a `new` or `building` idea, add it to that idea's `prs` instead of
making another.

## 3. Write ideas

For each new idea, write `title` (what the viewer sees, not the PR title), a one-line `pitch`, 2–4
`beats` that map onto studio steps, and a `layout`:

- `code` or `split` when the API is the point (a new prop, a composable part).
- `component` when the behavior is the point (motion, gestures, flows).

Default `format` to `landscape`. Use `portrait` for mobile and sheet work.

Check `packages/swingset/src/studio/showcases/` and skip ideas an existing showcase already covers.

Append the new ideas to the end of the array, never reorder or rewrite existing ones, and set
`mergedAfter` to the newest `mergedAt` you saw (including skipped PRs). Finish by listing the new
ideas the way `studio-ideas` pages them, plus a count of PRs skipped, and remind the user that the
file needs committing.
67 changes: 67 additions & 0 deletions .claude/skills/studio-ideas/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
name: studio-ideas
description: >-
List and page through suggested swingset studio showcases: demo ideas drawn from recently merged
Mosaic work, stored in `packages/swingset/src/studio/ideas.json`. Use when someone asks what to
record next, wants to browse, pick, claim, or dismiss showcase ideas, or says "next" while paging
through them.
---

# Studio ideas

`packages/swingset/src/studio/ideas.json` holds demo ideas for the studio (see the `studio` skill).
`studio-ideas-refresh` fills it from GitHub history; this skill reads it and changes statuses.

```jsonc
{
"mergedAfter": "2026-10-28T17:02:11Z", // newest merge refresh has seen
"ideas": [
{
"id": "table-row-actions", // kebab-case, stable
"title": "Tables with row actions and tree rows",
"pitch": "One line on what the viewer sees and why it's worth watching.",
"prs": [10414, 10421],
"mergedAt": "2026-10-21T09:12:00Z", // newest of its PRs
"strength": "strong", // strong | good | maybe
"layout": "split", // component | code | split
"format": "landscape", // landscape | square | portrait
"beats": ["Open the row menu", "Expand a tree row", "Select three rows"],
"status": "new", // new | building | built | dismissed
"showcase": "table-row-actions", // slug, once building
"notion": "https://…", // ready-to-post page, once built
"note": "Why it was dismissed, who is building it, …",
},
],
}
```

## Listing

Show `new` ideas first, then `building`, sorted by `strength` (strong, good, maybe) and then by
`mergedAt`, newest first. Leave out `built` and `dismissed` unless asked. Show **5 per page**, numbered
across pages (6–10 on page 2), and end with the page and total count:

```
1. Tables with row actions and tree rows · strong · split
Open the row menu, expand a tree row, then select three rows: the table primitive doing it all.
#10414 #10421 · Oct 21

Page 1 of 4 · 18 ideas. "next", "show 3", "build 3", "dismiss 2 <reason>".
```

If the newest `mergedAt` is more than two weeks old, or the list is empty, suggest running
`studio-ideas-refresh` first.

## Actions

- **next / prev / page N**: show that page. Remember the page from the last listing in the conversation.
- **show N**: the full idea, its beats, and each PR's title and link (`gh pr view <n> --json title,url`).
- **build N**: set `status: "building"` and `showcase` to the id, then load the `studio` skill and author
`src/studio/showcases/<id>.tsx` from the beats. Read the PRs' diffs and existing swingset stories
for fixtures first.
- **dismiss N <reason>**: set `status: "dismissed"` and `note` to the reason. Refresh never brings back a
dismissed idea, so a short reason helps whoever reads the list next.
- **reopen N**: back to `new`.

Edit `ideas.json` in place, keep its formatting (two-space JSON), and never reorder the array.
Statuses are shared through the repo, so mention that the change needs committing.
84 changes: 84 additions & 0 deletions .claude/skills/studio-promote/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
name: studio-promote
description: >-
Promote a finished swingset studio recording to the "Studio — Ready to post" Notion database:
encode an X-ready mp4, pull a poster frame, write the post copy and alt text, upload it all, and
mark the showcase idea built. Use when someone has recorded a showcase video and wants it ready to
post, or asks to promote, publish, or package a studio video.
---

# Promote a studio video

Input: the path to a recording of `/capture/<slug>` and the showcase slug. If either is missing, ask.
The slug picks the showcase in `packages/swingset/src/studio/showcases/` and its idea in
`packages/swingset/src/studio/ideas.json`, if it has one.

Ready-to-post entries live in the Notion database **Studio — Ready to post**:

- Database: https://app.notion.com/p/b8afbadf773d490f92282b80f034cd6d
- Data source: `collection://81ffcf11-945c-4879-8b75-8a840d5c5140`

Work in a fresh `mktemp -d` directory and tell the user its path at the end.

## 1. Encode the mp4

`ffprobe` the source for duration, size, and frame rate. X takes H.264 + AAC (or no audio), up to 60fps
and 1920×1200 (1200×1900 portrait). Notion uploads cap at 20 MiB, so target 18 MiB:
`kbps = min(10000, 18 * 8192 / seconds)`. Two-pass:

```sh
VF="scale=1920:1200:force_original_aspect_ratio=decrease:force_divisible_by=2" # 1200:1900 for portrait, 1200:1200 for square
ffmpeg -y -i in.mov -vf "$VF" -fpsmax 60 -c:v libx264 -preset slow -profile:v high -pix_fmt yuv420p \
-b:v ${kbps}k -pass 1 -an -f mp4 /dev/null
ffmpeg -y -i in.mov -vf "$VF" -fpsmax 60 -c:v libx264 -preset slow -profile:v high -pix_fmt yuv420p \
-b:v ${kbps}k -pass 2 -an -movflags +faststart <slug>.mp4
```

Trim dead time at either end with `-ss`/`-to` only when the user asks. Check the output is under
20 MiB before uploading.

## 2. Pick a poster

Pull a contact sheet of candidates, look at it, and pick the frame that shows the feature at its
clearest (usually the end of the showcase's last step, not the first frame):

```sh
ffmpeg -i <slug>.mp4 -vf "fps=1,scale=480:-2,tile=4x3" -frames:v 1 sheet.png
ffmpeg -ss <seconds> -i <slug>.mp4 -frames:v 1 poster.png
```

## 3. Write the copy

Read the showcase file, its idea, and its PRs (`gh pr view <n> --json title,body,url`). Write
`post.md` with:

- **Post**: up to 280 characters. Lead with what the viewer sees, then the one detail a front-end
developer would care about. American spelling, no hashtags, no emoji, no @-mentions.
- **Short**: a one-line variant under 120 characters.
- **Alt text**: up to 1000 characters, describing what happens on screen in order.
- **PRs**: links.

Mosaic work is often unreleased. Check whether the PRs' changesets have shipped (look for the change
in the package's `CHANGELOG.md`). If they haven't, don't write copy that says it's available, and add
an "Unreleased" warning at the top of the page.

Show the copy to the user and adjust it before uploading.

## 4. Upload to Notion

For the mp4 and the poster, call `notion-create-file-upload`, then POST the file to `upload_url` once:
`curl -sS -X POST "$URL" <-H for each upload_headers entry> -F "file=@<path>"`.

Create one page in the data source with `notion-create-pages`:

- Properties: `Name` (the idea's title), `Status` `Ready`, `Showcase` (slug), `PRs` (`#10414, #10421`),
`Format`, `Seconds`, `Video` and `Poster` (the upload IDs), `Promoted by` (the user from
`notion-get-self`).
- Body: the video and poster, placed with each upload's `markdown_source`, then the sections from
`post.md`.

## 5. Close the idea

In `ideas.json`, set the idea's `status` to `built` and `notion` to the page URL. If the showcase has no
idea, skip this step. Then give the user the Notion link and remind them that `ideas.json` needs
committing.
85 changes: 85 additions & 0 deletions .claude/skills/studio/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: studio
description: >-
Author or tune a swingset studio showcase: the dev-only video stage in
`packages/swingset/src/studio/` that records Mosaic components and code edits
for social posts (component, code, and split layouts, the step list, the
stand-in cursor, the capture route). Use when adding a showcase, framing a
component on the stage, or changing the stage's layout, motion, or grid.
---

# Swingset studio

The studio is a dev-only stage for recording Mosaic showcases. It lives in swingset, never in
`@clerk/mosaic`, and swingset's middleware returns 404 for its routes outside `next dev`.

- `/template?showcase=<slug>`: the workbench. Pick a step from the list, force a layout with `1`/`2`/`3`, replay the cursor with `c`, toggle dark with `d` and the 8px grid with `g`.
- `/capture/<slug>`: the bare stage that plays the showcase, for recording.
- `src/studio/showcases/<slug>.tsx`: one showcase. Register it in `src/studio/showcases/index.ts`.
- `src/studio/ideas.json`: suggested showcases. Browse them with `studio-ideas`, update them with
`studio-ideas-refresh`, and hand a finished recording to `studio-promote`.

A showcase is a `Showcase<State>` (`src/studio/types.ts`): a `View` that renders state, a `code(state)`
that prints the matching source, and `steps` that each set a layout, a partial state, and cursor
actions. Derive the code from state so the two never disagree. Set `file` to the path the snippet
would live at; the code panel shows it as a comment on the first line.

Print every prop the showcase changes, defaults included, one per line. A step then changes a value on
a line that was already there (`triggerVariant='full'` → `'condensed'`), so the diff reads as an edit
rather than a prop appearing or the whole tag being replaced.

Import Mosaic internals directly through the `@clerk/mosaic/*` source alias when the public exports
don't reach them (for example `@clerk/mosaic/features/switcher/switcher.view`).

## Frame a popover component by its expanded state

When the component opens a popover, menu, or any other popup from a trigger, frame the expanded
state, never the trigger:

- Set `focus: { width, height }` on the showcase to the size of the trigger plus its open popup.
- The stage centers that box, puts the trigger in its top-left corner, and draws the cell guide
lines around it, so the popup opens into the middle of the panel and inside the guides.
- The box grows to fit the popup the first time it opens and keeps that size, so the guides always
end up 8px outside it. Still set `focus` from the popup as rendered: open it in the workbench and
copy the size it settles at. Otherwise the first open in a capture grows the box and shifts the
frame.

Framed by the trigger, the stage re-centers when the popup opens and the guides cut through it.

## Cursor

A step's `cursor` actions are scripted: `{ at, target, click }` moves to `target` and clicks it,
`drag: '<selector>'` presses on `target` and drops on that selector, `park: true | { x, y }` nudges the
cursor off what it just clicked so the result is visible, and `hide` fades it out. After `hide`, or on
the first action, the cursor enters from below the frame.

- Moves into a menu, menubar, or listbox steer: nearly straight, never past the target, and landing
near the row's vertical middle, so they don't brush the neighboring item. Force it either way with
`steer: true | false`.
- `text: 'Label'` narrows `target` to the first match whose text starts with it, for controls that only
differ by their label (`{ target: 'button', text: 'Settings' }`). Targets resolve in the component
panel first, then anywhere on the stage.
- `scroll: 320` scrolls the nearest scrollable ancestor of `target` by that many pixels.
- `type: '284913'` types into whatever the click focused, one character at a time.
- A press also focuses the control, as a real mouse does, so focus-activated widgets (profile tabs)
respond. Focus rings are hidden on the stage, since nothing in a capture uses the keyboard.

A dialog that should cover the whole stage portals into `StageOverlayContext` (from `Stage.tsx`) with
Mosaic's `PortalProvider`, not into the component panel. `user-profile-combined` is the example.

- Each move replays a recorded human path (`src/studio/cursor-paths.ts`), stretched onto the real start
and end points, with its timing scaled by Fitts's law. With no recordings it falls back to a
synthetic path. Every choice is seeded by showcase and step, so a capture is identical on every run.
- The cursor sends real pointer and mouse events (`over`/`enter`/`move`/`down`/`up`/`leave`) to whatever
is under it. CSS `:hover` and `:active` can't be triggered from script, so `interactionStyles.ts`
copies every such rule to `[data-studio-hover]`/`[data-studio-active]`, and the cursor sets those.
- To record paths, open `/recorder`, click through the dots (and drag in Drag mode) with the device
whose feel you want, then **Copy cursor-paths.ts** and paste it over that file. Aim for 30 to 50 clicks
of mixed distances and directions. Recordings persist in the browser until copied.

## Stage rules

- Spacing on the 8px grid: 24 from the frame lines for code and chrome text, code at 16/24, chrome at
12/16, text trimmed to cap height. The logo block is the one element that ignores the grid.
- Motion: content leads and layout follows. Fade out, then move, then fade in, using Mosaic's easing
tokens. See `packages/mosaic/docs/motion.md`.
1 change: 1 addition & 0 deletions packages/mosaic/src/components/dialog/dialog.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ const profileSurface = {
// profile a scroll container, and focusing anything inside it that sits outside its box would
// scroll the profile itself.
overflow: 'clip',
overflowClipMargin: '1.5rem',
// Fills the viewport's content box rather than computing a height from `dvh`. The grid row
// is definite (see `styles.viewport`), so `stretch` lands the profile's edges on exactly the
// lines a bottom-anchored sheet reaches with `align-self: end`, and clamps to them.
Expand Down
7 changes: 4 additions & 3 deletions packages/mosaic/src/components/item/item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const item = stylex.create({
// last style that declares it, so the two can't merge.
'--_cl-icon-color': colorVars['--cl-color-foreground-secondary'],
borderRadius: radiusVars['--cl-radius-lg'],
borderStyle: 'none',
paddingInline: space['1.5'],
alignItems: 'center',
color: colorVars['--cl-color-foreground-secondary'],
Expand Down Expand Up @@ -38,7 +39,7 @@ export const item = stylex.create({
},
},
backgroundColor: {
default: null,
default: 'transparent',
':active:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-200'],
'@media (hover: hover)': {
':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-100'],
Expand All @@ -65,9 +66,9 @@ export const item = stylex.create({

interactiveOutline: {
borderColor: {
default: null,
default: colorVars['--cl-color-border'],
'@media (hover: hover)': {
default: null,
default: colorVars['--cl-color-border'],
':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-border'],
},
},
Expand Down
Loading
Loading