Brand and design · Specs

State matrix

Every screen by loading, empty, error (retryable or fatal), offline and partial data.

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.