# Error Pages & Empty States — Implementation Plan

**Spec:** docs/specs/2026-05-31-error-empty-states.md  ·  **Slug:** error-empty-states  ·  **Wave:** 2
**Depends on:** foundation-design-system

## Goal
Deliver the platform-wide visual and copy contract for every zero-data (empty) state, error condition, and loading state, plus the degraded-module alert, trial-expiry banner, onboarding-checklist dismissal rule, first-run onboarding state, and cursor-based pagination wiring. This spec is referenced by every product module, so the components and the copy catalog it exports become the single source other modules import rather than hand-rolling their own empty/error/loading UI.

## Architecture
This is a pure front-end spec — **no database tables, no API persistence**. All state it owns is UX-only and lives in `sessionStorage` (trial-banner dismissal) or `localStorage` (per-module `perPage`, onboarding step dismissal). The pagination section defines an **API request/response contract** (`?cursor=&limit=` → `{ data, meta }`) that downstream list endpoints must honor, but introduces no schema here.

Components plug into primitives from `foundation-design-system` (`packages/ui`):
- `Button` (variant `outline`/`link`, size `sm`, `asChild`) — used by `EmptyState`, `ErrorPage`, `ErrorState`.
- `Skeleton` — used by `DataTable` loading rows, `StatCard` loading variant, hand-authored detail skeletons.
- `Alert` (variant `warning`) — used by `DegradedModuleAlert` and the trial banner shell.
- `DataTable` — extended here: this spec replaces its `pagination?: boolean` prop with the structured `pagination` object and wires `loading` → skeleton rows and `emptyState` → `EmptyState`.
- `StatCard` — its existing `loading?: boolean` gets the skeleton variant defined here.
- `Spinner` — explicitly reserved for action feedback only, never data loading.
- Design tokens (`--ink-soft`/`text-text-muted`, `text-sm`, 8px grid, `--radius`) and the Tailwind preset from the same package.

The `EmptyState` primitive lives in `packages/ui` (cross-app). `ErrorPage`, `ErrorState`, `TrialBanner`, `DegradedModuleAlert`, `FirstRunOnboarding`, and the copy catalog live in `apps/zync-app/src` because they are app-shell-aware (routing guards, session role, subscription state). Error pages render **outside** the app shell.

## Tech Stack
- Package: `packages/ui` (Radix + Tailwind + CSS-var tokens) — `EmptyState`, `Skeleton` consumers, `DataTable`/`StatCard` loading variants, pagination control sub-component.
- App: `apps/zync-app` (Vite + React, TanStack Router/Query) — `ErrorPage`, `ErrorState`, `TrialBanner`, `DegradedModuleAlert`, `FirstRunOnboarding`, empty-state copy catalog, design-system page entry.
- Libraries: `@radix-ui/react-slot` (Button `asChild`), `lucide-react` (`AlertCircle` icon — used only by `ErrorState`, never by `EmptyState`), TanStack Table v8 (DataTable), TanStack Query (loading flags).
- Browser storage: `sessionStorage` (trial dismissal), `localStorage` (perPage, onboarding dismissal). No Cloudflare bindings, no DB, no Worker route added by this spec.
- a11y/i18n/reduced-motion (cross-cutting, preserved): static skeletons (no `animate-pulse`/shimmer) satisfy `prefers-reduced-motion`; `ErrorState` error message uses `role="alert"`; copy is RTL-safe (logical CSS props, `ms-*`/`me-*`); error `code` shown as muted metadata, never a stack trace.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1 | `packages/ui/src/data-display/empty-state.tsx`, barrel | yes |
| A | 2 | `apps/zync-app/src/lib/empty-state-catalog.ts` | yes (after T1 types) |
| B | 3, 4 | `apps/zync-app/src/components/error-page.tsx`, `error-state.tsx` | yes |
| B | 5 | `packages/ui/src/data-display/data-table.tsx`, `stat-card.tsx` (loading) | yes |
| C | 6 | `packages/ui/src/data-display/data-table.tsx` (pagination control) | after T5 |
| C | 7 | `apps/zync-app/src/components/degraded-module-alert.tsx` | yes |
| C | 8 | `apps/zync-app/src/components/trial-banner.tsx` | yes |
| C | 9 | `apps/zync-app/src/components/first-run-onboarding.tsx`, onboarding dismissal hook | yes |
| D | 10 | `apps/zync-app/src/pages/design-system.tsx` | after T1,T3–T9 |

