---
title: "Data binding"
description: "Every visible data point to its API field or local state, and the contract change requests."
source: "design/data-binding.md"
---

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

# Data binding — every visible data point, per screen

Every number, label and piece of content in the hi-fi screens is traceable to an API field in [`docs/api/openapi.yaml`](/api/openapi.yaml), a documented local state, or a derivation from those two. In the HTML, each bound element has a `data-bind="…"` attribute. Press **b** on any screen (or use "Show data-bind" in its spec panel or the gallery) to outline them. [`screens/manifest.json`](/design/gallery/manifest.json) lists every bind per screen file. Example JSON for every response lives in [`docs/api/examples/en/`](/design/examples/en) and [`docs/api/examples/zh-Hans/`](/design/examples/zh-Hans), indexed by [`index.json`](/design/examples/index.json).

## Notation

| Prefix in `data-bind` | Meaning |
|---|---|
| `me.*` | `GET /v1/me` → `Me` |
| `home.*` | `GET /v1/home` → `Home` |
| `session.*` | `GET /v1/sessions/{id}` / `POST /v1/sessions` → `Session` (`session.article` = `Article`) |
| `attempt.*` | `GET /v1/attempts/{id}` → `Attempt` |
| `assessment.*` | `Attempt.assessment` (round 1 feedback; diagnostic result) |
| `exemplar.*` | `GET /v1/attempts/{id}/exemplar` → `Exemplar` |
| `settlement.*` | `POST /v1/sessions/{id}/complete` → `Settlement` |
| `articles.items[n]` | `GET /v1/articles` → `ArticleSummary` |
| `growth.*` | `GET /v1/growth` → `Growth` |
| `weekly_quests.*` | `GET /v1/weekly-quests` → `WeeklyQuests` |
| `plan.*` | `GET /v1/plan` → `Plan` |
| `paywall.*` | `GET /v1/paywall` → `Paywall` |
| `config.*` | `GET /v1/config` → `Config` |
| `storekit[...]` | StoreKit 2 `Product` for that id (never the API) |
| `onboarding.*` | in-memory `OnboardingAnswers` until `POST /v1/onboarding` |
| `local:*` | device state (recorder, player, cache, network, filters) |
| `derived:` | computed on device from the fields named |
| `(Accept-Language)` | a **UI-locale string** the API localizes from the request's `Accept-Language` header (ADR-001 D13). The client sends its UI locale (`settings.ui_locale` ?? device) on **every** request and re-fetches when it changes. |
| `(article language)` | **content** in `session.language` / `article.language`, regardless of UI locale. Render with `lang=` (HTML) / `.environment(\.locale, Locale(identifier: language))` (SwiftUI) so VoiceOver uses the right voice. |

**Typed sources.** botgumi's check needs one typed source per shown value (`api GET /home level.title`, `local recorder.state`, `device network`; botgumi ADR-0006 D2), resolved against `openapi.yaml`. `tools/typed_sources.py` reads them from this notation when it exports `screens.json`: `a.b / c` is `a.b` and `a.c`, `[n]` is every item, a bare name in a row continues the reference before it. Where a row is prose, that file lists its sources by hand, and it declares every `local`, `device`, `store` and `derived` name once. The iOS APIs in this page map to platform-neutral names there (`NWPathMonitor` is `device network`, `Product.purchase()` is `store purchase`).

General fallbacks (unless a row says otherwise):

- **Loading:** skeleton blocks of the same size. Never "0" or "—" for a number that is still loading.
- **Null optional number:** hide the element (or show "—" in a table cell), never 0.
- **Null server string:** use the client fallback key in [`copy.md`](/design/copy/index.md).
- **Offline:** last cached value + offline banner with the cache time (`local:cache.<endpoint>.fetchedAt`).

---

## Contract change requests

The design track asks; the backend track edits `openapi.yaml`. CCR-1 to CCR-13 were applied in #8 (6810253): a **CCR-n** mark in the tables below now names where that field came from, and the fallback no longer applies. An `open` request is still missing from the contract: the fields that need it are `pending` in `screens.json` and fall back as noted.

