Skip to content

05 - Patterns

The global rules that cut across every screen. Ant Design-inspired structure: Feedback · Navigation · Data Entry · Data Display · Data Format · Button · Data List · Empty State. Plus three-tier disclosure, three-tier inline edit, and the error handling hierarchy.

If a screen violates these, the screen is wrong — even if it “looks fine”.

Prereqs: 01-principles.md, 02-foundations.md, 04-components.md.


Feedback

The user’s reaction to their own action. Every click deserves one. Silence is a bug.

The three feedback surfaces

SurfaceWhere it livesWhen to use
InlineNext to the action (ActionButton)Click-triggered mutation
Form-level<FormError> above ActionBarForm submit failed server-side
Toast (sonner)Top-right of viewportBackground event (SSE, import, auth) — no click

Rule: the feedback lives where the action lives. A toast above a Save button is covering the button the user just clicked. Wrong. See the GitHub commit fix(ui): ActionButton errorPlacement for why we default to zero-layout-shift tooltip feedback.

Click-triggered mutation

import { ActionButton } from '@na/ui/components/ActionButton';
function SaveAgent({ id }: { id: string }) {
const save = useUpdateAgent(id);
return (
<ActionButton
mutation={save}
onClick={() => save.mutate(data)}
idleIcon={<Save className="h-4 w-4" />}
pendingText="Saving…"
successText="Saved"
>
Save
</ActionButton>
);
}

State machine: idle → pending → (success → 1500ms → idle) | error.

Error placement per context

ContexterrorPlacementWhy
Toolbar, tight action bar"tooltip" (default)Zero layout shift — icon + hover for detail
Form bottom"below"Error visible immediately, not hover-gated
Last row of a dialog"below-absolute"Doesn’t push siblings down
Caller renders error elsewhere (FormError)"none"When a sibling owns the error

Form submit — lift errors to FormError

const form = useForm();
const save = useUpdateAgent(id);
const onSubmit = form.handleSubmit(async (values) => {
try {
await save.mutateAsync(values);
} catch (err) {
form.setError('root', { message: extractErrorMessage(err) });
}
});
return (
<form onSubmit={onSubmit}>
{/* fields */}
<FormError />
<ActionBar>
<Button type="submit">Save</Button>
</ActionBar>
</form>
);

Background event — toast

Only when the user isn’t watching the action:

import { toast } from 'sonner';
toast.error('Connection lost. Reconnecting...'); // SSE reconnect
toast.success('Import complete: 1,247 contacts'); // background job
toast.error('Session expired. Please sign in.'); // auth

React Immediately (A10)

Every action shows feedback within 100ms. Local state → 1 frame. Network → optimistic where safe, pending state within 500ms otherwise.

Rule: if you’re tempted to add a spinner somewhere, first ask whether the action can be optimistic.


The user must always know: which app (L1), which section (L2), which tab (L3). All three active at once.

Full rules in 03-layout.md → Navigation hierarchy. Pattern highlights:

Back button goes to the parent list, not browser back

<DetailPageBar backTo="/contacts" ... />

Why: browser history is unreliable (users land here via email links, direct URLs, refresh). “Back” always means “up a level”, not “where I came from”.

  • Edit one field → inline edit, not a page.
  • Edit related fields → a Sheet or Dialog, not a page.
  • Edit the whole entity → a page.

Navigating away loses context. Keep the user in place whenever possible.

Active state contrast escalates with level

  • L1 (rail): 4px accent bar — “you are in this app”
  • L2 (sidebar): bg tint + font-medium — “you are on this page”
  • L3 (tabs): 2px underline — “you are on this tab”

Users should see all three signals simultaneously.


Data Entry

Label placement

Above the input (stacked). Never to the left (misaligns at small widths, wastes space).

<div className="space-y-1.5">
<Label>Display name</Label>
<Input />
<p className="text-muted-foreground text-xs">Shown on the agent card.</p>
</div>

Required vs optional

  • Required: suffix * on the label with aria-required="true" on the input.
  • Optional: don’t mark. If everything is required, mark the optional ones instead. Never mark both sides.

Validation timing

  • On submit for fields that need server validation (uniqueness, remote checks).
  • On blur for client-side format checks (email, URL).
  • While typing only for real-time guidance (password strength, char counter).

Error placement

