Three sidebars once totalled 2,049 near-duplicate lines, and the same KPI card was hand-rolled dozens of times. Everything below exists because a block that is copied drifts — and the copies diverge in the parts nobody looks at, like an aria-label that got fixed on two surfaces out of three.
The rule of thumb: if you are about to copy a class string from one of these components, import the component instead.
Page patterns (components/patterns/)
All Server Components except ErrorState. All take already-translated strings — they never call t() themselves, which keeps them locale-agnostic and free of new i18n keys.
PageHeader
The page title row. Owns the page's only <h1>.
<PageHeader
title={t('admin.users.title')}
description={t('admin.users.subtitle')}
actions={<Button>{t('common.invite')}</Button>}
/>
| Prop | Type |
|---|---|
title | ReactNode |
description? | ReactNode |
actions? | ReactNode — right-aligned action row |
Zero hand-rolled <h1> page titles remain in the dashboards. Keep it that way.
StatCard
KPI cards for dashboard overview grids.
<StatCard
label={t('dashboard.credits')}
icon={Zap}
accent="chart-2"
surface="panel"
value={credits.toLocaleString(getIntlLocale(locale))}
hint={t('dashboard.creditsRemaining')}
/>
| Prop | Notes |
|---|---|
accent | chart-1 … chart-5 only. Not primary (a whole KPI row in one colour reads as one undifferentiated block) and not success/warning (that implies a status the metric does not have). |
surface | What the card sits on. canvas (default) lifts — white fill, hairline, hover shadow; correct for admin and org page shells. panel recesses — tinted, no border; required inside the private/org floating panel, where a bg-card card on a bg-card panel is invisible. |
value | Rendered text-2xl font-semibold tabular-nums. Omit when supplying a custom body via children. |
hint | Small muted supporting line. |
surface is a prop rather than a per-call-site className override on purpose: the choice belongs to the surface, and three call sites each passing border-0 bg-muted/50 shadow-none is precisely how a pattern drifts back into hand-rolled copies.
EmptyState
A list must never render a blank frame.
| Prop | Notes |
|---|---|
icon, title | Required |
description? | What to do next, or why this is empty |
action? | Primary CTA |
size? | sm inside a card body, md filling a page region |
For a zero state, say what the thing is, why it is empty, and what to do — "No documents yet" plus accepted formats and an upload button, not an empty table.
ErrorState
The shared shell behind every error.tsx boundary. The only Client Component here.
ErrorState renders error.message / error.digest only under NODE_ENV === 'development'. In production those can leak stack frames, database constraint names, connection strings or API keys to the client. It is the single place in the app allowed to render them — do not add a second such gate, and do not relax this one.
StatusPill
A status chip where colour is never the only signal.
<StatusPill tone="success" icon={CheckCircle2} label={t('dashboard.active')} />
icon is required, not optional — the icon is half of the "never colour alone" contract, so the API makes omitting it impossible. The tone colours the fill and the icon; the label stays text-foreground. That is not a stylistic preference: a tone rendered as text on a tint of its own hue measures under 4.5:1 at every alpha for success, so the pill would fail contrast exactly where it looks most correct.
Navigation (config/navigation.ts)
Three surfaces, one data model. Adding a dashboard page is one manifest entry, not three sidebar edits.
export const privateNav: NavItem[] = [
{ path: '/private-dashboard', labelKey: 'dashboard', icon: 'LayoutDashboard', exact: true },
{ path: '/private-dashboard/referrals', labelKey: 'referrals', icon: 'Gift', gate: 'referrals' },
]
An entry carries route, icon name, label key, and an optional feature gate. Labels resolve to i18n keys at render; gates are evaluated on the server and applied through the pure resolveNav(items, gates).
Two constraints shape the design, and both bite if ignored:
- Icons are names, not components. Gates are evaluated server-side, so a resolved manifest crosses the server→client boundary — and React cannot serialize a function.
components/navigation/nav-icons.tsmaps names to Lucide components on the client, typed as a completeRecord, so adding a name without adding the mapping is a type error rather than a blank space at runtime. config/navigation.tsmust stay client-importable. It never readsprocess.envand never calls the feature-gate helpers. It declares gates; the server resolves them.
Gate sources: b2b, licenses, changelog, referrals, affiliates, logs. The org manifest declares none — every org entry always renders.
Every route keeps its own server-side guard, and a manifest gate never replaces one. But an inverted gate surfaces feature-flagged destinations to the wrong users, and nothing else in the codebase would catch that — which is why __tests__/navigation/ pins gate and label semantics.
Active state
isNavItemActive(item, localelessPathname) — callers strip the locale prefix first. Matching is segment-aware, so /members does not light up for /members-archive. Overview entries need exact: true, or they prefix-match every child route and stay permanently lit.
The shared link
SidebarNavLink renders every nav row on all three surfaces, with a variant (private | org | admin) selecting the active style. The three look different on purpose — that is identity, not drift — but they share one implementation, because a fix applied to two of three files is the exact failure this component exists to prevent.
It also owns the collapsed-rail aria-label:
aria-label={isCollapsed ? label : undefined}
Radix sets aria-describedby on a tooltip trigger only while the tooltip is open — and a description is not a name even then. An icon-only link in a collapsed rail with only a tooltip therefore has no accessible name at all when nothing is hovered. A mouse user sees a label and assumes it is wired; a screen-reader user gets nothing. Keep the tooltip as a visual affordance, and give the control its own name.
Dashboard shells
Each dashboard has a shell that arranges rail, app bar and content panel:
| Surface | Shell | Rail appears at |
|---|---|---|
| Private | components/private/private-layout-client.tsx | lg: |
| Org | components/org/org-layout-client.tsx | md: |
| Admin | components/admin/admin-layout-client.tsx | md: |
They are ~210–320 lines of arrangement each and deliberately share a documented shape rather than a base component — what they share is the part that drifts: the manifest, the link, the widgets, the account dropdown.
The app bar
An h-16 row owning, left to right: the mobile drawer trigger, the surface identity, then notifications · theme toggle · account dropdown.
┌──────────────────────────────────────────────┐
│ ☰ Dashboard 🔔 ☾ ◯ │ h-16
├──────────────────────────────────────────────┤
│ ╭──────────────────────────────────────────╮ │
│ │ content panel — bg-card, rounded, │ │
│ │ shadow-card, scrolls internally │ │
Rules that are easy to break:
- The drawer trigger's breakpoint is the rail's breakpoint —
lg:hiddenon private,md:hiddenon org. The rule mirrored is "the trigger exists while the rail does not", not a literal token to copy between shells. - Drawer state belongs to the shell, not the sidebar. A drawer whose state sits inside the sidebar forces the trigger to live there too — which is how the private dashboard ended up with a
fixedbar of its own instead of a header. - Never
fixed-position a control against the viewport when it belongs to an inset panel. It floats on the canvas outside the surface it belongs to, and every page underneath pays padding to dodge it. Put it in the bar. - A shell's
loading.tsxmust include theh-16bar, or the page jumps 64px when the real layout swaps in.
The content panel
<main className="min-h-0 flex-1 overflow-y-auto scrollbar-stable bg-card
lg:my-3 lg:mr-3 lg:rounded-xl lg:shadow-card" />
Full-bleed on mobile — a margin would spend ~24px of a 375px screen on an ornament — then insetting and lifting once there is width to spare. That elevation is what replaces the full-height 1px rule between rail and content.
min-h-0 matters: this is a flex child that scrolls internally, and without it the panel grows to its content instead of its track.
The shell itself is fixed inset-0 rather than an in-flow h-dvh box. An app shell that owns its own scrolling must not contribute to the document's — in flow, <html> reported 1001px of scrollable content against an 812px viewport and drew a second scrollbar beside the panel's.
A rail inside a drawer
Both dashboard sidebars take variant: 'rail' | 'drawer'. In drawer the rail must:
- suppress its own identity band — the drawer draws one, and two stacked read as two sidebars;
- take
w-fullrather than restating aw-64desktop width inside aw-72panel, which leaves 32px of dead space down one side; - ignore the persisted collapse state — an icon-only column inside a slide-over is not a navigation menu.
Mobile navigation
There is no bottom tab bar. Every dashboard reaches navigation the same way below its rail breakpoint: the app-bar trigger opens the sidebar as a slide-over. Admin additionally has ⌘K.
The safe-area inset is still required. Whatever sits last above the viewport edge — the chat composer, a pinned action row — is against the iOS home indicator or Android gesture bar directly:
className="pb-[calc(1rem_+_env(safe-area-inset-bottom))]"