Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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 number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/user-profile-billing-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/user-profile-security-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/swingset/CLAUDE.md
Original file line number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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
23 changes: 21 additions & 2 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +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-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-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 Expand Up @@ -83,7 +100,9 @@ export function DocsViewer({ group, slug }: DocsViewerProps) {
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
<article
className={`prose relative mx-auto w-full min-w-0 p-8 ${meta?.layout === 'wide' ? 'max-w-7xl' : 'max-w-3xl'}`}
>
{meta?.source ? (
<div className='absolute right-8 top-8'>
<ViewSource source={meta.source} />
Expand Down
209 changes: 170 additions & 39 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line number Diff line number Diff 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,11 +17,116 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { getSidebarGroups } from '@/lib/registry';

const groups = getSidebarGroups();

const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);

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 category = component.mod.meta.navigation?.category ?? '';
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);

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 SidebarEntryMenu({
components,
groupSlug,
pathname,
}: {
components: SidebarEntry[];
groupSlug: string;
pathname: string;
}) {
return (
<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>
);
}

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname();

Expand Down Expand Up @@ -59,45 +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>
<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 (
<SidebarMenuItem key={mod.meta.title}>
<SidebarMenuButton
className='h-auto items-start py-1 text-xs leading-relaxed'
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='whitespace-normal! break-all font-mono text-[10px] leading-relaxed'>
{usage}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</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
Loading
Loading