Inline, directly below the field. Short (“Must be a valid email”), actionable, no blame (“Unable to save” not “You entered a bad value”).

<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage /> {/* auto-shown when field has an error */}
</FormItem>
)}
/>

Help text vs placeholder

  • Help text (text-xs text-muted-foreground below the input) — always visible, explains what to enter.
  • Placeholder — example or hint. Disappears on focus. Never the only source of information.

Bad: <Input placeholder="Email address" /> (placeholder is doing label’s job). Good: <Label>Email</Label> + <Input placeholder="you@company.com" />.

Default values

Pick one. “Select…” is a last resort. If you must prompt, use the word “Choose a plan” instead of ”— select —”.

Autocomplete + autofocus

  • Autofocus the first empty required field on load.
  • Use autocomplete="email", autocomplete="current-password" etc. for browser autofill.
  • Don’t autofocus inside dialogs that the user just opened deliberately (they’ll reach for the escape key and be confused).

Data Display

Tables are the default

For tabular data (a list of entities, each with multiple attributes), use <Table> or <DataTable>. Not card grids — cards cost vertical space and make scanning across columns impossible.

Cards for heterogeneous content

Use <CardGrid> only when:

  • Items have different shapes (some have images, some don’t).
  • The primary interaction is “pick one” (choose a plan, choose a template).
  • Vertical rhythm matters more than comparison.

Stat rows and metric cards — top of dashboards

Metrics come first. The user wants numbers before charts, charts before tables. See 03-layout.md → Template A.

Truncation vs wrap

  • Truncate for single-line identifiers (entity name in a page bar, email in a row) — add a tooltip with the full value if truncated.
  • Wrap for descriptions, long content in cards.
  • Never truncate a number. Truncating “$1,2…” is a bug.

Dates

ContextFormat
Recent (< 24h)Relative: “2 hours ago”
This week (< 7d)Relative: “3 days ago”
OlderAbsolute: “Apr 5, 2026”
Exact timestamp (audit logs)“2026-04-05 14:32:11 UTC”

Always show the absolute date on hover tooltip even when displaying relative.

Numbers and currency

  • Integers with thousands separator: 1,234 (locale-aware).
  • Decimals: consistent count per column. Don’t mix 1.2 and 1.00 in the same table.
  • Currency: symbol before value, no space: $1,234.56.
  • Percentages: one decimal unless otherwise documented: 89.2%.
  • Use tabular-nums and text-right for numeric columns.

Data Format

One canonical format per data type across every app. Ant calls this “data format”; we call it “stop arguing about commas”.

TypeFormatExample
IntegerIntl.NumberFormat with thousands1,234
Decimal1-2 fraction digits, consistent12.45 or 12.4
CurrencyIntl.NumberFormat with currency$1,234.56
Percentagenumber + %89.2%
Date (short)Apr 5, 2026
Date (long)April 5, 2026
Date + timeApr 5, 2026, 2:32 PM
Timestamp (UTC)2026-04-05 14:32:11 UTC
File size1.2 KB, 12.4 MB, 1.24 GB
Duration2h 14m, 45s, 2.3s
Phone (display)E.164: +1 202 555 0100
Emaillowercase, no trim
URLfull, clickable if applicable
ID (internal)font-mono text-xsagt_a1b2c3d4

Format at the display boundary. Don’t format in the API layer; don’t round in the DB. Display formatting is a UI concern.


Button — the global rule

Button is the most overloaded component. These rules apply everywhere.

Variant mapping

RoleVariant
Primary action (Save, Create, Submit)default
Secondary (Cancel, Filter, Reset)outline
Destructive (Delete, Revoke)destructive
Low-weight (row action, subtle action)ghost
Inline link-like actionlink

Size per zone

Zone-derived; see 03-layout.md → Action placement.

Destructive button position

In a two-button decision (Cancel + destructive), destructive goes on the right — the affirmative position. Cancel goes on the left.

// DialogFooter, ActionBar, ConfirmDialog
<Button variant="outline" onClick={cancel}>Cancel</Button>
<Button variant="destructive" onClick={confirm}>Delete</Button>

This is the macOS + Attio convention. Windows/Linux users sometimes expect the opposite, but consistency with the design-system ancestor (macOS dialogs, Attio) wins. The destructive button is the one with the irreversible consequence, so the user’s eye tracks to it last — putting it on the right puts it at the end of the read order.

