Documentation source
State Catalog
Canonical reference for loading, empty, error, and mobile state patterns across the Sprinter Platform. Extracted from design references and live in-codebase usage.
# State Catalog
A page is rarely just "data on screen". It is also "no data yet", "data is on the way", "data failed to load", "this screen is being viewed on a 375 px iPhone". This catalog is the single place where those four states are pinned down — for designers, for agents, and for the implementers (human or otherwise) who keep them coherent.
## Overview
### Purpose
- Give designers and agents a shared vocabulary so a "loading state" means the same thing across `/today`, `/entity/[id]`, the chat dock, and a tenant-custom workspace page.
- Anchor every pattern to a real file in the codebase _and_ a reference artboard from the Claude Design drops, so neither the spec nor the implementation can drift in isolation.
- Make the anti-patterns explicit. The Sprinter Platform has shipped each of these wrong before; the lesson stays with the code.
### When to use this catalog
- **Before authoring a new surface** — pick the loading/empty/error shape from this catalog before writing the JSX. Do not invent a new shape silently.
- **During code review** — if a PR introduces a new spinner, a new "Nothing here" panel, or a new mobile breakpoint, this page is the reviewer's baseline.
- **When critiquing a Claude Design export** — the design references are the source of intent. This catalog is the bridge between the artboard and the production component.
### Layering on existing rules
This catalog is descriptive, not prescriptive — it documents what the platform already does well and names the patterns. The hard rules still live in:
- [Contributing](/docs/contributing) — shadcn-only interactive UI, zero-chroma neutrals, no hardcoded colors.
- [Block System](/docs/features/block-system) — every block defines its own skeleton via `block-skeleton.tsx`.
- `.claude/rules/visual-verification.md` — every UI change has to be eyeballed at desktop **and** mobile before "done".
## Loading states
Loading is the answer to _"the data hasn't arrived yet, what do we show?"_. Pick the lightest pattern that holds the layout still.
### Smart skeleton
The default. A neutral shimmer in the **shape** of the content that is about to appear, so the viewport does not jump when data arrives.
- **Use when** — a route or panel is fetching its primary data on first paint.
- **Component** — [`components/loading/smart-skeleton.tsx`](https://github.com/) (`SmartSkeleton`, `SkeletonRow`). Six shapes: `card`, `row`, `avatar-line`, `tile`, `chat-bubble`, `form`.
- **Width-aware count** — `resolveSkeletonCountForWidth(shape, width)` returns 2-8 rows depending on container width; tile shapes fan out wider, form shapes stay compact.
- **Phase-aware copy** — at 4 s the skeleton flips from `loading` to `slow` ("Still working on it…"); at 10 s it offers a `retry` affordance instead of pulsing forever.
- **Anti-pattern** — a single generic grey rectangle the size of the viewport. The skeleton has to mimic the eventual layout (card grid, table rows, chat bubbles) or the cognitive jump on arrival is worse than no skeleton at all.
- **Anti-pattern** — leaving the shimmer animating with no `aria-busy="true"` on the wrapping region. Screen readers announce nothing, sighted users see a frozen page.
### Spinner
A small spinner inside an existing affordance — typically a `Button` while a mutation is in flight, or a tiny loader next to an inline field.
- **Use when** — the user just clicked something and you owe them less than 2 s of feedback while the request resolves.
- **Anti-pattern** — using a spinner for full-route loading. Spinners are for inline mutations; skeletons are for route data fetches.
- **Anti-pattern** — a spinner without a paired _disabled_ state on the trigger. Double-clicks fire double mutations.
### Progress (determinate)
When the operation has discrete steps (multi-step document processing, bundle install, batch import), show a progress bar with a step label.
- **Use when** — duration is bounded _and_ you have a real percentage. If the percentage is faked, prefer the skeleton with a `slow` phase instead.
- **Reference** — session executor + Inngest worker progress events feed determinate UIs on the document processing and bundle install surfaces (`features/sessions/lib/task-status-map.ts`).
### Optimistic UI
Mutate the local cache _before_ the server confirms, then reconcile.
- **Use when** — the mutation has a high success rate, is naturally idempotent, and the user would otherwise stare at a spinner. The chat composer, comment posting, and feed reactions all use this shape.
- **Pair with** — React Query `useMutation` + `queryClient.setQueryData()` for the optimistic write, plus a server-error rollback that surfaces a recoverable error toast (see [Error states](#error-states)).
- **Anti-pattern** — optimistic UI on destructive operations (delete, archive). The recovery story when the server rejects is worse than the brief spinner.
### Cascade rule
Inside a single viewport, do not mix more than two loading shapes. A skeleton inside a card inside a skeleton inside a route is visual noise. Pick the _coarsest_ loading boundary that matches the actual request boundary.
## Empty states
Empty is the answer to _"the data fetched fine, but there's nothing in it"_. The shape of the empty state tells the user **why** it is empty and what to do next.
### First-visit empty
The data type genuinely has zero rows because the workspace is brand-new.
- **Pattern** — friendly headline, one short paragraph naming the next concrete action, and one or two `Button`s (one primary, one secondary).
- **Component** — [`components/ui/empty-state.tsx`](https://github.com/) (`EmptyState`) for plain panels; [`features/onboarding/components/empty-state-cta.tsx`](https://github.com/) (`EmptyStateCta`) for the operator surfaces that need an annotated card.
- **Copy guideline** — name the _thing_ and the _verb_. "Create your first patient" beats "No data". Avoid "entities" in user-facing copy ([Contributing](/docs/contributing)).
- **CTA placement** — primary action centered, max one. Optional secondary action ("Start from template", "Watch the 60-second tour") on the right.
- **Illustration use** — a single lucide icon in a soft `bg-primary/8` chip, no marketing illustrations. Zero-chroma neutrals; the only hue is the navy primary on the icon.
### No-results-after-filter
The data type has rows, but the active filter / search / view config matched zero.
- **Pattern** — different from first-visit. The headline says "No matches", the body names the filter, and the action is "Clear filter" — _not_ "Create new". A "Create" CTA inside a filter-empty state is a UX trap.
- **Reference** — [`features/views/components/view-empty-state.tsx`](https://github.com/) (`ViewEmptyState`) handles the `"list"` variant for empty configured-views; downstream views layer filter-specific copy on top.
- **Anti-pattern** — reusing the first-visit panel verbatim. The user just told you they applied a filter; the page has to acknowledge it.
### No-permission
The user is on the right URL but their role does not let them see this data.
- **Pattern** — name the permission gap calmly. No alarming language ("Access denied!"); just "You need `{role}` to view this. Ask `{admin}` to upgrade your role." Provide a link to support or a back-to-dashboard secondary action.
- **Reference** — [`components/error-card.tsx`](https://github.com/) (`isPermissionError`) detects permission-class messages and renders a `Lock` icon instead of `AlertTriangle`. Mirror the same tone in empty states.
- **Anti-pattern** — a generic "Nothing here" empty state when the _real_ answer is "you do not have permission". Be explicit; vague empty states make users feel broken.
### No-data-yet (waiting upstream)
The slot is empty because an upstream agent / pipeline has not produced output yet. This is the most common shape on the Amble dashboard — a card has a definite contract but the field has not been populated.
- **Pattern** — a chip-bar or `dagstrip` showing the field state at a glance, with the empty cells rendered in `--border` not `--muted`. Inline copy says "Awaiting `{agent}`" or "Blocked on `{upstream-field}`".
- **Reference** — [`features/blocks/components/block-empty-state.tsx`](https://github.com/), and the entity card dagstrip in `entity-mobile.jsx` (cells with class `dagstrip__cell--empty`).
- **Anti-pattern** — showing `null` or `—` for a field that is _blocked_. Blocked is a _state_, not an absence of value.
### Component selection map
| Surface | First-visit empty | No-results empty | Code reference |
| ---------------------------------- | --------------------------------------------------- | ----------------------------- | ---------------------------------------------------- |
| Generic panel inside a card | `EmptyState` (icon + 1 CTA) | `EmptyState` ("Clear filter") | `components/ui/empty-state.tsx` |
| Operator first-run hero | `EmptyStateCta` (annotated card) | n/a | `features/onboarding/components/empty-state-cta.tsx` |
| Configured view (list / dashboard) | `ViewEmptyState` (`default` / `list` / `dashboard`) | layered by the view | `features/views/components/view-empty-state.tsx` |
| Block contents | `block-empty-state.tsx` (per-block) | per-block | `features/blocks/components/block-empty-state.tsx` |
| Chat dock first message | `ChatEmptyState` + `Suggestions` | n/a | `features/chat/components/chat-empty-state.tsx` |
| Single field awaiting upstream | dagstrip cell + chip | n/a | entity card / row |
## Error states
Error is the answer to _"data tried to load but failed"_. The catalog distinguishes errors by **recoverability** because the affordance changes.
### Recoverable inline
The error is bounded to a specific component and the user can retry without leaving the page.
- **Pattern** — small inline panel with the error message, the underlying status code (in `mono` font), and a `Retry` button. If the failure is upstream (third-party API), include a "View status page" link.
- **Reference** — `states.jsx::ErrorState` in the design pack:
```jsx
// Sprinter Platform Core Redesign Pack/states.jsx
<p className="state-card__sub">
Sprinter API returned <code className="mono">503</code> for
<code className="mono">GET /v1/me/focus</code>. Last successful sync was
4 minutes ago.
</p>
<button className="btn"><I.Refresh size={14}/> Retry</button>
<button className="btn btn--ghost">View status page</button>
```
- **Tone** — calm, factual, no apology theatre. "Couldn't load this workspace" beats "Whoops! Something went wrong!".
### Non-recoverable route error
The route's data fetch failed in a way that broke the whole page. Next.js routes a `error.tsx` boundary here automatically.
- **Pattern** — full-card error with heading, description, primary `Reset` (calls Next.js `reset()`), secondary `Back to dashboard`. Sentry digest visible to the user for support traceability.
- **Reference** — [`app/(app)/error.tsx`](https://github.com/) wraps [`components/error-card.tsx`](https://github.com/) (`ErrorCard`). Detection logic in `isPermissionError()` / `isTenantError()` swaps the icon and CTA copy.
- **Files in the tree** — every route segment has its own `error.tsx` (admin, today, entity, chat, settings, insights, tasks, [typeSlug]). Each one routes to the same `ErrorCard` but can customize `heading` / `description` / `backHref`.
### Permission-denied error
A thrown error message that includes `"permission" | "forbidden" | "unauthorized" | "admin role required" | "access denied" | "insufficient"`.
- **Pattern** — `Lock` icon (not `AlertTriangle`), copy names the role the user has and the role the page requires, and the primary CTA is _not_ `Reset` (a retry will fail the same way) but `Back to dashboard`.
- **Reference** — `ErrorCard` automatically detects via `isPermissionError()` and swaps icons + CTAs. Do not invent a parallel "you don't have access" screen elsewhere.
### Tenant-context error
The user is signed in but the URL's tenant slug does not resolve to a valid membership (the [URL-as-truth](/docs/contributing) rule of ADR-0003 failed closed).
- **Pattern** — explicit "Tenant membership not found" copy, with a link to the user's last-known-good tenant from the `last-tenant-slug` cookie UX hint. Detection via `isTenantError()` on the error message.
### Network failure / offline
The request never reached the server.
- **Pattern** — toast-level for non-blocking actions (mutation failed, retry available), inline panel for blocking actions (route fetch failed, no skeleton ever resolved).
- **Tooling** — `sonner` for the toast tier (`toast.error("Couldn't save. Retry?")` with a retry action); `ErrorCard` for the inline tier.
- **Anti-pattern** — silently swallowing a network failure in a `try/catch` and showing the previous data. The user does not know they are looking at stale state.
### Tone matrix
| Severity | Surface | Affordance | Component |
| ---------------------- | ---------------------- | -------------------------------- | ------------------------------ |
| Recoverable, scoped | inline panel | `Retry` + optional "View status" | `states.jsx::ErrorState` shape |
| Recoverable, action | toast | inline `Retry` link | `sonner` |
| Non-recoverable, route | full card | `Reset` + `Back` | `components/error-card.tsx` |
| Permission | full card, `Lock` icon | `Back to dashboard` | `ErrorCard` (auto-detected) |
| Tenant | full card | link to known tenant | `ErrorCard` (auto-detected) |
## Mobile states
Mobile is not a separate state — it is _every_ state rendered into 375 px. The catalog enforces three rules: a breakpoint vocabulary, a touch-target floor, and a degradation order.
### Viewport breakpoints
The platform uses Tailwind v4 defaults, exported from [`lib/responsive/breakpoints.ts`](https://github.com/) so JavaScript media queries and CSS classes stay in lockstep.
| Token | px | rem | Class prefix | Use |
| ------- | ----- | ---------- | ------------ | --------------------------------------------------- |
| (phone) | 0-639 | 0-39.9 rem | (default) | iPhone SE / 13 mini, 375 px is the canonical target |
| `sm` | 640 | 40 rem | `sm:` | larger phones / small tablets in portrait |
| `md` | 768 | 48 rem | `md:` | tablets, narrow desktops |
| `lg` | 1024 | 64 rem | `lg:` | the laptop floor |
| `xl` | 1280 | 80 rem | `xl:` | desktop |
- **`MOBILE_MAX_PX = 767`** — anything below this is "phone-class". This is the value `useIsMobile()` resolves against.
- **`useIsMobile()` from `@/hooks/use-mobile`** — the _only_ canonical viewport-class detector. Do not add parallel `useIsPhone()` hooks. SSR-safe — returns `false` on server, syncs post-mount.
- **`100dvh` not `100vh`** — dynamic viewport height handles iOS Safari's collapsing chrome correctly. Avoid `100vh` anywhere a mobile user might scroll.
### Media query vs container query
The rule, restated from `lib/responsive/index.ts`:
> "What fits on this screen" → media query. "What fits in this box" → container query.
- **Media query** — sidebar mode, FAB visibility, page-shell density, top-nav vs bottom-tab.
- **Container query** — block density inside a slot, embedded view chip count, card field rendering. A wide block in a narrow slot has to react to _the slot_, not the viewport.
### Touch targets
- **Minimum** — 44 × 44 CSS px hit area for any interactive element on phone-class viewports (Apple HIG floor, also our `aria-label` floor for icon-only buttons).
- **Spacing** — at least 8 px between adjacent touch targets to avoid mis-taps.
- **shadcn `Button` `size="icon"`** — already 36 × 36 visual, 44 × 44 hit area when wrapped in the standard `btn--icon` shell from the design pack. Do not shrink below.
### Drawer vs modal vs popover
| Surface | Phone | Desktop | Note |
| ---------------- | ----------------- | ------------- | ----------------------------------------------------------------- |
| Field-level edit | bottom drawer | popover | the design pack's `<MobileFieldDrawer/>` is the mobile shape |
| Multi-step flow | full-screen modal | dialog | hide phone chrome, restore on close |
| Tooltip / hint | inline pressable | hover popover | tooltips do not exist on touch — they have to be inline or hidden |
| Command palette | full-screen sheet | center dialog | `cmd-k` opens differently on mobile; same data, different shape |
### Table → card degradation
The platform's data tables degrade to card lists on phone-class viewports. The dagstrip is the canonical visual hook.
- **Pattern** — each row collapses into a card with: name + chip in the header row, secondary metadata muted below, dagstrip strip of cell states, and chips for `blocked` / `running` agent activity counts.
- **Reference** — `Sprinter Platform Core Redesign Pack/entity-mobile.jsx::MobileEntityList()` — the canonical mobile card shape.
- **Anti-pattern** — horizontal-scroll tables on phone. Users do not scroll horizontally; they bounce.
### Bottom tab bar
- **Use when** — the route has 3-5 sibling routes that the user pivots between (Focus / Queue / Search / More).
- **Reference** — `states.jsx::MobileShell()` — four tabs, `aria-pressed`, active pill above the icon.
- **Anti-pattern** — a "More" tab that hides primary navigation. If a route is primary, it gets a tab; if it is secondary, it does not need one.
## Design-reference excerpts
The canonical Claude Design exports live in the SprinterVault, _not_ in this repo:
> `~/SprinterVault/20-Ventures/Amble/03-Product/design-references/claude-design-docs-20260519/extracted/`
These six files are the load-bearing references for the patterns above:
1. **`Sprinter Platform Core Redesign Pack/states.jsx`** — the canonical source. Defines `EmptyDashboard`, `LoadingDashboard`, `ErrorState`, `MobileShell`, and `CmdK`. Every pattern in this catalog can be traced back to one of these five components.
2. **`Sprinter Platform Core Redesign Pack/entity-mobile.jsx`** — `MobileEntityList` and `MobileEntityDetail`. The mobile card shape, dagstrip degradation, and bottom-tab nav.
3. **`Sprinter Today Client Lanes Control Tower/artboard-mobile.jsx`** — the Today / Focus mobile artboard. Shows the "operator focus" headline pattern that the empty-state CTA copy guideline borrows from.
4. **`DOCS Intake + Protocol Preview/screens/mobile.jsx`** — the public intake mobile shape, including a full-screen multi-step flow (drawer-as-modal pattern).
5. **`Marbella SOP Automation Operating Partner/artboards/06-mobile-approval.jsx`** — the mobile approval interaction, including inline error-toast affordances on a destructive action (decline / approve flow).
6. **`Sprinter Views Canvas Redesign Pack/mobile.jsx`** — canvas-view degradation on phone (canvas keeps its own empty state per `view-empty-state.tsx`'s `"canvas"` branch).
When auditing a PR for design fidelity, the vault file is the ground truth; this catalog names the pattern; the in-codebase component is the implementation. All three have to align.
## Codebase examples
Where each pattern lives in production code today.
### Loading
- **Smart skeleton** — `components/loading/smart-skeleton.tsx`, instantiated by every `app/**/loading.tsx` (route-level) and consumed by `features/views/components/block-skeleton.tsx` (block-level).
- **Today skeleton** — `features/actions/components/today/today-skeleton.tsx` — example of a feature-specific skeleton built on the smart-skeleton primitives.
- **PDF skeleton** — `features/pdf/components/pdf-loading-skeleton.tsx` — example of a content-type-specific skeleton.
- **Chart skeleton** — `components/loading/chart-skeleton.tsx` — chart-aware shape that avoids the recharts pop-in flash.
- **Runtime palette skeleton** — `lib/runtime/primitives/skeleton.tsx` — the same primitive re-exported into the agent-authoring runtime palette (see `.claude/rules/agent-components.md`).
### Empty
- **Generic** — `components/ui/empty-state.tsx` (`EmptyState`). Used by `features/blocks/components/tool-block.tsx`, `chart-block.tsx`, `bubble-chart-block.tsx`, `form-flow-block.tsx`, `task-tree-block.tsx`, `block-renderer.tsx`, `features/actions/components/action-list.tsx`, and `features/navigation/components/source-items.tsx`.
- **Operator first-run** — `features/onboarding/components/empty-state-cta.tsx` (`EmptyStateCta`). Used by tenant operator surfaces (e.g., DOC360 clinic patient list).
- **Configured views** — `features/views/components/view-empty-state.tsx` (`ViewEmptyState`) — surface-aware copy via `getSurfaceTypeMeta()`.
- **Routines** — `features/actions/components/routines/routines-empty-state.tsx` — example of a feature-specific empty state.
- **Chat dock** — `features/chat/components/chat-empty-state.tsx` (`ChatEmptyState`) — agent-aware suggestions via `buildChatSuggestions()`.
- **Patient detail (tenant-custom)** — `features/custom/tenants/docs/details/patient-detail/empty-state.tsx` — tenant-module-local empty for a domain entity.
### Error
- **Route boundaries** — every `app/(app)/**/error.tsx` mounts `ErrorCard` from `components/error-card.tsx`. Detection: `isPermissionError()`, `isTenantError()`. Sentry capture in `useEffect`.
- **Toast errors** — `sonner` `toast.error(message)` is the canonical inline error tier. Production callers include `features/source-sync/components/source-row-actions.tsx`, `features/admin/components/override-in-workspace-button.tsx`, `features/agents/components/agent-action-buttons.tsx`.
- **Form-level recoverable** — `apiErrorResponse()` from `lib/api-utils.ts` shapes server-action errors; client mutations surface them via React Query `onError` + `toast.error`.
### Mobile
- **Viewport detection** — `hooks/use-mobile.ts` (`useIsMobile()`). Only callsite that should set `data-mobile` state.
- **Breakpoints** — `lib/responsive/breakpoints.ts` (`BREAKPOINT_PX`, `MOBILE_MAX_PX`, `MOBILE_MEDIA_QUERY`).
- **Container queries** — `lib/responsive/container.ts` (`CONTAINER_INLINE_SCOPE`, `containerStyle()`).
- **Safe-area** — `lib/responsive/safe-area.ts` (`FAB_CLEARANCE_VAR_DECL`, `FAB_CLEARANCE_CLASS`).
- **Page padding** — `components/app-shell/page-content.tsx` (`PageContent`, `PAGE_PADDING`) — the shell deliberately does NOT pad; pages do.
## Anti-patterns
The platform has shipped each of these wrong before. They are listed so the next reviewer can call them out by name.
- **Infinite spinner with no timeout.** A spinner that has spun for >10 s has crossed from "loading" to "broken". The smart skeleton's `retry` phase exists for this reason. If you ship a spinner, ship its timeout.
- **Wall of grey.** A full route filled with one monolithic grey rectangle, because someone reached for `<Skeleton className="h-screen" />`. The skeleton has to mimic the layout, or it adds nothing the empty `<main>` did not.
- **"Error: Something went wrong"** without a status code, without a retry, without a back-link. The user has no way to recover, no way to file a useful support ticket, and no signal whether to wait.
- **First-visit copy on a filter-empty state.** "Create your first patient" shown to a user who just typed a search term that matched zero rows. The CTA has to match the _cause_ of emptiness.
- **`toast.error("Failed")`.** A toast with no detail and no retry. The toast tier is for _recoverable_ failures; the message has to name the action and the next step.
- **Loading state with no `aria-busy`.** The skeleton looks right to sighted users but the page is silent to assistive tech. Wrap loading regions with `aria-busy="true"` and `data-loading="…"`.
- **Viewport-width branching for block density.** A block that re-flows based on `useIsMobile()` instead of the container query for the slot it lives in. A wide block can still live in a narrow slot — the slot is the source of truth, not the viewport. See `lib/responsive/index.ts`.
- **`100vh` on a mobile route.** iOS Safari's collapsing chrome eats the bottom of the page. Use `100dvh`.
- **Horizontal-scroll tables on phone.** Users do not horizontal-scroll. Degrade tables to card lists per the dagstrip pattern.
- **Tooltips as the only label.** Touch has no hover. If a button has only an icon, give it an `aria-label` _and_ a visible label on phone-class viewports.
- **Optimistic UI on destructive operations.** Optimistic-delete that has to un-delete on server reject is worse than a brief spinner.
- **Permission errors disguised as empty states.** "Nothing here" when the truth is "you do not have permission". Empty and forbidden are two different shapes; pick the right one.
- **Stuck skeleton on a slow third-party.** A skeleton that animates forever because an upstream API hung. The `slow` and `retry` phases of `SmartSkeleton` exist for this; pair them with an upstream timeout. Real prior incident: `documents/work/2026-05-14-docs-final-onboarding-audit/screenshots/prod-public-intake-final-recheck-stuck-skeleton.md`.
## Related
- [Contributing](/docs/contributing) — the hard rules (shadcn-only, zero-chroma, no entity in user copy).
- [View System](/docs/features/view-system) — empty state per surface type.
- [Block System](/docs/features/block-system) — per-block skeleton and empty state.
- [Chat](/docs/features/chat) — chat-specific empty + optimistic UI.
- `.claude/rules/visual-verification.md` — gate every UI change at desktop and mobile.
- `.claude/rules/agent-components.md` — the runtime palette mirrors the skeleton primitive into agent-authored components.