# Error Pages & Empty States

**Date:** 2026-05-31
**Spec:** 145
**Status:** Draft
**Depends on:** `foundation-design-system`
**Referenced by:** all module specs
**Consolidates:** spec 106 (`empty-error-states`) — retired; `ErrorState` inline component, first-run onboarding states, and additional catalog entries folded in here

---

## Overview

This spec defines the visual and copy contract for every zero-data state, every error condition, and every loading state across the platform. It also covers the degraded-module alert pattern, the trial expiry banner, and the onboarding checklist dismissal rule.

Two principles govern all copy here:

1. **One sentence. No marketing language. No exclamation marks.** Sounds like a person talking, not a product.
2. **One action.** Never two.

---

## 1. EmptyState Primitive

### Location

`packages/ui/src/data-display/empty-state.tsx`

### Component API

```ts
interface EmptyStateProps {
  heading: string          // the one sentence
  action?: {
    label: string
    href?: string
    onClick?: () => void
  }
  className?: string
}
```

**Intentionally omitted props** — considered and rejected:

| Prop | Why rejected |
|------|-------------|
| `icon` | Icons dilute the directness of the message; every module tried to use a different one |
| `description` | Forces a second sentence; the heading must carry the full meaning |
| `illustration` | Decorative; adds visual noise without aiding comprehension |

### Rendering contract

- Renders `heading` in `text-text-muted` at `text-sm` weight `medium`, centered.
- If `action` is present: renders a single `Button` (variant `outline`, size `sm`) below the heading.
- If `action.href` is set, the button renders as `<a>` via `asChild`.
- If `action.onClick` is set, the button fires the handler.
- `className` is forwarded to the root wrapper for spacing overrides by the parent.
- No icon. No illustration. No secondary link. Ever.

### Design-system page entry

`EmptyState` must be shown in the **Data Display** section of `/design-system` with three example states: one with an action and one without. This follows the existing rule that all primitives are catalogued in the same PR that introduces them.

---

## 2. Empty State Copy

One row per zero-data context. The `heading` is the full visible text. The `action.label` is the button text. Routes are app-relative (`/`-prefixed).

### Tasks module

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Tasks list — no tasks in workspace | You don't have any tasks yet. | Create a task | `/tasks/new` |
| My tasks — nothing assigned to me | Nothing is assigned to you right now. | View all tasks | `/tasks` |

### Projects module

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Projects list — no projects | No projects have been created yet. | Create a project | `/projects/new` |

### Customers module

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Customers list — no customers | No customers here yet. | Add a customer | `/customers/new` |

### Invoices module

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Invoices list — no invoices | No invoices have been issued yet. | Create an invoice | `/invoices/new` |

### Expenses module

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Expenses list — no expenses | No expenses have been logged yet. | Log an expense | `/expenses/new` |

### Support (CRM)

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Support tickets list — no tickets | No support tickets have come in yet. | Create a ticket | `/support/new` |

### Knowledge Base

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| KB space — no articles | This space doesn't have any articles yet. | Write the first article | `/kb/new` |

### Marketing

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Leads — no leads | No leads in the pipeline yet. | Add a lead | `/marketing/leads/new` |
| Campaigns — no campaigns | No campaigns have been created yet. | Create a campaign | `/marketing/campaigns/new` |

### Calendar

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Calendar — no events in view | Nothing scheduled here. | Add an event | `/calendar/new` |

### Contractor Payouts

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Contractor payouts — no bills | No payout requests have been submitted. | Submit a payout | `/payouts/new` |

### Notifications & Activity

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Notification dropdown — no notifications | You're all caught up. | — (no action) | — |
| Activity feed — no activity | No activity to show yet. | — (no action) | — |

### Search

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Search results — no matches | Nothing matched that search. | — (no action) | — |

### Reports

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Reports — no data for selected period | There's no data for this period. | — (no action) | — |

### Invoices (additional states)

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Invoice list — filter yields 0 results | No invoices match those filters. | Clear filters | (onClick: clear) |
| Payments list — no payments | No payments have been recorded yet. | — (no action) | — |
| Credit notes — no credit notes | No credit notes have been issued. | — (no action) | — |

### CRM (additional states)

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Lead form submissions — no submissions | No submissions yet. | — (no action) | — |
| Tickets — filter yields 0 | No tickets match those filters. | Clear filters | (onClick: clear) |

