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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cyan-regions-slide.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
"dev:swingset": "pnpm --filter @clerk/shared build && TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/swingset --filter=@clerk/shared",
"format": "turbo format && node scripts/format-non-workspace.mjs",
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
"preinstall": "npx only-allow pnpm",
Expand Down
104 changes: 104 additions & 0 deletions packages/swingset/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# Swingset

Component explorer for the Mosaic design system. Runs at http://localhost:6006.

## Dev

```
pnpm dev --filter @clerk/swingset
```

## Adding a component

**1. Create a story file** — `src/stories/my-component.stories.tsx`

```tsx
/** @jsxImportSource @emotion/react */
import type { StoryMeta } from '@/lib/types';
import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-component';

export const meta: StoryMeta = {
group: 'Components',
title: 'My Component',
styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
};

export function Default(props: Record<string, unknown>) {
return <MyComponent {...(props as MyComponentProps)}>Content</MyComponent>;
}
```

Knobs are generated automatically from the CVA `_variants` on the style object. Boolean variants (`true`/`false` keys) become toggles; all others become selects. Default values come from `defaultVariants`.

**2. Register in `src/lib/registry.ts`**

```ts
import { meta as myMeta, Default } from '../stories/my-component.stories';

const myModule: StoryModule = { meta: myMeta, Default };
export const registry: StoryModule[] = [..., myModule];
```

Import stories explicitly (not `import *`) to control sidebar order.

**3. Add docs** — `src/stories/my-component.mdx`

```mdx
import * as Stories from './my-component.stories';

# My Component

<Story
name='Default'
storyModule={Stories}
/>

## Props

| Prop | Type | Default |
| -------------------- | ---------------------- | ---------------------- |
| <code>variant</code> | <code>'primary'</code> | <code>'primary'</code> |
```

Register in `src/components/DocsViewer.tsx`:

```ts
const docModules = {
'my-component': () => import('../stories/my-component.mdx'),
};
```

Also update the root redirect in `src/app/page.tsx` if this is now the first component.

## PropTable

In MDX, use `<PropTable>` to auto-generate the props table from the CVA style object:

```mdx
import * as Stories from './my-component.stories';

<PropTable meta={Stories.meta} />
```

Variant props (type and default) are derived from `meta.styles._variants` and `meta.styles._defaultVariants`. The `sx` prop is always appended automatically. Pass `extra` for any other non-variant props.

## Architecture

```
src/
app/ Next.js App Router
components/
app-sidebar.tsx Left nav (reads from registry)
ClientRoot.tsx SidebarProvider + breadcrumb header
DocsViewer.tsx Renders MDX docs for /components/[slug]
StoryCanvas.tsx Renders a story + knobs for /components/[slug]/[story]
KnobPanel.tsx Auto-generated knob controls
VariablesPanel.tsx Mosaic CSS variable overrides
CodeBlock.tsx Shiki syntax highlighter (css-variables theme)
lib/
registry.ts Story registry — add new stories here
generateKnobs.ts CVA _variants → knob definitions
types.ts StoryMeta, StoryModule, KnobDef etc.
slug.ts URL slug utilities
stories/ Story and MDX files
```
25 changes: 25 additions & 0 deletions packages/swingset/components.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
25 changes: 25 additions & 0 deletions packages/swingset/mdx-components.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { CodeBlock } from './src/components/CodeBlock';
import { PropTable } from './src/components/PropTable';
import { StoryEmbed } from './src/components/StoryEmbed';

function PreBlock({ children }: { children?: React.ReactNode }) {
if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
const { className, children: code } = (children as React.ReactElement<{ className?: string; children: string }>)
.props;
return <CodeBlock className={className}>{code}</CodeBlock>;
}
return <pre>{children}</pre>;
}

type MDXComponents = Record<string, React.ElementType>;

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
pre: PreBlock,
Story: StoryEmbed,
PropTable,
};
}
6 changes: 6 additions & 0 deletions packages/swingset/next-env.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
36 changes: 36 additions & 0 deletions packages/swingset/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[
rehypeRaw,
{
passThrough: ['mdxjsEsm', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxFlowExpression', 'mdxTextExpression'],
},
],
],
allowDangerousHtml: true,
},
});

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['tsx', 'ts', 'mdx'],
compiler: {
emotion: true,
},
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
return config;
},
};

export default withMDX(nextConfig);
42 changes: 42 additions & 0 deletions packages/swingset/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
{
"name": "@clerk/swingset",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev --port 6006",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "^15.0.0",
"next-themes": "^0.4.6",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-grab": "^0.1.44",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
}
}
5 changes: 5 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
exportdefault{
plugins: {
'@tailwindcss/postcss': {},
},
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { StoryCanvas } from '@/components/StoryCanvas';

interface Props {
params: Promise<{ component: string; story: string }>;
}

export default async function StoryPage({ params }: Props) {
const { component, story } = await params;
return (
<StoryCanvas
componentSlug={component}
storySlug={story}
/>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/app/components/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { component } = await params;
return <DocsViewer slug={component} />;
}
Loading
Loading