diff --git a/.cursorignore b/.cursorignore
index fe443571f90..9ee59b13859 100644
--- a/.cursorignore
+++ b/.cursorignore
@@ -5,6 +5,7 @@
# Environment files
.env
.env.*
+!.env.example
# Secrets directory
secrets/
diff --git a/packages/swingset/.env.example b/packages/swingset/.env.example
new file mode 100644
index 00000000000..6af4e643fe1
--- /dev/null
+++ b/packages/swingset/.env.example
@@ -0,0 +1,6 @@
+# Copy to packages/swingset/.env.local and restart Swingset (`pnpm dev:swingset`).
+# Required for /live, /sign-in, and /sign-up. The component explorer works without it.
+NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
+
+# Optional. Enables the Clerk handshake for SSO / OAuth on /sign-in and /sign-up.
+CLERK_SECRET_KEY=
diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs
index a9c62bc3fb7..9eff37ea125 100644
--- a/packages/swingset/next.config.mjs
+++ b/packages/swingset/next.config.mjs
@@ -1,12 +1,14 @@
import createMDX from '@next/mdx';
import stylexPlugin from '@stylexjs/unplugin/webpack';
-import { resolve } from 'path';
+import { createRequire } from 'module';
+import { dirname, resolve } from 'path';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import { fileURLToPath } from 'url';
import { mosaicLightningCssTargets } from '../ui/stylex-lightningcss.config.mjs';
+const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const withMDX = createMDX({
@@ -98,6 +100,9 @@ const nextConfig = {
config.resolve.alias['@clerk/headless/hooks'] = resolve(__dirname, '../headless/src/hooks');
config.resolve.alias['@clerk/headless/utils'] = resolve(__dirname, '../headless/src/utils');
config.resolve.alias['@clerk/headless'] = resolve(__dirname, '../headless/src/primitives');
+ // Mosaic/headless source imports this. Webpack resolves from the aliased file's
+ // directory, which is outside swingset, so the package has to be named here.
+ config.resolve.alias['@floating-ui/react'] = dirname(require.resolve('@floating-ui/react/package.json'));
return config;
},
};
diff --git a/packages/swingset/package.json b/packages/swingset/package.json
index bce696a74bc..d1bc3f9274f 100644
--- a/packages/swingset/package.json
+++ b/packages/swingset/package.json
@@ -12,7 +12,10 @@
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/headless": "workspace:*",
+ "@clerk/nextjs": "workspace:*",
+ "@clerk/shared": "workspace:*",
"@clerk/ui": "workspace:*",
+ "@floating-ui/react": "catalog:repo",
"@stylexjs/stylex": "0.19.0",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
diff --git a/packages/swingset/src/app/(clerk)/layout.tsx b/packages/swingset/src/app/(clerk)/layout.tsx
new file mode 100644
index 00000000000..7cfd477d711
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/layout.tsx
@@ -0,0 +1,63 @@
+import { ClerkProvider } from '@clerk/nextjs';
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+
+import { LiveUserButton } from './live-user-button';
+
+function LiveChrome({ children, userButton }: { children: ReactNode; userButton?: ReactNode }) {
+ return (
+
+ );
+}
+
+export default function ClerkLayout({ children }: { children: ReactNode }) {
+ const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
+
+ if (!publishableKey) {
+ return (
+
+
+
Live
+
+ To access sign-in, sign-up, and live pages, set up a publishable key.
+
+
+ Copy packages/swingset/.env.example to{' '}
+ packages/swingset/.env.local and set{' '}
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, then restart Swingset.
+
+
+ CLERK_SECRET_KEY is optional. Add it for SSO and OAuth redirects.
+
+
+
+ );
+ }
+
+ return (
+
+ }>{children}
+
+ );
+}
diff --git a/packages/swingset/src/app/(clerk)/live-user-button.tsx b/packages/swingset/src/app/(clerk)/live-user-button.tsx
new file mode 100644
index 00000000000..a9cd463181b
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live-user-button.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
+import { UserButton } from '@clerk/ui/mosaic/user-button/user-button';
+
+export function LiveUserButton() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx
new file mode 100644
index 00000000000..8ac70fa3643
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import { SignOutButton, useUser } from '@clerk/nextjs';
+import { isReverificationCancelledError } from '@clerk/shared/error';
+import { Button } from '@clerk/ui/mosaic/components/button';
+import { Reverification, useReverificationWithState } from '@clerk/ui/mosaic/features/reverification';
+import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
+import Link from 'next/link';
+import { useState } from 'react';
+
+async function mockDelete() {
+ const response = await fetch('/api/live/mock-delete', { method: 'POST' });
+ const body = await response.json().catch(() => ({}));
+
+ if (body?.clerk_error?.reason === 'reverification-error') {
+ return body;
+ }
+ if (!response.ok) {
+ throw new Error(typeof body.error === 'string' ? body.error : `Mock delete failed (${response.status})`);
+ }
+ return body;
+}
+
+async function resetMockDelete() {
+ await fetch('/api/live/mock-delete', { method: 'DELETE' });
+}
+
+function DeleteAccountHarness() {
+ const { user } = useUser();
+ const [status, setStatus] = useState<'idle' | 'success' | 'cancelled' | 'error'>('idle');
+ const [message, setMessage] = useState(null);
+ const [deleteAccount, reverification] = useReverificationWithState(mockDelete);
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+
Signed in as {user.primaryEmailAddress?.emailAddress ?? user.id}
+
+
+
+
+
+
+
+ {message ?
{message}
: null}
+
+ );
+}
+
+// Throwaway live harness. Not a story.
+export default function ReverificationLivePage() {
+ const { isLoaded, isSignedIn } = useUser();
+
+ return (
+
+
+
+
Reverification
+
+ Throwaway live page. Delete account hits a mock route that returns a reverification hint, then a fake
+ success. The account is not deleted.
+
+
+ {!isLoaded ?
Loading…
: null}
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to use the live harness.
+
+ ) : null}
+ {isLoaded && isSignedIn ?
: null}
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(clerk)/sign-in/[[...sign-in]]/page.tsx b/packages/swingset/src/app/(clerk)/sign-in/[[...sign-in]]/page.tsx
new file mode 100644
index 00000000000..f6e768aa179
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/sign-in/[[...sign-in]]/page.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { SignIn } from '@clerk/nextjs';
+
+export default function SignInPage() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(clerk)/sign-up/[[...sign-up]]/page.tsx b/packages/swingset/src/app/(clerk)/sign-up/[[...sign-up]]/page.tsx
new file mode 100644
index 00000000000..231bc8bd358
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/sign-up/[[...sign-up]]/page.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { SignUp } from '@clerk/nextjs';
+
+export default function SignUpPage() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/swingset/src/app/[group]/[component]/page.tsx b/packages/swingset/src/app/(explorer)/[group]/[component]/page.tsx
similarity index 100%
rename from packages/swingset/src/app/[group]/[component]/page.tsx
rename to packages/swingset/src/app/(explorer)/[group]/[component]/page.tsx
diff --git a/packages/swingset/src/app/(explorer)/layout.tsx b/packages/swingset/src/app/(explorer)/layout.tsx
new file mode 100644
index 00000000000..449351b6cec
--- /dev/null
+++ b/packages/swingset/src/app/(explorer)/layout.tsx
@@ -0,0 +1,7 @@
+import type { ReactNode } from 'react';
+
+import { ClientRoot } from '@/components/ClientRoot';
+
+export default function ExplorerLayout({ children }: { children: ReactNode }) {
+ return {children};
+}
diff --git a/packages/swingset/src/app/page.tsx b/packages/swingset/src/app/(explorer)/page.tsx
similarity index 100%
rename from packages/swingset/src/app/page.tsx
rename to packages/swingset/src/app/(explorer)/page.tsx
diff --git a/packages/swingset/src/app/api/live/mock-delete/route.ts b/packages/swingset/src/app/api/live/mock-delete/route.ts
new file mode 100644
index 00000000000..fb0b9d7fd8c
--- /dev/null
+++ b/packages/swingset/src/app/api/live/mock-delete/route.ts
@@ -0,0 +1,23 @@
+import { reverificationErrorResponse } from '@clerk/nextjs/server';
+import { cookies } from 'next/headers';
+
+const COOKIE = 'swingset-mock-delete';
+
+export async function POST() {
+ const store = await cookies();
+
+ // First call: ask for step-up. After the card completes, the same fetcher is retried.
+ if (store.get(COOKIE)?.value !== '1') {
+ store.set(COOKIE, '1', { path: '/', maxAge: 120, sameSite: 'lax' });
+ return reverificationErrorResponse('strict');
+ }
+
+ store.delete(COOKIE);
+ return Response.json({ ok: true, at: new Date().toISOString() });
+}
+
+export async function DELETE() {
+ const store = await cookies();
+ store.delete(COOKIE);
+ return new Response(null, { status: 204 });
+}
diff --git a/packages/swingset/src/app/layout.tsx b/packages/swingset/src/app/layout.tsx
index 09ae389ad1c..33de3af1482 100644
--- a/packages/swingset/src/app/layout.tsx
+++ b/packages/swingset/src/app/layout.tsx
@@ -5,7 +5,6 @@ import { Geist } from 'next/font/google';
import Script from 'next/script';
import type React from 'react';
-import { ClientRoot } from '@/components/ClientRoot';
import { ThemeProvider } from '@/components/ThemeProvider';
import { cn } from '@/lib/utils';
@@ -32,9 +31,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
)}
-
- {children}
-
+ {children}