## Tasks

### Task 1: `EmptyState` primitive (full implementation)
**Blocks:** 2, 5, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/data-display/empty-state.tsx`
- Modify: `packages/ui/src/index.ts` (barrel re-export)
**Steps:**
- [ ] Implement the component per the locked API (no `icon`, `description`, or `illustration` props — these are intentionally rejected and must not be added).
- [ ] Render `heading` centered in `text-text-muted` (maps to `--ink-soft`), `text-sm`, weight `medium`.
- [ ] If `action` present, render exactly one `Button` (variant `outline`, size `sm`) below the heading, spaced on the 8px grid (`mt-4`).
- [ ] If `action.href` is set, render the button as an anchor via `asChild` wrapping `<a href={action.href}>`.
- [ ] If `action.onClick` is set (and no href), wire `onClick` to the button.
- [ ] Forward `className` to the root wrapper for parent spacing overrides; never render a secondary link, icon, or illustration.
- [ ] Re-export `EmptyState` and `EmptyStateProps` from the package barrel.
**Schema / Interfaces:**
```ts
// packages/ui/src/data-display/empty-state.tsx
export interface EmptyStateProps {
  heading: string;                 // the one sentence — full visible text
  action?: {
    label: string;
    href?: string;
    onClick?: () => void;
  };
  className?: string;
}
export function EmptyState(props: EmptyStateProps): JSX.Element;
```
**Acceptance:**
- [ ] Component compiles and is exported from `@zync/ui`.
- [ ] No `icon`/`description`/`illustration` prop exists on the type.
- [ ] With `action.href`, the rendered button is an `<a>` pointing at the href; with `action.onClick`, clicking fires the handler.
- [ ] Heading renders centered, muted, `text-sm medium`; no icon/illustration node in the DOM.

### Task 2: Empty-state copy catalog
**Blocks:** 10  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/lib/empty-state-catalog.ts`
**Steps:**
- [ ] Define a typed catalog keyed by a stable context id for every row in spec §2 (all modules) so each module imports its entry instead of inlining copy.
- [ ] Each entry carries `heading`, and `action` only when the spec row has an action label (`null`/absent for "no action" rows).
- [ ] For the two filter-clear rows (Invoices "Clear filters", Tickets "Clear filters"), model the action as an `onClick` placeholder the consumer supplies (no `href`); store `label` only and document the consumer wires `onClick`.
- [ ] Transcribe copy verbatim — one sentence, no exclamation marks, no marketing language.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/lib/empty-state-catalog.ts
import type { EmptyStateProps } from '@zync/ui';

export type EmptyStateContext =
  | 'tasks.empty' | 'tasks.mine.empty'
  | 'projects.empty'
  | 'customers.empty'
  | 'invoices.empty' | 'invoices.filtered' | 'payments.empty' | 'creditNotes.empty'
  | 'expenses.empty'
  | 'support.empty' | 'support.filtered' | 'leadForms.empty'
  | 'kb.empty'
  | 'leads.empty' | 'campaigns.empty' | 'sequences.empty'
  | 'calendar.empty'
  | 'payouts.empty'
  | 'notifications.empty' | 'activity.empty'
  | 'search.empty'
  | 'reports.empty'
  | 'webhooks.empty' | 'apiKeys.empty' | 'teamMembers.empty' | 'auditLog.empty' | 'dunning.empty'
  | 'admin.tenants.empty' | 'admin.tenantUsers.empty';