### Marketing (additional states)

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Email sequences — no sequences | No email sequences have been created yet. | Create a sequence | `/marketing/sequences/new` |

### Settings & System

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Webhook endpoints — no webhooks | No webhook endpoints configured. | Add a webhook | `/settings/integrations/webhooks/new` |
| API keys — no keys | No API keys created. | Create a key | `/settings/api-keys/new` |
| Team members — only you | No other team members yet. | Invite someone | `/settings/users/invite` |
| Audit log — no events in period | No events in this period. | — (no action) | — |
| Dunning schedules — none | No dunning schedule configured. | Add a step | `/settings/invoicing/dunning/new` |

### Admin dashboard

| Context | Heading | Action label | Action href |
|---------|---------|--------------|-------------|
| Admin: no tenants | No workspaces have been created yet. | Create a workspace | `/admin/tenants/new` |
| Admin: no users in tenant | This workspace has no users yet. | Invite a user | `/admin/tenants/[tenantId]/users/invite` |

---

## 3. Error Pages

Error pages render **outside the app shell** (no sidebar, no header). They are full-screen, with content centered both vertically and horizontally. This is the only context in the app where symmetric centering overrides the standard asymmetric sidebar layout — error states are an intentional exception.

The `ErrorPage` component lives at `apps/zync-app/src/components/error-page.tsx`. It accepts `heading`, `description`, `cta: { label, href }`, and optionally `code` (HTTP status, shown in small muted text above the heading for context). No stack traces are ever shown to users.

### Error page table

| Error | HTTP status | Heading | Description | CTA label | CTA href |
|-------|------------|---------|-------------|-----------|----------|
| Not found | 404 | That page doesn't exist. | It may have been moved or deleted. | Go to dashboard | `/` |
| Server error | 500 | Something went wrong on our end. | We've been notified and are looking into it. | Try again | (reload current URL) |
| Forbidden | 403 | You don't have access to this. | Ask your workspace admin if you think this is a mistake. | Go to dashboard | `/` |
| Session expired | 401 | Your session has expired. | Sign in again to continue where you left off. | Sign in | `/login` |
| Rate limited | 429 | You're moving a bit fast. | Wait a moment and try again. | Go to dashboard | `/` |
| Offline | — (no HTTP) | You appear to be offline. | Check your connection and try again. | Retry | (reload current URL) |
| Module disabled | — (routing guard) | This module is turned off. | *See note below* | *See note below* | *See note below* |

**Module disabled CTA — role-dependent:**

- If the current user is an admin: description = "You can turn it on in your workspace settings." CTA label = "Go to Settings", CTA href = `/settings/modules`.
- If the current user is staff (non-admin): description = "Ask your workspace admin to enable it." No CTA rendered (the `action` prop is omitted from `ErrorPage`).

The role check uses the session's `role` field. This is resolved at the routing guard level; the `ErrorPage` component receives a pre-resolved `cta` prop (or no prop).

---

## 3b. ErrorState (Inline Component)

`<ErrorState>` — for failed data loads within a page section. Distinct from `<ErrorPage>` (full-screen). Renders inline where data failed to load.

`apps/zync-app/src/components/error-state.tsx`

```ts
interface ErrorStateProps {
  title?: string               // default: "Something went wrong"
  description?: string         // default: "Failed to load data. Please try again."
  onRetry?: () => void         // shows [Try again] button if provided
  code?: string                // error code from API (shown in small text for support)
}
```

Visual: centered in the parent container. `AlertCircle` icon (48px), title, description, optional retry button, optional error code. No full-screen takeover — the rest of the page remains interactive.

Common variants:

| Scenario | Default title | Default description |
|----------|--------------|---------------------|
| Network timeout | "Connection timeout" | "Check your connection and try again." |
| 403 Forbidden | "Access denied" | "You don't have permission to view this." |
| 404 Entity not found | "{Entity} not found" | "This item may have been deleted or moved." |
| 500 Server error | "Something went wrong" | "Our server encountered an error. If this persists, contact support." |

---

## 4. Loading States

Loading states show **only in the section that is loading**. The rest of the page is interactive. A global loading blocker is never used.

**No shimmer or pulse animations.** Skeleton blocks are static. No `animate-pulse`, `animate-shimmer`, or keyframe sweep effects.

Skeleton blocks must mirror the **exact shape and layout** of the real content they replace — not generic equal-height bars.

### List views