Exception: in a three-button decision (Cancel + Save draft + Publish), the irreversible action (Publish) stays rightmost; the destructive or “exit without saving” action goes left.

Labels (copywriting)

Buttons are verbs + what they produce. Not “OK”, not “Submit”, not “Click here”. Match the verb to the outcome:

  • “Save” / “Save changes”
  • “Create agent” / “Delete agent”
  • “Invite member”
  • “Send message”
  • “Generate report”

Sentence case. No trailing period. No exclamation.

Full copy rules in 07-voice.md.

Neutral decision — two outline buttons

When the user must choose carefully (Accept/Reject, Approve/Deny), both buttons are variant="outline". Don’t lead the user by weighting one option.

<DialogFooter>
<Button variant="outline">Reject</Button>
<Button variant="outline">Approve</Button>
</DialogFooter>

One primary per decision

Two primary buttons side-by-side looks indecisive. One primary, one outline. Exception: the neutral decision above.


Data List

Lists of entities the user scans, selects, or acts on. Tables, card grids, row lists.

Selectable lists

ScenarioPattern
Select oneRow click → detail page, or RadioGroup
Select manyLeading checkbox per row
Bulk actionsCheckbox column + <ContentToolbar> with action set, visible only when ≥ 1 selected

Row hover

Interactive rows: hover:bg-muted/50 transition-colors. Non-interactive rows: no hover. If the row doesn’t respond to click, don’t fake an affordance.

Row actions (three-tier disclosure)

Applied principle A7, Fitts’s Law. Pick the right tier:

TierVisibilityUse for
Always visibleShown at restPrimary row action (open detail)
Hover-revealShown on hover: stateSecondary actions (edit, copy)
Toggle-revealOnly in <DropdownMenu>Rare actions (archive, debug, export)

Rule: a table row can show max 3 icon buttons always-visible. More = <DropdownMenu> with <MoreHorizontal /> trigger.

Pagination

Default page size 25. 1–25 of 127 count indicator (range bolded, “of N” muted). Chevron-only navigation — no numbered page links per 04-components.md § TablePagination § Why no numbered page links. No infinite scroll on admin tables — pagination is predictable, scroll memory isn’t.

Empty row count

Never hide a zero-count filter. If “Failed = 0”, show 0, not nothing. Zeros are information.


Empty State

A first-class pattern, not a leftover. Every empty state has:

  1. An icon (20-40px, text-muted-foreground color).
  2. A title stating the fact (“No contacts yet” — not “Oh no, empty!”).
  3. A description explaining what the user can do next.
  4. A primary action that creates the first item.
<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>}
/>

Variants of empty

VariantWhat it meansPattern
Blank slate (never added)Truly emptyIcon + title + description + primary CTA
Filtered-to-zeroData exists; filters hide it”No results” + “Clear filters” button
Permission-blockedUser can’t see this”You don’t have access” + “Request access” or similar
Error (network, API)Fetch failed”Couldn’t load” + retry button

Never conflate these four. A permission-blocked empty state with “Create your first” CTA is cruel.

Tables: drop chrome vs keep chrome

Variants tell you what the empty state means. The chrome axis tells you how to render the surrounding table. Every table empty state picks one of each — they’re orthogonal.

ModeWhat rendersUse when
Drop chrome (default)<EmptyState> alone — no <Table> / <TableHeader> wrappers.• Blank-slate on a primary list page (/agents, /tools, suite list) — user has no mental model of the columns yet.
• Filtered-to-zero on a primary list — search/filter input above carries context.
• Permission-blocked / error — chrome is misleading.
Keep chrome (in-table)Headers stay; empty content sits inside a single colspan row in <TableBody>.Streaming / live tables — rows are about to arrive (workflow run mid-execution, tool-call stream, polling log view). Headers frame incoming data. Same logic as the loading-skeleton rule.
Sub-table inside a multi-table detail page — dropping chrome on one of several stacked tables collapses the section’s visual rhythm.

Decision tree

Are rows about to arrive (live / streaming / polling)?
├─ YES → keep chrome (in-table)
└─ NO → Is this a sub-table inside a multi-table detail page?
├─ YES → keep chrome (in-table) for visual rhythm
└─ NO → drop chrome (standalone) ← default — answers ~95% of cases

How to render each mode