// Entry: heading always; action present only when the spec row has one.
// Filter-clear rows carry { label } and the consumer supplies onClick.
export const emptyStateCatalog: Record<EmptyStateContext, Pick<EmptyStateProps, 'heading'> & {
  action?: { label: string; href?: string; onClickKind?: 'clearFilters' };
}>;
```
Verbatim copy to encode (heading | actionLabel | href, "—" = none):
- tasks.empty: "You don't have any tasks yet." | Create a task | /tasks/new
- tasks.mine.empty: "Nothing is assigned to you right now." | View all tasks | /tasks
- projects.empty: "No projects have been created yet." | Create a project | /projects/new
- customers.empty: "No customers here yet." | Add a customer | /customers/new
- invoices.empty: "No invoices have been issued yet." | Create an invoice | /invoices/new
- expenses.empty: "No expenses have been logged yet." | Log an expense | /expenses/new
- support.empty: "No support tickets have come in yet." | Create a ticket | /support/new
- kb.empty: "This space doesn't have any articles yet." | Write the first article | /kb/new
- leads.empty: "No leads in the pipeline yet." | Add a lead | /marketing/leads/new
- campaigns.empty: "No campaigns have been created yet." | Create a campaign | /marketing/campaigns/new
- calendar.empty: "Nothing scheduled here." | Add an event | /calendar/new
- payouts.empty: "No payout requests have been submitted." | Submit a payout | /payouts/new
- notifications.empty: "You're all caught up." | — | —
- activity.empty: "No activity to show yet." | — | —
- search.empty: "Nothing matched that search." | — | —
- reports.empty: "There's no data for this period." | — | —
- invoices.filtered: "No invoices match those filters." | Clear filters | onClick(clearFilters)
- payments.empty: "No payments have been recorded yet." | — | —
- creditNotes.empty: "No credit notes have been issued." | — | —
- leadForms.empty: "No submissions yet." | — | —
- support.filtered: "No tickets match those filters." | Clear filters | onClick(clearFilters)
- sequences.empty: "No email sequences have been created yet." | Create a sequence | /marketing/sequences/new
- webhooks.empty: "No webhook endpoints configured." | Add a webhook | /settings/integrations/webhooks/new
- apiKeys.empty: "No API keys created." | Create a key | /settings/api-keys/new
- teamMembers.empty: "No other team members yet." | Invite someone | /settings/users/invite
- auditLog.empty: "No events in this period." | — | —
- dunning.empty: "No dunning schedule configured." | Add a step | /settings/invoicing/dunning/new
- admin.tenants.empty: "No workspaces have been created yet." | Create a workspace | /admin/tenants/new
- admin.tenantUsers.empty: "This workspace has no users yet." | Invite a user | /admin/tenants/[tenantId]/users/invite
**Acceptance:**
- [ ] Every spec §2 row has a catalog entry with verbatim heading.
- [ ] "No action" rows have no `action`; "Clear filters" rows carry `onClickKind: 'clearFilters'` and no `href`.
- [ ] Type-checks against `EmptyStateProps`.

### Task 3: `ErrorPage` component (full-screen, outside app shell)
**Blocks:** 10  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/components/error-page.tsx`
**Steps:**
- [ ] Render full-screen, content centered both vertically and horizontally (the one intentional exception to the app's asymmetric sidebar layout); render no sidebar/header — this mounts outside the app shell.
- [ ] Accept `heading`, `description`, optional `code` (HTTP status shown in small muted text above the heading), and `cta?: { label; href }`. When `cta` is omitted, render no button.
- [ ] CTA renders as a `Button` (`asChild` anchor when `href` is a route; for "reload current URL" cases the caller passes an `href` of the current URL or wires a reload — keep the component dumb).
- [ ] Never render stack traces; `code` is a short muted label only.
- [ ] Provide a small set of preset factories (or a `variant` map) producing the canonical table values so routes don't retype copy. Module-disabled CTA is **resolved by the routing guard**, not here — the component just receives the pre-resolved `cta` prop (or no prop).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/components/error-page.tsx
export interface ErrorPageProps {
  heading: string;
  description: string;
  code?: string;                       // e.g. "404"; muted, above heading
  cta?: { label: string; href: string };
}
export function ErrorPage(props: ErrorPageProps): JSX.Element;
```
Canonical presets to encode (status | heading | description | ctaLabel | ctaHref):
- 404 | "That page doesn't exist." | "It may have been moved or deleted." | Go to dashboard | /
- 500 | "Something went wrong on our end." | "We've been notified and are looking into it." | Try again | (reload current URL)
- 403 | "You don't have access to this." | "Ask your workspace admin if you think this is a mistake." | Go to dashboard | /
- 401 | "Your session has expired." | "Sign in again to continue where you left off." | Sign in | /login
- 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, role-dependent):
  - admin: description "You can turn it on in your workspace settings." · cta { "Go to Settings", "/settings/modules" }
  - staff (non-admin): description "Ask your workspace admin to enable it." · **no `cta` prop passed**
**Acceptance:**
- [ ] Renders centered full-screen with no shell chrome.
- [ ] Omitting `cta` renders no button (covers staff module-disabled case).
- [ ] `code` renders as small muted text above heading; no stack trace path exists.
- [ ] All seven canonical rows reproducible from the presets with verbatim copy.

### Task 4: `ErrorState` inline component (section-level load failure)
**Blocks:** 10  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/components/error-state.tsx`
**Steps:**
- [ ] Render centered within the parent container (not full-screen; rest of page stays interactive).
- [ ] Show `AlertCircle` icon (48px) from `lucide-react`, then `title`, `description`, optional `[Try again]` button (only when `onRetry` provided), optional `code` in small text for support.
- [ ] Default `title` = "Something went wrong"; default `description` = "Failed to load data. Please try again."
- [ ] Wrap the message in `role="alert"` so screen readers announce the failure.
- [ ] Export the four common variant presets as helper objects/factories.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/components/error-state.tsx
export interface ErrorStateProps {
  title?: string;        // default: "Something went wrong"
  description?: string;  // default: "Failed to load data. Please try again."
  onRetry?: () => void;  // renders [Try again] when provided
  code?: string;         // API error code, small text
}
export function ErrorState(props: ErrorStateProps): JSX.Element;
```
Common variants to encode (scenario | title | 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."
**Acceptance:**
- [ ] Renders inline (no full-screen takeover); surrounding page remains interactive.
- [ ] `[Try again]` appears only when `onRetry` is passed and invokes it.
- [ ] Message container has `role="alert"`; `AlertCircle` renders at 48px.
- [ ] Defaults match spec verbatim.

### Task 5: Loading states — `DataTable` skeleton rows & `StatCard` skeleton variant
**Blocks:** 6, 10  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/ui/src/data-display/data-table.tsx`
- Modify: `packages/ui/src/data-display/stat-card.tsx`
**Steps:**
- [ ] In `DataTable`, when `loading` is true, render `Skeleton` rows in place of real rows using the **same column widths and row height** so the layout does not shift on data arrival. Row count approximates expected data density (configurable, not a fixed 5).
- [ ] Skeleton blocks are **static** — no `animate-pulse`, no `animate-shimmer`, no keyframe sweep (satisfies `prefers-reduced-motion`; overrides retired spec 106's shimmer).
- [ ] When `loading` is false and `data` is empty, render the `emptyState` node (an `EmptyState` supplied by the module) instead of an empty table body.
- [ ] In `StatCard`, when `loading` is true render the skeleton variant: a short skeleton bar for the label area and a wider bar for the value area; card dimensions unchanged; no spinner.
- [ ] Do not introduce a full-page spinner anywhere; `Spinner` stays reserved for action feedback (submit/upload).
**Schema / Interfaces:**
```ts
// data-table.tsx — extend existing props (foundation-design-system defined the base)
interface DataTableLoadingBehavior {
  loading?: boolean;                 // true → skeleton rows matching column widths
  skeletonRowCount?: number;         // approximate expected density; default per module
  emptyState?: React.ReactNode;      // shown when !loading && data.length === 0
}
// stat-card.tsx — existing loading?: boolean renders label+value skeleton bars
```
**Acceptance:**
- [ ] `DataTable loading` renders static skeleton rows at real column widths; toggling to loaded causes no layout shift (no CLS).
- [ ] No `animate-pulse`/shimmer class appears in the rendered skeleton.
- [ ] Empty `DataTable` (non-loading) renders the passed `emptyState`.
- [ ] `StatCard loading` shows label+value skeleton bars at fixed card size, no spinner.

### Task 6: Cursor pagination control + `DataTable` pagination wiring
**Blocks:** 10  ·  **Blocked by:** 5
**Files:**
- Modify: `packages/ui/src/data-display/data-table.tsx`
- Create: `packages/ui/src/data-display/pagination-controls.tsx`
**Steps:**
- [ ] Replace/augment the base `pagination?: boolean` with the structured `pagination` object; when omitted, render no pagination controls (bounded dashboard lists).
- [ ] Render the control row below the table: left "Showing X–Y of Z" label (X = first row on page, Y = last row, Z = `total`); right side `[← Prev] <pageNumber> [Next →]` and a "Per page" select with options `20`, `50`, `100` (default `20`).
- [ ] Disable Prev at first page, Next at last page (`has_prev`/`has_next` from meta). Page counter is display-only — no jump-to-page (cursor pagination has no arbitrary jumps).
- [ ] Persist `perPage` in `localStorage` per module key (e.g. `table:invoices:perPage`); read on mount, write on change.
- [ ] Call `onPageChange(cursor, direction)` with `nextCursor`/`prevCursor` and `onPerPageChange(perPage)` on select change.
- [ ] Mobile (< 768px): replace Prev/Next + count with a single `[Load more]` button that **appends** the next page to existing rows; also trigger via an `IntersectionObserver` sentinel at the list bottom.
- [ ] Applying a filter resets the cursor to page 1; filter state is held in URL query params (cursor is transient, not in URL).
- [ ] Document the API request/response contract that backing list endpoints must honor (request `?cursor=<opaque>&limit=<20|50|100>`; response `{ data, meta:{ total, next_cursor, prev_cursor, has_next, has_prev } }`; `cursor` = base64 JSON `{ id, created_at }`; server applies `WHERE (created_at, id) < (cursor.created_at, cursor.id)` for next / `>` for prev — stable under concurrent inserts). No table/DDL is added by this spec.
**Schema / Interfaces:**
```ts
// data-table.tsx pagination prop
interface DataTablePagination {
  total: number;
  nextCursor: string | null;
  prevCursor: string | null;
  perPage: number;
  onPageChange: (cursor: string | null, direction: 'next' | 'prev') => void;
  onPerPageChange: (perPage: number) => void;
}
// List endpoint contract (consumed by every module list route; no DB change here):
//   Request:  ?cursor=<base64 {id, created_at}>&limit=20|50|100
//   Response: { data: T[]; meta: { total: number; next_cursor: string | null;
//               prev_cursor: string | null; has_next: boolean; has_prev: boolean } }
```
**Acceptance:**
- [ ] Omitting `pagination` renders no controls; supplying it renders the full control row.
- [ ] "Showing X–Y of Z" computes correctly; Prev/Next disable at boundaries; page counter is not clickable.
- [ ] `perPage` change persists to `localStorage` under the module key and survives reload.
- [ ] Below 768px the control collapses to `[Load more]` (append, not replace) with working scroll-sentinel.

### Task 7: `DegradedModuleAlert`
**Blocks:** 10  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/components/degraded-module-alert.tsx`
**Steps:**
- [ ] When a module is enabled but a soft dependency is disabled, render an `Alert` (variant `warning`) at the top of the module page — below the page header, above content.
- [ ] Text: "Some features are unavailable because [Dependency Module Name] is turned off." (interpolate the dependency's display name).
- [ ] If the user is an admin (`session.role`), include an inline link "Turn on [Dependency Module Name]" → `/settings/modules`; if not admin, render no link/CTA.
- [ ] The alert is **not dismissible**; it disappears only when the dependency is enabled.
- [ ] Accept the degraded condition + dependency name as props; the component does not query module state itself (caller/guard resolves it). Encode the example dependency map as reference data for callers.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/components/degraded-module-alert.tsx
export interface DegradedModuleAlertProps {
  dependencyName: string;     // display name of the disabled soft dependency
  isAdmin: boolean;           // from session.role
}
export function DegradedModuleAlert(props: DegradedModuleAlertProps): JSX.Element;
```
Reference soft-dependency map (module → dependency → hidden/disabled feature):
- 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
**Acceptance:**
- [ ] Renders a non-dismissible `warning` Alert with the interpolated dependency name.
- [ ] Admin sees the "Turn on [Dependency]" link to `/settings/modules`; non-admin sees no link.
- [ ] No dismiss control exists.

### Task 8: `TrialBanner`
**Blocks:** 10  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/components/trial-banner.tsx`
**Steps:**
- [ ] Show only when `subscription.status === 'trialing'` AND `subscription.trial_ends_at` is within 7 calendar days from now.
- [ ] Compute `N = Math.max(1, Math.ceil((trial_ends_at - now) / 86400000))`.
- [ ] Copy: "[N] days left in your trial. Add a payment method to keep your workspace." When `N === 1`, copy becomes "Your trial ends today." When `N === 0` (expired), render nothing (handled by billing enforcement layer, out of scope).
- [ ] CTA "Add payment method" → `/settings/plan`.
- [ ] Render inside the app shell, below the header, above `<Outlet />` (part of shell layout, not a floating overlay).
- [ ] Dismissible per session: on dismiss write `trial_banner_dismissed_at: <ISO timestamp>` to `sessionStorage`; on load within the same session, if the key is present, suppress the banner. New login = new session = `sessionStorage` cleared → banner reappears. Do **not** use `localStorage`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/components/trial-banner.tsx
export interface TrialBannerProps {
  subscription: { status: string; trial_ends_at: string | Date | null };
}
export function TrialBanner(props: TrialBannerProps): JSX.Element | null;
// sessionStorage key: "trial_banner_dismissed_at" (ISO timestamp)
```
**Acceptance:**
- [ ] Banner shows only when trialing and within 7 days; hidden otherwise and when `N === 0`.
- [ ] `N` math matches spec (ceil of days, floor 1); `N === 1` shows "Your trial ends today."
- [ ] Dismiss writes `trial_banner_dismissed_at` to `sessionStorage` and suppresses for the rest of the session; a fresh session restores it.
- [ ] CTA links to `/settings/plan`.

### Task 9: Onboarding checklist dismissal + First-Run onboarding state
**Blocks:** 10  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/components/first-run-onboarding.tsx`
- Create: `apps/zync-app/src/hooks/use-onboarding-dismissal.ts`
**Steps:**
- [ ] Onboarding-checklist dismissal rule: when a user dismisses the dashboard setup checklist without completing it, show **nothing** — the widget disappears. Do not render an "empty checklist" empty state or a restart prompt. Persist the dismissed state so it does not reappear on reload (client-side `localStorage`, no DB write).
- [ ] First-Run onboarding state: for new tenants (created within 7 days **OR** zero invoices + zero customers), the main list views (`/invoices`, `/customers`, `/dashboard`) show an enhanced empty state with three step callouts instead of the standard `EmptyState`:
  - ① "Add your first customer" → [Add customer]
  - ② "Create and send an invoice" → [Create invoice]
  - ③ "Set up your payment gateway" → [Settings]
  - Header line: "Welcome to Zync! Let's get you started."
- [ ] Each step is dismissed individually once the first entity of that type is created; track dismissal client-side via `localStorage` key `onboarding:step:{id}:done = true` — no DB writes. Resets on a new device (acceptable).
- [ ] The `use-onboarding-dismissal` hook reads/writes these `localStorage` keys and exposes `isDismissed(id)` / `dismiss(id)` for both the checklist and the step callouts.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/use-onboarding-dismissal.ts
export function useOnboardingDismissal(): {
  isDismissed: (key: string) => boolean;   // localStorage `onboarding:step:{id}:done`
  dismiss: (key: string) => void;
};
// apps/zync-app/src/components/first-run-onboarding.tsx
export interface FirstRunOnboardingProps {
  surface: 'invoices' | 'customers' | 'dashboard';
}
export function FirstRunOnboarding(props: FirstRunOnboardingProps): JSX.Element | null;
```
**Acceptance:**
- [ ] Dismissing the dashboard checklist removes the widget and shows no empty/restart state; dismissal persists across reload via `localStorage`.
- [ ] Qualifying new tenants see the three-step callout on `/invoices`, `/customers`, `/dashboard`.
- [ ] Each step disappears once its entity exists; dismissal stored under `onboarding:step:{id}:done`; no network/DB write occurs.

### Task 10: Design-system page entries
**Blocks:** —  ·  **Blocked by:** 1, 3, 4, 5, 6, 7, 8, 9
**Files:**
- Modify: `apps/zync-app/src/pages/design-system.tsx`
**Steps:**
- [ ] In the **Data Display** section, add `EmptyState` with three example states: one with an action and one without (per spec — covers action/no-action variants).
- [ ] Add a `DataTable` loading-skeleton example and an empty-with-`emptyState` example; add a `StatCard` loading-skeleton example.
- [ ] Add a pagination-controls example (desktop control row + mobile `[Load more]` note).
- [ ] Add `ErrorState` variant examples (timeout / 403 / 404 / 500) in the Feedback section.
- [ ] Add a `DegradedModuleAlert` (warning) example.
- [ ] Confirm the RTL preview section still renders these correctly under `dir="rtl"`.
**Acceptance:**
- [ ] `/design-system` shows `EmptyState` with ≥3 example states (≥1 with action, ≥1 without) in Data Display.
- [ ] `DataTable` loading + empty, `StatCard` loading, `ErrorState` variants, and `DegradedModuleAlert` all appear on the page.
- [ ] New primitives are catalogued in the same PR (design-system page is the registry).
