From c5205a4439b2e6bfec3675b50c431b1113b7a745 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 30 Oct 2026 14:48:46 -0600 Subject: [PATCH 1/3] feat(swingset): add a dev-only studio for recording Mosaic showcases Adds a studio to swingset for recording Mosaic components and code edits as social and docs videos. It has a workbench at /template, a bare capture route at /capture/[slug], and a cursor path recorder at /recorder. The middleware returns 404 for all of these outside `next dev`. The root layout moves into the (explorer) and (clerk) route groups, so the (studio) group can render its own document with no swingset chrome. Co-Authored-By: Claude Opus 5.5 --- .changeset/swingset-studio.md | 2 + .claude/skills/README.md | 1 + .claude/skills/studio/SKILL.md | 69 ++ packages/swingset/src/app/(clerk)/layout.tsx | 13 +- .../swingset/src/app/(explorer)/layout.tsx | 9 +- .../src/app/(explorer)/studio/[slug]/page.tsx | 6 + .../src/app/(explorer)/studio/page.tsx | 5 + .../src/app/(studio)/capture/[slug]/page.tsx | 35 + packages/swingset/src/app/(studio)/layout.tsx | 30 + .../src/app/(studio)/recorder/page.tsx | 5 + .../src/app/(studio)/template/page.tsx | 5 + .../swingset/src/components/CodeBlock.tsx | 2 +- .../SwingsetDocument.tsx} | 6 +- .../swingset/src/components/app-sidebar.tsx | 14 +- packages/swingset/src/middleware.ts | 15 +- packages/swingset/src/studio/CodeView.tsx | 137 +++ packages/swingset/src/studio/Cursor.tsx | 386 ++++++++ packages/swingset/src/studio/Recorder.tsx | 249 +++++ packages/swingset/src/studio/Stage.tsx | 351 +++++++ packages/swingset/src/studio/Studio.tsx | 148 +++ packages/swingset/src/studio/StudioEntry.tsx | 19 + .../swingset/src/studio/StudioPreview.tsx | 103 +++ packages/swingset/src/studio/Workbench.tsx | 328 +++++++ packages/swingset/src/studio/cursor-paths.ts | 3 + packages/swingset/src/studio/cursorPath.ts | 203 ++++ packages/swingset/src/studio/diff.ts | 35 + packages/swingset/src/studio/fonts/OFL.txt | 93 ++ .../src/studio/fonts/PaperMonoVF.woff2 | Bin 0 -> 53276 bytes .../swingset/src/studio/interactionStyles.ts | 40 + .../src/studio/showcases/button-variants.tsx | 68 ++ .../swingset/src/studio/showcases/index.ts | 9 + .../studio/showcases/user-button-display.tsx | 86 ++ packages/swingset/src/studio/studio.css | 865 ++++++++++++++++++ packages/swingset/src/studio/types.ts | 45 + packages/swingset/src/studio/useCursor.ts | 309 +++++++ 35 files changed, 3686 insertions(+), 8 deletions(-) create mode 100644 .changeset/swingset-studio.md create mode 100644 .claude/skills/studio/SKILL.md create mode 100644 packages/swingset/src/app/(explorer)/studio/[slug]/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/studio/page.tsx create mode 100644 packages/swingset/src/app/(studio)/capture/[slug]/page.tsx create mode 100644 packages/swingset/src/app/(studio)/layout.tsx create mode 100644 packages/swingset/src/app/(studio)/recorder/page.tsx create mode 100644 packages/swingset/src/app/(studio)/template/page.tsx rename packages/swingset/src/{app/layout.tsx => components/SwingsetDocument.tsx} (85%) create mode 100644 packages/swingset/src/studio/CodeView.tsx create mode 100644 packages/swingset/src/studio/Cursor.tsx create mode 100644 packages/swingset/src/studio/Recorder.tsx create mode 100644 packages/swingset/src/studio/Stage.tsx create mode 100644 packages/swingset/src/studio/Studio.tsx create mode 100644 packages/swingset/src/studio/StudioEntry.tsx create mode 100644 packages/swingset/src/studio/StudioPreview.tsx create mode 100644 packages/swingset/src/studio/Workbench.tsx create mode 100644 packages/swingset/src/studio/cursor-paths.ts create mode 100644 packages/swingset/src/studio/cursorPath.ts create mode 100644 packages/swingset/src/studio/diff.ts create mode 100644 packages/swingset/src/studio/fonts/OFL.txt create mode 100644 packages/swingset/src/studio/fonts/PaperMonoVF.woff2 create mode 100644 packages/swingset/src/studio/interactionStyles.ts create mode 100644 packages/swingset/src/studio/showcases/button-variants.tsx create mode 100644 packages/swingset/src/studio/showcases/index.ts create mode 100644 packages/swingset/src/studio/showcases/user-button-display.tsx create mode 100644 packages/swingset/src/studio/studio.css create mode 100644 packages/swingset/src/studio/types.ts create mode 100644 packages/swingset/src/studio/useCursor.ts diff --git a/.changeset/swingset-studio.md b/.changeset/swingset-studio.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-studio.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 5f19c6d836b..b9f1a5055ab 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -49,4 +49,5 @@ relevant. | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `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. | | `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/.claude/skills/studio/SKILL.md b/.claude/skills/studio/SKILL.md new file mode 100644 index 00000000000..37e51475aad --- /dev/null +++ b/.claude/skills/studio/SKILL.md @@ -0,0 +1,69 @@ +--- +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=`: 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/`: the bare stage that plays the showcase, for recording. +- `src/studio/showcases/.tsx`: one showcase. Register it in `src/studio/showcases/index.ts`. + +A showcase is a `Showcase` (`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: ''` 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. + +- 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`. diff --git a/packages/swingset/src/app/(clerk)/layout.tsx b/packages/swingset/src/app/(clerk)/layout.tsx index 45f75b113bd..34075212a4a 100644 --- a/packages/swingset/src/app/(clerk)/layout.tsx +++ b/packages/swingset/src/app/(clerk)/layout.tsx @@ -2,6 +2,7 @@ import { ClerkProvider } from '@clerk/nextjs'; import Link from 'next/link'; import type { ReactNode } from 'react'; +import { SwingsetDocument, swingsetMetadata } from '@/components/SwingsetDocument'; import { ThemeToggle } from '@/components/ThemeToggle'; import { Separator } from '@/components/ui/separator'; import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; @@ -37,7 +38,7 @@ function LiveChrome({ children, userButton }: { children: ReactNode; userButton? ); } -export default function ClerkLayout({ children }: { children: ReactNode }) { +function ClerkContent({ children }: { children: ReactNode }) { const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; if (!publishableKey) { @@ -74,3 +75,13 @@ export default function ClerkLayout({ children }: { children: ReactNode }) { ); } + +export const metadata = swingsetMetadata; + +export default function ClerkLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/swingset/src/app/(explorer)/layout.tsx b/packages/swingset/src/app/(explorer)/layout.tsx index 449351b6cec..ddf8f434093 100644 --- a/packages/swingset/src/app/(explorer)/layout.tsx +++ b/packages/swingset/src/app/(explorer)/layout.tsx @@ -1,7 +1,14 @@ import type { ReactNode } from 'react'; import { ClientRoot } from '@/components/ClientRoot'; +import { SwingsetDocument, swingsetMetadata } from '@/components/SwingsetDocument'; + +export const metadata = swingsetMetadata; export default function ExplorerLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/packages/swingset/src/app/(explorer)/studio/[slug]/page.tsx b/packages/swingset/src/app/(explorer)/studio/[slug]/page.tsx new file mode 100644 index 00000000000..30840c0693f --- /dev/null +++ b/packages/swingset/src/app/(explorer)/studio/[slug]/page.tsx @@ -0,0 +1,6 @@ +import { StudioPreview } from '@/studio/StudioPreview'; + +export default async function StudioPreviewPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + return ; +} diff --git a/packages/swingset/src/app/(explorer)/studio/page.tsx b/packages/swingset/src/app/(explorer)/studio/page.tsx new file mode 100644 index 00000000000..8074b33839d --- /dev/null +++ b/packages/swingset/src/app/(explorer)/studio/page.tsx @@ -0,0 +1,5 @@ +import { StudioIndex } from '@/studio/StudioPreview'; + +export default function StudioIndexPage() { + return ; +} diff --git a/packages/swingset/src/app/(studio)/capture/[slug]/page.tsx b/packages/swingset/src/app/(studio)/capture/[slug]/page.tsx new file mode 100644 index 00000000000..8e86b371c4b --- /dev/null +++ b/packages/swingset/src/app/(studio)/capture/[slug]/page.tsx @@ -0,0 +1,35 @@ +import type { StudioOptions } from '@/studio/Studio'; +import { StudioEntry } from '@/studio/StudioEntry'; +import type { StudioBadges, StudioFormat, StudioScheme } from '@/studio/types'; + +type SearchParams = Record; + +function toOptions(params: SearchParams): StudioOptions { + const read = (key: string) => (typeof params[key] === 'string' ? params[key] : undefined); + return { + step: Number(read('step')) || undefined, + hold: params.hold !== undefined, + autoplay: read('autoplay') !== '0', + loop: read('loop') !== '0', + cursor: read('cursor') !== '0', + badges: read('badges') as StudioBadges | undefined, + format: read('format') as StudioFormat | undefined, + scheme: read('scheme') as StudioScheme | undefined, + }; +} + +export default async function CapturePage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise; +}) { + const { slug } = await params; + return ( + + ); +} diff --git a/packages/swingset/src/app/(studio)/layout.tsx b/packages/swingset/src/app/(studio)/layout.tsx new file mode 100644 index 00000000000..0e04cc0300f --- /dev/null +++ b/packages/swingset/src/app/(studio)/layout.tsx @@ -0,0 +1,30 @@ +import '@/studio/studio.css'; + +import type { Metadata } from 'next'; +import { Geist } from 'next/font/google'; +import localFont from 'next/font/local'; +import type { ReactNode } from 'react'; + +const geist = Geist({ subsets: ['latin'], variable: '--font-sans' }); + +const paperMono = localFont({ + src: '../../studio/fonts/PaperMonoVF.woff2', + weight: '100 800', + variable: '--font-paper-mono', + display: 'block', +}); + +export const metadata: Metadata = { + title: 'Swingset Studio', +}; + +export default function StudioLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/swingset/src/app/(studio)/recorder/page.tsx b/packages/swingset/src/app/(studio)/recorder/page.tsx new file mode 100644 index 00000000000..711f9a18d4d --- /dev/null +++ b/packages/swingset/src/app/(studio)/recorder/page.tsx @@ -0,0 +1,5 @@ +import { Recorder } from '@/studio/Recorder'; + +export default function RecorderPage() { + return ; +} diff --git a/packages/swingset/src/app/(studio)/template/page.tsx b/packages/swingset/src/app/(studio)/template/page.tsx new file mode 100644 index 00000000000..6ef08852dd5 --- /dev/null +++ b/packages/swingset/src/app/(studio)/template/page.tsx @@ -0,0 +1,5 @@ +import { Workbench } from '@/studio/Workbench'; + +export default function TemplatePage() { + return ; +} diff --git a/packages/swingset/src/components/CodeBlock.tsx b/packages/swingset/src/components/CodeBlock.tsx index e6bb5b4f249..6ee1e411105 100644 --- a/packages/swingset/src/components/CodeBlock.tsx +++ b/packages/swingset/src/components/CodeBlock.tsx @@ -9,7 +9,7 @@ const cssVariablesTheme = createCssVariablesTheme({ name: 'css-variables', varia let highlighterPromise: Promise | null = null; -function getHighlighter(): Promise { +export function getHighlighter(): Promise { if (!highlighterPromise) { highlighterPromise = createHighlighter({ themes: [cssVariablesTheme], diff --git a/packages/swingset/src/app/layout.tsx b/packages/swingset/src/components/SwingsetDocument.tsx similarity index 85% rename from packages/swingset/src/app/layout.tsx rename to packages/swingset/src/components/SwingsetDocument.tsx index 33de3af1482..df027224068 100644 --- a/packages/swingset/src/app/layout.tsx +++ b/packages/swingset/src/components/SwingsetDocument.tsx @@ -1,4 +1,4 @@ -import './globals.css'; +import '@/app/globals.css'; import type { Metadata } from 'next'; import { Geist } from 'next/font/google'; @@ -10,11 +10,11 @@ import { cn } from '@/lib/utils'; const geist = Geist({ subsets: ['latin'], variable: '--font-sans' }); -export const metadata: Metadata = { +export const swingsetMetadata: Metadata = { title: 'Swingset — Mosaic Component Explorer', }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export function SwingsetDocument({ children }: { children: React.ReactNode }) { return ( + {process.env.NODE_ENV === 'development' && ( + + } + > + + Studio + + + )} diff --git a/packages/swingset/src/middleware.ts b/packages/swingset/src/middleware.ts index c4f559e021d..fd1fd0dee08 100644 --- a/packages/swingset/src/middleware.ts +++ b/packages/swingset/src/middleware.ts @@ -4,10 +4,23 @@ import { NextResponse } from 'next/server'; const withClerk = process.env.CLERK_SECRET_KEY ? clerkMiddleware() : null; +const studioPaths = /^\/(studio|template|capture|recorder)(\/|$)/; + export default function middleware(request: NextRequest, event: NextFetchEvent) { + if (studioPaths.test(request.nextUrl.pathname)) { + return process.env.NODE_ENV === 'development' ? NextResponse.next() : new NextResponse(null, { status: 404 }); + } return withClerk ? withClerk(request, event) : NextResponse.next(); } export const config = { - matcher: ['/sign-in(.*)', '/sign-up(.*)', '/live(.*)'], + matcher: [ + '/sign-in(.*)', + '/sign-up(.*)', + '/live(.*)', + '/studio(.*)', + '/template(.*)', + '/capture(.*)', + '/recorder(.*)', + ], }; diff --git a/packages/swingset/src/studio/CodeView.tsx b/packages/swingset/src/studio/CodeView.tsx new file mode 100644 index 00000000000..f9e06552501 --- /dev/null +++ b/packages/swingset/src/studio/CodeView.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { getHighlighter } from '@/components/CodeBlock'; + +import { diffLines } from './diff'; + +const EXIT_MS = 70; + +type Token = { content: string; color?: string }; + +type Line = { + key: number; + text: string; + tokens: Token[]; + previous?: Token[]; + state: 'idle' | 'enter' | 'exit'; +}; + +export function CodeView({ code, lang }: { code: string; lang: string }) { + const [lines, setLines] = useState([]); + const nextKey = useRef(0); + + useEffect(() => { + let alive = true; + void getHighlighter().then(highlighter => { + if (!alive) { + return; + } + const { tokens } = highlighter.codeToTokens(code, { lang: lang as 'tsx', theme: 'css-variables' }); + const texts = code.split('\n'); + setLines(previous => { + const live = previous.filter(line => line.state !== 'exit'); + const first = live.length === 0; + const ops = diffLines( + live.map(line => line.text), + texts, + ); + const next: Line[] = []; + for (let i = 0; i < ops.length; i++) { + const op = ops[i]; + const following = ops[i + 1]; + if (op.type === 'keep') { + next.push({ ...live[op.from], tokens: tokens[op.to], previous: undefined, state: 'idle' }); + } else if (op.type === 'remove' && following?.type === 'add') { + next.push({ + key: nextKey.current++, + text: texts[following.to], + tokens: tokens[following.to], + previous: live[op.from].tokens, + state: 'enter', + }); + i++; + } else if (op.type === 'remove') { + next.push({ ...live[op.from], state: 'exit' }); + } else { + next.push({ + key: nextKey.current++, + text: texts[op.to], + tokens: tokens[op.to], + state: first ? 'idle' : 'enter', + }); + } + } + return next; + }); + }); + return () => { + alive = false; + }; + }, [code, lang]); + + useEffect(() => { + const exiting = new Set(lines.filter(line => line.state === 'exit').map(line => line.key)); + if (exiting.size === 0) { + return; + } + const timer = setTimeout(() => setLines(previous => previous.filter(line => !exiting.has(line.key))), EXIT_MS); + return () => clearTimeout(timer); + }, [lines]); + + return ( +
0 || undefined} + > +
+        
+          {lines.map(line => (
+            
+              
+                
+                
+                  {line.previous && (
+                    
+                      {line.previous.map((token, i) => (
+                        
+                          {token.content}
+                        
+                      ))}
+                    
+                  )}
+                  
+                    {line.text === ''
+                      ? ' '
+                      : line.tokens.map((token, i) => (
+                          
+                            {token.content}
+                          
+                        ))}
+                  
+                
+              
+            
+          ))}
+        
+      
+
+ ); +} diff --git a/packages/swingset/src/studio/Cursor.tsx b/packages/swingset/src/studio/Cursor.tsx new file mode 100644 index 00000000000..1f9da53c39b --- /dev/null +++ b/packages/swingset/src/studio/Cursor.tsx @@ -0,0 +1,386 @@ +import type { ReactNode } from 'react'; + +export type CursorKind = 'default' | 'pointer' | 'grab' | 'grabbing' | 'text' | 'not-allowed' | 'progress'; + +export interface CursorState { + kind: CursorKind; + visible: boolean; + pressed: boolean; +} + +export function cursorKindOf(element: Element): CursorKind { + const cursor = getComputedStyle(element).cursor; + if (cursor === 'wait') { + return 'progress'; + } + return ( + (['pointer', 'grab', 'grabbing', 'text', 'not-allowed', 'progress'] as const).find(kind => kind === cursor) ?? + 'default' + ); +} + +function Shadow({ + id, + box, + blur, + opacity, +}: { + id: string; + box: [number, number, number, number]; + blur: number; + opacity: number; +}) { + return ( + + + + + + + + + + ); +} + +const HAND_POINTER = + 'M8.26999 16.28C7.98999 15.92 7.63999 15.19 7.02999 14.28C6.67999 13.78 5.81999 12.83 5.55999 12.34C5.37257 12.0422 5.31818 11.6796 5.40999 11.34C5.56695 10.6942 6.17956 10.2658 6.83999 10.34C7.3508 10.4426 7.82022 10.693 8.18999 11.06C8.44817 11.3032 8.68566 11.5674 8.89999 11.85C9.05999 12.05 9.09999 12.13 9.27999 12.36C9.45999 12.59 9.57999 12.82 9.48999 12.48C9.41999 11.98 9.29999 11.14 9.12999 10.39C8.99999 9.81999 8.96999 9.72999 8.84999 9.29999C8.72999 8.86999 8.65999 8.50999 8.52999 8.01999C8.41116 7.53856 8.3177 7.05122 8.24999 6.55999C8.12395 5.93169 8.21565 5.2792 8.50999 4.70999C8.85939 4.38136 9.37192 4.29462 9.80999 4.48999C10.2506 4.81532 10.5791 5.26964 10.75 5.78999C11.0121 6.43038 11.187 7.10305 11.27 7.78999C11.43 8.78999 11.74 10.25 11.75 10.55C11.75 10.18 11.68 9.39999 11.75 9.04999C11.8193 8.68511 12.073 8.3823 12.42 8.24999C12.7178 8.15861 13.0328 8.13807 13.34 8.18999C13.65 8.2548 13.9247 8.43313 14.11 8.68999C14.3417 9.27338 14.4703 9.89259 14.49 10.52C14.5168 9.97057 14.6108 9.42652 14.77 8.89999C14.9371 8.66454 15.1811 8.49478 15.46 8.41999C15.7906 8.35954 16.1294 8.35954 16.46 8.41999C16.7311 8.51062 16.9682 8.6815 17.14 8.90999C17.3518 9.44033 17.48 10.0003 17.52 10.57C17.52 10.71 17.59 10.18 17.81 9.82999C17.9243 9.49059 18.211 9.23796 18.5621 9.16726C18.9132 9.09657 19.2754 9.21855 19.5121 9.48726C19.7489 9.75597 19.8243 10.1306 19.71 10.47C19.71 11.12 19.71 11.09 19.71 11.53C19.71 11.97 19.71 12.36 19.71 12.73C19.6736 13.3152 19.5933 13.8968 19.47 14.47C19.296 14.9771 19.0537 15.4582 18.75 15.9C18.2644 16.44 17.8632 17.0502 17.56 17.71C17.4848 18.0378 17.4512 18.3738 17.46 18.71C17.459 19.0206 17.4993 19.33 17.58 19.63C17.1711 19.6732 16.7589 19.6732 16.35 19.63C15.96 19.57 15.48 18.79 15.35 18.55C15.2857 18.4211 15.154 18.3397 15.01 18.3397C14.866 18.3397 14.7343 18.4211 14.67 18.55C14.45 18.93 13.96 19.62 13.62 19.66C12.95 19.74 11.57 19.66 10.48 19.66C10.48 19.66 10.66 18.66 10.25 18.3C9.83999 17.94 9.41999 17.52 9.10999 17.24L8.26999 16.28Z'; + +const HAND_GRAB = + 'M8.38001 12.27C8.28001 11.9 8.18001 11.42 7.97001 10.72C7.76001 10.02 7.63001 9.86 7.50001 9.49C7.37001 9.12 7.20001 8.77 7.00001 8.31C6.81681 7.84005 6.66318 7.35912 6.54001 6.87C6.45677 6.4578 6.55919 6.02986 6.82001 5.7C7.17887 5.34982 7.69669 5.21656 8.18001 5.35C8.55866 5.51559 8.87908 5.79073 9.10001 6.14C9.39505 6.61167 9.63654 7.11478 9.82001 7.64C10.1018 8.35963 10.3062 9.10716 10.43 9.87L10.52 10.32C10.52 10.32 10.52 9.2 10.52 9.16C10.52 8.16 10.46 7.34 10.52 6.22C10.52 6.09 10.58 5.63 10.6 5.5C10.6259 5.06996 10.8821 4.68748 11.27 4.5C11.7153 4.30021 12.2247 4.30021 12.67 4.5C13.0686 4.67843 13.3347 5.06401 13.36 5.5C13.36 5.61 13.45 6.5 13.45 6.61C13.45 7.61 13.45 8.25 13.45 8.78C13.45 9.01 13.45 10.41 13.45 10.25C13.4736 8.93046 13.5872 7.61407 13.79 6.31C13.9085 5.90144 14.196 5.56301 14.58 5.38C15.0549 5.19294 15.5945 5.28543 15.98 5.62C16.2682 5.93629 16.4378 6.34269 16.46 6.77C16.46 7.18 16.46 7.67 16.46 8.02C16.46 8.89 16.46 9.34 16.46 10.14C16.46 10.14 16.46 10.44 16.46 10.32C16.55 10.04 16.65 9.78 16.73 9.58C16.81 9.38 16.97 8.97 17.09 8.72C17.2121 8.48166 17.3491 8.25121 17.5 8.03C17.6568 7.77573 17.8919 7.57922 18.17 7.47C18.4205 7.37318 18.6993 7.38115 18.9439 7.49213C19.1884 7.6031 19.378 7.80774 19.47 8.06C19.5295 8.42425 19.5295 8.79575 19.47 9.16C19.4033 9.71943 19.2862 10.2717 19.12 10.81C18.99 11.26 18.85 12.04 18.78 12.41C18.71 12.78 18.55 13.79 18.42 14.23C18.2275 14.7549 17.9615 15.2498 17.63 15.7C17.1448 16.2402 16.7437 16.8504 16.44 17.51C16.3653 17.8379 16.3317 18.1738 16.34 18.51C16.3384 18.8207 16.3788 19.1301 16.46 19.43C16.0512 19.4736 15.6389 19.4736 15.23 19.43C14.84 19.37 14.36 18.59 14.23 18.35C14.1657 18.2211 14.034 18.1397 13.89 18.1397C13.746 18.1397 13.6143 18.2211 13.55 18.35C13.32 18.73 12.84 19.42 12.5 19.46C11.83 19.54 10.45 19.46 9.36001 19.46C9.36001 19.46 9.55001 18.46 9.13001 18.1C8.71001 17.74 8.30001 17.32 7.99001 17.04L7.16001 16.12C6.88001 15.76 6.53001 15.03 5.92001 14.12C5.57001 13.62 4.92001 13.03 4.64001 12.54C4.40493 12.1423 4.33664 11.6678 4.45001 11.22C4.61981 10.6254 5.21067 10.2545 5.82001 10.36C6.28351 10.3905 6.72193 10.5815 7.06001 10.9C7.32772 11.1316 7.57837 11.3823 7.81001 11.65C7.97001 11.84 8.01001 11.93 8.19001 12.16C8.37001 12.39 8.49001 12.62 8.40001 12.28'; + +const HAND_GRABBING = + 'M8.00003 7.15001C8.48003 6.97001 9.43003 7.08001 9.68003 7.62001C9.93003 8.16001 10.08 8.86001 10.09 8.69001C10.0709 8.17331 10.1145 7.65619 10.22 7.15001C10.3312 6.82583 10.5858 6.57116 10.91 6.46001C11.2073 6.36598 11.523 6.3454 11.83 6.40001C12.1404 6.46391 12.4154 6.64244 12.6 6.90001C12.834 7.48314 12.9659 8.10216 12.99 8.73001C13.0149 8.19427 13.1056 7.66362 13.26 7.15001C13.4271 6.91456 13.6712 6.7448 13.95 6.67001C14.2806 6.60957 14.6194 6.60957 14.95 6.67001C15.2214 6.76007 15.4587 6.93107 15.63 7.16001C15.8424 7.69016 15.9706 8.25026 16.01 8.82001C16.01 8.96001 16.08 8.43001 16.3 8.08001C16.4768 7.55534 17.0454 7.27328 17.57 7.45001C18.0947 7.62674 18.3768 8.19534 18.2 8.72001C18.2 9.37001 18.2 9.34001 18.2 9.78001C18.2 10.22 18.2 10.61 18.2 10.98C18.164 11.5652 18.0838 12.1469 17.96 12.72C17.7865 13.2273 17.5442 13.7085 17.24 14.15C16.7546 14.6901 16.3534 15.3003 16.05 15.96C15.976 16.288 15.9424 16.6238 15.95 16.96C15.949 17.2706 15.9894 17.58 16.07 17.88C15.6612 17.9237 15.2489 17.9237 14.84 17.88C14.45 17.82 13.97 17.04 13.84 16.8C13.7757 16.6711 13.6441 16.5897 13.5 16.5897C13.356 16.5897 13.2243 16.6711 13.16 16.8C12.94 17.18 12.45 17.87 12.16 17.91C11.49 17.99 10.1 17.91 9.02003 17.91C9.02003 17.91 9.21003 16.91 8.79003 16.55C8.37003 16.19 7.96003 15.77 7.65003 15.49L6.82003 14.57C6.23472 14.0267 5.80641 13.3359 5.58003 12.57C5.37003 11.63 5.39003 11.18 5.58003 10.8C5.77381 10.4862 6.07642 10.2548 6.43003 10.15C6.7238 10.0967 7.02621 10.1173 7.31003 10.21C7.50629 10.2922 7.67592 10.4272 7.80003 10.6C8.03003 10.91 8.11003 11.06 8.01003 10.72C7.91003 10.38 7.69003 10.13 7.58003 9.72001C7.36588 9.23581 7.23731 8.71814 7.20003 8.19001C7.22047 7.70925 7.54059 7.29309 8.00003 7.15001Z'; + +const I_BEAM = + 'M11.4612 16.6303V12.5017H10.4612V11.5217H11.4612V6.43311C11.2526 5.91815 10.9325 5.4562 10.5586 5.10975C10.2895 4.90512 9.98649 4.74931 9.7131 4.662C9.31184 4.58169 8.90178 4.55435 8.49341 4.58069L8.42905 3.58276C8.92468 3.5508 9.42236 3.58398 9.95898 3.69405C10.3938 3.82853 10.8016 4.03826 11.1995 4.34356C11.4874 4.60809 11.7406 4.9063 11.9541 5.23094C12.1805 4.90075 12.4546 4.59839 12.7745 4.32926C13.1346 4.06386 13.5313 3.85227 14.0235 3.68138C14.5068 3.585 15.0006 3.55183 15.4926 3.58271L15.4299 4.58074C15.0245 4.55529 14.6174 4.58263 14.2902 4.64232C13.9605 4.76067 13.6498 4.92636 13.3936 5.11389C12.9817 5.46142 12.6587 5.90203 12.4512 6.39901V11.5217H13.4512V12.5017H12.4512V16.654C12.6612 17.1514 12.9863 17.5912 13.3786 17.9198C13.6598 18.1278 13.97 18.2933 14.233 18.3922C14.6277 18.4742 15.0313 18.5046 15.4338 18.4825L15.4886 19.481C14.9995 19.5078 14.5091 19.4709 13.9631 19.3526C13.5413 19.202 13.1439 18.9901 12.7608 18.7055C12.4532 18.4487 12.1834 18.1519 11.9579 17.8242C11.739 18.1562 11.4748 18.4654 11.1643 18.7494C10.802 19.0255 10.3938 19.2354 9.90961 19.382C9.42248 19.4797 8.92459 19.5129 8.4288 19.4807L8.49367 18.4828C8.90187 18.5093 9.31179 18.482 9.66399 18.4139C9.9868 18.3144 10.2895 18.1587 10.5227 17.9837C10.9318 17.6075 11.2523 17.1452 11.4612 16.6303Z'; + +function Knuckles({ d }: { d: string[] }) { + return ( + <> + {d.map(path => ( + + ))} + + ); +} + +const SMALL_ARROW = ( + <> + + + +); + +const CURSORS: Record = { + default: { + size: [24, 24], + hotspot: [7, 3], + art: ( + <> + + + + + + + + + ), + }, + pointer: { + size: [24, 24], + hotspot: [9.3, 4.4], + art: ( + <> + + + + + + + + + + ), + }, + grab: { + size: [24, 24], + hotspot: [12, 12], + art: ( + <> + + + + + + + + + + ), + }, + grabbing: { + size: [24, 24], + hotspot: [12, 12], + art: ( + <> + + + + + + + + + + ), + }, + text: { + size: [24, 24], + hotspot: [12, 11.5], + art: ( + <> + + + + + + + + ), + }, + 'not-allowed': { + size: [28, 40], + hotspot: [4, 2.5], + art: ( + <> + + + + {SMALL_ARROW} + + + + + + + + + + ), + }, + progress: { + size: [28, 40], + hotspot: [4, 2.5], + art: ( + <> + + + + {SMALL_ARROW} + + + + + + ), + }, +}; + +export function Cursor({ state }: { state: CursorState }) { + const { size, hotspot, art } = CURSORS[state.kind]; + return ( +
+ + {art} + +
+ ); +} diff --git a/packages/swingset/src/studio/Recorder.tsx b/packages/swingset/src/studio/Recorder.tsx new file mode 100644 index 00000000000..589b105b9e8 --- /dev/null +++ b/packages/swingset/src/studio/Recorder.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { SegmentedControl } from '@clerk/mosaic/components/segmented-control'; +import { Text } from '@clerk/mosaic/components/text'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { type PointerEvent, useEffect, useRef, useState } from 'react'; + +import { type CursorPath, type CursorPathKind, type CursorSample, normalize } from './cursorPath'; +import { FORMATS } from './Stage'; + +const { width: BOARD_W, height: BOARD_H } = FORMATS.landscape; +const SIZES = [24, 32, 40, 56, 80, 120]; +const MARGIN = 80; +const STORAGE_KEY = 'studio-cursor-paths'; + +interface Dot { + x: number; + y: number; + size: number; +} + +function randomDot(from?: Dot): Dot { + for (;;) { + const size = SIZES[Math.floor(Math.random() * SIZES.length)]; + const dot = { + x: MARGIN + Math.random() * (BOARD_W - 2 * MARGIN), + y: MARGIN + Math.random() * (BOARD_H - 2 * MARGIN), + size, + }; + if (!from || Math.hypot(dot.x - from.x, dot.y - from.y) > 120) { + return dot; + } + } +} + +function load(): CursorPath[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); + } catch { + return []; + } +} + +function save(paths: CursorPath[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(paths)); + } catch { + return; + } +} + +function toFile(paths: CursorPath[]) { + return [ + `import type { CursorPath } from './cursorPath';`, + ``, + `export const recordedPaths: CursorPath[] = [`, + ...paths.map(path => ` ${JSON.stringify(path)},`), + `];`, + ``, + ].join('\n'); +} + +export function Recorder() { + const boardRef = useRef(null); + const samples = useRef([]); + const down = useRef(null); + const [kind, setKind] = useState('move'); + const [from, setFrom] = useState(null); + const [to, setTo] = useState(() => ({ x: BOARD_W / 2, y: BOARD_H / 2, size: 56 })); + const [paths, setPaths] = useState([]); + const [trail, setTrail] = useState([]); + const [copied, setCopied] = useState(false); + + useEffect(() => setPaths(load()), []); + + const update = (next: CursorPath[]) => { + setPaths(next); + save(next); + }; + + const toBoard = (event: { clientX: number; clientY: number; timeStamp: number }): CursorSample => { + const box = boardRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, BOARD_W, BOARD_H); + const scale = box.width / BOARD_W; + return { t: event.timeStamp, x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale }; + }; + + const inside = (sample: CursorSample, dot: Dot) => Math.hypot(sample.x - dot.x, sample.y - dot.y) <= dot.size / 2; + + const landOn = (sample: CursorSample, dot: Dot): [number, number] => [ + Math.max(-1, Math.min(1, (sample.x - dot.x) / (dot.size / 2))), + Math.max(-1, Math.min(1, (sample.y - dot.y) / (dot.size / 2))), + ]; + + const reset = (next: CursorPathKind) => { + setKind(next); + setFrom(null); + setTrail([]); + samples.current = []; + down.current = null; + setTo(randomDot()); + }; + + const onMove = (event: PointerEvent) => { + const events = event.nativeEvent.getCoalescedEvents?.() ?? []; + for (const raw of events.length ? events : [event.nativeEvent]) { + samples.current.push(toBoard(raw)); + } + }; + + const onDown = (event: PointerEvent) => { + const sample = toBoard(event); + if (kind === 'drag') { + if (!inside(sample, from ?? to)) { + return; + } + if (!from) { + setFrom(to); + setTo(randomDot(to)); + } + down.current = sample; + samples.current = [sample]; + return; + } + if (!inside(sample, to)) { + return; + } + down.current = sample; + }; + + const onUp = (event: PointerEvent) => { + const sample = toBoard(event); + const pressed = down.current; + down.current = null; + if (!pressed) { + return; + } + if (kind === 'drag') { + if (inside(sample, to)) { + const path = normalize('drag', samples.current, sample, to.size, landOn(sample, to), 0); + if (path) { + update([...paths, path]); + } + setTrail(samples.current); + setFrom(to); + setTo(randomDot(to)); + } + samples.current = []; + return; + } + if (from) { + const recorded = samples.current.filter(s => s.t <= pressed.t); + const path = normalize('move', recorded, pressed, to.size, landOn(pressed, to), sample.t - pressed.t); + if (path) { + update([...paths, path]); + } + setTrail(recorded); + } + samples.current = [sample]; + setFrom(to); + setTo(randomDot(to)); + }; + + const copy = async () => { + await navigator.clipboard.writeText(toFile(paths)); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const counts = { + move: paths.filter(p => p.kind === 'move').length, + drag: paths.filter(p => p.kind === 'drag').length, + }; + + return ( + +
+ +
+ + {trail.length > 1 && `${s.x},${s.y}`).join(' ')} />} + {from && ( + + )} + + +
+
+
+ ); +} diff --git a/packages/swingset/src/studio/Stage.tsx b/packages/swingset/src/studio/Stage.tsx new file mode 100644 index 00000000000..308f5611dbe --- /dev/null +++ b/packages/swingset/src/studio/Stage.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { ClerkLogo } from '@clerk/mosaic/components/clerk-logo'; +import { PortalProvider } from '@clerk/mosaic/primitives/portal'; +import type { ComponentProps, CSSProperties, MutableRefObject, ReactNode, Ref } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import { CodeView } from './CodeView'; +import { Cursor, type CursorState } from './Cursor'; +import type { StudioBadges, StudioFormat, StudioLayout, StudioScheme } from './types'; + +export const FORMATS: Record = { + landscape: { width: 1280, height: 720 }, + square: { width: 1080, height: 1080 }, + portrait: { width: 1080, height: 1350 }, +}; + +function openBoxes(root: Element, boxes: DOMRect[] = []): DOMRect[] { + for (const child of root.children) { + if (child.matches(CLOSING) || child.querySelector(CLOSING)) { + continue; + } + const box = child.getBoundingClientRect(); + if (box.width > 0 && box.height > 0) { + boxes.push(box); + } else { + openBoxes(child, boxes); + } + } + return boxes; +} + +const CLOSING = '[data-closed], [data-ending-style]'; + +function useFocusFrame(resetKey: unknown) { + const frameRef = useRef(null); + const contentRef = useRef(null); + const portalRef = useRef(null); + const extent = useRef({ width: 0, height: 0 }); + + useLayoutEffect(() => { + extent.current = { width: 0, height: 0 }; + contentRef.current?.style.removeProperty('--measured-w'); + contentRef.current?.style.removeProperty('--measured-h'); + }, [resetKey]); + + useLayoutEffect(() => { + const frame = frameRef.current; + const content = contentRef.current; + const portal = portalRef.current; + if (!frame || !content || !portal) { + return; + } + let pending = 0; + const measure = () => { + const origin = frame.getBoundingClientRect(); + const scale = origin.width / frame.offsetWidth || 1; + const own = content.getBoundingClientRect(); + const popups = openBoxes(portal); + for (const popup of popups) { + if (popup.width === 0 || portal.querySelector('[data-starting-style]')) { + continue; + } + const width = (popup.right - own.left) / scale; + const height = (popup.bottom - own.top) / scale; + if (width > extent.current.width) { + extent.current.width = width; + content.style.setProperty('--measured-w', `${Math.ceil(width)}px`); + } + if (height > extent.current.height) { + extent.current.height = height; + content.style.setProperty('--measured-h', `${Math.ceil(height)}px`); + } + } + const boxes = [own, ...popups]; + const left = Math.min(...boxes.map(box => box.left)); + const right = Math.max(...boxes.map(box => box.right)); + const top = Math.min(...boxes.map(box => box.top)); + const bottom = Math.max(...boxes.map(box => box.bottom)); + frame.style.setProperty('--focus-x', `${((left + right) / 2 - origin.left) / scale}px`); + frame.style.setProperty('--focus-y', `${((top + bottom) / 2 - origin.top) / scale}px`); + }; + const schedule = () => { + cancelAnimationFrame(pending); + pending = requestAnimationFrame(measure); + }; + measure(); + const ready = requestAnimationFrame(() => frame.setAttribute('data-ready', '')); + const resize = new ResizeObserver(schedule); + resize.observe(content); + portal.addEventListener('transitionend', schedule); + portal.addEventListener('animationend', schedule); + const mutation = new MutationObserver(schedule); + mutation.observe(portal, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-open', 'data-closed', 'data-starting-style', 'data-ending-style', 'style'], + }); + return () => { + cancelAnimationFrame(pending); + cancelAnimationFrame(ready); + resize.disconnect(); + mutation.disconnect(); + portal.removeEventListener('transitionend', schedule); + portal.removeEventListener('animationend', schedule); + }; + }, []); + + return { frameRef, contentRef, portalRef }; +} + +export interface StageProps extends ComponentProps<'div'> { + layout: StudioLayout; + format: StudioFormat; + scheme: StudioScheme; + title?: string; + badges?: StudioBadges; + code?: string; + file?: string; + lang?: string; + view?: ReactNode; + focus?: { width: number; height: number }; + grid?: boolean; + cursor?: CursorState; + stageRef?: Ref; + viewRef?: Ref; + scaleRef?: MutableRefObject; +} + +export function Stage({ + layout, + format, + scheme, + title, + badges = 'both', + code, + file, + lang = 'tsx', + view, + focus, + grid, + cursor, + stageRef, + viewRef, + scaleRef, + children, + ...props +}: StageProps) { + const { width, height } = FORMATS[format]; + const viewportRef = useRef(null); + const { frameRef, contentRef, portalRef } = useFocusFrame(title); + const cellRef = useRef(null); + const previousLayout = useRef(layout); + const transition = useRef({ layout, from: layout }); + if (transition.current.layout !== layout) { + transition.current = { layout, from: transition.current.layout }; + } + + useLayoutEffect(() => { + const cell = cellRef.current; + if (!cell) { + return; + } + const box = cell.getBoundingClientRect(); + let fill: Element = cell; + for (const element of cell.querySelectorAll('*')) { + const rect = element.getBoundingClientRect(); + if (Math.abs(rect.width - box.width) < 1 && Math.abs(rect.height - box.height) < 1) { + fill = element; + } + } + const style = getComputedStyle(fill); + cell.style.borderRadius = fill === cell ? '' : style.borderRadius; + cell.style.setProperty('corner-shape', fill === cell ? '' : style.getPropertyValue('corner-shape')); + }); + + useLayoutEffect(() => { + const from = previousLayout.current; + previousLayout.current = layout; + const frame = frameRef.current; + if (from !== 'split' || layout !== 'component' || !frame) { + return; + } + const style = getComputedStyle(frame); + const out = parseFloat(style.getPropertyValue('--fade-out-ms')); + const fadeIn = parseFloat(style.getPropertyValue('--fade-in-ms')); + frame.animate( + [ + { opacity: 1, easing: style.getPropertyValue('--ease-fade').trim() || 'ease-out' }, + { + opacity: 0, + offset: out / (out + fadeIn), + easing: style.getPropertyValue('--ease-fade').trim() || 'ease-out', + }, + { opacity: 1 }, + ], + { duration: out + fadeIn }, + ); + }, [layout, frameRef]); + const [scale, setScale] = useState(1); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (!viewport) { + return; + } + const fit = () => { + const next = Math.min(1, viewport.clientWidth / width, viewport.clientHeight / height); + if (scaleRef) { + scaleRef.current = next; + } + setScale(next); + }; + fit(); + const observer = new ResizeObserver(fit); + observer.observe(viewport); + return () => observer.disconnect(); + }, [width, height, scaleRef]); + + return ( +
+
+
+
+ + + + +
+
+
+ {code && ( + + )} +
+ +
+
+
+ + + + + + +
+ {view} +
+
+
+
+
+
+
+ {badges !== 'none' && ( +
+ + {badges === 'both' && ( + + )} +
+ + + + +
+ {badges === 'both' && ( +
+ + + + + $ npm{' '} + install @clerk/mosaic + +
+ )} +
+ )} + {cursor && } +
+ {children} +
+ ); +} diff --git a/packages/swingset/src/studio/Studio.tsx b/packages/swingset/src/studio/Studio.tsx new file mode 100644 index 00000000000..3e68a33592a --- /dev/null +++ b/packages/swingset/src/studio/Studio.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Stage } from './Stage'; +import type { Showcase, StudioBadges, StudioFormat, StudioScheme } from './types'; +import { useCursor } from './useCursor'; + +const LOOP_HOLD = 1.2; + +export interface StudioOptions { + step?: number; + hold?: boolean; + autoplay?: boolean; + loop?: boolean; + cursor?: boolean; + badges?: StudioBadges; + format?: StudioFormat; + scheme?: StudioScheme; +} + +export function resolveSteps(showcase: Showcase) { + let state = showcase.initialState; + let code = showcase.code?.(state) ?? ''; + return showcase.steps.map(step => { + if (step.state) { + state = { ...state, ...step.state }; + code = showcase.code?.(state) ?? code; + } + if (step.code !== undefined) { + code = step.code; + } + return { state, code }; + }); +} + +export function Studio({ showcase, options = {} }: { showcase: Showcase; options?: StudioOptions }) { + const format = options.format ?? showcase.format ?? 'landscape'; + const scheme = options.scheme ?? showcase.scheme ?? 'light'; + const steps = showcase.steps; + const resolved = useMemo(() => resolveSteps(showcase), [showcase]); + + const [index, setIndexState] = useState(() => Math.min(Math.max((options.step ?? 1) - 1, 0), steps.length - 1)); + const [cycle, setCycle] = useState(0); + const [done, setDone] = useState(false); + const indexRef = useRef(index); + const stepStart = useRef(0); + const playing = useRef(options.autoplay !== false && !options.hold); + + const { cursor, play, cancel, stageRef, viewRef, scaleRef } = useCursor(); + + const setIndex = useCallback( + (next: number) => { + const clamped = Math.min(Math.max(next, 0), steps.length - 1); + indexRef.current = clamped; + stepStart.current = performance.now() / 1000; + setIndexState(clamped); + }, + [steps.length], + ); + + const restart = useCallback(() => { + setCycle(c => c + 1); + setDone(false); + setIndex(0); + }, [setIndex]); + + useEffect(() => { + stepStart.current = performance.now() / 1000; + let frame = 0; + const tick = () => { + const elapsed = performance.now() / 1000 - stepStart.current; + const current = indexRef.current; + const duration = steps[current].duration; + if (playing.current) { + if (current < steps.length - 1 && elapsed >= duration) { + setIndex(current + 1); + } else if (current === steps.length - 1 && elapsed >= duration + LOOP_HOLD) { + if (options.loop === false) { + playing.current = false; + setDone(true); + } else { + restart(); + } + } + } + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [steps, setIndex, restart, options.loop]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === ' ') { + playing.current = !playing.current; + event.preventDefault(); + } else if (event.key === 'ArrowRight') { + setIndex(indexRef.current + 1); + } else if (event.key === 'ArrowLeft') { + setIndex(indexRef.current - 1); + } else if (event.key === 'r') { + playing.current = true; + restart(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [setIndex, restart]); + + useEffect(() => { + play(options.cursor === false ? [] : (steps[index].cursor ?? []), `${showcase.slug}:${index}`); + return cancel; + }, [index, cycle, steps, showcase.slug, options.cursor, play, cancel]); + + const step = steps[index]; + const { state, code } = resolved[index]; + const View = showcase.View; + + return ( + + + + ) + } + cursor={cursor} + stageRef={stageRef} + viewRef={viewRef} + scaleRef={scaleRef} + data-step={index + 1} + data-steps={steps.length} + data-done={done || undefined} + /> + ); +} diff --git a/packages/swingset/src/studio/StudioEntry.tsx b/packages/swingset/src/studio/StudioEntry.tsx new file mode 100644 index 00000000000..b7858455640 --- /dev/null +++ b/packages/swingset/src/studio/StudioEntry.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { notFound } from 'next/navigation'; + +import { getShowcase } from './showcases'; +import { Studio, type StudioOptions } from './Studio'; + +export function StudioEntry({ slug, options }: { slug: string; options: StudioOptions }) { + const showcase = getShowcase(slug); + if (!showcase) { + notFound(); + } + return ( + + ); +} diff --git a/packages/swingset/src/studio/StudioPreview.tsx b/packages/swingset/src/studio/StudioPreview.tsx new file mode 100644 index 00000000000..908b739976f --- /dev/null +++ b/packages/swingset/src/studio/StudioPreview.tsx @@ -0,0 +1,103 @@ +'use client'; + +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { useState } from 'react'; + +import { Button, buttonVariants } from '@/components/ui/button'; + +import { getShowcase, showcases } from './showcases'; +import { FORMATS } from './Stage'; +import type { StudioFormat, StudioScheme } from './types'; + +export function StudioIndex() { + return ( +
+
+

Studio

+

+ Showcases for social video. Each one plays in a bare capture route at /capture/[slug]. +

+

+ + Open the template workbench + +

+
+
    + {showcases.map(showcase => ( +
  • + + {showcase.title} + {showcase.summary} + +
  • + ))} +
+
+ ); +} + +export function StudioPreview({ slug }: { slug: string }) { + const showcase = getShowcase(slug); + const [format, setFormat] = useState(showcase?.format ?? 'landscape'); + const [scheme, setScheme] = useState(showcase?.scheme ?? 'light'); + if (!showcase) { + notFound(); + } + const { width, height } = FORMATS[format]; + const src = `/capture/${slug}?format=${format}&scheme=${scheme}`; + + return ( +
+
+

{showcase.title}

+ {(Object.keys(FORMATS) as StudioFormat[]).map(option => ( + + ))} + + + Open capture view + +
+