// Drop chrome (default) — DataTable picks this when emptyMode is omitted.
<DataTable
data={agents}
columns={columns}
emptyState={
<EmptyState
variant="blank-slate"
icon={<Bot />}
title="No agents yet"
description="Create your first agent to get started."
action={<Button>New agent</Button>}
/>
}
/>
// Keep chrome — for streaming / live or stacked sub-tables.
<DataTable
data={runResults}
columns={resultColumns}
emptyMode="in-table"
emptyState={
<EmptyState
variant="blank-slate"
icon={<FlaskConical />}
title="Run pending"
description="Results stream in as cases finish."
/>
}
/>

The emptyMode="in-table" carve-out is rare. Default to 'standalone'. Don’t pick 'in-table' “to keep things looking like a table” — that’s the same impulse that produced the foot-gun this rule was written to fix (column headers above empty bodies, with no streaming context to justify them).

Don’t re-show tour empty states

If the user has created items before and returns to an empty list (they deleted everything), don’t show the first-run onboarding message. Show the “data exists but filtered” empty state or a quiet “No items” with a create button.


Three-tier inline edit (A5 Make it Direct)

Edit where the thing lives. Three tiers:

Tier 1 — Click to edit

For readability-first fields (entity titles, names, descriptions). Display text at rest; click or focus to edit.

// Pseudo-example
<h2
onDoubleClick={() => setEditing(true)}
className="hover:bg-muted/50 -mx-1 cursor-text rounded px-1"
>
{name}
</h2>

Tier 2 — Icon edit

Both display and edit states matter equally. A pencil icon next to the field opens an inline edit.

Email: user@example.com [✏️]

Tier 3 — Multi-field inline

Complex edits without a modal — e.g., workflow node config panel. The right sidebar renders form fields; changes commit on blur or via a local Save button. Used when multiple fields change together.

When to escalate to a modal: when the edit needs confirmation, touches entity identity (rename), or the form is > 6 fields.


Three-tier disclosure (A7 Keep it Lightweight)

Apply per action, not per surface.

TierVisibilityUse for
Always visibleRendered at restThe primary action of the row/screen
Hover-revealhover: shows the controlSecondary actions (edit, duplicate)
Toggle-revealInside a menu behind a triggerRare actions (archive, export, debug)

Don’t mix: if a table row has two always-visible icons and three hover-revealed icons, the user learns nothing consistent. Pick a tier and apply it uniformly across the row type.


Search / Filter / Sort

<SearchInput> at the top of a list page, either in ListPageBar center slot or ContentToolbar.

  • Debounce 200-300ms.
  • / focuses search (keyboard contract).
  • Escape clears + blurs.
  • Search is client-side until list grows > ~200 items, then server-side.

Filter chips

Use popover-anchored filter dropdowns. Active filters render as removable chips below the search bar.

  • Max 6 chips before wrapping to a new row.
  • “Clear all” button when ≥ 2 filters are active.
  • Filters persist in URL query params (so reload and share work).

Sort

Sort is a column-header action in tables. Click a sortable column to toggle ascending → descending → none.

  • Indicate the active sort with a caret icon.
  • Sort state in URL params.
  • Default sort is explicit, documented, and consistent per list.

URL state vs component state

What goes in the URL shapes what’s shareable, bookmarkable, and restorable. The rule is blunt: if the user could reasonably want to share a link that restores this state, it belongs in the URL.

Goes in the URL (query params or path):

  • Current list page (?page=3) and page size (?size=50).
  • Active filters (?status=open&owner=me).
  • Sort column + direction (?sort=created_at.desc).
  • Active view id when using <ViewSwitcher> (?view=board).
  • Selected record id for drawers (?record=<id>) — so closing clears the param and sharing the URL reopens the drawer on paint.
  • Expanded detail tab on a Detail page (already in path via route).

Stays in component state:

  • Collapsed / expanded sections in a <RightPanel> — user preference, not shareable state (persisted in localStorage per (pathname, sectionId)).
  • Hover state, focus state, dropdown-open state.
  • Scroll position within the page (browser handles this).
  • Unsaved form input in a Dialog, Sheet, or Focus page (lost on close — confirm-on-discard for every exit path; never silent).
  • Multi-select rows in a table — usually lost on navigation, because selection is task-local.

