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-blockwith 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--genericwith Go to Home. It never strands the user mid-session: session state is server-side and resumable.