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
| Surface | Where it lives | When to use |
|---|---|---|
| Inline | Next to the action (ActionButton) | Click-triggered mutation |
| Form-level | <FormError> above ActionBar | Form submit failed server-side |
| Toast (sonner) | Top-right of viewport | Background 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
| Context | errorPlacement | Why |
|---|---|---|
| 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 reconnecttoast.success('Import complete: 1,247 contacts'); // background jobtoast.error('Session expired. Please sign in.'); // authReact 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.
Navigation
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”.
Navigate only when the task belongs on a different page (A6 Stay on the Page)
- 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 witharia-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-foregroundbelow 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
| Context | Format |
|---|---|
| Recent (< 24h) | Relative: “2 hours ago” |
| This week (< 7d) | Relative: “3 days ago” |
| Older | Absolute: “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.2and1.00in the same table. - Currency: symbol before value, no space:
$1,234.56. - Percentages: one decimal unless otherwise documented:
89.2%. - Use
tabular-numsandtext-rightfor 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”.
| Type | Format | Example |
|---|---|---|
| Integer | Intl.NumberFormat with thousands | 1,234 |
| Decimal | 1-2 fraction digits, consistent | 12.45 or 12.4 |
| Currency | Intl.NumberFormat with currency | $1,234.56 |
| Percentage | number + % | 89.2% |
| Date (short) | Apr 5, 2026 | |
| Date (long) | April 5, 2026 | |
| Date + time | Apr 5, 2026, 2:32 PM | |
| Timestamp (UTC) | 2026-04-05 14:32:11 UTC | |
| File size | 1.2 KB, 12.4 MB, 1.24 GB | |
| Duration | 2h 14m, 45s, 2.3s | |
| Phone (display) | E.164: +1 202 555 0100 | |
| lowercase, no trim | ||
| URL | full, clickable if applicable | |
| ID (internal) | font-mono text-xs | agt_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
| Role | Variant |
|---|---|
| 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 action | link |
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
| Scenario | Pattern |
|---|---|
| Select one | Row click → detail page, or RadioGroup |
| Select many | Leading checkbox per row |
| Bulk actions | Checkbox 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:
| Tier | Visibility | Use for |
|---|---|---|
| Always visible | Shown at rest | Primary row action (open detail) |
| Hover-reveal | Shown on hover: state | Secondary actions (edit, copy) |
| Toggle-reveal | Only 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:
- An icon (20-40px,
text-muted-foregroundcolor). - A title stating the fact (“No contacts yet” — not “Oh no, empty!”).
- A description explaining what the user can do next.
- 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
| Variant | What it means | Pattern |
|---|---|---|
| Blank slate (never added) | Truly empty | Icon + title + description + primary CTA |
| Filtered-to-zero | Data exists; filters hide it | ”No results” + “Clear filters” button |
| Permission-blocked | User 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.
| Mode | What renders | Use 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 casesHow 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.
| Tier | Visibility | Use for |
|---|---|---|
| Always visible | Rendered at rest | The primary action of the row/screen |
| Hover-reveal | hover: shows the control | Secondary actions (edit, duplicate) |
| Toggle-reveal | Inside a menu behind a trigger | Rare 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
Search
<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 inlocalStorageper(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 + ordersort: [{ field: "created_at", dir: "desc" }]filter: { status: "open", owner: "me" }group_by: "status" // board mode onlyRules:
- 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” istext-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 withXicon) 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.
| Surface | Exit 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. Usealign="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 origin | Surface | Component |
|---|---|---|
| Field validation (client-side) | Below the field | <FormMessage> (inside <FormField>) |
| Form submit server error | Above the action bar | <FormError> |
| Action button mutation error | Inline on the button (tooltip or below) | <ActionButton> errorPlacement |
| Standalone inline mutation | Inline text | <MutationStatus> |
| Background / SSE / global | Top-right toast | toast (sonner) |
| Render crash | Error boundary page | route-level boundary |
| 404 / 403 / 500 | Exception page archetype | full-viewport page |
| Offline | Banner 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>withhover:bg-muted/50rows,tabular-numsfor 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;Escclears;j/knavigate selection (see 08-accessibility.md);Cmd+Kopens command palette.
If any of these feel wrong on a page, that page is diverging from the spec.
Next: 06-flows.md.