03 - Layout
Shell, page anatomy, and named page archetypes. If you’re building a new page, this is the doc you live in.
Prereqs: 01-principles.md for the why, 02-foundations.md for the tokens.
Named page archetypes
Every page in every app is exactly one of these seven types. If you’re not sure which, pick the closest and ask in review.
| Archetype | Purpose — why this shape exists | When to use | Primary components | Real examples |
|---|---|---|---|---|
| List | Browse and manage a collection of entities | Collection of entities (table, kanban, list view) | ListPageBar + PageContent + DataTable / CardGrid | Agents, Tools, Review queue |
| Detail | Inspect and act on a single entity. Three shapes — see below | Single entity — three shapes: 3a Split, 3b Tabbed, 3c Drawer (see below) | DetailPageBar + PageContent + (optional RightPanel) | /review/:id, /agents/:id |
| Form | Create, edit, or configure. Document-style reading rhythm | Create / edit / settings — flat single-column following Attio’s Settings arch | ListPageBar + PageContent narrow + FormSection + Separator + ActionBar | New Agent form, /agents/:id Agent tab, Billing |
| Workbench | The user’s primary daily workspace. Maximum density and efficiency | User does their primary work here all day | Custom within shell, dense layout | Chat, Playground |
| Dashboard | Aggregated metrics at a glance. Monitoring, not editing | Aggregated metrics + charts + summary tables | ListPageBar + CardGrid (metrics) + flat tables | Usage Overview |
| Canvas | Visual-first editing where spatial arrangement matters | Large visual-first editor (graph, map, timeline) | Full-viewport canvas + overlay controls | Workflow editor |
| Focus | Multi-step linear flow that needs full attention. Sidebar stays visible | Multi-step wizard (import, onboarding, complex create) — main-content takeover, sidebar stays visible | FocusLayout + 2-row header + Stepper + centered content + sticky bottom action bar | KB bulk import (future), Agent creation wizard, Onboarding |
The archetype dictates the structure. Two detail pages in two different apps must feel like siblings. A workbench and a form must feel distinct. No ambiguity.
Detail sub-shapes — choosing the right one
The Detail archetype has three shapes. Choosing wrong is the most common layout mistake.
| Shape | Visual | When to use | When NOT to use | Attio example |
|---|---|---|---|---|
| 3a Split | Main content (left, scrollable) + Property panel (right, scrollable) | The entity has ongoing activity (timeline, notes, emails) AND first-class properties that benefit from a persistent panel. The main area’s job is activity feed; the right panel’s job is metadata inspector. | When the “main area” would just be more form fields. If both sides are properties, you have a Form, not a Split. | Company detail, People detail |
| 3b Tabbed full-page | Tabs switch full-page content. Each tab is a self-contained surface | The entity has many distinct concerns (settings, resources, workflow, distribution) where each tab is conceptually separate. Tab content varies wildly in shape. | When you need a persistent property panel visible while browsing activity. Use 3a Split instead. | (Attio uses this for Settings pages with sidebar nav) |
| 3c Drawer | List remains visible; clicking a row opens a slide-over panel | The list is the primary context and the user triages items sequentially. The preview is ephemeral — not a full record page. | When the detail content is rich enough to deserve its own URL and full viewport. Use 3a Split or 3b Tabbed instead. | Notifications, task inbox |
Key decision rule: If your entity has properties + activity, use 3a Split. If your entity has many configuration surfaces, use 3b Tabbed. If the list is primary and detail is a glance, use 3c Drawer.
Result and Exception are templates within existing archetypes, not archetypes themselves. A “payment complete” page is a Form (in result state); a 404 is an Exception template rendered inside the current archetype. Both live under Page type templates below — not here.
For when to pick each Detail shape (Split vs Tabbed vs Drawer) see references/attio-layout-patterns.md §3. For when Form belongs over Detail (settings is not a detail-split) see references/attio-layout-decision-guide.md.
Step 0 for every new surface — card or flat?
Before you reach for any component, answer this for each section of your page:
Use a <Card> only if the element is one of:
- A dashboard metric / KPI tile (
<StatRow>contents). - A clickable entity preview in a grid (agent card, tool card).
- An empty-state CTA — large centered card with icon + title + button.
- The Danger Zone at the bottom of a settings page (red-tinted, one per page).
Use flat — no card, no border wrapper — for everything else. Especially:
- Form field groups (use
<FormSection>+<Separator>). - Right-panel property panels (use
<PropertyList>inside<CollapsibleSection>). - Settings sections.
- Tables (edge-to-edge, no wrapper div — see §Tables below).
- Progress banners, alerts, status strips (use
<Alert>or a flatrole="status"div).
This is the most-violated rule in the spec. The compliance script catches <div className="rounded-lg border"><Table> but it cannot catch a <Card> that wraps a <FormSection>. Reviewer judgment required.
Full decision tree with worked nx-agent examples: references/attio-layout-decision-guide.md §4.
Responsive behavior per archetype
Admin is desktop-first. Below md (768px) most admin surfaces degrade gracefully rather than re-flowing elegantly — a stated tradeoff, not an accident. Chat is mobile-aware (the chat app is a consumer surface; expect 50%+ mobile usage).
| Archetype | sm (<640) | md (≥768) | lg (≥1024) |
|---|---|---|---|
| List | Sidebar hides; table horizontal-scrolls within <PageContent>. No column collapsing (tables are dense by design). | Sidebar collapses to rail; table fits without scroll if ≤6 columns. | Full sidebar + table. |
| Detail-3a Split | Right panel hidden; toggle via kebab menu to open as bottom sheet. Main area full-width. | Right panel opens, 280px narrow. | Right panel 320px (narrow) or 400px (wide). |
| Detail-3b Tabbed | Tabs horizontal-scroll. Content reflows to full width. | Normal layout. | Normal. |
| Detail-3c Drawer | Drawer becomes full-viewport sheet. | Drawer 400-600px overlay. | Drawer 400-600px overlay. |
| Form | <PageContent narrow> max-width 100%. ActionBar sticks to bottom. | 2xl max-width. | 2xl max-width. |
| Workbench | Custom per surface — usually hide secondary panels, focus main content. | Full layout. | Full layout. |
| Dashboard | <CardGrid columns={1}> — one metric per row. | <CardGrid columns={2}>. | <CardGrid columns={3-4}>. |
| Canvas | Canvas takes full viewport; floating controls stack vertically. | Full canvas + controls. | Full canvas + controls. |
| Focus | Sidebar collapses behind drawer (per shell rule). Stepper collapses to “Step N of M” text. Content centered, max-width fits viewport. | Sidebar visible. Full stepper on its own row + centered content. | Sidebar visible. Full stepper on its own row + centered content. |
What we deliberately don’t do on mobile: stack workflow-canvas nodes (canvas becomes pan/zoom only), render the right-panel properties on a phone-sized screen (hidden by default), or reflow a Detail-3a Split into tabs (use the archetype’s own responsive behavior — if a screen genuinely needs mobile-first rethinking, pick Drawer or Form instead).
Shell anatomy
The app viewport is 100vh. No whole-page scroll — ever.
ShellLayout├── IconRail (48px, hidden for single-app users)├── AppSidebar (collapsible, 200px default)└── Main Area (flex-1, flex-col, overflow-hidden) ├── [ListPageBar | DetailPageBar] (h-12, shrink-0) ← NEVER scrolls └── <PageContent> ← ONLY scroll zoneDimensions
| Element | Size | Component / class | Behavior |
|---|---|---|---|
| Icon Rail | 48px | w-12 | Full height. Auto-hides if 1 app only. |
| App Sidebar | 200px | Radix collapsible | Collapses to icons. Toggle: Cmd+B. |
| Sidebar Hdr | 48px | h-12 border-b | Aligns with page bar. |
| Page Bar | 48px | <ListPageBar> / <DetailPageBar> | Fixed above scroll. |
| Content | remaining | <PageContent> | The one and only scroll container. |
Viewport contract
- The entire app fits in
100vh. The browser chrome shows; the app never scrolls as a whole. SidebarInsetusesoverflow-hidden.- Only
<PageContent>scrolls. - Page bar sits above the scroll area and is never affected by it.
Icon rail behavior
- Hidden entirely when
permittedApps.length === 1— a single-app user must not feel the multi-app system. - Active indicator: 4px accent bar on left edge (
bg-primary, 20px tall, rounded). - Hover: background tint.
- Width 48px, icons 20px (
h-5 w-5), centered in 40px hit target.
User avatar home
- Multi-app: rail footer, bottom-left.
- Single-app: sidebar footer, bottom-left.
Same corner, always.
Layout components
Every page uses a fixed set of primitives. No raw divs for layout.
Page-level
import { ListPageBar } from '@na/ui/components/ListPageBar';import { DetailPageBar } from '@na/ui/components/DetailPageBar';import { PageContent } from '@na/ui/components/PageContent';Content-level (inside PageContent)
import { ContentSection } from '@na/ui/components/ContentSection';import { ContentToolbar } from '@na/ui/components/ContentToolbar';import { SplitContent } from '@na/ui/components/SplitContent';import { CardGrid } from '@na/ui/components/CardGrid';import { StatRow } from '@na/ui/components/StatRow';import { FormSection } from '@na/ui/components/FormSection';import { ActionBar } from '@na/ui/components/ActionBar';Attio-native primitives (Plan 2)
Net-new primitives required to match Attio’s layout vocabulary. Specified here for reference; implementation tracked in docs/plans/2026-04-24-attio-layout-migration.md. Do not roll your own — wait for the primitive.
// Right-side detail panel on genuine 3a Split screens.// Second allowed scroll zone (beyond <PageContent>). Flat, no card wrapper.// Has its own tab bar (e.g., Details / Comments).import { RightPanel } from '@na/ui/components/RightPanel';
// Flat key-value list inside a RightPanel or settings section.// Icon + label + inline-editable value per row.import { PropertyList, PropertyRow } from '@na/ui/components/PropertyList';import { InlineEditField } from '@na/ui/components/InlineEditField';
// Section with ▾ chevron, persists collapsed state per user in localStorage.// Extension of ContentSection; prefer over a raw <div><h3></h3></div>.import { CollapsibleSection } from '@na/ui/components/CollapsibleSection';
// Segmented control for table / kanban / calendar on task/flow screens only.// URL-backed state via ?view=<id>. Not for registries or audit logs.import { ViewSwitcher } from '@na/ui/components/ViewSwitcher';
// Pill-style filter chips above a collection.// Replaces button-rows-as-filters. AND semantics; clicking a chip re-opens its picker.import { FilterChips } from '@na/ui/components/FilterChips';
// Anchored popover on entity-name hover in a table row.// 200ms open / 100ms close; contains avatar, key fields, quick actions.import { HoverCardPreview } from '@na/ui/components/HoverCardPreview';
// Inbox-archetype primitive: list left + preview right with j/k/e keyboard triage.import { InboxList } from '@na/ui/components/InboxList';
// Kanban board: columns = status values, drag-drop + multi-select.import { BoardView } from '@na/ui/components/BoardView';
// Focus archetype shell: main-pane takeover (sidebar stays visible), two-row header, ✕ top-left.import { FocusLayout, Stepper } from '@na/ui/components/FocusLayout';These aren’t “maybe someday” — each closes a specific gap where engineers currently reach for raw <div> + Tailwind or misuse <Dialog> / <Card> / <SplitContent>. Use the existing primitives until Plan 2 ships, but flag the gap in review so the migration doesn’t stall.
<ListPageBar> — top bar for List + Form pages
<ListPageBar title="Contacts" actions={<Button size="sm">+ New Contact</Button>}> <SearchInput placeholder="Search..." /> {/* optional center slot */}</ListPageBar>titleis the entity plural name (“Contacts”, “Agents”, “Tools”).- Max 1 primary action,
size="sm". - No subtitle. Descriptions go in the content area.
<DetailPageBar> — top bar for Detail pages
<DetailPageBar backTo="/contacts" title={contact.name} basePath={`/contacts/${id}`} tabs={[ { label: 'Overview', path: '' }, { label: 'Activity', path: '/activity' }, { label: 'Notes', path: '/notes' }, ]} actions={<Button size="sm">Edit</Button>}/>- Back button always goes to parent list URL — never browser back.
- Entity name truncates at
max-w-48. - Max 8 tabs. More than that means this should be two detail pages.
- Tabs: text only, no icons. Active tab = 2px underline
bg-foreground. - Max 1 primary action,
size="sm".
<PageContent> — the only scroll zone
<PageContent> // default padding p-4 md:p-6<PageContent spaced> // adds space-y-6 between children<PageContent narrow> // constrains to max-w-2xl (Form archetype)<PageContent spaced narrow>// bothThis is the ONLY element on the page that scrolls. No nested scroll containers.
<ContentSection> — grouped content
<ContentSection title="Tools" description="External APIs this agent can call" actions={ <Button variant="outline" size="sm" > Add Tool </Button> }> <CardGrid>{/* ... */}</CardGrid></ContentSection>Section actions are variant="outline" size="sm" — never primary (Save is owned by ActionBar). Section actions are “add item” level, not “commit the page” level.
<ContentToolbar> — filters + bulk actions
<ContentToolbar actions={<Button size="sm">Upload</Button>}> <SearchInput /> <Select>...</Select></ContentToolbar>Left slot: filters, search, sort. Right slot: bulk actions or upload.
<SplitContent> — two-column layout
<SplitContent sidebar={<Card>Model Settings</Card>}> <FormSection>Main fields</FormSection></SplitContent>Stacks on mobile (below md). Sidebar widths: 25% | 30% | 35% | 40% | 50%. Default 30%.
<CardGrid> — responsive card grid
<CardGrid columns={3}> <Card>A</Card> <Card>B</Card> <Card>C</Card></CardGrid>columns: 2 | 3 | 4. Single column on mobile, grid at md: and up.
<StatRow> — horizontal KPI row
<StatRow items={[ { label: 'Total', value: '128' }, { label: 'Indexed', value: '120' }, { label: 'Failed', value: <span className="text-destructive">8</span> }, ]}/><FormSection> — form field group
<FormSection title="Basic Info" description="Shown on the agent card"> <div className="space-y-1.5"> <Label>Name</Label> <Input /> </div> <div className="space-y-1.5"> <Label>Description</Label> <Textarea /> </div></FormSection>Internal field spacing is space-y-4. Label-to-input is space-y-1.5.
<ActionBar> — sticky form actions
<ActionBar> <Button variant="outline">Cancel</Button> <Button>Save</Button></ActionBar>
<ActionBar align="between"> <Button variant="destructive">Delete</Button> <div className="flex gap-2"> <Button variant="outline">Cancel</Button> <Button>Save</Button> </div></ActionBar>CRITICAL: ActionBar is a sibling of <PageContent>, not inside it. If it’s inside, it scrolls away with content. Cancel on the left, primary action on the right.
Action placement — the 6 zones
Every action button has exactly one correct zone. Not optional.
┌──────────────────────────────────────────────────────────┐│ ① PAGE BAR — page-level primary action (max 1) │├──────────────────────────────────────────────────────────┤│ ② SECTION HEADER — "add item" level actions ││ ││ ③ CONTENT TOOLBAR — bulk / selection actions ││ ││ ④ INLINE ROW — edit/delete per-item (ghost icon-xs) ││ ││ ⑤ ACTION BAR — form Save/Cancel (sticky bottom) │└──────────────────────────────────────────────────────────┘ ⑥ DIALOG/SHEET FOOTER — modal confirm/cancel ⑦ RIGHT-PANEL PROPERTY ROW — inline edit on click, no action button ⑧ FOCUS-VIEW ACTION BAR — Cancel / Previous / Next, sticky bottom of focus body (NOT a floating pill)| Action type | Zone | Variant | Size |
|---|---|---|---|
| Create entity | ① Page bar | primary | sm |
| Preview / Launch | ① Page bar | primary | sm |
| Add item to section | ② Section hdr | outline | sm |
| Save form | ⑤ Action bar | primary | default |
| Cancel form | ⑤ Action bar | outline | default |
| Delete with confirm | ⑤ Action bar | destructive | default |
| Bulk delete | ③ Toolbar | destructive | sm |
| Row edit | ④ Inline | ghost | icon-xs |
| Row delete | ④ Inline | ghost | icon-xs (wrap in ConfirmPopover) |
| Dialog confirm | ⑥ Dialog ftr | primary | default |
| Dialog destructive confirm | ⑥ Dialog ftr | destructive | default |
| Right-panel property edit | ⑦ Row | inline | click-to-edit, blur commits |
| Wizard Next | ⑧ Focus bar | primary | default |
| Wizard Previous | ⑧ Focus bar | outline | default |
| Wizard Cancel | ⑧ Focus bar | ghost | default |
Forbidden action patterns
| NEVER | DO |
|---|---|
| Save button in sidebar or section header | <ActionBar> as sibling of <PageContent> |
<ActionBar> inside <PageContent> | Sibling outside the scroll area |
| > 1 primary action in page bar | Move secondary actions to section headers |
| ”OK” or “Confirm” as dialog button label | Use the verb: “Delete”, “Create”, “Save Changes” |
| Delete without confirm | Wrap in <ConfirmPopover> or <ConfirmDialog> |
| > 3 icon buttons per table row | Use <DropdownMenu> for overflow |
| Full-width buttons outside dialogs | Only cards + dialog footers use full-width |
| Back button inside content | Back belongs in <DetailPageBar> |
| Mixed button sizes in same zone | One size per zone (table above) |
| Floating-pill wizard actions | Use actions prop on <FocusLayout> — sticky bar, full-width container, right-aligned cluster |
| Stepper crammed onto title row of focus | Two-row <FocusLayout> header — stepper gets its own row |
Focus route mounted outside AppLayout | Mount inside AppLayout so the Sidebar stays visible (focus is main-pane, not viewport) |
Page type templates
The three basic page types every app ships.
Type 1 — List page
<> <ListPageBar title="Contacts" actions={ <Button size="sm"> <Plus className="h-4 w-4" /> New Contact </Button> } > <SearchInput placeholder="Search contacts..." /> </ListPageBar> <PageContent spaced>{/* table, cards, or grid */}</PageContent></>Rules: title = entity plural, primary action top-right size="sm", optional center search, content handles own empty/loading.
Type 2 — Detail page with tabs
<> <DetailPageBar backTo="/contacts" title={contact.name} basePath={`/contacts/${id}`} tabs={TABS} actions={<Button size="sm">Edit</Button>} /> <PageContent> <Outlet /> </PageContent></>Rules: back goes to parent list, max 8 tabs, text-only tabs, max 1 primary action.
Type 3 — Full page / form
{ /* Settings form */}<> <ListPageBar title="Settings" /> <PageContent narrow>{/* form fields */}</PageContent> <ActionBar> <Button variant="outline">Cancel</Button> <Button>Save</Button> </ActionBar></>;
{ /* Dashboard */}<> <ListPageBar title="Dashboard" /> <PageContent spaced>{/* charts, widgets */}</PageContent></>;Rules: settings/forms use narrow, dashboards use spaced (full width).
Advanced templates
Beyond the three basic types, these compositions cover the common complex cases. For the use-case-indexed catalog (which template to pick for which screen) and net-new templates (Calendar, Map, Tree, Audit Log, Permissions Matrix, Auth, Command Palette, Notifications inbox, Status page, etc.), see 11-templates-catalog.md.
Template A — Dashboard (Dashboard archetype)
Metrics → charts → detail table. Top-to-bottom priority.
<> <ListPageBar title="Dashboard" actions={ <Button size="sm" variant="outline" > Export </Button> } > <DateRangePicker /> </ListPageBar> <PageContent spaced> {/* ① Metrics — max 4 per row, equal height */} <CardGrid columns={4}> <MetricCard label="Total Users" value="1,234" trend="+12%" /> <MetricCard label="Active" value="567" trend="-3%" /> <MetricCard label="Conversion" value="89.2%" trend="0%" /> <MetricCard label="Revenue" value="$12k" trend="+8%" /> </CardGrid>
{/* ② Charts — 2 per row, equal height */} <CardGrid columns={2}> <Card> <CardContent> <LineChart /> </CardContent> </Card> <Card> <CardContent> <BarChart /> </CardContent> </Card> </CardGrid>
{/* ③ Detail table — full width, always last */} <ContentSection title="Recent Activity"> <DataTable columns={columns} data={data} /> </ContentSection> </PageContent></>Template B — Detail 3a Split (record with activity)
Purpose: The canonical record detail — the page you land on when clicking an entity name in a list. Shows the entity’s ongoing life (activity, timeline, notes) in the main area and its static metadata (properties) in a persistent right panel.
Attio reference: This is exactly how Attio renders Company, People, and Deal records. The structure below is measured from app.attio.com (April 2026).
Anatomy (Attio-measured):
┌─ DetailPageBar ─────────────────────────────────────────────────────┐│ Row 1 (h-10): ← Back · "1 of N in List" · [action icons] ││ Row 2 (h-14): [avatar] Entity Name ☆ · description ││ Row 3 (h-10): Overview | Activity | Emails | Notes | +2 more │├─ Main (flex-1, scrollable, ~70%) ──┬─ Right Panel (~30%) ──────────┤│ │ ││ § Highlights (3×2 stat card grid) │ Tabs: Details | Comments ││ ┌────┐ ┌────┐ ┌────┐ │ ▾ Record Details ││ │ │ │ │ │ │ │ icon + label → value ││ └────┘ └────┘ └────┘ │ icon + label → value ││ ┌────┐ ┌────┐ ┌────┐ │ icon + label → [tag chips] ││ │ │ │ │ │ │ │ ▸ Show all values ││ └────┘ └────┘ └────┘ │ ▾ Lists / Tags ││ │ (secondary grouping) ││ § Activity › (collapsible) │ ││ [timeline entries] │ ││ │ ││ § Notes (1) › [+] │ ││ [note entries] │ │└─────────────────────────────────────┴───────────────────────────────┘Split ratio: ~70% main / ~30% right panel. Right panel width: w-80 (320px) narrow, w-[400px] wide.
Code pattern:
<DetailPageBar backTo="/entities" title={entity.name} basePath={`/entities/${id}`} tabs={[{ label: 'Overview', path: '' }, { label: 'Activity', path: '/activity' }]}/><div className="flex flex-1 overflow-hidden"> <PageContent> {/* ① Highlights — stat cards are the ONE card use on this page */} <CardGrid columns={3}> <HighlightCard label="Status" value={status} icon={Circle} /> <HighlightCard label="Score" value={score} icon={Target} /> <HighlightCard label="Owner" value={owner} icon={User} /> </CardGrid> {/* ② Activity / timeline — the primary main-area content */} <ContentSection title="Activity">…</ContentSection> {/* ③ Notes — optional, collapsible */} <ContentSection title="Notes" actions={<Button size="sm">+</Button>}>…</ContentSection> </PageContent> <RightPanel tabs={[{ label: 'Details' }, { label: 'Comments' }]}> <CollapsibleSection title="Record Details" defaultOpen> <PropertyList> <PropertyRow icon={User} label="Agent"><InlineEditField … /></PropertyRow> <PropertyRow icon={Clock} label="Duration"><InlineEditField … /></PropertyRow> </PropertyList> <button className="text-sm text-muted-foreground">Show all values ›</button> </CollapsibleSection> <CollapsibleSection title="Tags" defaultOpen> … </CollapsibleSection> </RightPanel></div>Main area content hierarchy:
- Highlights — stat cards in a 3-column grid. Max 2 rows (6 cards). These are the ONLY cards on this page.
- Activity — reverse-chronological timeline. Each entry: avatar + actor + verb + object + timestamp.
- Notes — optional. Collapsible section with
+add button in section header.
Right panel rules:
- Has its own tab bar (Details | Comments). Tab content scrolls independently.
- Uses flat
<PropertyList>with<CollapsibleSection>groups. No<Card>. - Property rows: icon + label (left) + inline-editable value (right). ~32px row height.
- “Show all values” toggle hides low-priority properties by default.
- Is the second allowed scroll zone (exception to “only
<PageContent>scrolls”).
When to use 3a Split:
- ✅ Entity has ongoing activity (timeline, notes, communication history)
- ✅ Entity has first-class metadata that benefits from a persistent panel
- ✅ Main area content type is feed/timeline, not form fields
When NOT to use 3a Split:
- ❌ Main area would just be more form fields → use Template D (Settings)
- ❌ Entity has many distinct configuration surfaces → use 3b Tabbed
- ❌ Right panel would be empty or contain only 2-3 properties → skip the split, use full-width
Template C — Monitoring / live status (Dashboard archetype, real-time)
ListPageBar: title + auto-refresh toggle ① Status banner (conditional — only on alert) ② Status grid — CardGrid with colored dot indicators ③ Live chart — full width, updating ④ Event log — reverse chronologicalRules: never show “all good” banners (wastes space); color-code with left border or dot, not full card bg; event log shows relative times (”30s ago”).
Template D — Settings / Configuration (Form archetype)
Purpose: Document-style settings that the user reads top-to-bottom, modifies specific fields, and saves. This is the template for all configuration surfaces — agent settings, workspace preferences, billing, security, integrations.
Attio reference: Attio’s Objects, Members, Billing pages all follow this pattern: a large standalone title in the content area (not in the page bar), centered ~600px max-width, generous 40–60px whitespace between sections, and no <Separator> lines between sections.
ListPageBar: title (slim, breadcrumb-style for global settings) PageContent narrow [Optional: H1 page title (24px) inside content for standalone settings pages] FormSection "Voice Settings" description: "How the agent sounds" [form fields, space-y-4 between fields]
[40px whitespace gap — space-y-10, NO <Separator>]
FormSection "RAG Settings" description: "Knowledge base and retrieval" [form fields]
[40px whitespace gap]
FormSection "Model" [form fields]
[40px whitespace gap]
CollapsibleSection "Advanced" (collapsed by default) [rarely-touched fields] ActionBar: [Cancel] [Save]Spacing rules (Attio-measured):
- Section title → description:
space-y-2(8px) - Description → first field: 24px gap
- Between fields within a section:
space-y-4(16px) - Between
<FormSection>blocks:space-y-10(40px) — NOTspace-y-6 - Use whitespace to separate sections.
<Separator>lines are allowed but secondary — Attio prefers whitespace-only separation.
Standalone settings page title: When building a global settings page (not a tab within a detail page), render the page title as <H1> (24px) inside <PageContent>, not as the PageBar title. The PageBar shows only a slim breadcrumb. This creates the Attio-style document reading feel where the title is part of the content.
When this IS a tab (e.g., agent Settings tab within a Detail page): The DetailPageBar already has the entity title (20px H2). Section headings (H3 16px) are sufficient — no need for a standalone H1.
Progressive disclosure: Group fields into:
- Primary — always visible. The fields a user touches on first setup and occasionally adjusts (Language, Voice, LLM Model).
- Advanced — collapsed by default via
<CollapsibleSection>. Fields that are set-and-forget or expert-only (speaking rate, pitch, reranking, max tokens).
This matches Attio’s “Show all values” pattern — common properties are visible, rare ones are behind a disclosure control.
Rules: always PageContent narrow; 40px gaps between sections; danger zone always last, uses <ConfirmDialog>.
Template E — Split form (list-nav + settings) (Form archetype)
Used when a tab has a list of items (tools, knowledge-base docs, channels) and the user selects one to configure. The “split” is list-for-navigation + settings-for-content, not main-and-properties. Do not mistake this for 3a Split.
DetailPageBar SplitContent (30/70 — list left, settings right) Sidebar: list of items (selection only, no actions per row) Main: PageContent narrow FormSection (for selected item's config) + Separator between groups OR PropertyList + InlineEditField (if every field is independently savable) ActionBar: [Cancel] [Save] — only if form-mode (not inline-edit)Rules:
- Sidebar is selection only. No bulk actions, no filters here; those go in a
<ContentToolbar>above the sidebar if needed. - Right side is flat —
<FormSection>+<Separator>. No card wrappers, no right-panel-inside-right-side. - If every field is independently savable →
<PropertyList>+<InlineEditField>, no<ActionBar>. - If fields are related and commit together →
<FormSection>+<ActionBar>at bottom. - Both sides scroll independently — this is allowed because it’s the sidebar-as-nav pattern, not a general “two scroll zones are fine” permission. The right side is the only place the user reads/edits; the left is just navigation.
Template F — Focus View wizard (Focus archetype)
Main-content takeover for multi-step linear flows. Sidebar stays visible, app chrome stays addressable, centered content, two-row header, sticky bottom action bar. Body uses bg-background — no surface tint. See references/attio-layout-patterns.md §3d for full anatomy.
<FocusLayout closeTo="/agents" // ✕ top-left returns here, not browser-back breadcrumb={['Agents', 'Create']} stepper={{ current: 2, steps: ['Template', 'Configure', 'Connect tools', 'Review'], }} actions={ <> <Button variant="ghost" onClick={cancel} > Cancel </Button> <Button variant="outline" onClick={prev} > Previous </Button> <Button onClick={next}>Next</Button> </> }> {/* step-specific content, centered, max-width-constrained */} <div className="mx-auto max-w-2xl space-y-6 py-12"> <FormSection title="Choose a template">…</FormSection> </div></FocusLayout>Mount inside AppLayout so the Sidebar renders as a sibling — do not register the route outside the app shell.
// router.tsx — CORRECT{ element: <AppLayout />, children: [ { path: 'agents/new', element: <AgentCreatePage /> }, // FocusLayout fills the main pane { path: 'agents', element: <AgentListPage /> }, // ... ],}
// router.tsx — WRONG (overlays the Sidebar){ path: '/agents/new', element: <AgentCreatePage /> }Rules:
- Mount inside
AppLayoutso the Sidebar stays visible. The focus view is scoped to the main pane, not the viewport. ✕top-left closes and returns tocloseTo. Not a back button. Esc same.- Two-row header — row 1 close + breadcrumb (
h-12), row 2 stepper (h-12). Single-row crowding is forbidden — the stepper needs its own row to breathe. - Linear stepper — numbered
① ② ③ ④, future steps muted, no random-access. - Body bg is
bg-background— same as the rest of the app. The focus is established by structure (two-row header + sidebar context + sticky bottom action bar), NOT by surface tint. Earlier spec drafts called for a tinted body; that was a misread of Attio (the tinted color in references is the old sidebar bg, not the focus body). - Centered, max-width content — never edge-to-edge.
- Sticky bottom action bar via the
actionsprop —border-t bg-background px-3 py-2, right-aligned button cluster. Same shape as the Form archetype’sActionBar. Floating-pill action clusters are forbidden — they look misplaced and clip on small viewports. - No app-level actions at top. Actions are step-local (bottom action bar).
- Not a modal — no
role="dialog", no focus trap. The user can click into the Sidebar to navigate away (with discard confirm if dirty). This is a focused page, not an overlay. - Use when: 3+ sequential steps, user must finish to commit (import, onboarding, complex creation).
- Do NOT use when: single-step form (Dialog or Form page), config the user dips in/out of (Sheet or settings page), record detail (Split or Tabbed).
Metric Card pattern
<Card> <CardContent className="py-4"> <p className="text-muted-foreground text-sm">{label}</p> <div className="flex items-baseline gap-2"> <span className="text-2xl font-semibold tabular-nums">{value}</span> {trend && ( <span className={cn( 'text-xs font-medium', trend.startsWith('+') ? 'text-primary' : 'text-destructive' )} > {trend} </span> )} </div> </CardContent></Card>Rules: label (minor) → value (major, text-2xl tabular-nums) → trend (colored small). Max 4 per row. Never 5+.
Result template (within Form / Focus archetypes)
Not a standalone archetype — a final-state render that belongs at the end of a Form or Focus flow. For post-action feedback (import complete, payment received, export started).
PageContent (centered) ┌─────────────────────────┐ │ [big icon, 64px] │ │ Primary outcome title │ │ Secondary details line │ │ [Primary CTA] [Back] │ └─────────────────────────┘Rules: max two actions, clear next step, not a dead end. Don’t use it for field-level “saved” feedback — that’s inline (see 05-patterns.md).
Exception template (rendered inside the current archetype)
Not a standalone archetype — an error state that replaces <PageContent> children while keeping the shell. For 404 / 403 / 500 / offline.
Full viewport, centered ┌─────────────────────────┐ │ [muted large icon] │ │ "Page not found" │ │ "The page you're looking for doesn't exist" │ │ [Go home] [Go back] │ └─────────────────────────┘Rules: never show an error code without a plain-language title. Always offer at least one recovery action.
Visual hierarchy inside pages
Three contrast tiers
| Tier | Role | Text | Bg |
|---|---|---|---|
| Major | Primary content, primary action | text-foreground ± font-semibold | bg-background |
| Minor | Secondary info, secondary action | text-muted-foreground | bg-muted / transparent |
| Background | Chrome, borders, disabled | text-muted-foreground/50 | bg-muted/50 |
One major per row. 30% major / 70% minor.
Zone-inherited action weights
| Zone | Variant |
|---|---|
| ① Page bar | primary (major) |
| ② Section header | outline sm (minor) |
| ③ Content toolbar | varies (minor by default) |
| ④ Inline row | ghost icon-xs (bg tier) |
| ⑤ Action bar | primary (major) |
| ⑥ Dialog footer | primary or destructive |
Putting a primary Save button in a section header breaks the zoning. The page bar already has the primary action; sections are secondary.
Navigation hierarchy
Three levels. Each has a distinct visual weight.
| Level | Element | Bg | Active indicator |
|---|---|---|---|
| L1 — App | Icon Rail | bg-sidebar darkest | 4px accent bar, left edge |
| L2 — Section | Sidebar | bg-sidebar medium | Bg tint + font-medium |
| L3 — Sub-section | DetailPageBar tabs | Transparent | 2px underline bg-foreground |
Rules:
- Never mix levels. Sidebar doesn’t contain tabs; tabs don’t contain sidebars.
- Active state at every level simultaneously.
- One active item per level.
- Deeper levels are dismissible (L3 only on detail pages; L1 hides for single-app users).
- Navigation never scrolls horizontally on desktop.
- Clicking a higher level resets lower levels (switching sidebar items resets to default tab).
Tables
All tables use <Table> from @na/ui/components/table. Never raw <table>.
Default is edge-to-edge flat — no card wrapping, no rounded-lg border div. Attio’s tables span the content width with a subtle bottom-border on the header row only. This is the live-inspected rule from references/attio-design-language.md L25–31.
<Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Status</TableHead> <TableHead className="text-right">Count</TableHead> </TableRow> </TableHeader> <TableBody> {items.map((item) => ( <TableRow key={item.id} className="hover:bg-muted/50 transition-colors" > <TableCell className="font-medium">{item.name}</TableCell> <TableCell> <Badge>{item.status}</Badge> </TableCell> <TableCell className="text-right tabular-nums">{item.count}</TableCell> </TableRow> ))} </TableBody></Table>Rules:
- No wrapper div with border + rounded corners. The table is flat, edge-to-edge within
<PageContent>. If you need visual separation from surrounding content, use spacing or<Separator>, not a box. - Column header. 12px, muted foreground,
font-medium— not uppercase, not bold (matches the live-inspected Attio column header). - Numeric columns:
text-right tabular-numson both header and cell. - Interactive rows:
hover:bg-muted/50 transition-colors. - Non-interactive rows: no hover.
- Empty state: single
<TableCell colSpan={n}>with centered muted text or<EmptyState>. - For built-in search/sort/pagination/bulk-select/filter-chips: use
<DataTable>. Its defaultvariant="flat"matches the Attio edge-to-edge rule; opt intovariant="bordered"only for rare cases (e.g., a table nested inside a card-based dashboard widget). - Footer row is Attio-native: count on the left, optional per-column calculations. Sticky to the bottom of the scroll zone when the table is long. Use
<DataTable footer>prop or the<TableFooter>primitive.
Forbidden: wrapping the table in <Card>, <div className="rounded-lg border">, or any box that imposes a boundary other than the table’s own header/footer. See also attio-layout-decision-guide.md § “Settings is not a Detail-Split” — the same flat-first rule applies to settings sections.
Cards
All cards use <Card> from @na/ui/components/card.
<Card> <CardContent>{/* default p-4 — do not override */}</CardContent></Card>
<Card> <CardHeader className="pb-3"> <CardTitle className="text-sm">Section Title</CardTitle> </CardHeader> <CardContent className="space-y-4">{/* ... */}</CardContent></Card>Rules:
CardContentdefault padding isp-4— do not override.- Cards at rest: border only, no shadow.
- Selected/active card:
ring-2 ring-primary(notborder-primary). - No hover state unless the whole card is clickable.
When to use a card (Attio card-vs-flat rule)
From references/attio-design-language.md L152–162 — Attio’s flat-first philosophy. Cards are for content containers — self-contained, potentially interactive entities. Everything else is flat.
Use <Card> when… | Use flat <FormSection> / <PropertyList> / <Separator> when… |
|---|---|
| Dashboard metric tile (label + big number + trend) | Form field groups inside a settings page |
| Clickable entity preview (agent, tool, channel in a grid) | Right-side property panel (<RightPanel> content) |
| Empty-state CTA (centered “create your first X” block) | Sequential form sections (name → description → permissions) |
| Self-contained widget (iframe preview, embedded player) | Settings / configuration sections |
| Danger zone (red-tinted card, always last) | Right side of a split-form layout |
Forbidden uses — these are violations:
- ❌
<Card>wrapping a<Table>— tables are edge-to-edge flat. - ❌
<Card>around form field groups — use<FormSection>+<Separator>. - ❌
<Card>around a right-side property panel — use flat<PropertyList>. - ❌
<Card>wrapping a progress bar, alert, or toast-like status banner — banners are flat alerts (role="status"/role="alert"), not content containers. - ❌ Nested cards (card inside card).
- ❌
<Card>around a collapsible section — the section chevron owns the boundary.
See references/attio-layout-decision-guide.md § “Card vs flat” for nx-agent worked examples.
Loading and empty states
Loading
Skeleton inside PageContent. Never a full-page spinner.
<> <ListPageBar title="Contacts" /> <PageContent spaced> <Skeleton className="h-8 w-64" /> <Skeleton className="h-64 w-full" /> </PageContent></>Empty
<EmptyState> inside PageContent. Every empty state has: icon, title, description, primary action.
<PageContent> <EmptyState icon={<Inbox className="h-10 w-10" />} title="No contacts yet" description="Create your first contact to get started." action={<Button>Add Contact</Button>} /></PageContent>Forbidden layout patterns (the big table)
| NEVER do this | Do this instead |
|---|---|
<div className="flex-1 overflow-auto p-4 ..."> | <PageContent> |
<div className="max-w-2xl"> for forms | <PageContent narrow> |
| Raw div for content wrapper | <PageContent> |
| Breadcrumb bar | Back button in <DetailPageBar> |
<h1> outside page bar | title prop in <ListPageBar> / <DetailPageBar> |
position: sticky inside content | Page bar is already fixed; nothing else needs to stick |
position: fixed inside content | Put it in page bar or layout-level |
| Full-page loading spinner | Skeleton loaders inside <PageContent> |
| Hero section / banner | Content starts immediately |
| Nav bar below page bar | tabs prop in <DetailPageBar> |
| Modal for forms > 4 fields | <Sheet> or dedicated Form page |
| Nested scroll containers | <PageContent> is the only scroll zone |
overflow-auto on custom divs | Only <PageContent> scrolls |
Raw colors (text-gray-500) | Semantic tokens (text-muted-foreground) |
| Custom layout chrome | <ListPageBar> or <DetailPageBar> only |
| > 8 tabs | Group into fewer tabs with sections |
| Subtitle in page bar | Move description to content area |
min-h-screen / whole-page scroll | Viewport is 100vh, <PageContent> scrolls |
| Fourth navigation tier | Two sidebar tiers + inline tabs. No deeper. |
<div><h2>Title</h2>...</div> for sections | <ContentSection title="..."> |
<div className="flex items-center gap-3"> toolbar | <ContentToolbar> |
<div className="flex flex-col lg:flex-row"> split | <SplitContent sidebar={...}> |
<div className="grid grid-cols-3 gap-4"> cards | <CardGrid columns={3}> |
<div className="flex items-center gap-4"> stats | <StatRow items={[...]} /> |
<div className="space-y-4"> for form groups | <FormSection> |
| Raw sticky div for submit buttons | <ActionBar> |
font-bold (700 weight) | font-semibold (600) max — two weights only |
| Left-aligned numbers in table columns | Right-align with text-right tabular-nums |
Odd spacing (p-5, gap-3.5, p-7) | 8px ladder: p-2 (8), p-4 (16), p-6 (24) |
| Shadows on resting cards | Border only; shadow only on hover for clickable cards |
| Both buttons primary in a neutral decision | Use outline for both |
<ActionBar> inside <PageContent> | Sibling, outside the scroll area |
| Two active items at same nav level | One active per level |
Section action as primary variant | Section actions are outline sm |
Row actions as default / primary | Row actions are ghost icon-xs |
<div className="rounded-lg border"><Table> wrapper | Edge-to-edge flat — no wrapper at all (Attio rule) |
<Card> around a <Table> | Edge-to-edge flat — tables never wrap in cards |
<Card> wrapping a progress bar / alert / status banner | Flat alert (role="status" or role="alert" div) — banners are not content containers |
<Card> around form field groups | <FormSection> + <Separator> between groups |
<Card> around a right-side property panel | Flat <PropertyList> in <RightPanel> |
Nested <Card> inside another <Card> | Flatten — one of them isn’t really a content container |
<Dialog> at h-[80vh]+ / max-w-4xl+ / with custom header replacing <DialogHeader> | Outgrown Dialog — promote to <Sheet> (triage), dedicated page (shareable), or Focus View (wizard) |
<Dialog> containing a stepper, tab bar, or “Step N of M” inside the body | Wizard crammed into Dialog — promote to Focus View (3d) with <FocusLayout> + <Stepper> |
Buttons-as-filters (<Button variant="outline"> row with count badges) | <FilterChips> — pill-style, clickable to edit, AND semantics |
<Button variant="link"> with underline on entity names in a table row | Plain text link with hover underline; hover opens <HoverCardPreview> |
| Settings sub-tab rendered as Detail 3a Split | Settings is Form archetype — flat FormSection + Separator, no right panel, no nested scroll |
Exemptions
These surfaces are exempt from the shell system — they intentionally break out.
Standalone pages
Preview, Playground, Embed widget. Simulate end-user experience. Must live outside AppLayout:
- Own full-screen layout (
h-screen). - Still use
@na/uicomponents (Button, Input, etc.) and semantic color tokens. - Only the shell itself is exempt; everything inside still follows the spec.
Intra-tab sub-navigation
Inside a tab (L3), you can add a narrow horizontal sub-toolbar for intra-tab switching. This is not a fourth nav tier — it’s a switcher within tab content. Keep it lightweight (text segments, not tabs).
Chat panel
The chat panel is a floating layer available from any app, not part of the page layout.
| Mode | Behavior | Trigger |
|---|---|---|
| Slide-out | 400px panel slides from right, overlays | Default open |
| Split | Content splits 60/40, resizable | Drag panel edge left |
| Full view | Panel takes the entire content area | Click expand |
| Minimized | Small floating bubble, bottom-right | Click minimize |
| Detached | Opens in a new browser window | Click pop-out |
Rules:
- Panel state (mode, width) persists in
localStorageper user. - Panel is accessible from any app — not CRM-specific.
- Minimized bubble shows unread count badge.
- Cross-app deep link:
?chat=<contactId>opens panel on that conversation. - Panel never pushes the page bar — it overlays or splits content only.
Repetition principle (from 01-principles.md)
Every table looks like every other table. Every card has the same padding. Every form section uses FormSection. Originality in layout is a bug. Reuse, don’t invent.
Checklist for a new page
Before you submit:
Archetype + shape
- Page is one of the 7 archetypes: List / Detail / Form / Workbench / Dashboard / Canvas / Focus.
- Detail picks one of 3a Split / 3b Tabbed / 3c Drawer (see patterns doc §3).
- 3a Split only used when main area has genuine activity/timeline content — not just more properties (if it’s properties-only, it’s Form, not Detail).
- Multi-step wizard uses Focus archetype (
<FocusLayout>+<Stepper>), not a giant<Dialog>. - Detail page has max 8 tabs. (If more, split into two detail pages.)
Overlay choice
- Dialog only for ≤4-field short commits / confirmations. Max
sm:max-w-lg. - No
<Dialog>ath-[80vh]+,max-w-4xl+, or with tabs/steppers inside. - Drawer / Sheet used for triage preview (URL-backed
?id=<x>) or config side-tasks. - Never stacking overlays (no drawer-in-dialog, no dialog-over-drawer).
Chrome + scroll
- Uses
<ListPageBar>or<DetailPageBar>— no custom chrome. - Content uses
<PageContent>— no raw divs withoverflow-auto. - Second scroll zone only allowed inside
<RightPanel>(3a Split) or Template E split-form. - No
<h1>or title outside the page bar. - No breadcrumbs.
- No sticky/fixed inside content.
- Whole page fits in
100vh— no page-level scroll. - Back button goes to parent list (not browser back).
Flat-first composition
- Tables are edge-to-edge flat — no
<div className="rounded-lg border">wrapper, no<Card>around table. - Form sections use
<FormSection>+<Separator>— no<Card>around field groups. - Settings sub-tabs are Form archetype (single-column
narrow), not Detail 3a Split. - Right-side property panels use flat
<PropertyList>, not<Card>. - Progress bars / alerts / status banners are flat (
role="status"/role="alert"), not wrapped in<Card>. - No nested
<Card>inside<Card>.
Attio-native primitives
- Filter rows use
<FilterChips>, not<Button variant="outline">rows with count badges. - Table row entity names use plain links with hover underline + optional
<HoverCardPreview>, not<Button variant="link">. - Property editing uses
<InlineEditField>inside<PropertyList>, not per-row Edit buttons. - Collapsible sections use
<CollapsibleSection>, not a hand-rolled<details>or<div>+ chevron.
Actions
-
<ActionBar>is a sibling of<PageContent>— not inside it. - Page bar has max 1 primary action.
- Section actions are
outline sm. Row actions areghost icon-xs(max 3). - Destructive actions wrapped in
<ConfirmPopover>or<ConfirmDialog>. - Dialog confirm labels use the verb (“Delete”, “Create”) — never “OK”.
- Focus-view step actions are bottom-right (zone ⑧), not in a sticky
<ActionBar>.
Navigation
- Navigation shows active state at every level (L1, L2, L3).
- Only one active item per level.
- Section headers use
<ContentSection>, toolbars use<ContentToolbar>, grids use<CardGrid>.
View switching
-
<ViewSwitcher>only on task/flow screens (review queue, test results). Not on registries, settings, audit logs.
Visuals
- No raw colors — all semantic tokens.
- Spacing on the 8px ladder (
2,4,6). - Numbers in tables are
text-right tabular-nums. - One focal point per row — not everything bold.
- Empty state uses
<EmptyState>with icon + title + description + action. - Loading uses
<Skeleton>— never a full-page spinner.
Next: 04-components.md. See also references/attio-layout-decision-guide.md for per-screen mapping and decision trees.