---
title: "State matrix"
description: "Every screen by loading, empty, error (retryable or fatal), offline and partial data."
source: "design/states.md"
---

> Part of [Brand and design](/design/llms.txt). Every page: [llms.txt](/llms.txt).

# State matrix

Every screen × {loading, empty, error-retryable, error-fatal, offline, partial data}. Cells name the rendered file when one exists (`screens/<group>/…`, each in zh-Hans and `.en`). Otherwise they describe the behaviour, which uses the shared patterns at the bottom.

**Retryable vs fatal** follows `ErrorEnvelope.error.retryable`. Fatal (non-retryable, unknown, `401` after refresh fails) goes to `global/error--generic`. `402 entitlement_required` always routes to `paywall--expired-return`. `rate_limited` shows a toast and retries after `Retry-After`.

| Screen | Loading | Empty | Error — retryable | Error — fatal | Offline | Partial data |
|---|---|---|---|---|---|---|
| Onboarding (6 steps) | n/a (local) | n/a | `POST /onboarding` failed: inline banner + Retry; answers kept | generic error, answers kept | answers kept; submit queued; diagnostic needs network → "Connect to start your diagnostic" | `GET /topics` failed → topic chips hidden (optional step) |
| Diagnostic intro | skeleton until `/onboarding` returns | n/a | Retry | generic error | "Connect to start" (the diagnostic is server-assessed) | — |
| Diagnostic read | article skeleton | n/a | `read.load_error` + Retry | generic error | cached article from `/onboarding` response | missing `reading_hint` → hint card hidden |
| Diagnostic record | — | — | upload failure → processing failed | — | recording works; upload queued (`processing--offline`) | — |
| Diagnostic processing | `diag-processing--uploading/transcribing/assessing` | — | `diag-processing--failed` | non-retryable → generic error | `processing--offline` | `unreliable` → `diag-processing--unreliable` |
| Diagnostic result | ring at 0 until data (never shown before `assessed`) | — | — | — | cached assessment | dimension `insufficient_evidence` → "not enough evidence"; no key points → section hidden |
| Plan | spinner (plan job may lag the diagnostic) | — | 404 "no plan yet" → poll 3× then Retry | generic error | cached plan | missing reason note → sentence hidden |
| Paywall | `paywall--loading-products` (StoreKit) + skeleton projection (`/paywall`) | — | `/paywall` failed → projection & calendar replaced by Retry card; products still purchasable | StoreKit unavailable → "App Store unavailable" + Restore | cached `/paywall`; purchase disabled with "Connect to subscribe" | `sample_n < 30` → "not enough data" (no number); `plan_preview` missing → calendar hidden, timeline kept; `social_proof` null → hidden |
| Home | `home--loading` | `home--no-quest` (CCR-1); `home--topic-empty`; first-time = `home--first-time` | `home--error` | generic error | `home--offline` (cache + banner) | `focus` null → chip hidden; `resume_session` null → no sheet; `plan_day` null → tag hidden |
| Library | `library--loading` | `library--filter-empty` | `library--error` | generic error | cached first page + banner; filters on cached data only | missing `best_overall` → status falls back to "Practised"; locked without `unlock_hint` → fallback copy |
| Read | article skeleton | n/a | `read.load_error` + Retry, never another article | 404 → generic error, session abandoned | cached article (daily quest is prefetched) | `source.url` null → name only |
| Record R1 / R2 | — | — | — | — | records normally; banner via `global/offline--toast` | `previous_focus` null → no chip; R2 without round-1 duration → clause hidden |
| Permission | — | — | `record--permission-denied` (recoverable via Settings) | — | — | — |
| Processing | `processing--uploading/transcribing/assessing` | — | `processing--failed` (retry, no re-upload) | non-retryable → generic error, the recording stays retrievable | `processing--offline` | `processing--unreliable` (reason-specific copy); `processing--long-wait` |
| Feedback | not reachable before `assessed` | — | exemplar `feedback--exemplar-failed`; audio URL failure → toast | — | cached assessment; playback & exemplar disabled with "needs network" | `feedback--low-confidence`; `exemplar-pending`; audio 410 → `feedback.evidence.expired`; no `key_point_coverage` → hidden |
| Complete | skeleton blocks while `/complete` runs (idempotent; safe to retry) | — | Retry (same Idempotency-Key) | 409 precondition → back to the feedback of the missing round | queued; shows "will settle when online" | `complete--replayed`; ability null → baseline n/3 (`complete--achievement`); `weekly_quests` missing → card hidden |
| Growth | chart/card skeletons | `growth--empty` | inline Retry card | generic error | cached `/growth` + banner | `growth--baseline` (1–2 sessions); `growth--gap` (empty weeks drawn as gaps); single language → no language chips |
| Profile | skeleton stats | weekly quests none → section hidden | inline Retry | generic error | cached `/me` | ability null → "Baseline"; SIWA card only for anonymous |
| Settings | toggles disabled until `/me` | — | PATCH failed → revert toggle + toast | — | changes queued; retention / delete disabled offline | system notifications off → row shows `settings.notifications_off` |
| Settings · language | — | — | PATCH failed → revert | — | change disabled offline (needs a refetch in the new locale) | `config.supported_*` missing → bundled `[en, zh-Hans]` |
| Delete confirms | button spinner | — | alert "Couldn't delete, nothing changed" + Retry | — | buttons disabled + "Connect to delete" | — |
| Global sheets | — | — | — | `global/error--generic`, `global/force-update--default` | `global/offline--toast` | `global/resume-session--sheet` |

## Shared patterns

- **Skeleton:** same geometry as the content (`.skel`). Shimmer stops with reduced motion. Never show zeros.
- **Inline retry card:** the section is replaced by "Couldn't load · Retry" and the rest of the screen stays usable.
- **Full-screen error:** `state-block` with icon, title, `error.message`, Retry, and the code line (`common.error_code_retryable`).
- **Offline banner** (ink pill at the top of tab screens) plus a **toast** on transition. The cache timestamp is always shown.
- **Fatal:** `global/error--generic` with Go to Home. It never strands the user mid-session: session state is server-side and resumable.
