Skip to content
Draft
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 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