| # | Request | Why (screen) | Fallback until merged | Status |
|---|---|---|---|---|
| **CCR-1** | `Home.today_quest` → `nullable: true` (no eligible article in the user's practice languages / topics today). | `home--no-quest`: today the schema forces a quest object, so the server would have to invent one. | Server must always return a quest; the no-quest state is unreachable. | applied (#8) |
| **CCR-2** | `Home.today_quest.summary: SessionSummary \| null` once `status = completed`. | `home--completed` shows "68 → 79 · 41 s shorter · +55 XP". | Hide the result row; show only the "Done" tag and the free-practice CTA. | applied (#8) |
| **CCR-3** | `ArticleSummary.unlock_hint: string \| null` (Accept-Language), e.g. "Unlocks at ability 80". | Locked rows in the library. The client must not hard-code tier rules (D9 can change). | Generic `library.row.locked` fallback copy. | applied (#8) |
| **CCR-4** | Clarify `today_quest.xp_reward` = base XP excluding the improvement bonus, and add `xp_reward_max` (base + improve). | Quest badge shows "+45 XP". A user who improves earns 55; showing max would over-promise and showing base under-promises. | Show `xp_reward` as is (base). | applied (#8) |
| **CCR-5** | `Growth.min_sessions_per_point` (proposed 2) and `series[].is_partial` (current week, in progress). | The growth chart's dashed "next point" marker and the "train tomorrow to unlock this week's point" hook. Today a week with one session would draw a point from n=1. | Treat `value = null && sample_n > 0` as partial. | applied (#8) |
| **CCR-6** | `ErrorEnvelope.error.request_id: string`. | Generic error and processing-failed screens show "request 7f3a-91c2" for support. | Omit the request id. | applied (#8) |
| **CCR-7** | Expose answers: `Me.onboarding.answers: OnboardingAnswers` and `Paywall.default_weekly_days`. | Plan card "5 days a week", paywall slider default, Settings. After reinstall / SIWA on a new device, local answers are gone. | Use the locally cached answers; default K = 5. | applied (#8) |
| **CCR-8** | `Streak.last_freeze_used_local_date: date \| null`. | Home notice "A streak freeze was used yesterday" (`home.freeze_used`) after a missed day. Without it a silent freeze looks like a bug. | No notice. | applied (#8) |
| **CCR-9** | `Settlement.round1_duration_ms`, `round2_duration_ms`. | The complete screen's before/after durations (02:18 → 01:37) need a second request today (`GET /v1/sessions/{id}`), and replayed settlements are often shown offline. | Second request; hide durations if it fails. | applied (#8) |
| **CCR-10** | Enum for `Settlement.xp.breakdown[].type`: `round1 \| round2 \| complete \| improve \| weekly_quest`, plus `weekly_quest_id` when type = weekly_quest. ADR-001 must also state whether weekly-quest XP counts toward the 165 daily cap. | XP breakdown lines are localized client-side by type (`complete.xp.<type>`). Weekly quest rewards need a line too. | Unknown types render with the server-sent `type` string. | applied (#8) |
| **CCR-11** | `Paywall.baseline: { overall, source: diagnostic \| ability }`. | Expired-return paywall projects from current ability, not the diagnostic. The server should decide, so the projection matches the cohort job. | Client uses `me.stats.ability` when `entitlement.status = expired`, else `diagnostic.overall`. | applied (#8) |
| **CCR-12** | `Paywall.diagnostic.band` + `band_label` (Accept-Language). | Paywall hero "Good foundation". | Client derives the band from D3 thresholds and uses `band.*` keys. | applied (#8) |
| **CCR-13** | `Article.length` of the **transcript**: `Attempt.transcript.length` exists. Also add `Attempt.validity_floor: TextLength`. | The unreliable screen says "17 words (at least 30 needed)". The floor is per language (D2) and must not be hard-coded on the client. | Client table `{zh-Hans: 40 chars, en: 30 words}` mirrored from D2 (documented duplication). | applied (#8) |
| **CCR-14** | Document the error responses on every operation the screens read: `4XX` and `5XX` → `#/components/responses/Error` (`ErrorEnvelope`), and `402` `entitlement_required` on the hard-paywalled reads the API description lists (`/home`, `/articles*`, `/topics`, `/sessions`, `/growth`, `/achievements`, `/weekly-quests`, `/plan`). Today only a few operations list any error, `GET /home` none. | Home's error state and the generic error sheet show `error.code`, `error.message` and `error.request_id`; botgumi's check cannot find an error `GET /home` documents. | The client decodes any non-2xx body as `ErrorEnvelope`, as the API description says. | open |

---

## Onboarding

| Screen · element | Source | Format | Fallback |
|---|---|---|---|
| welcome · legal links | `paywall.legal.terms_url / privacy_url` (or `config` once fetched) | link | Bundled URLs |
| language · options | `config.supported_practice_languages` | endonym (`lang.<tag>`), device locale first and pre-selected | `[en, zh-Hans]` |
| language · selection | `onboarding.practice_language` | — | device locale if supported, else `en` |
| language · UI note | `local:Locale.current` | `onboarding.language.ui_note` | — |
| goal / scenario / self-level · options | static catalog keys `onboarding.<step>.<enum>` mapping to the `OnboardingAnswers` enums | — | — |
| scenario · topic chips | `GET /v1/topics → topics[].name` (Accept-Language) | chip, multi-select → `onboarding.preferred_topics` | hide the section if the request fails |
| commitment · 3/5/7 | `onboarding.weekly_days` | — | 5 pre-highlighted |
| commitment · wheel | `onboarding.reminder_time` | `HH:mm` local, 5-min steps | 08:30 |
| notification-priming · preview | `onboarding.reminder_time`, diagnostic `article.title` | `notif.daily.body` | generic body without title |
| notification-priming · submit | `POST /v1/onboarding` with all answers; `PUT /v1/me/push-token` if granted | — | on failure: inline retry, answers kept |

## Diagnostic

| Element | Source | Format | Fallback |
|---|---|---|---|
| intro · language tag | `onboarding.practice_language` → `session.language` | `diag.intro.lang` with endonym | — |
| read · title / byline / paragraphs / hint | `session.article.*` (article language) | as is; `lang=` set | skeleton; on 404 → generic error |
| read · length | `session.article.length.label` (Accept-Language) | as is ("307 字" / "152 words") | hide |
| read · read time | `session.article.est_read_seconds` | `common.read_*` (30 s steps) | hide |
| record · max | `config.limits.diagnostic_max_ms` | `mm:ss` | 01:30 |
| record · timer, waveform, states | `local:recorder.*` | `mm:ss` effective time | — |
| processing · stages | `attempt.progress.stage / stage_index / stage_count`, `attempt.status` | 3 rows | upload stage is client-side (URLSession progress) |
| processing · ETA | `attempt.progress.eta_ms` | `processing.eta` rounded to 5 s | hide line |
| result · score ring + count-up | `assessment.overall`, `assessment.band` (colour), `assessment.band_label` (Accept-Language) | 0→N count-up 1200 ms | — (screen only shown when assessed) |
| result · summary, notes, evidence, one-action | `assessment.*` (article language) | — | dimension with `insufficient_evidence` → `dimension.insufficient` |
| result · evidence chips | `assessment.evidence[].start_ms` | `mm:ss` | — |
| result · key points | `assessment.key_point_coverage[]` | covered ✓ / missed ! | hide section if empty |

## Plan

| Element | Source | Format | Fallback |
|---|---|---|---|
| focus title | `plan.target_micro_skill` → micro-skill title (API, Accept-Language; fallback `micro.*`) | — | — |
| baseline | `plan.baseline_overall` | integer | — |
| reason | the weakest dimension of the diagnostic (`derived:` lowest `assessment.dimensions.<key>.score`), its label (`dimension.<key>`) and `assessment.dimensions.<weakest>.note` | `plan.reason` | omit sentence |
| tags | weekly days (**CCR-7**), `me.settings.reminder_time`, `session.language` | — | hide tag |
| day rows | `plan.days[].day / date / title / status` | date via `Date.FormatStyle` (`10月17日` / `Oct 17`) | `GET /v1/plan` 404 → plan not ready: spinner + retry |

## Paywall

| Element | Source | Format | Fallback |
|---|---|---|---|
| score | `paywall.diagnostic.overall` | display | — |
| band label | **CCR-12** / derived from D3 | `band.*` | — |
| weakest | `paywall.diagnostic.weakest_label` (Accept-Language) | `paywall.weakest` | hide line |
| title skill | `paywall.diagnostic.one_action_title` (Accept-Language) | `paywall.title` | generic title without skill |
| K slider | `local:paywall.selectedWeeklyDays`, default `onboarding.weekly_days` (**CCR-7**) | 3 / 5 / 7 detents | 5 |
| projection | `derived:` `paywall.benchmarks[]` where `weekly_days = K` and `baseline_bucket = bucket(diagnostic.overall)` → `day14_avg_overall`, `sample_n`, `weakest_dim_avg_delta` | `paywall.projection` (+ `weakest_delta`) — see [paywall.md](/design/paywall/index.md) | `sample_n < 30` or missing → `paywall.projection.insufficient` (no number) |
| pilot label | `paywall.benchmark_source = pilot_benchmark` → `paywall.benchmark_label` (Accept-Language) | amber tag | `paywall.source.pilot` key |
| calendar days | `paywall.plan_preview.days[]` + `derived:` practice pattern for K + streak milestones reachable by that pattern + billing day = trial_days | 7-col grid, Monday first | if plan missing: hide calendar, keep timeline |
| timeline dates | `derived:` today + `trial_days` − 1 (reminder), today + `trial_days` (billing), plan day 14 | `Date.FormatStyle` | — |
| billing price | `storekit[id].displayPrice` + `subscriptionPeriod` | `paywall.timeline.billing.*` | `paywall.timeline.billing.loading` |
| product cards | `paywall.products[]` (kind, trial_days, is_default, badge — badge Accept-Language) × `storekit[id]` (displayPrice, price, introductoryOffer, isEligibleForIntroOffer) | per-month = `price / 12` with `priceFormatStyle` | StoreKit not loaded → skeleton cards, CTA disabled |
| CTA + caption | `derived:` selected product, trial eligibility, billing date | `paywall.cta.trial` / `paywall.cta.subscribe` + caption | loading / purchasing / failed / restored states own the CTA |
| expired · stats | `me.stats.completed_sessions`, `me.stats.ability`, `me.focus.title` | — | ability null → "—" |
| expired · trial end date | `me.entitlement.expires_at` / `trial_ends_at` | date | omit date |
| legal | `paywall.legal.terms_url / privacy_url` | link | bundled URLs |

## Home

| Element | Source | Format | Fallback |
|---|---|---|---|
| streak pill | `home.streak.current / today_done / at_risk` | amber; filled flame when `today_done`; outline when `at_risk`; grey at 0 | cached |
| date label | `home.local_date_label` (Accept-Language) | as is | weekday from device |
| greeting + name | `home.greeting` (Accept-Language) + `home.display_name` | `home.greeting.format` | name fallback "练习者 / Learner" from API |
| level | `home.level.level / title (Accept-Language) / xp_into_level / xp_for_next` | lime-on-ink bar | — |
| quest badge | `home.today_quest.xp_reward` (**CCR-4**) | `+N XP` | hide badge |
| quest meta | `today_quest.article.topic.name` (Accept-Language), `article.length.label` (Accept-Language), `est_read_seconds` | `topic · length · read time` | drop missing parts |
| quest title | `today_quest.article.title` (article language) | `lang=` | — |
| plan day tag | `today_quest.plan_day` | `home.quest.plan_day` | hide when null |
| focus chip | `home.focus.title` (Accept-Language) | `home.quest.focus.last` (or `.diagnostic` when `focus.suggested_count = 1` and no completed sessions) | hide when `focus` null |
| 3-step path | `today_quest.steps[].key / label (Accept-Language) / state` | done = lime ✓, current = white ring | — |
| CTA | `today_quest.status`, `session_id` | start / continue (`home.quest.cta.resume.<stage>`) | — |
| completed summary | **CCR-2** `today_quest.summary` | `68 → 79`, `secs_delta`, `+XP` | hide row |
| streak-risk card | `home.streak.at_risk`, `freezes` | `home.risk.*` | hide card |
| topic chips | `home.topics[].name` (Accept-Language) | — | hide row |
| quick pick | `home.quick_picks[topic = selected]` → `title`, `subtitle` (Accept-Language), `article`, `topic.glyph/color_token` | — | none for topic → empty card (`home.picks.empty.*`) |
| resume sheet | `home.resume_session.article.title / stage / created_at / round1_overall` | `resume.stage` | not shown when null |
| offline banner | `NWPathMonitor` + `local:cache.home.fetchedAt` | `home.offline` (HH:mm) | — |

## Library

| Element | Source | Format | Fallback |
|---|---|---|---|
| language filter | `local:library.filter.language` → `GET /v1/articles?language=`; options = `config.supported_practice_languages` | "All languages" + endonyms; default = all practice languages (no param) | — |
| topic chips + counts | `GET /v1/topics → name (Accept-Language), article_count` | chip with count | hide counts if missing |
| status segment | `local:library.filter.status` → `?status=all\|unseen\|completed` | — | — |
| row tile | `articles.items[n].topic.glyph / color_token` (glyph Accept-Language: 产 / Pr) | 48pt square | — |
| row title | `articles.items[n].title` (article language) | `lang=` | — |
| row language tag | `articles.items[n].language` | `lang.short.<tag>` — only when the list mixes languages | hidden |
| row meta | `topic.name`, `length.label` (Accept-Language), `difficulty` | `topic · length · Level n` | — |
| status tag | `user_status`, `best_overall`, `locked` + **CCR-3** `unlock_hint` | New / In progress / Best N / lock | — |
| pagination | `next_cursor` | infinite scroll | — |
| empty | `items = []` for filter | `library.empty.*` | — |

## Read

| Element | Source | Format | Fallback |
|---|---|---|---|
| kind tag | `session.kind` | `read.tag.<kind>` | — |
| topic tag | `session.article.topic.name` (Accept-Language) | — | hide |
| language tag | `session.language` | `lang.short.<tag>` (title = endonym) | — |
| read time | `session.article.est_read_seconds` | `common.read_*` | hide |
| length | `session.article.length.label` | as is | hide |
| title / byline / paragraphs / hint | `session.article.*` (article language) | `t-reading`, `lang=` | load error → `read.load_error`, never another article |
| source line | `session.article.source.name`, `version` | `read.source` | hide |
| CTA | `POST /v1/sessions/{id}/reading-complete {read_duration_ms = now − local:reader.openedAt}` | — | offline: queued, proceed |

## Record (round 1, round 2)

| Element | Source | Format | Fallback |
|---|---|---|---|
| step | `session.stage` → 2/3 or 3/3 | — | — |
| focus chip (R1) | `session.previous_focus.title` (Accept-Language) | `record.focus.round1` | hide when null |
| previous advice card (R2) | `session.round1_assessment.one_action.instruction / example_sentence` (article language) | ink card | — |
| last duration (R2) | `session.attempts[round=1, status=assessed].duration_ms` | `mm:ss` | hide clause |
| timer | `local:recorder.effectiveMs` | `mm:ss`, tabular | — |
| max | `session.max_round1_ms` / `max_round2_ms` | `/ mm:ss` | config limits |
| min | `session.min_valid_ms` | gates CTA; "N more seconds" | 20 000 |
| waveform | `local:recorder.levelDb` (30 Hz) | 34 bars | flat bars |
| indicator | `local:recorder.state` | recording / paused | — |
| interrupted sheet | `AVAudioSession.interruptionNotification` / route change + `local:recorder.fileURL` | `record.interrupted.*` | — |
| permission | `AVAudioApplication.recordPermission` | denied card + Settings deep link | — |
| submit | `POST /v1/sessions/{id}/attempts {round, duration_ms, content_type, size_bytes, sha256, pause_count}` → PUT → `POST /v1/attempts/{id}/submit` | — | offline: persist + queue (processing--offline) |

## Processing

| Element | Source | Format | Fallback |
|---|---|---|---|
| stage rows | `attempt.status`, `attempt.progress.stage / stage_index / stage_count`; upload stage = `local:uploadQueue` | ✓ / spinner / pending | — |
| upload sub | `local:recorder.effectiveMs` | `processing.sub.upload` | — |
| transcribed length | `attempt.transcript.length.label` | `processing.done.transcribe` | "Transcribed" without count |
| ETA | `attempt.progress.eta_ms` | seconds | hide |
| long wait | `config.limits.poll_timeout_ms` exceeded | `processing.slow.*` | — |
| failed | `attempt.error.code / message (Accept-Language) / retryable / stage` | retry if `retryable` | non-retryable → generic error |
| unreliable | `attempt.unreliable_reason` → `unreliable.<reason>.*`; `attempt.transcript.length` vs floor (**CCR-13**); language_mismatch uses `session.language` endonym | — | generic unreliable copy |
| own-audio playback | `GET /v1/attempts/{id}/audio` | player | 410 → hide player |

## Feedback

| Element | Source | Format | Fallback |
|---|---|---|---|
| "You did it ✓" | `assessment.achieved_previous_action = true` + `previous_action` → micro-skill title | success banner, first element | — |
| "not yet" line | `achieved_previous_action = false` | neutral row | hidden when null |
| low-confidence | `assessment.confidence = low` + `confidence_note` (article language) | warning banner | generic sentence |
| score ring | `assessment.overall`, `band` (colour), `band_label` (Accept-Language) | 104pt ring | — |
| summary | `assessment.summary` (article language) | title3 | — |
| dimension name | `dimension.<key>` client key (or API label) | — | — |
| dimension note / score / evidence chip | `assessment.dimensions.<key>.note / score / evidence_ids[0] → evidence.start_ms` | `mm:ss` chip | `insufficient_evidence` → `dimension.insufficient` |
| one action | `assessment.one_action.title / instruction / example_sentence / evidence_id` (article language) | ink card | — |
| evidence list | `assessment.evidence[]` sorted by `start_ms` | quote + note | — |
| playback | `GET /v1/attempts/{id}/audio → url (15-min TTL)`, `local:player.positionMs` | seek `start_ms`, stop `end_ms` | 410 → `feedback.evidence.expired` |
| key points | `assessment.key_point_coverage[]` (article language) | count `derived:` | hide |
| exemplar | `exemplar.status / text / duration_ms / label` (label Accept-Language; text + voice in article language) | pending skeleton / ready player / failed text + retry | `unavailable` → hide section |
| meta | `assessment.rubric_version`, `model_ref`, `confidence` | caption | — |
| helpful | `POST /v1/assessments/{id}/feedback {helpful, reason?}` | thumbs; 👎 opens reason sheet | queued offline |

## Complete

| Element | Source | Format | Fallback |
|---|---|---|---|
| eyebrow | `session.kind`, `settlement.replayed` | — | — |
| headline / subline | `settlement.headline / subline` (Accept-Language) | — | `complete.headline.<verdict>` |
| icon + rays | `settlement.comparison.verdict` | trophy only for `improved` | — |
| before/after | `settlement.round1_overall / round2_overall`, durations (**CCR-9**) | count-up r1→r2 | — |
| session delta | `settlement.comparison.overall_delta`, `duration_delta_ms` | `±N`, `secs_delta` | — |
| lost points | `comparison.key_points_lost[]` (article language) | warning banner | hidden when empty |
| XP tile | `settlement.xp.awarded` | count-up `+N` | — |
| streak tile | `settlement.streak.current`, `streak_incremented` | `+1` only when incremented and not replayed | — |
| ability tile | `ability_after − ability_before`; `ability_sample_count < 3` → "building baseline n/3" | separate from session delta | — |
| level card | `level_after.level / title (Accept-Language) / xp_into_level / xp_for_next`; animation from `level_before` | bar fill 900 ms | — |
| XP lines | `xp.breakdown[]` (**CCR-10**), missing improve shown struck through | — | — |
| cap / repeat notes | `xp.daily_cap_hit`, `xp.repeat_discount` | `complete.xp.cap_hit` / `.repeat` | — |
| weekly quests | `settlement.weekly_quests.quests[]` (titles Accept-Language) | bars | — |
| next focus | `settlement.next_suggestion.title / instruction` (Accept-Language) | — | — |
| level-up overlay | `level_after.level > level_before.level` | overlay | — |
| achievement sheet | `achievements_unlocked[]` (Accept-Language), `plan_day_completed` | sheet per item, queued | — |
| replayed banner | `settlement.replayed`, `settled_at` | date + time | — |

## Growth

| Element | Source | Format | Fallback |
|---|---|---|---|
| range | `GET /v1/growth?range=4w\|8w\|12w` | segmented | 8w |
| language chips | `growth.languages[]` → `?language=` | shown only when `languages.length > 1`; "All languages" + endonym + session count | hidden |
| ability | `growth.ability`, `ability_delta_30d`, `sample_count`, `algo_version` | display + delta | null → baseline card `growth.baseline.title` with `sample_count` |
| level badge | `me.level.level` | — | — |
| trend label | `growth.trend` | `growth.trend.<value>` | — |
| chart | `growth.series[].week_start / value / sample_n` | one point per week; `value = null && sample_n = 0` → gap marker "None"; no interpolation; weeks before the first session are blank; partial current week (**CCR-5**) dashed | — |
| hook | `growth.next_unlock` (Accept-Language) | lock pill | hidden when null |
| dimensions | `growth.dimensions[].label (Accept-Language) / score / delta_30d / color_token` | bars | null score → "—" + "shown after 3 sessions" |
| micro-skills | `growth.micro_skills[].title (Accept-Language) / suggested / achieved / mastery` + current focus = `me.focus.micro_skill_id` | pips + % | `suggested = 0` → "not suggested yet" |
| achievements | `growth.achievements[]` (Accept-Language) | unlocked lime / locked progress | — |
| recent sessions | `growth.recent_sessions[]` → `article.title` (article language), `completed_at`, `round1_overall`, `round2_overall`, `duration_delta_ms`, `xp_awarded` | row | hide section when empty |
| empty | `growth.state = empty` | empty state | — |

## Profile & settings

| Element | Source | Format | Fallback |
|---|---|---|---|
| avatar initial | `derived:` first grapheme of `me.display_name` | violet tile | "?" |
| name / title / level | `me.display_name`, `me.level.title (Accept-Language) / level` | — | — |
| stats | `me.streak.current`, `me.stats.completed_sessions`, `me.stats.ability` | — | ability null → `profile.stat.ability.baseline` |
| SIWA card | `me.auth_provider = anonymous` | — | hidden for apple |
| weekly quests | `weekly_quests.week_start / week_end / quests[]` (titles Accept-Language) | date range | — |
| subscription row | `me.entitlement.status / product_id / trial_ends_at / expires_at` | `profile.sub.*` | — |
| languages row | `me.settings.practice_languages` | endonyms joined | — |
| app language | `me.settings.ui_locale` (null = follow device); options `config.supported_ui_locales` | "Follow system · English" | follow device |
| practice languages | `me.settings.practice_languages` (first = primary); options `config.supported_practice_languages` | toggles; last one can't be turned off | — |
| reminders | `me.settings.reminders_enabled / reminder_time / streak_risk_alert` + `UNUserNotificationCenter` status | toggles | system-off → `settings.notifications_off` |
| retention | `me.settings.audio_retention_days` | 7 / 30 / 365 segmented | 30 |
| delete data | `DELETE /v1/me/training-data` → `{job_id, purge_backups_by}`; count from `me.stats.completed_sessions` | alert | — |
| delete account | `DELETE /v1/me` → then `POST /v1/auth/anonymous` (fresh) | alert | — |
| version | Bundle version/build + `config.rubric_version` | caption | — |

## Global

| Element | Source | Format | Fallback |
|---|---|---|---|
| resume sheet | `home.resume_session` (SessionSummary) or local unsent recording | — | — |
| leave recording | `local:recorder.effectiveMs` | — | — |
| offline toast | `NWPathMonitor.status` | — | — |
| generic error | `error.code`, `error.message` (Accept-Language), **CCR-6** `request_id` | — | — |
| force update | `config.min_app_version` vs `Bundle.shortVersion` | — | — |
