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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
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/swingset-sidebar-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f

### Layers

`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings:
`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:

| Group | What lives here | Archetype |
| ------------ | -------------------------------------------------------------- | --------- |
| `User` | Composed flow UI (e.g. `UserButton`) | C |
| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
| `Components` | Styled Mosaic components β€” simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
| `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B |
| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |

`User` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`User Button` / `User Profile` β†’ `Components` β†’ `Primitives` runs high-level-composition β†’ low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).

`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example β†’ Usage β†’ Parts β†’ Styling) with `Props` replaced by whatever the export
Expand DownExpand Up@@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th

**Document the default value for every prop in a dedicated Default column.** Every props table β€” auto and hand-written β€” has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `β€”` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`).

### Archetype C β€” composed layer (`User`)
### Archetype C β€” composed layer (`User Button`, `User Profile`)

These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX:

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,13 @@ export interface CompositionPiece {
name: string;
/** Route to the piece's page in swingset (e.g. `/components/button`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */
/** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high β†’ low. Drives the order the composition groups render in.
// Matches the sidebar group names.
const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives'];
const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
Expand Down
14 changes: 8 additions & 6 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug β†’ `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
'user-profile': {
'user-page': dynamic(() => import('../stories/user-page.mdx')),
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')),
'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')),
'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')),
'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')),
'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')),
'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')),
'user-profile-payment-methods-section': dynamic(
() => import('../stories/user-profile-payment-methods-section.mdx'),
),
'user-profile-billing-history-section': dynamic(
() => import('../stories/user-profile-billing-history-section.mdx'),
),
'user-profile-connected-accounts-section': dynamic(
() => import('../stories/user-profile-connected-accounts-section.mdx'),
),
Expand Down
241 changes: 152 additions & 89 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
'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,
Expand All@@ -15,78 +17,113 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';
import type { StoryModule } from '@/lib/types';

const groups = getSidebarGroups();

type SidebarEntry = { mod: StoryModule; componentSlug: string };
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

function getNavigationFamilies(components: SidebarEntry[]) {
const families = new Map<string, Map<string, SidebarEntry[]>>();
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];

// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and
// entry order both follow first appearance in the registry; uncategorized entries get no subheading.
function byCategory(components: SidebarEntry[]) {
const categories: { category: string; components: SidebarEntry[] }[] = [];
for (const component of components) {
const family = component.mod.meta.navigation?.family ?? '';
const category = component.mod.meta.navigation?.category ?? '';
const categories = families.get(family) ?? new Map<string, SidebarEntry[]>();
const entries = categories.get(category) ?? [];

entries.push(component);
categories.set(category, entries);
families.set(family, categories);
const bucket = categories.find(c => c.category === category);
if (bucket) {
bucket.components.push(component);
} else {
categories.push({ category, components: [component] });
}
}
return categories;
}

function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) {
const labelRef = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);

return Array.from(families, ([family, categories]) => ({
family,
categories: Array.from(categories, ([category, components]) => ({
category,
components: components.sort(
(a, b) =>
(a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) -
(b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER),
),
})),
}));
React.useEffect(() => {
const label = labelRef.current;
if (!label) {
return;
}
const check = () => setIsTruncated(label.scrollWidth > label.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(label);
return () => observer.disconnect();
}, []);

return (
<SidebarMenuItem>
<Tooltip disabled={!isTruncated}>
<TooltipTrigger
delay={300}
render={
<SidebarMenuButton
className='h-auto py-1 text-xs'
isActive={isActive}
render={<Link href={href} />}
>
<span
ref={labelRef}
className='truncate font-mono text-[10px] leading-relaxed'
>
{usage}
</span>
</SidebarMenuButton>
}
/>
<TooltipContent
side='right'
className='font-mono text-[10px]'
>
{usage}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}

function SidebarEntryLink({
entry,
function SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
entry: SidebarEntry;
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
const { mod, componentSlug } = entry;
const href = `/${groupSlug}/${componentSlug}`;
const usage = mod.meta.label
? mod.meta.label
: mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;

return (
<SidebarMenuItem>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span
className={
mod.meta.label
? 'whitespace-normal text-[11px] leading-relaxed'
: 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed'
}
>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenu>
{components.map(({ mod, componentSlug }) => {
const href = `/${groupSlug}/${componentSlug}`;
// How an entry is USED differs by layer, so the label follows the layer rather
// than a guess at the title: hooks are called, atomic styles are a set of
// exports with no single call form worth privileging, and everything else is a
// component rendered as JSX.
const usage =
mod.meta.group === 'Hooks'
? `${mod.meta.title}()`
: mod.meta.group === 'Styles'
? mod.meta.title
: `<${mod.meta.title} />`;
return (
<SidebarUsageItem
key={mod.meta.title}
usage={usage}
href={href}
isActive={pathname === href}
/>
);
})}
</SidebarMenu>
);
}

Expand DownExpand Up@@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<SidebarGroup
key={group}
className='py-1'
data-section={group}
>
<SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'>
{group}
</SidebarGroupLabel>
<SidebarGroupContent>
{getNavigationFamilies(components).map(({ family, categories }) => (
<div key={family || group}>
{family ? (
<div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div>
) : null}
{categories.map(({ category, components }) => (
<div key={category || group}>
{category ? (
<div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
{category}
</div>
) : null}
<SidebarMenu className={category ? 'px-1' : undefined}>
{components.map(entry => (
<SidebarEntryLink
key={entry.mod.meta.title}
entry={entry}
groupSlug={groupSlug}
pathname={pathname}
/>
))}
</SidebarMenu>
</div>
))}
</div>
))}
</SidebarGroupContent>
</SidebarGroup>
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
>
<SidebarGroup
className='py-1'
data-section={group}
>
<SidebarGroupLabel
className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'
render={<CollapsibleTrigger />}
>
{group}
<ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{byCategory(components).map(({ category, components }) =>
category ? (
<Collapsible
key={category}
// Collapsed by default, unless it holds the page being viewed.
defaultOpen={components.some(
({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`,
)}
className='group/category'
>
<CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'>
<span
aria-hidden='true'
className='font-mono text-[10px] leading-none'
>
β””
</span>
{category}
<ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='border-sidebar-border ml-3 border-l pl-1'>
<SidebarEntryMenu
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarEntryMenu
key={group}
components={components}
groupSlug={groupSlug}
pathname={pathname}
/>
),
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</React.Fragment>
))}
</SidebarContent>
<SidebarRail />
Expand Down
5 changes: 4 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = {
};

export const registry: StoryModule[] = [
// User
// User Button
userButtonModule,
// User Profile
userPageModule,
// User Profile Β· Panels
userProfileProfilePanelModule,
userProfileSecurityPanelModule,
userProfileBillingPanelModule,
userProfileApiKeysPanelModule,
// User Profile Β· Sections
userProfileAccountSectionModule,
userProfilePasswordSectionModule,
userProfilePasskeysSectionModule,
Expand Down
3 changes: 1 addition & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types';
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
group: 'User Button',
title: 'UserButton',
label: 'User button',
navigation: { family: 'User button', category: 'Compositions', order: 10 },
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};

Expand Down
Loading
Loading