Stays in local storage:

  • Sidebar collapsed / expanded — user preference across sessions.
  • Theme (light / dark).
  • CollapsibleSection open/closed state per (route, sectionId).

Rule of thumb: if two users share a URL, they should see the same thing. Filters and selected records yes; personal UI preferences no.


Saved views

When a collection page earns a <ViewSwitcher> (see 04-components.md § ViewSwitcher for the four conditions), each view is a named bundle of state:

name: "My open reviews"
type: "board" | "table" | "calendar"
columns: ["status", "agent", "created_at"] // visibility + order
sort: [{ field: "created_at", dir: "desc" }]
filter: { status: "open", owner: "me" }
group_by: "status" // board mode only

Rules:

  • Workspace-scoped. A named view belongs to the workspace, not the user. Everyone on the team sees the same “My open reviews” view. (The filter inside can still be personal via owner: {me} token.)
  • Column widths are per-user. A user dragging a column wider shouldn’t shift the column for teammates.
  • Default view is the first one. If no ?view= param, load the first view in the list.
  • Deletable but not shadowable. A user can’t override a named view for themselves; they delete-and-replace or create a new view.
  • No “unsaved changes” tracking inside a view. If the user changes columns on-the-fly, they either save-as-new-view or the next reload reverts. Don’t surface “You have unsaved changes” — that’s a muddy mental model.

Today nx-agent has zero saved views. When the first screen earns one (/review board+table is the likely first), add a /v1/views API surface. Model is in the plan doc — this section is the spec for when it ships.


”Show all values” disclosure

When a <PropertyList> contains more than 6 rows, hide low-priority attributes behind a <ShowAllValuesToggle> at the bottom of the list. “Low priority” means:

  • System / audit fields (id, created_at, updated_at, s3Key) when not the user’s focus.
  • Computed fields that don’t change the user’s next action (last_synced_at on an already-indexed doc).
  • Rarely-edited fields (custom attributes without a value set).

Always-visible regardless of row count: the entity’s identity fields (name, status, owner, primary relationship) and anything the user just edited in the last 30 seconds.

The toggle text is Show all values when collapsed and Show fewer values when expanded. State is component-local (not persisted) — opening a detail page starts collapsed every time. The premise is “I’ll rarely need these; show them when I ask.”


Optimistic update + rollback

For mutations where expected P95 latency is under 100ms (toggles, single-property inline edits, reorder, add-to-list), update the UI before the network call returns. Rules:

  • Commit optimistically the moment the user releases the input — click for a Switch, blur for an <InlineEditField>, drop for a drag-drop.
  • Show the success state silently — no spinner, no toast. The UI already shows the new value.
  • On failure within 3 seconds: roll back the optimistic value to the prior state, render an inline error next to the field (role="alert", text-destructive, 12px). Stay in the field if it’s an editor.
  • On failure after 3 seconds: roll back, show the inline error, AND surface a toast. After three seconds the user may have moved on; the field-local error alone isn’t enough.
  • Never optimistically update a destructive action. Delete, archive, revoke — these run through <ActionButton> with pending state, not optimistic UI. A “rollback” on a destructive is confusing.
  • Never optimistically update a multi-field form submit. Partial-commit on failure is a mess. Use <ActionButton> pending.

The 3-second deadline matches Attio’s behavior and <MutationStatus> default. Longer means the user is guessing whether the change stuck.

For the multi-user case — what to do when the rollback is caused by a concurrent edit (server says 409 Conflict) — see 12-cross-cutting-patterns.md § Concurrent edit collisions.


Bulk action toolbar

When <DataTable> has onRowSelectionChange wired and the selected set is non-empty, a bulk-action toolbar appears. Rules:

  • Placement. Above the table, inside a <ContentToolbar> with the standard left-filter/right-action layout. Not floating, not sticky to bottom — keep it where other table controls live.
  • Label. "{N} selected" on the left. The number is bold; the word “selected” is text-muted-foreground.
  • Actions on the right. Max 3 visible (variant="outline" size="sm"), rest collapse into a <DropdownMenu>. Destructive actions go inside the dropdown when they coexist with non-destructive — not mixed in the visible row.
  • Clear. A "Clear" button (ghost, icon-xs with X icon) on the far right. Esc also clears selection.
  • Persist through error. A failed bulk action keeps the selection so the user can retry. A succeeded bulk action clears the selection.
  • Sticky on scroll. When the user scrolls the table down, the toolbar stays visible above the header — you’re acting on a set, not a single row.