Every list view (tasks, projects, invoices, etc.) renders skeleton rows while data is being fetched. Not a spinner. The rows match the column structure of the table (same column widths, same row height) so the layout does not shift when data arrives. Row count should approximate the expected data density — not a fixed 5 for every table.

Implementation: `DataTable` already accepts `loading?: boolean`. When `loading` is true, it renders `<Skeleton>` rows in place of real rows, using the same column widths. Module implementors pass `loading={isLoading}` and do not build their own loading states.

### StatCard (KPI cards)

`StatCard` already has `loading?: boolean`. When true, it renders a skeleton variant: the label area is a short skeleton bar and the value area is a wider bar. No spinner. The card dimensions stay the same.

### Detail views

Detail views (invoice detail, project detail, task detail) render a skeleton that mirrors the layout structure:

- Heading area: one wide skeleton bar at heading height
- Meta row (status, dates, assignee): skeleton bars sized and positioned to match each meta element
- Body content area: skeleton bars at paragraph height, matching the real content's column width
- Action bar: skeleton bar at button width and height

The skeleton is hand-authored per detail view to match its actual layout. There is no generic detail skeleton component.

### Global rule

The specific data-fetching boundary that is loading shows its skeleton. Sections whose data has already loaded (or does not depend on the loading query) remain interactive. Never use a full-page spinner or a layout-blocking overlay. `<Spinner>` is for action feedback only (button submit, file upload progress) — not for data loading.

---

## 5. Degraded Module State

A degraded module is one that is **enabled** but has a soft dependency on another module that is currently **disabled**.

When this condition is detected at page load:

- Render an `Alert` (variant `warning`) at the top of the module page, below the page header, above the content.
- Alert text: "Some features are unavailable because [Dependency Module Name] is turned off."
- If the user is an admin: include an inline link "Turn on [Dependency Module Name]" pointing to `/settings/modules`.
- If the user is not an admin: no link, no CTA.
- The module's data still loads and displays normally. Only the specific features that require the disabled dependency are hidden or rendered as disabled controls.

**Examples:**

| Module | Soft dependency | Hidden/disabled features when dependency off |
|--------|----------------|----------------------------------------------|
| Invoices | Customers | "Link to customer" field disabled |
| Contractor Payouts | Invoices | "Create invoice from payout" action hidden |
| Calendar | Tasks | "Create task from event" action hidden |
| KB | Support (CRM) | "Attach article to ticket" action hidden |

The alert is not dismissible. It disappears when the dependency module is turned on.

---

## 6. Trial Banner

**Condition:** `subscription.status === 'trialing'` AND `subscription.trial_ends_at` is within 7 calendar days from now.

**Placement:** Persistent banner rendered inside the app shell, below the header, above `<Outlet />`. It is part of the shell layout, not a floating overlay.

**Content:**

> [N] days left in your trial. Add a payment method to keep your workspace.

Where `N` is `Math.ceil((trial_ends_at - now) / 86400000)`, minimum 1.

**CTA:** "Add payment method" → `/settings/plan`

**Dismissal:** Dismissible per session. On dismiss, write `trial_banner_dismissed_at: <ISO timestamp>` to `sessionStorage`. On next page load within the same session, if the key is present, the banner is suppressed. On new login (new session), `sessionStorage` is cleared and the banner reappears.

**When N = 1:** Copy changes to "Your trial ends today." (same CTA).

**When N = 0 (expired):** The banner is not shown; the subscription state is handled by the billing enforcement layer (out of scope for this spec).

---

## 7. Onboarding Checklist After Dismissal

When a user dismisses the onboarding setup checklist on the dashboard (without completing it):

- Show nothing. The dashboard widget disappears.
- Do not show an "empty checklist" empty state.
- Do not show a prompt to restart the checklist.
- The dismissed state is persisted so it does not reappear on reload.

This is a deliberate product decision: users who dismiss the checklist have self-selected out of guided setup. Prompting them again creates friction.

---

## 8. Pagination

All list views (invoices, tasks, projects, contacts, etc.) use **cursor-based pagination** server-side. The `DataTable` component renders pagination controls below the table.

### Pagination control layout

```
Showing 21–40 of 143          [← Prev]  2  [Next →]   Per page: [20 ▾]
```

