From 1b46264eca74cc021cd7a8064a9ca65d8f74c2ac Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 10 Jun 2026 17:19:15 -0400 Subject: [PATCH 1/3] feat(swingset): single-page overviews with inline playground Replace the per-story knob sub-pages with one overview page per component. The interactive playground now lives inline in the MDX overview, with shared per-page state (PlaygroundContext) driving a live whose props are edited through controls embedded in the Value column. Design-token overrides move into a VariablesPanel attached to the preview, the sidebar is flattened to a single link per component under section labels, and each page gains a GitHub 'View source' link and a generated Usage snippet derived from the story source. Docs (CLAUDE.md, README) updated to match. --- .changeset/swingset-overview-preview.md | 2 + packages/swingset/CLAUDE.md | 14 ++- packages/swingset/README.md | 32 ++++-- packages/swingset/mdx-components.tsx | 4 + .../components/[component]/[story]/page.tsx | 15 --- .../swingset/src/components/CodeBlock.tsx | 3 +- .../swingset/src/components/DocsViewer.tsx | 23 +++- .../swingset/src/components/KnobControl.tsx | 75 +++++++++++++ .../swingset/src/components/KnobPanel.tsx | 96 ---------------- .../src/components/PlaygroundContext.tsx | 55 +++++++++ .../swingset/src/components/PropTable.tsx | 48 ++++++-- .../swingset/src/components/StoryCanvas.tsx | 92 ---------------- .../swingset/src/components/StoryPreview.tsx | 76 +++++++++++++ .../swingset/src/components/UsageBlock.tsx | 104 ++++++++++++++++++ .../src/components/VariablesPanel.tsx | 12 +- .../swingset/src/components/ViewSource.tsx | 30 +++++ .../swingset/src/components/app-sidebar.tsx | 93 +++++----------- .../src/components/ui/collapsible.tsx | 5 +- .../swingset/src/lib/extractStorySource.ts | 41 +++++++ packages/swingset/src/lib/registry.ts | 53 ++------- packages/swingset/src/lib/source.ts | 8 ++ packages/swingset/src/lib/types.ts | 6 + packages/swingset/src/stories/button.mdx | 44 ++++---- .../swingset/src/stories/button.stories.tsx | 1 + .../src/stories/collapsible.stories.tsx | 1 + packages/swingset/src/stories/input.mdx | 43 ++++---- .../swingset/src/stories/input.stories.tsx | 1 + packages/swingset/src/types/raw.d.ts | 7 ++ 28 files changed, 595 insertions(+), 389 deletions(-) create mode 100644 .changeset/swingset-overview-preview.md delete mode 100644 packages/swingset/src/app/components/[component]/[story]/page.tsx create mode 100644 packages/swingset/src/components/KnobControl.tsx delete mode 100644 packages/swingset/src/components/KnobPanel.tsx create mode 100644 packages/swingset/src/components/PlaygroundContext.tsx delete mode 100644 packages/swingset/src/components/StoryCanvas.tsx create mode 100644 packages/swingset/src/components/StoryPreview.tsx create mode 100644 packages/swingset/src/components/UsageBlock.tsx create mode 100644 packages/swingset/src/components/ViewSource.tsx create mode 100644 packages/swingset/src/lib/extractStorySource.ts create mode 100644 packages/swingset/src/lib/source.ts create mode 100644 packages/swingset/src/types/raw.d.ts diff --git a/.changeset/swingset-overview-preview.md b/.changeset/swingset-overview-preview.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-overview-preview.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index 17668ac27d2..bc322aa9db9 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -24,12 +24,18 @@ These require reading several files together; the `README.md` covers the step-by - **Knobs are generated from CVA metadata, not hand-written.** A story's `meta.styles` is a Mosaic CVA style object exposing `_variants` / `_defaultVariants`. `lib/generateKnobs.ts` turns each variant into a control: variants whose keys are only `true`/`false` become boolean toggles, everything else becomes a select. Knob values are passed as props straight into the story component. This is why story functions take `Record` and cast to the real prop type. -- **`lib/registry.ts` is the single source of truth for stories**, and stories are imported *explicitly* (never `import *`) so sidebar order is deterministic. `getSidebarGroups`, `findStory`, and slugging (`lib/slug.ts`, from `meta.title` and export name) all read from it. Adding a component touches up to three wiring points: `registry.ts` (knobs/canvas), `DocsViewer.tsx`'s `docModules` map (MDX docs), and the hardcoded redirect in `app/page.tsx`. +- **`lib/registry.ts` is the single source of truth for which components exist**, and they are imported *explicitly* (never `import *`) so sidebar order is deterministic. `getSidebarGroups`, `getModuleBySlug`, and slugging (`lib/slug.ts`, from `meta.title`) read from it. Adding a component touches up to three wiring points: `registry.ts` (sidebar entry + per-page playground lookup), `DocsViewer.tsx`'s `docModules` map (MDX docs), and the hardcoded redirect in `app/page.tsx`. -- **Routing.** `/components/[component]` renders MDX docs via `DocsViewer`; `/components/[component]/[story]` renders the interactive `StoryCanvas`. `app/page.tsx` is a static redirect (currently to `/components/button`) because `registry.ts` eagerly imports story modules (Emotion / `createContext`), so registry-derived data can't be computed in a Server Component. +- **Routing.** Each component is a single page: `/components/[component]` renders its MDX overview via `DocsViewer`. There are no per-story sub-pages — the interactive playground lives *inside* the overview. `app/page.tsx` is a static redirect (currently to `/components/button`) because `registry.ts` eagerly imports story modules (Emotion / `createContext`), so registry-derived data can't be computed in a Server Component. `DocsViewer` also renders a "View source" link (`ViewSource.tsx`) from `meta.source` — a repo-root-relative path turned into a GitHub URL by `lib/source.ts`. -- **Every story renders inside `MosaicProvider`.** `StoryCanvas` (interactive route) wraps the component in `MosaicProvider` and exposes a `VariablesPanel` that overrides `MosaicVariables` (color, rounded, spacing) live. `StoryEmbed` (used inside MDX via ``) renders with default knob values and no variable panel. +- **Shared playground state.** `DocsViewer` wraps each overview in a `PlaygroundProvider` (`PlaygroundContext.tsx`), keyed by slug and seeded from the component's `meta` via `getModuleBySlug`. It owns the knob values (props) and live `MosaicVariables`. The `` and the interactive `` both read/write this single context, so editing a prop in the table updates the preview above it. -- **MDX.** `mdx-components.tsx` injects custom components into all MDX: `` (→ `StoryEmbed`), `` (derives a props table from `meta.styles._variants`/`_defaultVariants`, always appends `sx`), 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.
+- **Every story renders inside `MosaicProvider`.** `StoryPreview` (the MDX ``) renders a named story with the playground's knob values as props, applies the variable overrides, and exposes a Reset button plus a collapsible `VariablesPanel` attached to the preview. `StoryEmbed` (the MDX ``) renders a single static variation with default knob values and no controls.
+
+- **The prop table is the knob surface.** `PropTable` (MDX ``) derives rows from `meta.styles._variants`/`_defaultVariants` (always appends `sx`); each variant row renders a `KnobControl` in its **Value** column, seeded with the prop's default and bound to the playground context. Non-variant rows (`sx`, `extra`) stay static.
+
+- **Variables live in the preview.** The `VariablesPanel` is a collapsible attached to `StoryPreview` (toggled from the preview's header), bound to the shared playground context so editing a Mosaic token override immediately re-themes the story rendered above it.
+
+- **MDX.** `mdx-components.tsx` injects custom components into all MDX: `` (→ `StoryPreview`), `` (→ `StoryEmbed`, static), `` (→ interactive `PropTable`), 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.
 
 - **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/ui/mosaic`. Don't confuse the two. Mosaic stories use Emotion (`/** @jsxImportSource @emotion/react */` pragma; `compiler.emotion` enabled in Next).
diff --git a/packages/swingset/README.md b/packages/swingset/README.md
index 593b8e042a5..620764a1b68 100644
--- a/packages/swingset/README.md
+++ b/packages/swingset/README.md
@@ -20,6 +20,7 @@ import { MyComponent, myComponentStyles } from '@clerk/ui/mosaic/components/my-c
 export const meta: StoryMeta = {
   group: 'Components',
   title: 'My Component',
+  source: 'packages/ui/src/mosaic/components/my-component.tsx', // repo-root path → "View source" link
   styles: myComponentStyles, // CVA style object — knobs auto-generated from _variants
 };
 
@@ -30,6 +31,8 @@ export function Default(props: Record) {
 
 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`.
 
+`source` is the path to the component's exporting file relative to the monorepo root; `DocsViewer` renders it as a "View source" link to the file on GitHub (`lib/source.ts`).
+
 **2. Register in `src/lib/registry.ts`**
 
 ```ts
@@ -48,18 +51,24 @@ import * as Stories from './my-component.stories';
 
 # My Component
 
-
 
 ## Props
 
-| Prop                 | Type                   | Default                |
-| -------------------- | ---------------------- | ---------------------- |
-| variant | 'primary' | 'primary' |
+
 ```
 
+`` renders the live component inline in the overview — there are no separate
+per-story pages. Its props are edited through the controls in the `` below it,
+and a collapsible Variables panel attached to the preview exposes Mosaic token overrides
+that re-theme it. Both share the page's playground state. Use `` for
+additional static demos of specific variations (no controls).
+
 Register in `src/components/DocsViewer.tsx`:
 
 ```ts
@@ -90,15 +99,20 @@ src/
   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)
+    DocsViewer.tsx       Renders MDX docs for /components/[slug]; provides PlaygroundContext
+    PlaygroundContext.tsx Shared per-page knob values + Mosaic variables
+    StoryPreview.tsx     Live  embed, props driven by the playground state
+    StoryEmbed.tsx       Static  embed: a single variation, no controls
+    PropTable.tsx        Interactive props table — controls live in the Value column
+    KnobControl.tsx      A single auto-generated control (switch/select/input)
+    VariablesPanel.tsx   Mosaic CSS variable overrides (collapsible, attached to the preview)
+    CodeBlock.tsx        Shiki syntax highlighter (css-variables theme)
+    ViewSource.tsx       "View source" link to the component's file on GitHub
   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
+    source.ts          Builds GitHub URLs from meta.source paths
   stories/             Story and MDX files
 ```
diff --git a/packages/swingset/mdx-components.tsx b/packages/swingset/mdx-components.tsx
index 1b4fe1715fd..da231751449 100644
--- a/packages/swingset/mdx-components.tsx
+++ b/packages/swingset/mdx-components.tsx
@@ -3,6 +3,8 @@ import * as React from 'react';
 import { CodeBlock } from './src/components/CodeBlock';
 import { PropTable } from './src/components/PropTable';
 import { StoryEmbed } from './src/components/StoryEmbed';
+import { StoryPreview } from './src/components/StoryPreview';
+import { UsageBlock } from './src/components/UsageBlock';
 
 function PreBlock({ children }: { children?: React.ReactNode }) {
   if (React.isValidElement(children) && (children as React.ReactElement).type === 'code') {
@@ -20,6 +22,8 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
     ...components,
     pre: PreBlock,
     Story: StoryEmbed,
+    Preview: StoryPreview,
     PropTable,
+    Usage: UsageBlock,
   };
 }
diff --git a/packages/swingset/src/app/components/[component]/[story]/page.tsx b/packages/swingset/src/app/components/[component]/[story]/page.tsx
deleted file mode 100644
index bd7748fe26c..00000000000
--- a/packages/swingset/src/app/components/[component]/[story]/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-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 (
-    
-  );
-}
diff --git a/packages/swingset/src/components/CodeBlock.tsx b/packages/swingset/src/components/CodeBlock.tsx
index fe012e2f5d4..e4d7c0dd9e9 100644
--- a/packages/swingset/src/components/CodeBlock.tsx
+++ b/packages/swingset/src/components/CodeBlock.tsx
@@ -59,8 +59,9 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
       {html ? (
         // safe: shiki output is developer-controlled source code
         
) : (
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 044a4598290..d84acda1d60 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -2,6 +2,11 @@
 
 import dynamic from 'next/dynamic';
 
+import { getModuleBySlug } from '@/lib/registry';
+
+import { PlaygroundProvider } from './PlaygroundContext';
+import { ViewSource } from './ViewSource';
+
 const docModules: Record = {
   button: dynamic(() => import('../stories/button.mdx')),
   input: dynamic(() => import('../stories/input.mdx')),
@@ -17,9 +22,21 @@ export function DocsViewer({ slug }: DocsViewerProps) {
   if (!DocContent) {
     return 
No docs found for "{slug}".
; } + const meta = getModuleBySlug(slug)?.meta; return ( -
- -
+ // Keyed by slug so navigating between components resets the playground state. + +
+ {meta?.source ? ( +
+ +
+ ) : null} + +
+
); } diff --git a/packages/swingset/src/components/KnobControl.tsx b/packages/swingset/src/components/KnobControl.tsx new file mode 100644 index 00000000000..47170b69b61 --- /dev/null +++ b/packages/swingset/src/components/KnobControl.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { KnobDef, KnobValues } from '@/lib/types'; + +interface KnobControlProps { + id: string; + def: KnobDef; + value: KnobValues[string]; + onChange: (value: KnobValues[string]) => void; +} + +/** A single auto-generated control for one knob, used inside the interactive prop table. */ +export function KnobControl({ id, def, value, onChange }: KnobControlProps) { + switch (def.type) { + case 'boolean': + return ( + onChange(checked === true)} + /> + ); + case 'text': + return ( + onChange(e.target.value)} + className='h-7 text-xs' + /> + ); + case 'number': + return ( + onChange(e.target.valueAsNumber)} + className='h-7 w-24 text-xs' + /> + ); + case 'select': { + const items = def.options.map(opt => ({ label: opt, value: opt })); + return ( + + ); + } + } +} diff --git a/packages/swingset/src/components/KnobPanel.tsx b/packages/swingset/src/components/KnobPanel.tsx deleted file mode 100644 index c84ff36aec8..00000000000 --- a/packages/swingset/src/components/KnobPanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import type { KnobRecord, KnobValues } from '@/lib/types'; - -interface KnobPanelProps { - knobs: KnobRecord; - values: KnobValues; - onChange: (values: KnobValues) => void; -} - -export function KnobPanel({ knobs, values, onChange }: KnobPanelProps) { - const entries = Object.entries(knobs); - if (entries.length === 0) { - return null; - } - - return ( -
- {entries.map(([key, def]) => ( -
- - - {def.type === 'boolean' && ( - onChange({ ...values, [key]: checked === true })} - /> - )} - - {def.type === 'text' && ( - onChange({ ...values, [key]: e.target.value })} - className='h-7 text-xs' - /> - )} - - {def.type === 'number' && ( - onChange({ ...values, [key]: e.target.valueAsNumber })} - className='h-7 text-xs' - /> - )} - - {def.type === 'select' && - (() => { - const items = def.options.map(opt => ({ label: opt, value: opt })); - return ( - - ); - })()} -
- ))} -
- ); -} diff --git a/packages/swingset/src/components/PlaygroundContext.tsx b/packages/swingset/src/components/PlaygroundContext.tsx new file mode 100644 index 00000000000..c981d5794b6 --- /dev/null +++ b/packages/swingset/src/components/PlaygroundContext.tsx @@ -0,0 +1,55 @@ +'use client'; + +import type { MosaicVariables } from '@clerk/ui/mosaic/variables'; +import type React from 'react'; +import { createContext, useContext, useMemo, useState } from 'react'; + +import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; +import type { KnobRecord, KnobValues, StoryMeta } from '@/lib/types'; + +interface PlaygroundContextValue { + /** Knob definitions derived from the component's CVA `_variants`. */ + knobs: KnobRecord; + /** Current value for each knob (props passed into the live preview). */ + values: KnobValues; + setValue: (key: string, value: KnobValues[string]) => void; + /** Live Mosaic design-token overrides applied via `MosaicProvider`. */ + variables: MosaicVariables; + setVariables: (variables: MosaicVariables) => void; + /** Restore every knob to its default and clear variable overrides. */ + reset: () => void; +} + +const PlaygroundContext = createContext(null); + +/** + * Holds the shared playground state for a single component's overview page. The + * `` reads it to render the live component while `` renders the + * matching interactive controls, so editing a prop's value updates the preview. + */ +export function PlaygroundProvider({ meta, children }: { meta?: StoryMeta; children: React.ReactNode }) { + const knobs = useMemo(() => (meta ? generateKnobs(meta) : {}), [meta]); + const [values, setValues] = useState(() => initKnobValues(knobs)); + const [variables, setVariables] = useState({}); + + const value = useMemo( + () => ({ + knobs, + values, + setValue: (key, v) => setValues(prev => ({ ...prev, [key]: v })), + variables, + setVariables, + reset: () => { + setValues(initKnobValues(knobs)); + setVariables({}); + }, + }), + [knobs, values, variables], + ); + + return {children}; +} + +export function usePlayground(): PlaygroundContextValue | null { + return useContext(PlaygroundContext); +} diff --git a/packages/swingset/src/components/PropTable.tsx b/packages/swingset/src/components/PropTable.tsx index 7b647600f73..634698e5eaf 100644 --- a/packages/swingset/src/components/PropTable.tsx +++ b/packages/swingset/src/components/PropTable.tsx @@ -1,5 +1,10 @@ +'use client'; + import type { StoryMeta } from '@/lib/types'; +import { KnobControl } from './KnobControl'; +import { usePlayground } from './PlaygroundContext'; + interface ExtraProp { name: string; type: string; @@ -14,6 +19,7 @@ interface PropTableProps { const SX_ROW: ExtraProp = { name: 'sx', type: 'StyleRule | (theme) => StyleRule' }; export function PropTable({ meta, extra = [] }: PropTableProps) { + const playground = usePlayground(); const variants = meta.styles?._variants ?? {}; const defaults = meta.styles?._defaultVariants ?? {}; @@ -37,21 +43,39 @@ export function PropTable({ meta, extra = [] }: PropTableProps) { Prop Type - Default + Value - {rows.map(row => ( - - - {row.name} - - - {row.type} - - {row.default !== undefined ? {row.default} : '—'} - - ))} + {rows.map(row => { + // Variant props become live controls (seeded with their default); everything + // else (sx, extra props) stays a static default cell. + const knob = playground?.knobs[row.name]; + return ( + + + {row.name} + + + {row.type} + + + {knob && playground ? ( + playground.setValue(row.name, v)} + /> + ) : row.default !== undefined ? ( + {row.default} + ) : ( + '—' + )} + + + ); + })} ); diff --git a/packages/swingset/src/components/StoryCanvas.tsx b/packages/swingset/src/components/StoryCanvas.tsx deleted file mode 100644 index 4c64ee21f8a..00000000000 --- a/packages/swingset/src/components/StoryCanvas.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; -import type { MosaicVariables } from '@clerk/ui/mosaic/variables'; -import type React from 'react'; -import { useEffect, useState } from 'react'; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; -import { findStory } from '@/lib/registry'; -import type { KnobRecord, KnobValues } from '@/lib/types'; - -import { KnobPanel } from './KnobPanel'; -import { VariablesPanel } from './VariablesPanel'; - -interface StoryCanvasProps { - componentSlug: string; - storySlug: string; -} - -export function StoryCanvas({ componentSlug, storySlug }: StoryCanvasProps) { - const found = findStory(componentSlug, storySlug); - - const [knobs, setKnobs] = useState(() => (found ? generateKnobs(found.mod.meta) : {})); - const [knobValues, setKnobValues] = useState(() => initKnobValues(knobs)); - const [variables, setVariables] = useState({}); - - useEffect(() => { - if (!found) { - return; - } - const nextKnobs = generateKnobs(found.mod.meta); - setKnobs(nextKnobs); - setKnobValues(initKnobValues(nextKnobs)); - // `found` is derived synchronously from the slugs each render and is a fresh - // object every time, so depending on it would re-run this effect on every - // render. Depend on the stable slug strings instead. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [componentSlug, storySlug]); - - if (!found) { - return
Story not found.
; - } - - const StoryComp = found.mod[found.storyName] as React.ComponentType>; - - return ( -
-
-
- - - -
-
- -
- - - Knobs - Variables - - - - - - - - -
-
- ); -} diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx new file mode 100644 index 00000000000..68e53a16e57 --- /dev/null +++ b/packages/swingset/src/components/StoryPreview.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { RotateCcwIcon, SlidersHorizontalIcon } from 'lucide-react'; +import type React from 'react'; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; +import type { StoryModule } from '@/lib/types'; + +import { usePlayground } from './PlaygroundContext'; +import { VariablesPanel } from './VariablesPanel'; + +interface StoryPreviewProps { + name: string; + storyModule: StoryModule; +} + +/** + * Interactive preview embedded in a component's MDX overview. Renders the named story + * inside `MosaicProvider`; its props are driven by the shared playground state, which is + * edited through the controls in the `` below it. A collapsible `VariablesPanel` + * attached to the preview overrides Mosaic design tokens to re-theme it live. + */ +export function StoryPreview({ name, storyModule }: StoryPreviewProps) { + const StoryComp = storyModule[name] as React.ComponentType>; + const playground = usePlayground(); + + if (!StoryComp) { + return ( +
Story "{name}" not found
+ ); + } + + // Fall back to the story's own defaults if rendered outside a PlaygroundProvider. + const values = playground?.values ?? initKnobValues(generateKnobs(storyModule.meta)); + const variables = playground?.variables ?? {}; + + return ( + +
+ +
+ +
+ + + +
+ +
+ + + Variables + +
+ + {playground ? ( + + + + ) : null} +
+ ); +} diff --git a/packages/swingset/src/components/UsageBlock.tsx b/packages/swingset/src/components/UsageBlock.tsx new file mode 100644 index 00000000000..c2c80315912 --- /dev/null +++ b/packages/swingset/src/components/UsageBlock.tsx @@ -0,0 +1,104 @@ +'use client'; + +import * as React from 'react'; + +import type { KnobValues } from '@/lib/types'; + +import { CodeBlock } from './CodeBlock'; +import { usePlayground } from './PlaygroundContext'; + +interface UsageBlockProps { + /** JSX tag rendered in the snippet, e.g. `Button`. */ + component: string; + /** Import source; when set, an `import { component } from 'module'` line is prepended. */ + module?: string; + /** Static props authored in MDX (e.g. `placeholder`) that aren't variant knobs. */ + props?: Record; + /** Inner content of the component (rendered as JSX children text). */ + children?: React.ReactNode; +} + +function formatProp(key: string, value: KnobValues[string]): string | null { + if (typeof value === 'boolean') { + // Booleans read as usage code: bare attribute when on, omitted when off. + return value ? key : null; + } + if (typeof value === 'number') { + return `${key}={${value}}`; + } + return `${key}='${value}'`; +} + +function childrenToString(node: React.ReactNode): string { + if (node == null || typeof node === 'boolean') { + return ''; + } + if (typeof node === 'string') { + return node; + } + if (typeof node === 'number') { + return String(node); + } + if (Array.isArray(node)) { + return node.map(childrenToString).join(''); + } + if (React.isValidElement(node)) { + return childrenToString((node.props as { children?: React.ReactNode }).children); + } + return ''; +} + +function buildCode(component: string, module: string | undefined, attrs: string[], inner: string): string { + const lines: string[] = []; + + if (module) { + lines.push(`import { ${component} } from '${module}';`, ''); + } + + if (attrs.length === 0) { + lines.push(inner ? `<${component}>${inner}` : `<${component} />`); + } else { + lines.push(`<${component}`); + for (const attr of attrs) { + lines.push(` ${attr}`); + } + if (inner) { + lines.push('>', ` ${inner}`, ``); + } else { + lines.push('/>'); + } + } + + return lines.join('\n'); +} + +/** + * A live `Usage` code block for a component overview. Reads the shared playground state so + * editing a prop in the `` regenerates the snippet in place: variant props come + * from the current knob values, then any static props authored in MDX are appended. + */ +export function UsageBlock({ component, module, props: staticProps = {}, children }: UsageBlockProps) { + const playground = usePlayground(); + + const attrs: string[] = []; + // Variant props first, in knob order, skipping any the author overrides statically below. + for (const key of playground ? Object.keys(playground.knobs) : []) { + if (key in staticProps || !playground) { + continue; + } + const formatted = formatProp(key, playground.values[key]); + if (formatted !== null) { + attrs.push(formatted); + } + } + for (const [key, value] of Object.entries(staticProps)) { + const formatted = formatProp(key, value); + if (formatted !== null) { + attrs.push(formatted); + } + } + + const code = buildCode(component, module, attrs, childrenToString(children).trim()); + + return {code}; +} diff --git a/packages/swingset/src/components/VariablesPanel.tsx b/packages/swingset/src/components/VariablesPanel.tsx index 7879c9b0bbe..7acd3ef9e31 100644 --- a/packages/swingset/src/components/VariablesPanel.tsx +++ b/packages/swingset/src/components/VariablesPanel.tsx @@ -44,7 +44,7 @@ export function VariablesPanel({ variables, onChange }: VariablesPanelProps) {
-
Colors
+
Colors
{(Object.keys(colors) as Array).map(key => (
@@ -67,7 +67,7 @@ export function VariablesPanel({ variables, onChange }: VariablesPanelProps) {
-
Radius
+
Radius
{(Object.keys(radii) as Array) .filter(k => k !== 'full') .map(key => ( @@ -77,7 +77,7 @@ export function VariablesPanel({ variables, onChange }: VariablesPanelProps) { > @@ -92,11 +92,11 @@ export function VariablesPanel({ variables, onChange }: VariablesPanelProps) {
-
Spacing
+
Spacing
diff --git a/packages/swingset/src/components/ViewSource.tsx b/packages/swingset/src/components/ViewSource.tsx new file mode 100644 index 00000000000..ab0658d2f8f --- /dev/null +++ b/packages/swingset/src/components/ViewSource.tsx @@ -0,0 +1,30 @@ +import { sourceUrl } from '@/lib/source'; + +/** Official GitHub mark — lucide-react no longer ships brand icons, so inline it. */ +function GitHubIcon({ className }: { className?: string }) { + return ( + + ); +} + +/** Links to the file on GitHub that exports the documented component. */ +export function ViewSource({ source }: { source: string }) { + return ( + + + View source + + ); +} diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 564626c43a8..41aeaf4c49c 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -1,11 +1,9 @@ 'use client'; -import { ChevronRightIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import * as React from 'react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Sidebar, SidebarContent, @@ -19,8 +17,6 @@ import { SidebarRail, } from '@/components/ui/sidebar'; import { getSidebarGroups } from '@/lib/registry'; -import { toSlug } from '@/lib/slug'; -import { cn } from '@/lib/utils'; const groups = getSidebarGroups(); @@ -62,72 +58,37 @@ export function AppSidebar({ ...props }: React.ComponentProps) { Mosaic - Swingset - {groups.map(({ group, stories }) => ( -
( + -
+ {group} -
- {stories.map(({ mod, componentSlug, names }) => { - const docsHref = `/components/${componentSlug}`; - const isOpen = pathname.startsWith(docsHref); - - return ( - - - svg]:size-2.5', - isOpen && 'text-brand', - )} - render={} - > - {mod.meta.title} - - - - - - - } - > - Overview - - {`<${mod.meta.title} />`} - - - - {names.map(name => { - const href = `/components/${componentSlug}/${toSlug(name)}`; - return ( - - } - > - {name} - - - ); - })} - - - - - - ); - })} -
+ + + + {components.map(({ mod, componentSlug }) => { + const href = `/components/${componentSlug}`; + return ( + + } + > + {mod.meta.title} + + {`<${mod.meta.title} />`} + + + + ); + })} + + + ))}
diff --git a/packages/swingset/src/components/ui/collapsible.tsx b/packages/swingset/src/components/ui/collapsible.tsx index cde0a4d0890..40c1a005bba 100644 --- a/packages/swingset/src/components/ui/collapsible.tsx +++ b/packages/swingset/src/components/ui/collapsible.tsx @@ -2,6 +2,8 @@ import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'; +import { cn } from '@/lib/utils'; + function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { return ( ); diff --git a/packages/swingset/src/lib/extractStorySource.ts b/packages/swingset/src/lib/extractStorySource.ts new file mode 100644 index 00000000000..5cb4305dc7e --- /dev/null +++ b/packages/swingset/src/lib/extractStorySource.ts @@ -0,0 +1,41 @@ +/** + * Pulls a single exported story function's source out of a story module's raw text + * (the `__source` a `*.stories.tsx` file exposes via a `?raw` self-import). Used by + * the `` Code tab to show the actual source of the example being previewed, + * rather than the SWC/Emotion-compiled output `Function.prototype.toString()` returns. + * + * Matches `export function (` and returns through the brace-balanced function + * body. Story files contain no unbalanced braces inside strings, so a simple depth + * counter is sufficient (and avoids pulling in a parser). + */ +export function extractStorySource(source: string | undefined, name: string): string | null { + if (!source) { + return null; + } + + const signature = new RegExp(`export function ${name}\\s*\\(`); + const match = signature.exec(source); + if (!match) { + return null; + } + + const bodyStart = source.indexOf('{', match.index); + if (bodyStart === -1) { + return null; + } + + let depth = 0; + for (let i = bodyStart; i < source.length; i++) { + const char = source[i]; + if (char === '{') { + depth++; + } else if (char === '}') { + depth--; + if (depth === 0) { + return source.slice(match.index, i + 1); + } + } + } + + return null; +} diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index a2d13f5917c..63d3f8cce13 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -15,64 +15,31 @@ const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled } const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid }; -// Headless primitives are documented as overview-only: the registry entry carries -// just `meta` (no story functions), so the sidebar shows a single "Overview" link and -// no interactive knob canvas. The overview's live demos come from `` embeds in -// the MDX, which import the stories module directly (not through this registry). +// Headless primitives carry just `meta` (no story functions). Like every component +// they're documented as a single overview page; their live demos come from `` / +// `` embeds in the MDX, which import the stories module directly. const collapsibleModule: StoryModule = { meta: collapsibleMeta }; export const registry: StoryModule[] = [buttonModule, inputModule, collapsibleModule]; -export interface RegistryEntry { - mod: StoryModule; - storyName: string; -} - -/** Find a story by component slug (from meta.title) and story slug (from export name). */ -export function findStory(componentSlug: string, storySlug: string): RegistryEntry | null { - for (const mod of registry) { - if (toSlug(mod.meta.title) !== componentSlug) { - continue; - } - for (const [exportName, value] of Object.entries(mod)) { - if (exportName === 'meta') { - continue; - } - if (typeof value !== 'function') { - continue; - } - if (toSlug(exportName) === storySlug) { - return { mod, storyName: exportName }; - } - } - } - return null; -} - -export function getStoryNames(mod: StoryModule): string[] { - return Object.keys(mod).filter(k => k !== 'meta' && typeof mod[k] === 'function'); +/** Look up a component's story module from its slug (derived from `meta.title`). */ +export function getModuleBySlug(slug: string): StoryModule | undefined { + return registry.find(mod => toSlug(mod.meta.title) === slug); } export function getSidebarGroups(): Array<{ group: string; - stories: Array<{ mod: StoryModule; componentSlug: string; names: string[] }>; + components: Array<{ mod: StoryModule; componentSlug: string }>; }> { - const groupMap = new Map>(); + const groupMap = new Map>(); for (const mod of registry) { const { group, title } = mod.meta; if (!groupMap.has(group)) { groupMap.set(group, []); } - const groupStories = groupMap.get(group); - if (groupStories) { - groupStories.push({ - mod, - componentSlug: toSlug(title), - names: getStoryNames(mod), - }); - } + groupMap.get(group)?.push({ mod, componentSlug: toSlug(title) }); } - return Array.from(groupMap.entries()).map(([group, stories]) => ({ group, stories })); + return Array.from(groupMap.entries()).map(([group, components]) => ({ group, components })); } diff --git a/packages/swingset/src/lib/source.ts b/packages/swingset/src/lib/source.ts new file mode 100644 index 00000000000..0d1f4898f98 --- /dev/null +++ b/packages/swingset/src/lib/source.ts @@ -0,0 +1,8 @@ +// The repo these components are documented from. `source` paths in `StoryMeta` are +// relative to the monorepo root and resolved against this base on the default branch. +const REPO_BLOB_BASE = 'https://github.com/clerk/javascript/blob/main'; + +/** Build a GitHub URL to the file that exports a component, from a repo-root-relative path. */ +export function sourceUrl(path: string): string { + return `${REPO_BLOB_BASE}/${path.replace(/^\/+/, '')}`; +} diff --git a/packages/swingset/src/lib/types.ts b/packages/swingset/src/lib/types.ts index 858f5877aba..5ced9e1bfbb 100644 --- a/packages/swingset/src/lib/types.ts +++ b/packages/swingset/src/lib/types.ts @@ -37,6 +37,12 @@ export type KnobValues = Record; export interface StoryMeta { group: string; title: string; + /** + * Path to the file that exports the documented component, relative to the monorepo + * root (e.g. `packages/ui/src/mosaic/components/button.tsx`). Rendered as a "View + * source" link to the file on GitHub. See `lib/source.ts`. + */ + source?: string; styles?: { _variants: Record>; _defaultVariants?: Record; diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx index b6c4a4190b8..f630acf988b 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -4,40 +4,42 @@ import * as ButtonStories from './button.stories'; The `Button` component is the primary action element in Mosaic. It supports color and size variants and uses Emotion CSS-in-JS for styling via the Mosaic CVA utility. -## Default +## Playground - -## Sizes +## Props + + + +## Usage + +The snippet below reflects the props selected in the table above — change a prop and it updates here. + + + Click me + + +--- + +## Examples + +### Sizes -## Disabled +### Disabled - -## Usage - -```tsx -import { Button } from '@clerk/ui/mosaic/components/button'; - - -``` - -## Props - - diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index 22420cdd012..edc17c6a73e 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -7,6 +7,7 @@ import type { StoryMeta } from '@/lib/types'; export const meta: StoryMeta = { group: 'Components', title: 'Button', + source: 'packages/ui/src/mosaic/components/button.tsx', styles: buttonStyles, }; diff --git a/packages/swingset/src/stories/collapsible.stories.tsx b/packages/swingset/src/stories/collapsible.stories.tsx index 82567620c15..9afdc99c872 100644 --- a/packages/swingset/src/stories/collapsible.stories.tsx +++ b/packages/swingset/src/stories/collapsible.stories.tsx @@ -12,6 +12,7 @@ import type { StoryMeta } from '@/lib/types'; export const meta: StoryMeta = { group: 'Primitives', title: 'Collapsible', + source: 'packages/headless/src/primitives/collapsible/index.ts', }; export function Default() { diff --git a/packages/swingset/src/stories/input.mdx b/packages/swingset/src/stories/input.mdx index b30e35e0c8a..7726e8e690a 100644 --- a/packages/swingset/src/stories/input.mdx +++ b/packages/swingset/src/stories/input.mdx @@ -4,45 +4,48 @@ import * as InputStories from './input.stories'; The `Input` component is the standard text field in Mosaic. It uses a fixed height (shadcn-style) with size and disabled variants. -## Default +## Playground - -## Sizes +## Props + + + +## Usage + +The snippet below reflects the props selected in the table above — change a prop and it updates here. + + + +--- + +## Examples + +### Sizes -## Disabled +### Disabled -## Invalid +### Invalid - -## Usage - -```tsx -import { Input } from '@clerk/ui/mosaic/components/input'; - - -``` - -## Props - - diff --git a/packages/swingset/src/stories/input.stories.tsx b/packages/swingset/src/stories/input.stories.tsx index b7d69ee4475..8cf2b06ba47 100644 --- a/packages/swingset/src/stories/input.stories.tsx +++ b/packages/swingset/src/stories/input.stories.tsx @@ -7,6 +7,7 @@ import type { StoryMeta } from '@/lib/types'; export const meta: StoryMeta = { group: 'Components', title: 'Input', + source: 'packages/ui/src/mosaic/components/input.tsx', styles: inputStyles, }; diff --git a/packages/swingset/src/types/raw.d.ts b/packages/swingset/src/types/raw.d.ts new file mode 100644 index 00000000000..cb8995b2bc0 --- /dev/null +++ b/packages/swingset/src/types/raw.d.ts @@ -0,0 +1,7 @@ +// `?raw` imports return the importee's source as a string (see the `asset/source` +// webpack rule in `next.config.mjs`). Used by story modules to expose their own +// source for the `` Code tab. +declare module '*?raw' { + const content: string; + export default content; +} From 763f61071c3807a8670c853954845fae6e73f62c Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 10 Jun 2026 17:22:31 -0400 Subject: [PATCH 2/3] fix(swingset): drop redundant click-to-copy handler failing a11y lint The whole-codeblock
tripped jsx-a11y/click-events-have-key-events and no-static-element-interactions, failing 'next build'. The accessible Copy button already covers the copy action, so remove the div handler (and its now misleading cursor-pointer). --- packages/swingset/src/components/CodeBlock.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/swingset/src/components/CodeBlock.tsx b/packages/swingset/src/components/CodeBlock.tsx index e4d7c0dd9e9..fe012e2f5d4 100644 --- a/packages/swingset/src/components/CodeBlock.tsx +++ b/packages/swingset/src/components/CodeBlock.tsx @@ -59,9 +59,8 @@ export function CodeBlock({ children, className }: CodeBlockProps) { {html ? ( // safe: shiki output is developer-controlled source code
) : (

From 396378f8068ed94e26c38e35cae23ef7dde8b470 Mon Sep 17 00:00:00 2001
From: Kyle MacDonald 
Date: Wed, 10 Jun 2026 18:35:58 -0400
Subject: [PATCH 3/3] docs(swingset): document headless primitives

Add single-page overviews for the remaining @clerk/headless primitives
(accordion, autocomplete, dialog, menu, popover, select, tabs, tooltip),
mirroring the existing Collapsible page: an unstyled `` demo plus
Parts/Props/Styling tables sourced from each primitive's parts. Wired into
the sidebar registry and the DocsViewer MDX map.
---
 .changeset/document-headless-primitives.md    |   2 +
 .../swingset/src/components/DocsViewer.tsx    |   9 ++
 packages/swingset/src/lib/registry.ts         |  31 +++-
 packages/swingset/src/stories/accordion.mdx   | 123 ++++++++++++++++
 .../src/stories/accordion.stories.tsx         |  44 ++++++
 .../swingset/src/stories/autocomplete.mdx     | 127 ++++++++++++++++
 .../src/stories/autocomplete.stories.tsx      |  55 +++++++
 packages/swingset/src/stories/dialog.mdx      | 121 +++++++++++++++
 .../swingset/src/stories/dialog.stories.tsx   |  35 +++++
 packages/swingset/src/stories/menu.mdx        | 139 ++++++++++++++++++
 .../swingset/src/stories/menu.stories.tsx     |  34 +++++
 packages/swingset/src/stories/popover.mdx     | 132 +++++++++++++++++
 .../swingset/src/stories/popover.stories.tsx  |  33 +++++
 packages/swingset/src/stories/select.mdx      | 139 ++++++++++++++++++
 .../swingset/src/stories/select.stories.tsx   |  50 +++++++
 packages/swingset/src/stories/tabs.mdx        | 129 ++++++++++++++++
 .../swingset/src/stories/tabs.stories.tsx     |  31 ++++
 packages/swingset/src/stories/tooltip.mdx     | 113 ++++++++++++++
 .../swingset/src/stories/tooltip.stories.tsx  |  29 ++++
 19 files changed, 1375 insertions(+), 1 deletion(-)
 create mode 100644 .changeset/document-headless-primitives.md
 create mode 100644 packages/swingset/src/stories/accordion.mdx
 create mode 100644 packages/swingset/src/stories/accordion.stories.tsx
 create mode 100644 packages/swingset/src/stories/autocomplete.mdx
 create mode 100644 packages/swingset/src/stories/autocomplete.stories.tsx
 create mode 100644 packages/swingset/src/stories/dialog.mdx
 create mode 100644 packages/swingset/src/stories/dialog.stories.tsx
 create mode 100644 packages/swingset/src/stories/menu.mdx
 create mode 100644 packages/swingset/src/stories/menu.stories.tsx
 create mode 100644 packages/swingset/src/stories/popover.mdx
 create mode 100644 packages/swingset/src/stories/popover.stories.tsx
 create mode 100644 packages/swingset/src/stories/select.mdx
 create mode 100644 packages/swingset/src/stories/select.stories.tsx
 create mode 100644 packages/swingset/src/stories/tabs.mdx
 create mode 100644 packages/swingset/src/stories/tabs.stories.tsx
 create mode 100644 packages/swingset/src/stories/tooltip.mdx
 create mode 100644 packages/swingset/src/stories/tooltip.stories.tsx

diff --git a/.changeset/document-headless-primitives.md b/.changeset/document-headless-primitives.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/document-headless-primitives.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index d84acda1d60..30275bcb9c7 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -10,7 +10,16 @@ import { ViewSource } from './ViewSource';
 const docModules: Record = {
   button: dynamic(() => import('../stories/button.mdx')),
   input: dynamic(() => import('../stories/input.mdx')),
+  // Headless primitives — alphabetical.
+  accordion: dynamic(() => import('../stories/accordion.mdx')),
+  autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
   collapsible: dynamic(() => import('../stories/collapsible.mdx')),
+  dialog: dynamic(() => import('../stories/dialog.mdx')),
+  menu: dynamic(() => import('../stories/menu.mdx')),
+  popover: dynamic(() => import('../stories/popover.mdx')),
+  select: dynamic(() => import('../stories/select.mdx')),
+  tabs: dynamic(() => import('../stories/tabs.mdx')),
+  tooltip: dynamic(() => import('../stories/tooltip.mdx')),
 };
 
 interface DocsViewerProps {
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 63d3f8cce13..1137b984ef4 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -1,6 +1,9 @@
 // Import stories explicitly to control order and avoid type casting through unknown.
+import { meta as accordionMeta } from '../stories/accordion.stories';
+import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
 import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
 import { meta as collapsibleMeta } from '../stories/collapsible.stories';
+import { meta as dialogMeta } from '../stories/dialog.stories';
 import {
   Default,
   Disabled as InputDisabled,
@@ -8,6 +11,11 @@ import {
   meta as inputMeta,
   Sizes as InputSizes,
 } from '../stories/input.stories';
+import { meta as menuMeta } from '../stories/menu.stories';
+import { meta as popoverMeta } from '../stories/popover.stories';
+import { meta as selectMeta } from '../stories/select.stories';
+import { meta as tabsMeta } from '../stories/tabs.stories';
+import { meta as tooltipMeta } from '../stories/tooltip.stories';
 import { toSlug } from './slug';
 import type { StoryModule } from './types';
 
@@ -18,9 +26,30 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,
 // Headless primitives carry just `meta` (no story functions). Like every component
 // they're documented as a single overview page; their live demos come from `` /
 // `` embeds in the MDX, which import the stories module directly.
+const accordionModule: StoryModule = { meta: accordionMeta };
+const autocompleteModule: StoryModule = { meta: autocompleteMeta };
 const collapsibleModule: StoryModule = { meta: collapsibleMeta };
+const dialogModule: StoryModule = { meta: dialogMeta };
+const menuModule: StoryModule = { meta: menuMeta };
+const popoverModule: StoryModule = { meta: popoverMeta };
+const selectModule: StoryModule = { meta: selectMeta };
+const tabsModule: StoryModule = { meta: tabsMeta };
+const tooltipModule: StoryModule = { meta: tooltipMeta };
 
-export const registry: StoryModule[] = [buttonModule, inputModule, collapsibleModule];
+export const registry: StoryModule[] = [
+  buttonModule,
+  inputModule,
+  // Primitives — alphabetical within the group.
+  accordionModule,
+  autocompleteModule,
+  collapsibleModule,
+  dialogModule,
+  menuModule,
+  popoverModule,
+  selectModule,
+  tabsModule,
+  tooltipModule,
+];
 
 /** Look up a component's story module from its slug (derived from `meta.title`). */
 export function getModuleBySlug(slug: string): StoryModule | undefined {
diff --git a/packages/swingset/src/stories/accordion.mdx b/packages/swingset/src/stories/accordion.mdx
new file mode 100644
index 00000000000..ba7bae68de8
--- /dev/null
+++ b/packages/swingset/src/stories/accordion.mdx
@@ -0,0 +1,123 @@
+import * as AccordionStories from './accordion.stories';
+
+# Accordion
+
+A set of stacked sections where each is toggled by its own heading button, from
+`@clerk/headless`. It is a **headless** primitive: it supplies behavior, single/multiple
+open state, roving-focus keyboard navigation, ARIA wiring, and the expand/collapse
+animation lifecycle, but ships **no styles** — you bring your own CSS by targeting the
+`data-cl-*` attributes each part emits.
+
+## Example
+
+The demo below is intentionally unstyled — it renders the raw primitive so you can see its
+behavior and ARIA wiring. Click a trigger to open its panel; in `single` mode opening one
+closes the others, and arrow keys move focus between triggers.
+
+
+
+## Usage
+
+```tsx
+import { Accordion } from '@clerk/headless/accordion';
+
+
+  
+    
+      Section 1
+    
+    Content for section 1
+  
+  
+    
+      Section 2
+    
+    Content for section 2
+  
+;
+```
+
+### Single (one item open at a time)
+
+```tsx
+{/* items */}
+```
+
+### Controlled
+
+```tsx
+const [value, setValue] = useState(['item-1']);
+
+
+  {/* items */}
+;
+```
+
+## Parts
+
+| Part                | Default Element | Description                                                 |
+| ------------------- | --------------- | ----------------------------------------------------------- |
+| `Accordion.Root`    | `
` | Root wrapper; owns open-item state and Home/End/arrow nav | +| `Accordion.Item` | `
` | Wraps one section; provides item context (value, open, IDs) | +| `Accordion.Header` | `

` | Heading wrapper for the trigger | +| `Accordion.Trigger` | `