For the per-action confirmation policy (which bulk actions confirm, which run inline, and how friction escalates with item count), see 12-cross-cutting-patterns.md § Bulk-action confirmation matrix.


Confirm-on-discard

Any surface that holds unsaved user input (Dialog, Sheet, Focus page) must protect that input on every exit path. The bug pattern: developer wires up the obvious close button, forgets the alternative paths (Cancel, Esc, sidebar/back), and leaves them as silent no-ops. From the user’s perspective, those buttons look broken.

Hard rule. If the user can reach an exit path while dirty, that path MUST trigger a visible confirmation. A close handler that returns early on isDirty with no UI is a blocker — see 10-review-checklist.md common smell list.

SurfaceExit paths the user can reach
<Dialog>✕, footer Cancel, Esc, click-outside (overlay)
<Sheet>✕, footer Cancel (if any), Esc, click-outside (overlay)
<FocusLayout>✕, action-bar Cancel, Esc, sidebar click, browser back

The pattern.

  • Trigger-anchored exits (✕, Cancel button) → wrap each in its own <ConfirmPopover> when dirty. Same copy, same destructive variant. Anchored to the trigger so the prompt appears where the user clicked. Use align="start" if the trigger is on the left side of the screen, align="end" (default) if on the right.
  • Keyboard exit (Esc) → don’t build a separate confirm path. Programmatically click the ✕ button ref to reuse the same popover.
  • Navigation exits (sidebar click, browser back) → intercept with React Router useBlocker (or equivalent) and show a <ConfirmDialog>. This is the one case that escalates to a modal because there’s no in-flow trigger to anchor a popover.

One copy, one prompt. Don’t render different messages for ✕ vs Cancel vs Esc vs sidebar-click. The user shouldn’t have to learn multiple flows for the same outcome.

For the canonical Focus-page implementation, see 04-components.md § FocusLayout § Confirm-on-discard and 06-flows.md § Exit-path close behavior.


Error handling hierarchy

Where the error surfaces is determined by where the error happens, not by personal preference.

Error originSurfaceComponent
Field validation (client-side)Below the field<FormMessage> (inside <FormField>)
Form submit server errorAbove the action bar<FormError>
Action button mutation errorInline on the button (tooltip or below)<ActionButton> errorPlacement
Standalone inline mutationInline text<MutationStatus>
Background / SSE / globalTop-right toasttoast (sonner)
Render crashError boundary pageroute-level boundary
404 / 403 / 500Exception page archetypefull-viewport page
OfflineBanner at the top of content<Alert variant="destructive">

Copy rules (objective, no blame):

  • “Unable to save. Name must be unique.” ✓
  • “ERROR 500: Request failed” ✗
  • “You entered a bad value.” ✗ (blame)

Copywriting

A full chapter in 07-voice.md. Key rules to remember when picking patterns:

  • No periods on labels, buttons, titles, tooltips, table headers.
  • Sentence case everywhere except product names.
  • Benefit-labeled buttons: “Create agent”, not “Submit”.
  • No blame language in errors: “Unable to save”, not “You did something wrong”.
  • Arabic numerals always.

Composition — how patterns fit together on one page

Example: the “Contacts” list page.

  • Navigation — ListPageBar title “Contacts”, L2 sidebar active on Contacts, L1 rail active on CRM.
  • Data Entry<SearchInput> in the page bar, filter chips below, ”+ New Contact” primary action top-right.
  • Data Display<DataTable> with hover:bg-muted/50 rows, tabular-nums for numeric columns, truncated email with tooltip.
  • Data List — Three always-visible inline actions per row (open, edit, delete with <ConfirmPopover>). <DropdownMenu> if more.
  • Empty State<EmptyState> if list is empty (icon + title + description + Add action).
  • Loading<Skeleton> for the table on first fetch.
  • Feedback — Row delete uses <ConfirmPopover>; bulk delete uses <ConfirmDialog>. Toast only if the bulk delete is a background job taking > 2s.
  • Pagination<TablePagination> below table, default 25.
  • Keyboard/ focuses search; Esc clears; j/k navigate selection (see 08-accessibility.md); Cmd+K opens command palette.

If any of these feel wrong on a page, that page is diverging from the spec.

Next: 06-flows.md.