` (→ `UsageBlock`, a live code snippet that reflects the current knob values), and a `` override routing fenced code through Shiki (`CodeBlock`). `next.config.mjs` configures `remark-gfm` and `rehype-raw` (with MDX node pass-through) so raw HTML in tables works.
- **`` examples can show their source in a collapsible code footer.** When a story module exposes its own source as `__source` — via a `?raw` self-import (`export { default as __source } from './x.stories?raw'`) — `StoryEmbed` runs `extractStorySource` (`src/lib/extractStorySource.ts`) to pull the _previewed story function's_ source out of that raw text, then `toUsageSnippet` (`src/lib/exampleSnippet.ts`) to reduce that knob harness to a clean usage snippet (unwraps `export function …() { return (…) }` down to the returned JSX and strips the `{...knobsAsProps(props)}` / `{...props}` knob plumbing), and renders a `CodeFooter` (`CodeFooter.tsx`): a "View code" toggle that's collapsed by default and reveals the snippet with a height animation (Base UI's `--collapsible-panel-height` + `data-starting/ending-style`). It's **opt-in per module** — only modules that export `__source` get a footer, and it's keyed to whichever story `name` the `` renders, so each example shows its own code. Shiki highlighting is shared with the ``/`CodeBlock` path through the `useShikiHtml` hook. A `` can carry both a code footer and a `composition` footer; they stack under the preview.
- - The `?raw` query is wired in `next.config.mjs`: an `asset/source` rule handles `?raw` imports, and — crucially — a recursive `excludeRawQuery` pass adds `resourceQuery: { not: [/raw/] }` to every _other_ loader so Next's SWC loader doesn't compile the file first (otherwise `__source` would contain `_jsxDEV(…)` output instead of the authored source).
+ - The `?raw` query uses Turbopack's native `raw` module type in `next.config.mjs`. The StyleX Babel loader excludes these imports so the footer receives the authored source.
- **Two component layers.** `src/components/ui/*` are shadcn/ui primitives (`components.json`, `base-nova` style, neutral base) used for swingset's _own_ chrome (sidebar, tabs, inputs). The components being _documented_ come from `@clerk/mosaic`. Don't confuse the two.
diff --git a/packages/swingset/eslint-suppressions.json b/packages/swingset/eslint-suppressions.json
deleted file mode 100644
index 176402928ce..00000000000
--- a/packages/swingset/eslint-suppressions.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "src/components/CommandPalette.tsx": {
- "react-hooks/set-state-in-effect": {
- "count": 2
- }
- },
- "src/components/StoryPreview.tsx": {
- "react-hooks/set-state-in-effect": {
- "count": 1
- }
- },
- "src/components/ThemeToggle.tsx": {
- "react-hooks/set-state-in-effect": {
- "count": 1
- }
- },
- "src/components/app-sidebar.tsx": {
- "react-hooks/set-state-in-effect": {
- "count": 1
- }
- },
- "src/hooks/use-mobile.ts": {
- "react-hooks/set-state-in-effect": {
- "count": 1
- }
- },
- "src/stories/fixtures/api-keys-table.tsx": {
- "react-hooks/set-state-in-effect": {
- "count": 1
- }
- },
- "src/stories/user-button.stories.tsx": {
- "react-hooks/set-state-in-effect": {
- "count": 1
- }
- }
-}
diff --git a/packages/swingset/next-env.d.ts b/packages/swingset/next-env.d.ts
index 830fb594ca2..644e5801cd4 100644
--- a/packages/swingset/next-env.d.ts
+++ b/packages/swingset/next-env.d.ts
@@ -1,6 +1,7 @@
///
///
-///
+import './.next/types/routes.d.ts';
+import './.next/types/root-params.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs
index 9e1fae9619a..6822022d246 100644
--- a/packages/swingset/next.config.mjs
+++ b/packages/swingset/next.config.mjs
@@ -1,28 +1,24 @@
import createMDX from '@next/mdx';
-import stylexPlugin from '@stylexjs/unplugin/webpack';
import { createRequire } from 'module';
-import { dirname, resolve } from 'path';
-import rehypeRaw from 'rehype-raw';
-import rehypeSlug from 'rehype-slug';
-import remarkGfm from 'remark-gfm';
+import { dirname, relative, resolve } from 'path';
import { fileURLToPath } from 'url';
-import { mosaicLightningCssTargets } from '../mosaic/stylex-lightningcss.config.mjs';
-
const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
+const mosaicRoot = resolve(__dirname, '../mosaic');
+const isDev = process.env.NODE_ENV !== 'production';
const withMDX = createMDX({
options: {
- remarkPlugins: [remarkGfm],
+ remarkPlugins: ['remark-gfm'],
rehypePlugins: [
[
- rehypeRaw,
+ 'rehype-raw',
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
- rehypeSlug,
+ 'rehype-slug',
],
allowDangerousHtml: true,
},
@@ -31,81 +27,56 @@ const withMDX = createMDX({
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
- eslint: {
- ignoreDuringBuilds: true,
- },
- webpack(config) {
- // `import src from './foo.stories.tsx?raw'` must return the file's *untransformed* text.
- // Next's SWC loader matches by extension and ignores the `?raw` query, so on its own it
- // would compile the file before our `asset/source` rule captures it (yielding `_jsxDEV(…)`
- // output instead of source). Exclude `?raw` from every existing loader, then emit the raw
- // bytes ourselves. Story modules self-import this way to expose `__source` for the
- // `` code footer.
- const excludeRawQuery = rules => {
- for (const rule of rules) {
- if (!rule || typeof rule !== 'object') {
- continue;
- }
- if (Array.isArray(rule.oneOf)) {
- excludeRawQuery(rule.oneOf);
- }
- if (Array.isArray(rule.rules)) {
- excludeRawQuery(rule.rules);
- }
- if (rule.oneOf || rule.rules || rule.type === 'asset/source') {
- continue;
- }
- if (!rule.resourceQuery) {
- rule.resourceQuery = { not: [/raw/] };
- } else if (rule.resourceQuery.not) {
- rule.resourceQuery.not.push(/raw/);
- }
- }
- };
- excludeRawQuery(config.module.rules);
- config.module.rules.push({ resourceQuery: /raw/, type: 'asset/source' });
-
- // Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be
- // compiled here — otherwise the calls hit the runtime and throw. The unplugin transforms the
- // StyleX *JS only*, keeping SWC intact so `next/font` and the Emotion transform keep working.
- //
- // The `@stylexjs/postcss-plugin` (see `postcss.config.mjs`) is what extracts the CSS — the
- // token `:root { --cl-* }` defaults and the atoms — in both dev and prod. This unplugin only
- // transforms the StyleX *calls* in the JS. `runtimeInjection` forks by env:
- // - Prod: `false`. Atoms are static class refs resolved against the extracted sheet.
- // - Dev: `true`. On top of the extracted sheet, StyleX also injects each atom at runtime under
- // its content hash, so editing a `.styles.ts` file hot-reloads a fresh atom (the extracted
- // sheet goes stale because Next won't re-run the `globals.css` PostCSS pass on Mosaic-source
- // edits). The `:root` token defaults come from the extraction and never change mid-session,
- // so they stay correct — `runtimeInjection` can't emit them (`defineVars` is compile-only).
- // Both passes share the same babel version/options so atom hashes match.
- const isDev = process.env.NODE_ENV !== 'production';
- config.plugins.push(
- stylexPlugin({
- dev: isDev,
- runtimeInjection: isDev,
- unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../mosaic') },
- // Stories reach `tokens.stylex.ts` through the same alias; StyleX resolves it itself.
- aliases: { '@clerk/mosaic/*': [resolve(__dirname, '../mosaic/src/*')] },
- useCSSLayers: true,
- lightningcssOptions: { targets: mosaicLightningCssTargets },
- }),
- );
-
- // Dev-only: StyleX's runtime injector drops named `@container` rules after the first per
- // query — see the loader for the bug. Scoped to that one module.
- if (isDev) {
- config.module.rules.push({
- test: /[\\/]@stylexjs[\\/]stylex[\\/]lib[\\/](es|cjs)[\\/]inject\.(mjs|js)$/,
- use: [{ loader: resolve(__dirname, 'src/lib/loaders/stylex-inject-named-container.cjs') }],
- });
- }
-
- config.resolve.alias['@clerk/mosaic'] = resolve(__dirname, '../mosaic/src');
- // 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;
+ experimental: { turbopackUseBuiltinBabel: false },
+ turbopack: {
+ root: resolve(__dirname, '../..'),
+ resolveAlias: {
+ '@clerk/mosaic': '../mosaic/src',
+ '@clerk/mosaic/*': '../mosaic/src/*',
+ '@floating-ui/react': relative(__dirname, dirname(require.resolve('@floating-ui/react/package.json'))).replaceAll(
+ '\\',
+ '/',
+ ),
+ },
+ rules: {
+ '*': {
+ condition: { query: /[?&]raw(?:[=&]|$)/ },
+ type: 'raw',
+ },
+ '*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}': {
+ condition: {
+ all: [{ not: 'foreign' }, { not: { query: /[?&]raw(?:[=&]|$)/ } }, { content: /@stylexjs\/stylex/ }],
+ },
+ loaders: [
+ {
+ loader: 'babel-loader',
+ options: {
+ babelrc: false,
+ configFile: false,
+ presets: [require.resolve('@babel/preset-typescript')],
+ plugins: [
+ require.resolve('@babel/plugin-syntax-jsx'),
+ [
+ require.resolve('@stylexjs/babel-plugin'),
+ {
+ dev: isDev,
+ runtimeInjection: isDev,
+ unstable_moduleResolution: { type: 'commonJS', rootDir: mosaicRoot },
+ aliases: { '@clerk/mosaic/*': [resolve(mosaicRoot, 'src/*')] },
+ },
+ ],
+ ],
+ },
+ },
+ ],
+ },
+ '*.{js,mjs}': {
+ condition: {
+ all: ['development', { path: /@stylexjs\/stylex\/lib\/(es|cjs)\/inject\.(mjs|js)$/ }],
+ },
+ loaders: [resolve(__dirname, 'src/lib/loaders/stylex-inject-named-container.cjs')],
+ },
+ },
},
};
diff --git a/packages/swingset/oxlint.config.mjs b/packages/swingset/oxlint.config.mjs
new file mode 100644
index 00000000000..3c36865fd4b
--- /dev/null
+++ b/packages/swingset/oxlint.config.mjs
@@ -0,0 +1,16 @@
+import { defineConfig } from 'oxlint';
+
+import rootConfig from '../../oxlint.config.mjs';
+
+export default defineConfig({
+ ...rootConfig,
+ overrides: [
+ ...rootConfig.overrides,
+ {
+ files: ['src/**/*'],
+ rules: {
+ 'turbo/no-undeclared-env-vars': ['error', { allowList: ['SWINGSET_DASHBOARD_URL'] }],
+ },
+ },
+ ],
+});
diff --git a/packages/swingset/package.json b/packages/swingset/package.json
index 53528686f44..452447ae0a5 100644
--- a/packages/swingset/package.json
+++ b/packages/swingset/package.json
@@ -8,8 +8,16 @@
"dev": "node scripts/dev.mjs",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
- "lint": "eslint src"
+ "lint": "oxlint -c oxlint.config.mjs src",
+ "typecheck": "next typegen && tsc --noEmit"
},
+ "browserslist": [
+ "Chrome >= 123",
+ "Edge >= 123",
+ "Firefox >= 121",
+ "Safari >= 17.5",
+ "iOS >= 17.5"
+ ],
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/mosaic": "workspace:*",
@@ -22,7 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
- "next": "^15.0.0",
+ "next": "^16.4.3",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
@@ -36,20 +44,19 @@
"@babel/preset-typescript": "^7.29.7",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
- "@next/mdx": "^15.0.0",
+ "@next/mdx": "^16.4.3",
"@stylexjs/babel-plugin": "0.19.0",
"@stylexjs/postcss-plugin": "0.19.0",
- "@stylexjs/unplugin": "0.19.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
+ "babel-loader": "^10.1.1",
"get-port": "^5.1.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
- "typescript": "catalog:repo",
- "unplugin": "^2.3.11"
+ "typescript": "7.0.2"
}
}
diff --git a/packages/swingset/postcss.config.mjs b/packages/swingset/postcss.config.mjs
index 7bc3bc16552..331ed1a3814 100644
--- a/packages/swingset/postcss.config.mjs
+++ b/packages/swingset/postcss.config.mjs
@@ -1,29 +1,22 @@
import { createRequire } from 'module';
-import { dirname, resolve } from 'path';
-import { fileURLToPath } from 'url';
+import { resolve } from 'path';
const require = createRequire(import.meta.url);
-const __dirname = dirname(fileURLToPath(import.meta.url));
+const __dirname = process.cwd();
const mosaicRoot = resolve(__dirname, '../mosaic');
const isDev = process.env.NODE_ENV !== 'production';
-// StyleX CSS. `@stylexjs/postcss-plugin` scans the Mosaic source, runs the StyleX babel
-// transform, and replaces the `@stylex;` directive in `globals.css` with the generated CSS:
-// the token `:root { --cl-* }` defaults *and* the atoms. This runs in BOTH dev and prod
-// because it is the only thing that emits the `:root` token defaults — StyleX's `defineVars`
-// is compile-time-only (its runtime export throws), so `runtimeInjection` alone leaves every
-// `var(--cl-*)` unresolved (unstyled). Its babel `dev`/`rootDir` must match the unplugin in
-// `next.config.mjs` so atom hashes line up.
-//
-// In dev this sheet goes stale on `.styles.ts` edits (Next won't re-run the `globals.css`
-// PostCSS pass for files outside the CSS import graph), but that's fine: the unplugin's
-// `runtimeInjection` (see `next.config.mjs`) injects the *fresh* atom at runtime under a new
-// content hash, which HMR tracks. The stale extracted atom is dead CSS; the `:root` token
-// defaults never change mid-session, so they stay correct.
+// Extract token defaults and atoms from the Mosaic and Swingset sources
+// Keep the Babel options aligned with next.config.mjs so class names match
+// Watch the token module directly so edits rebuild the generated defaults
const stylexExtraction = {
'@stylexjs/postcss-plugin': {
- include: [resolve(__dirname, 'src/**/*.{ts,tsx}'), resolve(mosaicRoot, 'src/**/*.{ts,tsx}')],
+ include: [
+ resolve(__dirname, 'src/**/*.{ts,tsx}'),
+ resolve(mosaicRoot, 'src/**/*.{ts,tsx}'),
+ resolve(mosaicRoot, 'src/tokens.stylex.ts'),
+ ],
useCSSLayers: true,
babelConfig: {
babelrc: false,
diff --git a/packages/swingset/src/components/CommandPalette.tsx b/packages/swingset/src/components/CommandPalette.tsx
index 430fbf62941..a0125b80cff 100644
--- a/packages/swingset/src/components/CommandPalette.tsx
+++ b/packages/swingset/src/components/CommandPalette.tsx
@@ -73,11 +73,13 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh
const results = q ? entries.filter(entry => entry.search.includes(q)) : entries;
React.useEffect(() => {
+ // eslint-disable-next-line react/set-state-in-effect -- Reset the selected result after the search query changes
setActiveIndex(0);
}, [q]);
React.useEffect(() => {
if (!open) {
+ // eslint-disable-next-line react/set-state-in-effect -- Clear the search state when the controlled palette closes
setQuery('');
setActiveIndex(0);
}
diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx
index 6f6333ca48e..16998f21d57 100644
--- a/packages/swingset/src/components/StoryPreview.tsx
+++ b/packages/swingset/src/components/StoryPreview.tsx
@@ -25,6 +25,7 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
const StoryComp = storyModule[name] as React.ComponentType>;
const playground = usePlayground();
const [mounted, setMounted] = useState(false);
+ // eslint-disable-next-line react/set-state-in-effect -- Render browser-dependent stories only after hydration
useEffect(() => setMounted(true), []);
if (!StoryComp) {
diff --git a/packages/swingset/src/components/ThemeToggle.tsx b/packages/swingset/src/components/ThemeToggle.tsx
index 9cef006beda..381a8010487 100644
--- a/packages/swingset/src/components/ThemeToggle.tsx
+++ b/packages/swingset/src/components/ThemeToggle.tsx
@@ -11,6 +11,7 @@ export function ThemeToggle() {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
+ // eslint-disable-next-line react/set-state-in-effect -- Read the client theme only after hydration
setMounted(true);
}, []);
diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx
index c10a7a7f733..cf02b59baca 100644
--- a/packages/swingset/src/components/app-sidebar.tsx
+++ b/packages/swingset/src/components/app-sidebar.tsx
@@ -32,6 +32,7 @@ export function AppSidebar({
const [isMac, setIsMac] = React.useState(false);
React.useEffect(() => {
+ // eslint-disable-next-line react/set-state-in-effect -- Read the client platform only after hydration
setIsMac(navigator.platform.toLowerCase().includes('mac'));
}, []);
diff --git a/packages/swingset/src/hooks/use-mobile.ts b/packages/swingset/src/hooks/use-mobile.ts
index ba553c6f558..6fca3ad0c91 100644
--- a/packages/swingset/src/hooks/use-mobile.ts
+++ b/packages/swingset/src/hooks/use-mobile.ts
@@ -11,6 +11,7 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
+ // eslint-disable-next-line react/set-state-in-effect -- Initialize the breakpoint state while subscribing to media changes
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
diff --git a/packages/swingset/src/middleware.ts b/packages/swingset/src/middleware.ts
deleted file mode 100644
index c4f559e021d..00000000000
--- a/packages/swingset/src/middleware.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { clerkMiddleware } from '@clerk/nextjs/server';
-import type { NextFetchEvent, NextRequest } from 'next/server';
-import { NextResponse } from 'next/server';
-
-const withClerk = process.env.CLERK_SECRET_KEY ? clerkMiddleware() : null;
-
-export default function middleware(request: NextRequest, event: NextFetchEvent) {
- return withClerk ? withClerk(request, event) : NextResponse.next();
-}
-
-export const config = {
- matcher: ['/sign-in(.*)', '/sign-up(.*)', '/live(.*)'],
-};
diff --git a/packages/swingset/src/proxy.ts b/packages/swingset/src/proxy.ts
new file mode 100644
index 00000000000..9a031403b02
--- /dev/null
+++ b/packages/swingset/src/proxy.ts
@@ -0,0 +1,10 @@
+import { clerkMiddleware } from '@clerk/nextjs/server';
+import { NextResponse } from 'next/server';
+
+const proxy = process.env.CLERK_SECRET_KEY ? clerkMiddleware() : () => NextResponse.next();
+
+export default proxy;
+
+export const config = {
+ matcher: ['/sign-in(.*)', '/sign-up(.*)', '/live(.*)'],
+};
diff --git a/packages/swingset/src/stories/fixtures/api-keys-table.tsx b/packages/swingset/src/stories/fixtures/api-keys-table.tsx
index beec2b19415..13dc99b4167 100644
--- a/packages/swingset/src/stories/fixtures/api-keys-table.tsx
+++ b/packages/swingset/src/stories/fixtures/api-keys-table.tsx
@@ -127,6 +127,7 @@ export function useAPIKeysTableFixture({
const pageCount = Math.ceil(filtered.length / pageSize);
useEffect(() => {
if (page > Math.max(1, pageCount)) {
+ // eslint-disable-next-line react/set-state-in-effect -- Keep the fixture page within range after its data changes
setPage(Math.max(1, pageCount));
}
}, [page, pageCount]);
diff --git a/packages/swingset/src/stories/flow.component.stories.tsx b/packages/swingset/src/stories/flow.component.stories.tsx
index 3c7348e1a42..c160d2c2413 100644
--- a/packages/swingset/src/stories/flow.component.stories.tsx
+++ b/packages/swingset/src/stories/flow.component.stories.tsx
@@ -14,7 +14,7 @@ export const meta: StoryMeta = {
source: 'packages/mosaic/src/components/flow/flow.tsx',
};
-function FlowDemo(): JSX.Element {
+function FlowDemo() {
const [step, setStep] = useState('details');
const [direction, setDirection] = useState(1);
@@ -67,11 +67,11 @@ function FlowDemo(): JSX.Element {
);
}
-export function Default(): JSX.Element {
+export function Default() {
return ;
}
-export function Customized(): JSX.Element {
+export function Customized() {
return (