- **"Showing X–Y of Z"** label: X = first row on page, Y = last row, Z = total count from query. Shown on the left.
- **Prev / Next** buttons: disabled when at first/last page. Arrow icons + label text.
- **Per page** select: options `20`, `50`, `100`. Default: `20`. Persisted in `localStorage` per module key (e.g. `table:invoices:perPage`).
- **Page counter** (`2` in example): displays current page number, not a clickable page list. Jump-to-page not supported (cursor pagination doesn't support arbitrary jumps).

### API contract

All list endpoints accept `?cursor=<opaque>&limit=<20|50|100>` and return:

```json
{
  "data": [...],
  "meta": {
    "total": 143,
    "next_cursor": "eyJpZCI6IjEyMzQ1In0",
    "prev_cursor": "eyJpZCI6IjEwMDAxIn0",
    "has_next": true,
    "has_prev": true
  }
}
```

`cursor` is a base64-encoded JSON pointer (e.g. `{ "id": "<last-row-id>", "created_at": "<ts>" }`). The server decodes it and applies `WHERE (created_at, id) < (cursor.created_at, cursor.id)` (or `>` for prev). This is stable under concurrent inserts.

### Mobile

On viewports < 768px: replace Prev/Next + count label with a single **[Load more]** button. Appends next page to existing rows rather than replacing. Triggered also by scroll-to-bottom (IntersectionObserver on a sentinel element at the list bottom).

### Filtering + pagination

Applying any filter resets cursor to page 1. Filter state is held in URL query params so sharing the URL preserves the filter (but not the cursor — cursor is transient state).

### `DataTable` prop

```typescript
pagination?: {
  total: number;
  nextCursor: string | null;
  prevCursor: string | null;
  perPage: number;
  onPageChange: (cursor: string | null, direction: 'next' | 'prev') => void;
  onPerPageChange: (perPage: number) => void;
}
```

When `pagination` is omitted, `DataTable` renders without pagination controls (used for bounded lists like "top 5 recent invoices" on dashboard).

---

## 9. First-Run Onboarding States

For new tenants (created within 7 days **or** zero invoices + zero customers), the main list views show an enhanced empty state with step callouts instead of the standard `EmptyState`:

```
┌─────────────────────────────���──────────────────────────────��─┐
│  Welcome to Zync! Let's get you started.                     │
│                                                              │
│  ① Add your first customer          [Add customer]           │
│  ② Create and send an invoice        [Create invoice]        │
│  ③ Set up your payment gateway       [Settings]              │
└──────────────────────────────────────────────────────────────┘
```

Shown on `/invoices`, `/customers`, and `/dashboard` for qualifying new tenants. Each step is dismissed individually once the first entity of that type is created. Dismissed state tracked client-side via `localStorage` (`onboarding:step:{id}:done = true`) — no DB writes for onboarding state. Resets on new device (acceptable — onboarding is time-bounded).

---

## Architecture Decisions

| # | Decision | Rationale |
|---|----------|-----------|
| 1 | `EmptyState` has no `icon`, `description`, or `illustration` prop | Keeping the API minimal enforces the one-sentence, no-decoration rule at the component level rather than relying on convention |
| 2 | Error pages render outside the app shell | Error states must be reachable even when the shell itself fails to load (e.g., auth error, routing failure) |
| 3 | Error pages use symmetric centering as an exception to the asymmetric layout rule | Error states are high-stress moments; visual simplicity and symmetry reduce cognitive load |
| 4 | List loading = structural skeleton rows matching column layout, not a spinner | Skeleton maintains layout stability (no CLS); row count approximates expected data density rather than a fixed 5 |
| 5 | Module disabled CTA is role-dependent, resolved before `ErrorPage` receives props | Keeps the `ErrorPage` component dumb; role logic lives at the routing/guard layer |
| 6 | Degraded module alert is non-dismissible | The condition is actionable by an admin and should not be silently hidden |
| 7 | Trial banner uses `sessionStorage` for dismissal, not `localStorage` | Per-session is the right scope: the user should see the banner on their next working session but not be nagged mid-session |
| 8 | No empty state after checklist dismissal | Users who dismiss have communicated intent; showing an empty state re-introduces noise they explicitly removed |
| 9 | No shimmer/pulse animation on skeletons | Spec 106 had animated shimmer; this spec overrides: static skeleton blocks reduce perceived motion and are simpler to implement; shimmer adds no information |
| 10 | `localStorage` for onboarding dismiss | Not a DB column — onboarding state is UX-only, per-browser; no server round-trip; resets on new device (acceptable) |
