[
 {
  "file": "screens/onboarding/welcome--default.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "welcome",
  "state": "default",
  "title": "Welcome",
  "description": "Brand promise + the loop in one glance (article → one action → before/after). Legal + AI processing disclosure under the CTA (D6).",
  "api": [
   "POST /v1/auth/anonymous (on launch, before this screen)",
   "GET /v1/config"
  ],
  "local": [],
  "data_binds": []
 },
 {
  "file": "screens/onboarding/language--selected.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "language",
  "state": "selected",
  "title": "Practice language · selected",
  "description": "NEW (D13). Default = device locale, pre-selected. Sets OnboardingAnswers.practice_language; the diagnostic article is in this language. UI language is separate (follows the device, changeable in Settings).",
  "api": [
   "GET /v1/config → supported_practice_languages"
  ],
  "local": [
   "local:onboarding.answers.practice_language"
  ],
  "data_binds": [
   "onboarding.practice_language = en (GET /v1/config → supported_practice_languages)",
   "onboarding.practice_language = zh-Hans (GET /v1/config → supported_practice_languages)"
  ]
 },
 {
  "file": "screens/onboarding/goal--default.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "goal",
  "state": "default",
  "title": "Goal · default",
  "description": "Single choice → OnboardingAnswers.goal. CTA disabled until a choice is made. Haptic: selection.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.goal"
  ],
  "data_binds": [
   "onboarding.goal = create_content",
   "onboarding.goal = remember_more",
   "onboarding.goal = report_clearly",
   "onboarding.goal = speak_concisely"
  ]
 },
 {
  "file": "screens/onboarding/goal--selected.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "goal",
  "state": "selected",
  "title": "Goal · selected",
  "description": "Single choice → OnboardingAnswers.goal. CTA disabled until a choice is made. Haptic: selection.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.goal"
  ],
  "data_binds": [
   "onboarding.goal = create_content",
   "onboarding.goal = remember_more",
   "onboarding.goal = report_clearly",
   "onboarding.goal = speak_concisely"
  ]
 },
 {
  "file": "screens/onboarding/scenario--default.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "scenario",
  "state": "default",
  "title": "Scenario + topics · default",
  "description": "Single choice → scenario; optional multi-select topics → preferred_topics (names from GET /v1/topics, localized).",
  "api": [
   "GET /v1/topics"
  ],
  "local": [
   "local:onboarding.answers.scenario",
   "local:onboarding.answers.preferred_topics"
  ],
  "data_binds": [
   "onboarding.preferred_topics[] ∋ knowledge (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ product (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ psychology (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ tech (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ workplace (GET /v1/topics, Accept-Language)",
   "onboarding.scenario = creator",
   "onboarding.scenario = social",
   "onboarding.scenario = study",
   "onboarding.scenario = work_meeting"
  ]
 },
 {
  "file": "screens/onboarding/scenario--selected.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "scenario",
  "state": "selected",
  "title": "Scenario + topics · selected",
  "description": "Single choice → scenario; optional multi-select topics → preferred_topics (names from GET /v1/topics, localized).",
  "api": [
   "GET /v1/topics"
  ],
  "local": [
   "local:onboarding.answers.scenario",
   "local:onboarding.answers.preferred_topics"
  ],
  "data_binds": [
   "onboarding.preferred_topics[] ∋ knowledge (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ product (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ psychology (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ tech (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ workplace (GET /v1/topics, Accept-Language)",
   "onboarding.scenario = creator",
   "onboarding.scenario = social",
   "onboarding.scenario = study",
   "onboarding.scenario = work_meeting"
  ]
 },
 {
  "file": "screens/onboarding/self-level--default.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "self-level",
  "state": "default",
  "title": "Self-assessed level · default",
  "description": "self_level is a prior only; the diagnostic overrides it for the plan.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.self_level"
  ],
  "data_binds": [
   "onboarding.self_level = beginner",
   "onboarding.self_level = mostly_clear",
   "onboarding.self_level = sometimes_clear"
  ]
 },
 {
  "file": "screens/onboarding/self-level--selected.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "self-level",
  "state": "selected",
  "title": "Self-assessed level · selected",
  "description": "self_level is a prior only; the diagnostic overrides it for the plan.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.self_level"
  ],
  "data_binds": [
   "onboarding.self_level = beginner",
   "onboarding.self_level = mostly_clear",
   "onboarding.self_level = sometimes_clear"
  ]
 },
 {
  "file": "screens/onboarding/commitment--default.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "commitment",
  "state": "default",
  "title": "Weekly days + reminder time · default",
  "description": "weekly_days 3/5/7 (pre-fills the paywall slider) + reminder_time wheel (default 08:30).",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.weekly_days",
   "local:onboarding.answers.reminder_time"
  ],
  "data_binds": [
   "onboarding.reminder_time (HH:mm, local)",
   "onboarding.weekly_days = 3",
   "onboarding.weekly_days = 5",
   "onboarding.weekly_days = 7"
  ]
 },
 {
  "file": "screens/onboarding/commitment--selected.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "commitment",
  "state": "selected",
  "title": "Weekly days + reminder time · selected",
  "description": "weekly_days 3/5/7 (pre-fills the paywall slider) + reminder_time wheel (default 08:30).",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.weekly_days",
   "local:onboarding.answers.reminder_time"
  ],
  "data_binds": [
   "onboarding.reminder_time (HH:mm, local)",
   "onboarding.weekly_days = 3",
   "onboarding.weekly_days = 5",
   "onboarding.weekly_days = 7"
  ]
 },
 {
  "file": "screens/onboarding/notification-priming--default.html",
  "lang": "zh-Hans",
  "group": "onboarding",
  "screen": "notification-priming",
  "state": "default",
  "title": "Notification priming",
  "description": "Pre-permission screen before the system prompt. Either button submits POST /v1/onboarding; 'Turn on' then triggers the iOS prompt.",
  "api": [
   "POST /v1/onboarding (OnboardingAnswers incl. practice_language)",
   "PUT /v1/me/push-token (if granted)",
   "PATCH /v1/me {settings.reminders_enabled}"
  ],
  "local": [],
  "data_binds": [
   "UNUserNotificationCenter.requestAuthorization → PUT /v1/me/push-token",
   "onboarding.reminder_time"
  ]
 },
 {
  "file": "screens/diagnostic/diag-intro--default.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-intro",
  "state": "default",
  "title": "Diagnostic · intro",
  "description": "What will happen (~3 min): read → retell (90 s) → result, in the chosen practice language. States it's real and free.",
  "api": [
   "POST /v1/onboarding → {diagnostic_session, article}"
  ],
  "local": [],
  "data_binds": [
   "POST /v1/onboarding → diagnostic_session",
   "onboarding.practice_language → session.language"
  ]
 },
 {
  "file": "screens/diagnostic/diag-read--normal.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-read",
  "state": "normal",
  "title": "Diagnostic · read",
  "description": "Diagnostic article in the practice language (tier 1, 307 字). Same reader as the daily loop; step 1/2.",
  "api": [
   "POST /v1/onboarding → {diagnostic_session, article}",
   "POST /v1/sessions/{id}/reading-complete"
  ],
  "local": [],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/mic-purpose--sheet.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "mic-purpose",
  "state": "sheet",
  "title": "Mic purpose sheet (pre-permission)",
  "description": "Shown on the first tap of the record button, before the system microphone prompt (D6).",
  "api": [
   "(none) — AVAudioApplication.requestRecordPermission"
  ],
  "local": [
   "local:permissions.microphone"
  ],
  "data_binds": [
   "AVAudioApplication.requestRecordPermission",
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "me.settings.audio_retention_days",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--idle.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "idle",
  "title": "Diagnostic · retell · idle",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--recording.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "recording",
  "title": "Diagnostic · retell · recording",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--paused.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "paused",
  "title": "Diagnostic · retell · paused",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--autostop.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "autostop",
  "title": "Diagnostic · retell · autostop",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--too-short.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "too-short",
  "title": "Diagnostic · retell · too-short",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.min_valid_ms",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--permission-denied.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "permission-denied",
  "title": "Diagnostic · retell · permission-denied",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "UIApplication.openSettingsURLString",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--uploading.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "uploading",
  "title": "Diagnostic · processing · uploading",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--transcribing.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "transcribing",
  "title": "Diagnostic · processing · transcribing",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--assessing.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "assessing",
  "title": "Diagnostic · processing · assessing",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--failed.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "failed",
  "title": "Diagnostic · processing · failed",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "POST /v1/attempts/{id}/discard",
   "POST /v1/attempts/{id}/retry (attempt.error.retryable)",
   "attempt.error.code + attempt.error.stage",
   "attempt.error.message (Accept-Language)",
   "attempt.progress.stage / stage_index / stage_count",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--unreliable.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "unreliable",
  "title": "Diagnostic · processing · unreliable",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "POST /v1/attempts/{id}/discard → record idle",
   "attempt.transcript.length.label vs per-language floor (D2)",
   "attempt.unreliable_reason = too_short",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-result--reveal.html",
  "lang": "zh-Hans",
  "group": "diagnostic",
  "screen": "diag-result",
  "state": "reveal",
  "title": "Diagnostic result · animated reveal",
  "description": "Count-up reveal of overall (ring) then each dimension; weakest dimension feeds the plan and paywall; one action with evidence.",
  "api": [
   "GET /v1/attempts/{id} → assessment"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/plan",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[id=ev_d1].start_ms",
   "assessment.evidence[id=ev_d2].start_ms",
   "assessment.evidence[id=ev_d3].start_ms",
   "assessment.evidence[id=ev_d4].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)"
  ]
 },
 {
  "file": "screens/plan/plan--default.html",
  "lang": "zh-Hans",
  "group": "plan",
  "screen": "plan",
  "state": "default",
  "title": "14-day plan",
  "description": "Generated from the diagnostic; each day has a theme micro-skill (titles localized by the API). Advisory — the loop never changes.",
  "api": [
   "GET /v1/plan",
   "GET /v1/attempts/{diagnostic attempt} (structure note)"
  ],
  "local": [],
  "data_binds": [
   "diagnostic assessment.dimensions.structure.note",
   "me.settings.reminder_time",
   "onboarding answers weekly_days (CCR-7)",
   "plan.baseline_overall",
   "plan.days[0]",
   "plan.days[0].date",
   "plan.days[0].title",
   "plan.days[10]",
   "plan.days[10].date",
   "plan.days[10].title",
   "plan.days[11]",
   "plan.days[11].date",
   "plan.days[11].title",
   "plan.days[12]",
   "plan.days[12].date",
   "plan.days[12].title",
   "plan.days[13]",
   "plan.days[13].date",
   "plan.days[13].title",
   "plan.days[1]",
   "plan.days[1].date",
   "plan.days[1].title",
   "plan.days[2]",
   "plan.days[2].date",
   "plan.days[2].title",
   "plan.days[3]",
   "plan.days[3].date",
   "plan.days[3].title",
   "plan.days[4]",
   "plan.days[4].date",
   "plan.days[4].title",
   "plan.days[5]",
   "plan.days[5].date",
   "plan.days[5].title",
   "plan.days[6]",
   "plan.days[6].date",
   "plan.days[6].title",
   "plan.days[7]",
   "plan.days[7].date",
   "plan.days[7].title",
   "plan.days[8]",
   "plan.days[8].date",
   "plan.days[8].title",
   "plan.days[9]",
   "plan.days[9].date",
   "plan.days[9].title",
   "plan.target_micro_skill → micro-skill title (Accept-Language)",
   "session.language"
  ]
 },
 {
  "file": "screens/paywall/paywall--default.html",
  "lang": "zh-Hans",
  "group": "paywall",
  "screen": "paywall",
  "state": "default",
  "title": "Paywall · default",
  "description": "Hard paywall (no close). Own diagnostic score + weakest dimension; 3/5/7 slider live-updates the projection from benchmarks and the 14-day calendar (practice days, streak milestones the schedule can actually reach, trial→paid date). Prices only from StoreKit JSON. 'Pilot data' label because benchmark_source = pilot_benchmark.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--loading-products.html",
  "lang": "zh-Hans",
  "group": "paywall",
  "screen": "paywall",
  "state": "loading-products",
  "title": "Paywall · loading-products",
  "description": "StoreKit products not loaded yet: skeleton cards, CTA disabled. Projection & calendar still work (they come from /v1/paywall).",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on"
  ]
 },
 {
  "file": "screens/paywall/paywall--purchasing.html",
  "lang": "zh-Hans",
  "group": "paywall",
  "screen": "paywall",
  "state": "purchasing",
  "title": "Paywall · purchasing",
  "description": "Purchase in progress: CTA busy, cards locked; the system purchase sheet appears above this.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--purchase-failed.html",
  "lang": "zh-Hans",
  "group": "paywall",
  "screen": "paywall",
  "state": "purchase-failed",
  "title": "Paywall · purchase-failed",
  "description": "Purchase cancelled / failed: inline alert says nothing was charged; CTA becomes Retry.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "Product.purchase()",
   "StoreKit Product.PurchaseResult / error",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--restored.html",
  "lang": "zh-Hans",
  "group": "paywall",
  "screen": "paywall",
  "state": "restored",
  "title": "Paywall · restored",
  "description": "Restore found an active entitlement (server-verified): toast + CTA to start training.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--expired-return.html",
  "lang": "zh-Hans",
  "group": "paywall",
  "screen": "paywall",
  "state": "expired-return",
  "title": "Paywall · expired-return",
  "description": "entitlement.status = expired on reopen: welcome back with the user's own progress (me.stats); projection uses current ability; no trial (not eligible).",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "me.focus.title",
   "me.stats.ability",
   "me.stats.completed_sessions",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/home/home--first-time.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "first-time",
  "title": "Home · first-time",
  "description": "First home after the paywall: streak 0, Lv.1 with diagnostic XP, quest = plan day 1, focus from the diagnostic.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.plan_day",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--normal.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "normal",
  "title": "Home · normal",
  "description": "Returning user, quest not started. Steps reflect the real stage (read = current, nothing pre-checked). Quest article is in a practice language.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--in-progress.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "in-progress",
  "title": "Home · in-progress",
  "description": "today_quest.status = in_progress: read & retell done, compress current; CTA resumes the session.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.session_id → GET /v1/sessions/{id}",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--completed.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "completed",
  "title": "Home · completed",
  "description": "Quest done today: result summary (needs CCR-2), streak flame filled, free practice offered.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.status",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.summary (CCR-2) → round1_overall / round2_overall / duration_delta_ms / xp_awarded",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--streak-risk.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "streak-risk",
  "title": "Home · streak-risk",
  "description": "After 20:00 local with today not done (streak.at_risk): gentle card, mentions the freeze, no guilt copy.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.at_risk + freezes",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--offline.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "offline",
  "title": "Home · offline",
  "description": "No network: cached /v1/home with timestamp; training still starts (article cached, upload queued).",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "NWPathMonitor + local:cache.home.fetchedAt",
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--no-quest.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "no-quest",
  "title": "Home · no-quest",
  "description": "today_quest = null (CCR-1): no eligible article today → send to library.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "home.display_name",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest = null (CCR-1)",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--topic-empty.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "topic-empty",
  "title": "Home · topic-empty",
  "description": "Quick-pick topic with no articles → explicit empty state, never a mismatched article.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[topic=tech] = ∅ (topic.article_count = 0)",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--loading.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "loading",
  "title": "Home · loading skeleton",
  "description": "Skeleton mirrors the real layout so nothing jumps; shown only on cold start with no cache.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [],
  "data_binds": []
 },
 {
  "file": "screens/home/home--error.html",
  "lang": "zh-Hans",
  "group": "home",
  "screen": "home",
  "state": "error",
  "title": "Home · error",
  "description": "GET /v1/home failed with a retryable error and no cache available.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [],
  "data_binds": [
   "error.code",
   "error.message (Accept-Language)"
  ]
 },
 {
  "file": "screens/library/library--loading.html",
  "lang": "zh-Hans",
  "group": "library",
  "screen": "library",
  "state": "loading",
  "title": "Library · loading",
  "description": "Skeleton rows. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)"
  ]
 },
 {
  "file": "screens/library/library--list.html",
  "lang": "zh-Hans",
  "group": "library",
  "screen": "library",
  "state": "list",
  "title": "Library · list",
  "description": "All languages + topics: unseen / in progress / completed (best score) / locked tier. A language tag shows on each row when more than one practice language exists. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "articles.items[0].id",
   "articles.items[0].language",
   "articles.items[0].title",
   "articles.items[0].topic",
   "articles.items[0].topic.name / length.label / difficulty",
   "articles.items[0].user_status",
   "articles.items[1].best_overall",
   "articles.items[1].id",
   "articles.items[1].language",
   "articles.items[1].title",
   "articles.items[1].topic",
   "articles.items[1].topic.name / length.label / difficulty",
   "articles.items[2].best_overall",
   "articles.items[2].id",
   "articles.items[2].language",
   "articles.items[2].title",
   "articles.items[2].topic",
   "articles.items[2].topic.name / length.label / difficulty",
   "articles.items[3].id",
   "articles.items[3].language",
   "articles.items[3].title",
   "articles.items[3].topic",
   "articles.items[3].topic.name / length.label / difficulty",
   "articles.items[3].user_status",
   "articles.items[4].best_overall",
   "articles.items[4].id",
   "articles.items[4].language",
   "articles.items[4].title",
   "articles.items[4].topic",
   "articles.items[4].topic.name / length.label / difficulty",
   "articles.items[5].id",
   "articles.items[5].language",
   "articles.items[5].title",
   "articles.items[5].topic",
   "articles.items[5].topic.name / length.label / difficulty",
   "articles.items[5].user_status",
   "articles.items[6].id",
   "articles.items[6].language",
   "articles.items[6].title",
   "articles.items[6].topic",
   "articles.items[6].topic.name / length.label / difficulty",
   "articles.items[6].user_status",
   "articles.items[7].id",
   "articles.items[7].language",
   "articles.items[7].title",
   "articles.items[7].topic",
   "articles.items[7].topic.name / length.label / difficulty",
   "articles.items[7].user_status",
   "articles.items[8].id",
   "articles.items[8].locked + unlock_hint (CCR-3)",
   "articles.items[8].title",
   "articles.items[8].topic",
   "articles.items[8].topic.name / length.label / difficulty",
   "articles.items[9].id",
   "articles.items[9].locked + unlock_hint (CCR-3)",
   "articles.items[9].title",
   "articles.items[9].topic",
   "articles.items[9].topic.name / length.label / difficulty"
  ]
 },
 {
  "file": "screens/library/library--language-en.html",
  "lang": "zh-Hans",
  "group": "library",
  "screen": "library",
  "state": "language-en",
  "title": "Library · language-en",
  "description": "Language filter = English (GET /v1/articles?language=en). No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "articles.items[0].best_overall",
   "articles.items[0].id",
   "articles.items[0].language",
   "articles.items[0].title",
   "articles.items[0].topic",
   "articles.items[0].topic.name / length.label / difficulty",
   "articles.items[1].id",
   "articles.items[1].language",
   "articles.items[1].title",
   "articles.items[1].topic",
   "articles.items[1].topic.name / length.label / difficulty",
   "articles.items[1].user_status"
  ]
 },
 {
  "file": "screens/library/library--filtered.html",
  "lang": "zh-Hans",
  "group": "library",
  "screen": "library",
  "state": "filtered",
  "title": "Library · filtered",
  "description": "Topic filter = Product. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "articles.items[0].id",
   "articles.items[0].language",
   "articles.items[0].title",
   "articles.items[0].topic",
   "articles.items[0].topic.name / length.label / difficulty",
   "articles.items[0].user_status",
   "articles.items[1].best_overall",
   "articles.items[1].id",
   "articles.items[1].language",
   "articles.items[1].title",
   "articles.items[1].topic",
   "articles.items[1].topic.name / length.label / difficulty",
   "articles.items[2].best_overall",
   "articles.items[2].id",
   "articles.items[2].language",
   "articles.items[2].title",
   "articles.items[2].topic",
   "articles.items[2].topic.name / length.label / difficulty"
  ]
 },
 {
  "file": "screens/library/library--filter-empty.html",
  "lang": "zh-Hans",
  "group": "library",
  "screen": "library",
  "state": "filter-empty",
  "title": "Library · filter-empty",
  "description": "Topic with 0 articles → empty state + clear filter. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/articles?topic=tech → items = []",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)"
  ]
 },
 {
  "file": "screens/library/library--error.html",
  "lang": "zh-Hans",
  "group": "library",
  "screen": "library",
  "state": "error",
  "title": "Library · error",
  "description": "Retryable load failure. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "error.code",
   "error.message (Accept-Language)"
  ]
 },
 {
  "file": "screens/read/read--normal.html",
  "lang": "zh-Hans",
  "group": "read",
  "screen": "read",
  "state": "normal",
  "title": "Read · normal",
  "description": "1/3. Article body, language tag, length.label and est_read_seconds are server-computed per language (no hard-coded 字/words). CTA posts reading-complete, then hides the text.",
  "api": [
   "POST /v1/sessions {article_id, kind: daily}",
   "GET /v1/sessions/{id} (session.article, session.language)",
   "POST /v1/sessions/{id}/reading-complete {read_duration_ms}"
  ],
  "local": [
   "local:reader.openedAt (for read_duration_ms)"
  ],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.paragraphs[4]",
   "session.article.paragraphs[5]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/read/read--long-scroll.html",
  "lang": "zh-Hans",
  "group": "read",
  "screen": "read",
  "state": "long-scroll",
  "title": "Read · long article, scrolled to end",
  "description": "Tier-2 article scrolled to the end: the last paragraph and source line sit fully above the CTA dock.",
  "api": [
   "GET /v1/sessions/{id}"
  ],
  "local": [],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.paragraphs[4]",
   "session.article.paragraphs[5]",
   "session.article.paragraphs[6]",
   "session.article.paragraphs[7]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/read/read--dynamic-type-xl.html",
  "lang": "zh-Hans",
  "group": "read",
  "screen": "read",
  "state": "dynamic-type-xl",
  "title": "Read · Dynamic Type AX1 (≈135%)",
  "description": "Same screen at Dynamic Type ≈ AX1. Meta row wraps; reading style scales from 18pt.",
  "api": [
   "GET /v1/sessions/{id}"
  ],
  "local": [],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.paragraphs[4]",
   "session.article.paragraphs[5]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--idle.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "idle",
  "title": "Retell · round 1 · idle",
  "description": "Mic button ink, CTA disabled. Recording never starts without a tap (D6).",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--recording.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "recording",
  "title": "Retell · round 1 · recording",
  "description": "Coral button = pause. Timer counts effective (unpaused) time. Persistent in-app recording indicator + iOS orange dot. CTA enabled at ≥ 20 s.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--paused.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "paused",
  "title": "Retell · round 1 · paused",
  "description": "Paused time is not counted. Waveform freezes grey. Submit allowed if ≥ 20 s.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--autostop.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "autostop",
  "title": "Retell · round 1 · autostop",
  "description": "Reached max duration; capture stopped automatically, confirm sheet. Haptic: warning.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--too-short.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "too-short",
  "title": "Retell · round 1 · too-short",
  "description": "User tried to finish (or the level meter saw silence) under 20 s effective. Continue keeps the same file.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.min_valid_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--permission-denied.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "permission-denied",
  "title": "Retell · round 1 · permission-denied",
  "description": "Microphone denied. No attempt is created. Deep link to Settings; article and stage preserved.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "UIApplication.openSettingsURLString",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--interrupted.html",
  "lang": "zh-Hans",
  "group": "record",
  "screen": "record",
  "state": "interrupted",
  "title": "Retell · round 1 · interrupted",
  "description": "Call / Siri / route change paused capture and persisted the partial file. Resume or re-record (D2).",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--uploading.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "uploading",
  "title": "Processing · uploading",
  "description": "Stage 1/3. Presigned PUT in flight. Leaving is safe — the file stays queued.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--transcribing.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "transcribing",
  "title": "Processing · transcribing",
  "description": "Stage 2/3. Poll GET /v1/attempts/{id} every 2 s (backoff to 5 s). ASR receives session.language as a hint.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--assessing.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "assessing",
  "title": "Processing · assessing",
  "description": "Stage 3/3. ETA from attempt.progress.eta_ms when present.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--failed.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "failed",
  "title": "Processing · failed",
  "description": "attempt.status = failed with a retryable error at stage assess. Retry re-runs the stage without re-upload.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "POST /v1/attempts/{id}/discard",
   "POST /v1/attempts/{id}/retry (attempt.error.retryable)",
   "attempt.error.code + attempt.error.stage",
   "attempt.error.message (Accept-Language)",
   "attempt.progress.stage / stage_index / stage_count",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--unreliable.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "unreliable",
  "title": "Processing · unreliable",
  "description": "attempt.status = unreliable (too_short; floor per language: 40 chars zh-Hans / 30 words en). No score, no XP; free re-record.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "POST /v1/attempts/{id}/discard → record idle",
   "attempt.transcript.length.label vs per-language floor (D2)",
   "attempt.unreliable_reason = too_short",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--long-wait.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "long-wait",
  "title": "Processing · long-wait",
  "description": "Poll exceeded 120 s (config.limits.poll_timeout_ms). Offer 'check later'; the result lands in Home resume.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--offline.html",
  "lang": "zh-Hans",
  "group": "processing",
  "screen": "processing",
  "state": "offline",
  "title": "Processing · offline",
  "description": "Network lost during upload. File persisted; upload resumes automatically when reachable.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--full.html",
  "lang": "zh-Hans",
  "group": "feedback",
  "screen": "feedback",
  "state": "full",
  "title": "Feedback · full",
  "description": "Summary → 4 dimensions (each with evidence) → one action (ink) → evidence → key points → model answer (labelled reference) → helpful?",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--low-confidence.html",
  "lang": "zh-Hans",
  "group": "feedback",
  "screen": "feedback",
  "state": "low-confidence",
  "title": "Feedback · low-confidence",
  "description": "confidence = low: warning banner with confidence_note; a dimension without evidence shows 'not enough evidence' and does not lower the score; one action still shown.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.confidence + confidence_note",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--achieved.html",
  "lang": "zh-Hans",
  "group": "feedback",
  "screen": "feedback",
  "state": "achieved",
  "title": "Feedback · achieved",
  "description": "achieved_previous_action = true: 'You did it ✓' banner comes first; the new one-action moves to the next micro-skill.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action + previous_action",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.evidence[id=ev_a1].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--playback.html",
  "lang": "zh-Hans",
  "group": "feedback",
  "screen": "feedback",
  "state": "playback",
  "title": "Feedback · playback",
  "description": "Evidence row tapped: signed audio URL fetched, playback seeks to start_ms and stops at end_ms; row highlighted.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/audio → url; local:player.positionMs",
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--exemplar-pending.html",
  "lang": "zh-Hans",
  "group": "feedback",
  "screen": "feedback",
  "state": "exemplar-pending",
  "title": "Feedback · exemplar-pending",
  "description": "TTS is async (voice per article language): exemplar.status = pending → skeleton + spinner; poll until ready.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.label",
   "exemplar.status = pending",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--exemplar-failed.html",
  "lang": "zh-Hans",
  "group": "feedback",
  "screen": "feedback",
  "state": "exemplar-failed",
  "title": "Feedback · exemplar-failed",
  "description": "exemplar.status = failed: text fallback + retry; never blocks the second take.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.label",
   "exemplar.status = failed",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--idle.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "idle",
  "title": "Second take · round 2 · idle",
  "description": "Mic button ink, CTA disabled. Recording never starts without a tap (D6). Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--recording.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "recording",
  "title": "Second take · round 2 · recording",
  "description": "Coral button = pause. Timer counts effective (unpaused) time. Persistent in-app recording indicator + iOS orange dot. CTA enabled at ≥ 20 s. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--paused.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "paused",
  "title": "Second take · round 2 · paused",
  "description": "Paused time is not counted. Waveform freezes grey. Submit allowed if ≥ 20 s. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--autostop.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "autostop",
  "title": "Second take · round 2 · autostop",
  "description": "Reached max duration; capture stopped automatically, confirm sheet. Haptic: warning. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--too-short.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "too-short",
  "title": "Second take · round 2 · too-short",
  "description": "User tried to finish (or the level meter saw silence) under 20 s effective. Continue keeps the same file. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.min_valid_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--permission-denied.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "permission-denied",
  "title": "Second take · round 2 · permission-denied",
  "description": "Microphone denied. No attempt is created. Deep link to Settings; article and stage preserved. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "UIApplication.openSettingsURLString",
   "session.attempts[round=1].duration_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--interrupted.html",
  "lang": "zh-Hans",
  "group": "compress",
  "screen": "compress",
  "state": "interrupted",
  "title": "Second take · round 2 · interrupted",
  "description": "Call / Siri / route change paused capture and persisted the partial file. Resume or re-record (D2). Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/complete/complete--improved.html",
  "lang": "zh-Hans",
  "group": "complete",
  "screen": "complete",
  "state": "improved",
  "title": "Complete · improved",
  "description": "verdict improved: celebration, round1→round2 block, XP bar fills 240→295, ability change (+2) shown separately from the +11 session delta.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/complete/complete--same.html",
  "lang": "zh-Hans",
  "group": "complete",
  "screen": "complete",
  "state": "same",
  "title": "Complete · same",
  "description": "verdict same: neutral icon, no rays, no improvement bonus (struck through); ability may drop by 1 — shown honestly.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp"
  ]
 },
 {
  "file": "screens/complete/complete--shorter-lost.html",
  "lang": "zh-Hans",
  "group": "complete",
  "screen": "complete",
  "state": "shorter-lost",
  "title": "Complete · shorter-lost",
  "description": "verdict shorter_but_lost_points: no praise. Lists the lost key point; next suggestion = keep_constraints.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.key_points_lost[] (article language)",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp"
  ]
 },
 {
  "file": "screens/complete/complete--level-up.html",
  "lang": "zh-Hans",
  "group": "complete",
  "screen": "complete",
  "state": "level-up",
  "title": "Complete · level-up",
  "description": "level_after.level > level_before.level: full-screen ink overlay with lime badge and new title.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.level_after.xp_total / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/complete/complete--achievement.html",
  "lang": "zh-Hans",
  "group": "complete",
  "screen": "complete",
  "state": "achievement",
  "title": "Complete · achievement",
  "description": "First-ever session: achievements_unlocked[0] sheet; ability tile shows 'building baseline 1/3'.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_sample_count",
   "settlement.achievements_unlocked[0].description",
   "settlement.achievements_unlocked[0].title",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.plan_day_completed",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/complete/complete--replayed.html",
  "lang": "zh-Hans",
  "group": "complete",
  "screen": "complete",
  "state": "replayed",
  "title": "Complete · replayed",
  "description": "settlement.replayed = true (reopened): no animations, no +1 flourish; banner explains rewards were already granted.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.replayed + settled_at",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/growth/growth--empty.html",
  "lang": "zh-Hans",
  "group": "growth",
  "screen": "growth",
  "state": "empty",
  "title": "Growth · empty",
  "description": "growth.state = empty (0 sessions).",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.state = empty"
  ]
 },
 {
  "file": "screens/growth/growth--baseline.html",
  "lang": "zh-Hans",
  "group": "growth",
  "screen": "growth",
  "state": "baseline",
  "title": "Growth · baseline",
  "description": "growth.state = baseline (1–2 sessions): 'building baseline n/3'; no ability, no skills grid.",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.dimensions[0].delta_30d",
   "growth.dimensions[0].label (Accept-Language)",
   "growth.dimensions[0].score",
   "growth.dimensions[1].delta_30d",
   "growth.dimensions[1].label (Accept-Language)",
   "growth.dimensions[1].score",
   "growth.dimensions[2].delta_30d",
   "growth.dimensions[2].label (Accept-Language)",
   "growth.dimensions[2].score",
   "growth.dimensions[3].delta_30d",
   "growth.dimensions[3].label (Accept-Language)",
   "growth.dimensions[3].score",
   "growth.next_unlock (Accept-Language)",
   "growth.recent_sessions[0]",
   "growth.recent_sessions[1]",
   "growth.sample_count (ability = null)",
   "growth.series[].week_start / value / sample_n",
   "growth.trend"
  ]
 },
 {
  "file": "screens/growth/growth--ready.html",
  "lang": "zh-Hans",
  "group": "growth",
  "screen": "growth",
  "state": "ready",
  "title": "Growth · ready",
  "description": "Ability (EMA, ability-v1) with 30-day delta, weekly trend, 4 dimensions, micro-skill mastery, achievements, the 'next point' hook, recent sessions. Language chips appear because growth.languages has >1 entry (zh persona).",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.ability",
   "growth.ability_delta_30d",
   "growth.achievements[0] (Accept-Language)",
   "growth.achievements[1] (Accept-Language)",
   "growth.achievements[2] (Accept-Language)",
   "growth.achievements[3] (Accept-Language)",
   "growth.achievements[4] (Accept-Language)",
   "growth.achievements[5] (Accept-Language)",
   "growth.achievements[6] (Accept-Language)",
   "growth.dimensions[0].delta_30d",
   "growth.dimensions[0].label (Accept-Language)",
   "growth.dimensions[0].score",
   "growth.dimensions[1].delta_30d",
   "growth.dimensions[1].label (Accept-Language)",
   "growth.dimensions[1].score",
   "growth.dimensions[2].delta_30d",
   "growth.dimensions[2].label (Accept-Language)",
   "growth.dimensions[2].score",
   "growth.dimensions[3].delta_30d",
   "growth.dimensions[3].label (Accept-Language)",
   "growth.dimensions[3].score",
   "growth.languages[0].language / sessions → GET /v1/growth?language=",
   "growth.languages[1].language / sessions → GET /v1/growth?language=",
   "growth.micro_skills[0] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[1] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[2] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[3] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[4] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[5] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[6] (mastery = achieved/suggested, last 10 sessions)",
   "growth.next_unlock (Accept-Language)",
   "growth.recent_sessions[0]",
   "growth.recent_sessions[1]",
   "growth.recent_sessions[2]",
   "growth.sample_count + algo_version",
   "growth.series[].week_start / value / sample_n",
   "growth.trend",
   "me.level.level"
  ]
 },
 {
  "file": "screens/growth/growth--gap.html",
  "lang": "zh-Hans",
  "group": "growth",
  "screen": "growth",
  "state": "gap",
  "title": "Growth · gap",
  "description": "Weeks with no sessions are drawn as gaps with a hollow marker — never interpolated. Single language → no language chips.",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.ability",
   "growth.ability_delta_30d",
   "growth.achievements[0] (Accept-Language)",
   "growth.achievements[1] (Accept-Language)",
   "growth.achievements[2] (Accept-Language)",
   "growth.achievements[3] (Accept-Language)",
   "growth.achievements[4] (Accept-Language)",
   "growth.achievements[5] (Accept-Language)",
   "growth.achievements[6] (Accept-Language)",
   "growth.dimensions[0].delta_30d",
   "growth.dimensions[0].label (Accept-Language)",
   "growth.dimensions[0].score",
   "growth.dimensions[1].delta_30d",
   "growth.dimensions[1].label (Accept-Language)",
   "growth.dimensions[1].score",
   "growth.dimensions[2].delta_30d",
   "growth.dimensions[2].label (Accept-Language)",
   "growth.dimensions[2].score",
   "growth.dimensions[3].delta_30d",
   "growth.dimensions[3].label (Accept-Language)",
   "growth.dimensions[3].score",
   "growth.micro_skills[0] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[1] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[2] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[3] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[4] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[5] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[6] (mastery = achieved/suggested, last 10 sessions)",
   "growth.next_unlock (Accept-Language)",
   "growth.recent_sessions[0]",
   "growth.recent_sessions[1]",
   "growth.recent_sessions[2]",
   "growth.sample_count + algo_version",
   "growth.series[].week_start / value / sample_n",
   "growth.trend",
   "me.level.level"
  ]
 },
 {
  "file": "screens/profile/profile--normal.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "profile",
  "state": "normal",
  "title": "Me · anonymous user",
  "description": "Stats from GET /v1/me (same source as home → no 12 vs 13 mismatch); weekly quests; languages row; SIWA card for anonymous users.",
  "api": [
   "GET /v1/me",
   "GET /v1/weekly-quests"
  ],
  "local": [],
  "data_binds": [
   "derived: first grapheme of me.display_name",
   "me.auth_provider = anonymous",
   "me.display_name",
   "me.entitlement.status / product_id / trial_ends_at",
   "me.level.title + level",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.stats.ability",
   "me.stats.completed_sessions",
   "me.streak.current",
   "weekly_quests.quests[0] (title: Accept-Language)",
   "weekly_quests.quests[1] (title: Accept-Language)",
   "weekly_quests.quests[2] (title: Accept-Language)",
   "weekly_quests.week_start / week_end"
  ]
 },
 {
  "file": "screens/profile/weekly-quests--default.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "weekly-quests",
  "state": "default",
  "title": "This week's quests",
  "description": "Week boundaries in user tz, counting rules, XP per quest.",
  "api": [
   "GET /v1/weekly-quests"
  ],
  "local": [],
  "data_binds": [
   "weekly_quests.quests[0]",
   "weekly_quests.quests[1]",
   "weekly_quests.quests[2]",
   "weekly_quests.week_start / week_end / timezone"
  ]
 },
 {
  "file": "screens/profile/settings--default.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "settings",
  "state": "default",
  "title": "Settings",
  "description": "App language, practice languages, reminders, retention 7/30/365, privacy, delete data/account, subscription.",
  "api": [
   "GET /v1/me",
   "PATCH /v1/me {settings}"
  ],
  "local": [],
  "data_binds": [
   "AppStore.showManageSubscriptions",
   "Bundle version + config.rubric_version",
   "DELETE /v1/me",
   "DELETE /v1/me/training-data",
   "me.settings.audio_retention_days → PATCH /v1/me",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.settings.reminders_enabled",
   "me.settings.streak_risk_alert",
   "me.settings.ui_locale (null = follow device) → Accept-Language"
  ]
 },
 {
  "file": "screens/profile/settings--language.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "settings",
  "state": "language",
  "title": "Settings · languages",
  "description": "UI language (follow device or override → settings.ui_locale; drives Accept-Language) is separate from practice languages (settings.practice_languages, first = primary).",
  "api": [
   "GET /v1/config → supported_ui_locales / supported_practice_languages",
   "PATCH /v1/me {settings.ui_locale, settings.practice_languages}"
  ],
  "local": [],
  "data_binds": [
   "PATCH /v1/me {settings.ui_locale: en}",
   "PATCH /v1/me {settings.ui_locale: zh-Hans}",
   "me.settings.practice_languages[] ∋ en (GET /v1/config → supported_practice_languages)",
   "me.settings.practice_languages[] ∋ zh-Hans (GET /v1/config → supported_practice_languages)",
   "me.settings.ui_locale = null"
  ]
 },
 {
  "file": "screens/profile/settings--delete-data-confirm.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "settings",
  "state": "delete-data-confirm",
  "title": "Settings · delete training data",
  "description": "Destructive confirm with exact consequences (counts from me.stats).",
  "api": [
   "DELETE /v1/me/training-data → 202 {job_id, purge_backups_by}"
  ],
  "local": [],
  "data_binds": [
   "AppStore.showManageSubscriptions",
   "Bundle version + config.rubric_version",
   "DELETE /v1/me",
   "DELETE /v1/me/training-data",
   "DELETE /v1/me/training-data → {job_id, purge_backups_by}",
   "me.settings.audio_retention_days → PATCH /v1/me",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.settings.reminders_enabled",
   "me.settings.streak_risk_alert",
   "me.settings.ui_locale (null = follow device) → Accept-Language",
   "me.stats.completed_sessions"
  ]
 },
 {
  "file": "screens/profile/settings--delete-account-confirm.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "settings",
  "state": "delete-account-confirm",
  "title": "Settings · delete account",
  "description": "Warns that the App Store subscription is not cancelled automatically.",
  "api": [
   "DELETE /v1/me → 204"
  ],
  "local": [],
  "data_binds": [
   "AppStore.showManageSubscriptions",
   "Bundle version + config.rubric_version",
   "DELETE /v1/me",
   "DELETE /v1/me → 204 → POST /v1/auth/anonymous (fresh)",
   "DELETE /v1/me/training-data",
   "me.settings.audio_retention_days → PATCH /v1/me",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.settings.reminders_enabled",
   "me.settings.streak_risk_alert",
   "me.settings.ui_locale (null = follow device) → Accept-Language"
  ]
 },
 {
  "file": "screens/profile/siwa--sheet.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "siwa",
  "state": "sheet",
  "title": "Sign in with Apple upgrade",
  "description": "Anonymous → Apple upgrade keeps the same account (D5). 409 conflict handled per contract.",
  "api": [
   "POST /v1/auth/apple {identity_token, authorization_code, install_id}"
  ],
  "local": [],
  "data_binds": [
   "ASAuthorizationAppleIDProvider → POST /v1/auth/apple",
   "derived: first grapheme of me.display_name",
   "me.auth_provider = anonymous",
   "me.display_name",
   "me.entitlement.status / product_id / trial_ends_at",
   "me.level.title + level",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.stats.ability",
   "me.stats.completed_sessions",
   "me.streak.current",
   "weekly_quests.quests[0] (title: Accept-Language)",
   "weekly_quests.quests[1] (title: Accept-Language)",
   "weekly_quests.quests[2] (title: Accept-Language)",
   "weekly_quests.week_start / week_end"
  ]
 },
 {
  "file": "screens/profile/privacy--default.html",
  "lang": "zh-Hans",
  "group": "profile",
  "screen": "privacy",
  "state": "default",
  "title": "Privacy & AI disclosure",
  "description": "Plain-language disclosure required by D6 (third-party AI, language hint, no training, retention, deletion, analytics, age).",
  "api": [
   "GET /v1/me (settings.audio_retention_days)",
   "GET /v1/config"
  ],
  "local": [],
  "data_binds": [
   "me.settings.audio_retention_days + config.rubric_version"
  ]
 },
 {
  "file": "screens/global/resume-session--sheet.html",
  "lang": "zh-Hans",
  "group": "global",
  "screen": "resume-session",
  "state": "sheet",
  "title": "Resume session",
  "description": "On launch/home when home.resume_session != null (or today_quest in progress after a kill).",
  "api": [
   "GET /v1/home → resume_session",
   "POST /v1/sessions/{id}/abandon"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/sessions/{id} → route by stage",
   "POST /v1/sessions {article_id, kind: daily}",
   "POST /v1/sessions/{id}/abandon",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.resume_session.article.title",
   "home.resume_session.round1_overall",
   "home.resume_session.stage + created_at",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/global/leave-recording--alert.html",
  "lang": "zh-Hans",
  "group": "global",
  "screen": "leave-recording",
  "state": "alert",
  "title": "Leave recording confirm",
  "description": "Back during capture: capture pauses immediately, partial file kept (D2/F10).",
  "api": [],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/global/offline--toast.html",
  "lang": "zh-Hans",
  "group": "global",
  "screen": "offline",
  "state": "toast",
  "title": "Offline toast",
  "description": "Connectivity lost: non-blocking toast; recording continues locally.",
  "api": [],
  "local": [
   "NWPathMonitor"
  ],
  "data_binds": [
   "NWPathMonitor.status = unsatisfied",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/global/error--generic.html",
  "lang": "zh-Hans",
  "group": "global",
  "screen": "error",
  "state": "generic",
  "title": "Generic error",
  "description": "Non-retryable / unknown error. Shows error.code and request id (CCR-6).",
  "api": [
   "ErrorEnvelope {code, message (Accept-Language), retryable, stage, details}"
  ],
  "local": [],
  "data_binds": [
   "error.code",
   "error.message (Accept-Language)"
  ]
 },
 {
  "file": "screens/global/force-update--default.html",
  "lang": "zh-Hans",
  "group": "global",
  "screen": "force-update",
  "state": "default",
  "title": "Force update",
  "description": "Bundle version < config.min_app_version. Blocks the app.",
  "api": [
   "GET /v1/config"
  ],
  "local": [],
  "data_binds": [
   "config.min_app_version vs Bundle.shortVersion"
  ]
 },
 {
  "file": "screens/onboarding/welcome--default.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "welcome",
  "state": "default",
  "title": "Welcome",
  "description": "Brand promise + the loop in one glance (article → one action → before/after). Legal + AI processing disclosure under the CTA (D6).",
  "api": [
   "POST /v1/auth/anonymous (on launch, before this screen)",
   "GET /v1/config"
  ],
  "local": [],
  "data_binds": []
 },
 {
  "file": "screens/onboarding/language--selected.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "language",
  "state": "selected",
  "title": "Practice language · selected",
  "description": "NEW (D13). Default = device locale, pre-selected. Sets OnboardingAnswers.practice_language; the diagnostic article is in this language. UI language is separate (follows the device, changeable in Settings).",
  "api": [
   "GET /v1/config → supported_practice_languages"
  ],
  "local": [
   "local:onboarding.answers.practice_language"
  ],
  "data_binds": [
   "onboarding.practice_language = en (GET /v1/config → supported_practice_languages)",
   "onboarding.practice_language = zh-Hans (GET /v1/config → supported_practice_languages)"
  ]
 },
 {
  "file": "screens/onboarding/goal--default.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "goal",
  "state": "default",
  "title": "Goal · default",
  "description": "Single choice → OnboardingAnswers.goal. CTA disabled until a choice is made. Haptic: selection.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.goal"
  ],
  "data_binds": [
   "onboarding.goal = create_content",
   "onboarding.goal = remember_more",
   "onboarding.goal = report_clearly",
   "onboarding.goal = speak_concisely"
  ]
 },
 {
  "file": "screens/onboarding/goal--selected.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "goal",
  "state": "selected",
  "title": "Goal · selected",
  "description": "Single choice → OnboardingAnswers.goal. CTA disabled until a choice is made. Haptic: selection.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.goal"
  ],
  "data_binds": [
   "onboarding.goal = create_content",
   "onboarding.goal = remember_more",
   "onboarding.goal = report_clearly",
   "onboarding.goal = speak_concisely"
  ]
 },
 {
  "file": "screens/onboarding/scenario--default.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "scenario",
  "state": "default",
  "title": "Scenario + topics · default",
  "description": "Single choice → scenario; optional multi-select topics → preferred_topics (names from GET /v1/topics, localized).",
  "api": [
   "GET /v1/topics"
  ],
  "local": [
   "local:onboarding.answers.scenario",
   "local:onboarding.answers.preferred_topics"
  ],
  "data_binds": [
   "onboarding.preferred_topics[] ∋ knowledge (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ product (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ psychology (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ tech (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ workplace (GET /v1/topics, Accept-Language)",
   "onboarding.scenario = creator",
   "onboarding.scenario = social",
   "onboarding.scenario = study",
   "onboarding.scenario = work_meeting"
  ]
 },
 {
  "file": "screens/onboarding/scenario--selected.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "scenario",
  "state": "selected",
  "title": "Scenario + topics · selected",
  "description": "Single choice → scenario; optional multi-select topics → preferred_topics (names from GET /v1/topics, localized).",
  "api": [
   "GET /v1/topics"
  ],
  "local": [
   "local:onboarding.answers.scenario",
   "local:onboarding.answers.preferred_topics"
  ],
  "data_binds": [
   "onboarding.preferred_topics[] ∋ knowledge (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ product (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ psychology (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ tech (GET /v1/topics, Accept-Language)",
   "onboarding.preferred_topics[] ∋ workplace (GET /v1/topics, Accept-Language)",
   "onboarding.scenario = creator",
   "onboarding.scenario = social",
   "onboarding.scenario = study",
   "onboarding.scenario = work_meeting"
  ]
 },
 {
  "file": "screens/onboarding/self-level--default.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "self-level",
  "state": "default",
  "title": "Self-assessed level · default",
  "description": "self_level is a prior only; the diagnostic overrides it for the plan.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.self_level"
  ],
  "data_binds": [
   "onboarding.self_level = beginner",
   "onboarding.self_level = mostly_clear",
   "onboarding.self_level = sometimes_clear"
  ]
 },
 {
  "file": "screens/onboarding/self-level--selected.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "self-level",
  "state": "selected",
  "title": "Self-assessed level · selected",
  "description": "self_level is a prior only; the diagnostic overrides it for the plan.",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.self_level"
  ],
  "data_binds": [
   "onboarding.self_level = beginner",
   "onboarding.self_level = mostly_clear",
   "onboarding.self_level = sometimes_clear"
  ]
 },
 {
  "file": "screens/onboarding/commitment--default.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "commitment",
  "state": "default",
  "title": "Weekly days + reminder time · default",
  "description": "weekly_days 3/5/7 (pre-fills the paywall slider) + reminder_time wheel (default 08:30).",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.weekly_days",
   "local:onboarding.answers.reminder_time"
  ],
  "data_binds": [
   "onboarding.reminder_time (HH:mm, local)",
   "onboarding.weekly_days = 3",
   "onboarding.weekly_days = 5",
   "onboarding.weekly_days = 7"
  ]
 },
 {
  "file": "screens/onboarding/commitment--selected.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "commitment",
  "state": "selected",
  "title": "Weekly days + reminder time · selected",
  "description": "weekly_days 3/5/7 (pre-fills the paywall slider) + reminder_time wheel (default 08:30).",
  "api": [
   "(answers held locally until the last step)"
  ],
  "local": [
   "local:onboarding.answers.weekly_days",
   "local:onboarding.answers.reminder_time"
  ],
  "data_binds": [
   "onboarding.reminder_time (HH:mm, local)",
   "onboarding.weekly_days = 3",
   "onboarding.weekly_days = 5",
   "onboarding.weekly_days = 7"
  ]
 },
 {
  "file": "screens/onboarding/notification-priming--default.en.html",
  "lang": "en",
  "group": "onboarding",
  "screen": "notification-priming",
  "state": "default",
  "title": "Notification priming",
  "description": "Pre-permission screen before the system prompt. Either button submits POST /v1/onboarding; 'Turn on' then triggers the iOS prompt.",
  "api": [
   "POST /v1/onboarding (OnboardingAnswers incl. practice_language)",
   "PUT /v1/me/push-token (if granted)",
   "PATCH /v1/me {settings.reminders_enabled}"
  ],
  "local": [],
  "data_binds": [
   "UNUserNotificationCenter.requestAuthorization → PUT /v1/me/push-token",
   "onboarding.reminder_time"
  ]
 },
 {
  "file": "screens/diagnostic/diag-intro--default.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-intro",
  "state": "default",
  "title": "Diagnostic · intro",
  "description": "What will happen (~3 min): read → retell (90 s) → result, in the chosen practice language. States it's real and free.",
  "api": [
   "POST /v1/onboarding → {diagnostic_session, article}"
  ],
  "local": [],
  "data_binds": [
   "POST /v1/onboarding → diagnostic_session",
   "onboarding.practice_language → session.language"
  ]
 },
 {
  "file": "screens/diagnostic/diag-read--normal.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-read",
  "state": "normal",
  "title": "Diagnostic · read",
  "description": "Diagnostic article in the practice language (tier 1, 152 words). Same reader as the daily loop; step 1/2.",
  "api": [
   "POST /v1/onboarding → {diagnostic_session, article}",
   "POST /v1/sessions/{id}/reading-complete"
  ],
  "local": [],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/mic-purpose--sheet.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "mic-purpose",
  "state": "sheet",
  "title": "Mic purpose sheet (pre-permission)",
  "description": "Shown on the first tap of the record button, before the system microphone prompt (D6).",
  "api": [
   "(none) — AVAudioApplication.requestRecordPermission"
  ],
  "local": [
   "local:permissions.microphone"
  ],
  "data_binds": [
   "AVAudioApplication.requestRecordPermission",
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "me.settings.audio_retention_days",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--idle.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "idle",
  "title": "Diagnostic · retell · idle",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--recording.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "recording",
  "title": "Diagnostic · retell · recording",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--paused.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "paused",
  "title": "Diagnostic · retell · paused",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--autostop.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "autostop",
  "title": "Diagnostic · retell · autostop",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--too-short.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "too-short",
  "title": "Diagnostic · retell · too-short",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "config.limits.diagnostic_max_ms",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.min_valid_ms",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-record--permission-denied.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-record",
  "state": "permission-denied",
  "title": "Diagnostic · retell · permission-denied",
  "description": "Diagnostic round 1 only, capped at config.limits.diagnostic_max_ms (90 s). Same recorder states as the daily loop.",
  "api": [
   "POST /v1/sessions/{id}/attempts {round:1}",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "UIApplication.openSettingsURLString",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--uploading.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "uploading",
  "title": "Diagnostic · processing · uploading",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--transcribing.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "transcribing",
  "title": "Diagnostic · processing · transcribing",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--assessing.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "assessing",
  "title": "Diagnostic · processing · assessing",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--failed.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "failed",
  "title": "Diagnostic · processing · failed",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "POST /v1/attempts/{id}/discard",
   "POST /v1/attempts/{id}/retry (attempt.error.retryable)",
   "attempt.error.code + attempt.error.stage",
   "attempt.error.message (Accept-Language)",
   "attempt.progress.stage / stage_index / stage_count",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-processing--unreliable.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-processing",
  "state": "unreliable",
  "title": "Diagnostic · processing · unreliable",
  "description": "Real pipeline (not a demo). Same processing states as the daily loop.",
  "api": [
   "GET /v1/attempts/{id}"
  ],
  "local": [],
  "data_binds": [
   "POST /v1/attempts/{id}/discard → record idle",
   "attempt.transcript.length.label vs per-language floor (D2)",
   "attempt.unreliable_reason = too_short",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/diagnostic/diag-result--reveal.en.html",
  "lang": "en",
  "group": "diagnostic",
  "screen": "diag-result",
  "state": "reveal",
  "title": "Diagnostic result · animated reveal",
  "description": "Count-up reveal of overall (ring) then each dimension; weakest dimension feeds the plan and paywall; one action with evidence.",
  "api": [
   "GET /v1/attempts/{id} → assessment"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/plan",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[id=ev_d1].start_ms",
   "assessment.evidence[id=ev_d2].start_ms",
   "assessment.evidence[id=ev_d3].start_ms",
   "assessment.evidence[id=ev_d4].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)"
  ]
 },
 {
  "file": "screens/plan/plan--default.en.html",
  "lang": "en",
  "group": "plan",
  "screen": "plan",
  "state": "default",
  "title": "14-day plan",
  "description": "Generated from the diagnostic; each day has a theme micro-skill (titles localized by the API). Advisory — the loop never changes.",
  "api": [
   "GET /v1/plan",
   "GET /v1/attempts/{diagnostic attempt} (structure note)"
  ],
  "local": [],
  "data_binds": [
   "diagnostic assessment.dimensions.structure.note",
   "me.settings.reminder_time",
   "onboarding answers weekly_days (CCR-7)",
   "plan.baseline_overall",
   "plan.days[0]",
   "plan.days[0].date",
   "plan.days[0].title",
   "plan.days[10]",
   "plan.days[10].date",
   "plan.days[10].title",
   "plan.days[11]",
   "plan.days[11].date",
   "plan.days[11].title",
   "plan.days[12]",
   "plan.days[12].date",
   "plan.days[12].title",
   "plan.days[13]",
   "plan.days[13].date",
   "plan.days[13].title",
   "plan.days[1]",
   "plan.days[1].date",
   "plan.days[1].title",
   "plan.days[2]",
   "plan.days[2].date",
   "plan.days[2].title",
   "plan.days[3]",
   "plan.days[3].date",
   "plan.days[3].title",
   "plan.days[4]",
   "plan.days[4].date",
   "plan.days[4].title",
   "plan.days[5]",
   "plan.days[5].date",
   "plan.days[5].title",
   "plan.days[6]",
   "plan.days[6].date",
   "plan.days[6].title",
   "plan.days[7]",
   "plan.days[7].date",
   "plan.days[7].title",
   "plan.days[8]",
   "plan.days[8].date",
   "plan.days[8].title",
   "plan.days[9]",
   "plan.days[9].date",
   "plan.days[9].title",
   "plan.target_micro_skill → micro-skill title (Accept-Language)",
   "session.language"
  ]
 },
 {
  "file": "screens/paywall/paywall--default.en.html",
  "lang": "en",
  "group": "paywall",
  "screen": "paywall",
  "state": "default",
  "title": "Paywall · default",
  "description": "Hard paywall (no close). Own diagnostic score + weakest dimension; 3/5/7 slider live-updates the projection from benchmarks and the 14-day calendar (practice days, streak milestones the schedule can actually reach, trial→paid date). Prices only from StoreKit JSON. 'Pilot data' label because benchmark_source = pilot_benchmark.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--loading-products.en.html",
  "lang": "en",
  "group": "paywall",
  "screen": "paywall",
  "state": "loading-products",
  "title": "Paywall · loading-products",
  "description": "StoreKit products not loaded yet: skeleton cards, CTA disabled. Projection & calendar still work (they come from /v1/paywall).",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on"
  ]
 },
 {
  "file": "screens/paywall/paywall--purchasing.en.html",
  "lang": "en",
  "group": "paywall",
  "screen": "paywall",
  "state": "purchasing",
  "title": "Paywall · purchasing",
  "description": "Purchase in progress: CTA busy, cards locked; the system purchase sheet appears above this.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--purchase-failed.en.html",
  "lang": "en",
  "group": "paywall",
  "screen": "paywall",
  "state": "purchase-failed",
  "title": "Paywall · purchase-failed",
  "description": "Purchase cancelled / failed: inline alert says nothing was charged; CTA becomes Retry.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "Product.purchase()",
   "StoreKit Product.PurchaseResult / error",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--restored.en.html",
  "lang": "en",
  "group": "paywall",
  "screen": "paywall",
  "state": "restored",
  "title": "Paywall · restored",
  "description": "Restore found an active entitlement (server-verified): toast + CTA to start training.",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "derived: band(paywall.diagnostic.overall)",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.diagnostic.one_action_title",
   "paywall.diagnostic.overall",
   "paywall.diagnostic.weakest_label (Accept-Language)",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/paywall/paywall--expired-return.en.html",
  "lang": "en",
  "group": "paywall",
  "screen": "paywall",
  "state": "expired-return",
  "title": "Paywall · expired-return",
  "description": "entitlement.status = expired on reopen: welcome back with the user's own progress (me.stats); projection uses current ability; no trial (not eligible).",
  "api": [
   "GET /v1/paywall (Accept-Language: labels, badge, weakest_label)",
   "GET /v1/me (entitlement, stats — expired-return)",
   "StoreKit Product.products(for:)",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions {signed_transaction}",
   "AppStore.sync() (restore)"
  ],
  "local": [
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedProductId",
   "local:storekit.products"
  ],
  "data_binds": [
   "AppStore.sync()",
   "AppStore.sync() → POST /v1/subscriptions/apple/transactions",
   "Product.purchase() → POST /v1/subscriptions/apple/transactions",
   "derived: introductoryOffer + price/12 (priceFormatStyle)",
   "derived: trial end = today + trial_days; storekit.displayPrice",
   "local:paywall.selectedWeeklyDays",
   "local:paywall.selectedWeeklyDays (default onboarding.weekly_days)",
   "me.focus.title",
   "me.stats.ability",
   "me.stats.completed_sessions",
   "paywall.benchmark_source = pilot_benchmark → benchmark_label",
   "paywall.legal.privacy_url",
   "paywall.legal.terms_url",
   "paywall.plan_preview.starts_on – ends_on",
   "paywall.products[kind=annual].badge (Accept-Language)",
   "storekit.subscriptionPeriod",
   "storekit[clarisay.pro.annual]",
   "storekit[clarisay.pro.annual].displayPrice",
   "storekit[clarisay.pro.monthly]",
   "storekit[clarisay.pro.monthly].displayPrice"
  ]
 },
 {
  "file": "screens/home/home--first-time.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "first-time",
  "title": "Home · first-time",
  "description": "First home after the paywall: streak 0, Lv.1 with diagnostic XP, quest = plan day 1, focus from the diagnostic.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.plan_day",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--normal.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "normal",
  "title": "Home · normal",
  "description": "Returning user, quest not started. Steps reflect the real stage (read = current, nothing pre-checked). Quest article is in a practice language.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--in-progress.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "in-progress",
  "title": "Home · in-progress",
  "description": "today_quest.status = in_progress: read & retell done, compress current; CTA resumes the session.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.session_id → GET /v1/sessions/{id}",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--completed.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "completed",
  "title": "Home · completed",
  "description": "Quest done today: result summary (needs CCR-2), streak flame filled, free practice offered.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.status",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.summary (CCR-2) → round1_overall / round2_overall / duration_delta_ms / xp_awarded",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--streak-risk.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "streak-risk",
  "title": "Home · streak-risk",
  "description": "After 20:00 local with today not done (streak.at_risk): gentle card, mentions the freeze, no guilt copy.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.at_risk + freezes",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--offline.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "offline",
  "title": "Home · offline",
  "description": "No network: cached /v1/home with timestamp; training still starts (article cached, upload queued).",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "NWPathMonitor + local:cache.home.fetchedAt",
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--no-quest.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "no-quest",
  "title": "Home · no-quest",
  "description": "today_quest = null (CCR-1): no eligible article today → send to library.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "home.display_name",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.streak.current / today_done / at_risk",
   "home.today_quest = null (CCR-1)",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--topic-empty.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "topic-empty",
  "title": "Home · topic-empty",
  "description": "Quick-pick topic with no articles → explicit empty state, never a mismatched article.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [
   "local:cache.home (offline read cache)",
   "local:home.selectedTopic"
  ],
  "data_binds": [
   "POST /v1/sessions {article_id, kind: daily}",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[topic=tech] = ∅ (topic.article_count = 0)",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/home/home--loading.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "loading",
  "title": "Home · loading skeleton",
  "description": "Skeleton mirrors the real layout so nothing jumps; shown only on cold start with no cache.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [],
  "data_binds": []
 },
 {
  "file": "screens/home/home--error.en.html",
  "lang": "en",
  "group": "home",
  "screen": "home",
  "state": "error",
  "title": "Home · error",
  "description": "GET /v1/home failed with a retryable error and no cache available.",
  "api": [
   "GET /v1/home (Accept-Language)",
   "GET /v1/config (cached 5 min)"
  ],
  "local": [],
  "data_binds": [
   "error.code",
   "error.message (Accept-Language)"
  ]
 },
 {
  "file": "screens/library/library--loading.en.html",
  "lang": "en",
  "group": "library",
  "screen": "library",
  "state": "loading",
  "title": "Library · loading",
  "description": "Skeleton rows. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)"
  ]
 },
 {
  "file": "screens/library/library--list.en.html",
  "lang": "en",
  "group": "library",
  "screen": "library",
  "state": "list",
  "title": "Library · list",
  "description": "All languages + topics: unseen / in progress / completed (best score) / locked tier. A language tag shows on each row when more than one practice language exists. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "articles.items[0].id",
   "articles.items[0].title",
   "articles.items[0].topic",
   "articles.items[0].topic.name / length.label / difficulty",
   "articles.items[0].user_status",
   "articles.items[1].best_overall",
   "articles.items[1].id",
   "articles.items[1].title",
   "articles.items[1].topic",
   "articles.items[1].topic.name / length.label / difficulty",
   "articles.items[2].best_overall",
   "articles.items[2].id",
   "articles.items[2].title",
   "articles.items[2].topic",
   "articles.items[2].topic.name / length.label / difficulty",
   "articles.items[3].id",
   "articles.items[3].title",
   "articles.items[3].topic",
   "articles.items[3].topic.name / length.label / difficulty",
   "articles.items[3].user_status",
   "articles.items[4].id",
   "articles.items[4].title",
   "articles.items[4].topic",
   "articles.items[4].topic.name / length.label / difficulty",
   "articles.items[4].user_status",
   "articles.items[5].id",
   "articles.items[5].title",
   "articles.items[5].topic",
   "articles.items[5].topic.name / length.label / difficulty",
   "articles.items[5].user_status",
   "articles.items[6].id",
   "articles.items[6].locked + unlock_hint (CCR-3)",
   "articles.items[6].title",
   "articles.items[6].topic",
   "articles.items[6].topic.name / length.label / difficulty",
   "articles.items[7].id",
   "articles.items[7].locked + unlock_hint (CCR-3)",
   "articles.items[7].title",
   "articles.items[7].topic",
   "articles.items[7].topic.name / length.label / difficulty"
  ]
 },
 {
  "file": "screens/library/library--filtered.en.html",
  "lang": "en",
  "group": "library",
  "screen": "library",
  "state": "filtered",
  "title": "Library · filtered",
  "description": "Topic filter = Product. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "articles.items[0].id",
   "articles.items[0].title",
   "articles.items[0].topic",
   "articles.items[0].topic.name / length.label / difficulty",
   "articles.items[0].user_status",
   "articles.items[1].best_overall",
   "articles.items[1].id",
   "articles.items[1].title",
   "articles.items[1].topic",
   "articles.items[1].topic.name / length.label / difficulty"
  ]
 },
 {
  "file": "screens/library/library--filter-empty.en.html",
  "lang": "en",
  "group": "library",
  "screen": "library",
  "state": "filter-empty",
  "title": "Library · filter-empty",
  "description": "Topic with 0 articles → empty state + clear filter. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/articles?topic=tech → items = []",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)"
  ]
 },
 {
  "file": "screens/library/library--error.en.html",
  "lang": "en",
  "group": "library",
  "screen": "library",
  "state": "error",
  "title": "Library · error",
  "description": "Retryable load failure. No import button in v1 (config.flags.import_enabled = false).",
  "api": [
   "GET /v1/topics (Accept-Language)",
   "GET /v1/articles?language=&topic=&status=&cursor=",
   "POST /v1/sessions {article_id, kind: free}"
  ],
  "local": [
   "local:library.filter.language",
   "local:library.filter.topic",
   "local:library.filter.status"
  ],
  "data_binds": [
   "GET /v1/articles?language=en",
   "GET /v1/articles?language=zh-Hans",
   "GET /v1/articles?status=all|unseen|completed",
   "GET /v1/topics → topics[].name / article_count (Accept-Language)",
   "error.code",
   "error.message (Accept-Language)"
  ]
 },
 {
  "file": "screens/read/read--normal.en.html",
  "lang": "en",
  "group": "read",
  "screen": "read",
  "state": "normal",
  "title": "Read · normal",
  "description": "1/3. Article body, language tag, length.label and est_read_seconds are server-computed per language (no hard-coded 字/words). CTA posts reading-complete, then hides the text.",
  "api": [
   "POST /v1/sessions {article_id, kind: daily}",
   "GET /v1/sessions/{id} (session.article, session.language)",
   "POST /v1/sessions/{id}/reading-complete {read_duration_ms}"
  ],
  "local": [
   "local:reader.openedAt (for read_duration_ms)"
  ],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.paragraphs[4]",
   "session.article.paragraphs[5]",
   "session.article.paragraphs[6]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/read/read--long-scroll.en.html",
  "lang": "en",
  "group": "read",
  "screen": "read",
  "state": "long-scroll",
  "title": "Read · long article, scrolled to end",
  "description": "Tier-2 article scrolled to the end: the last paragraph and source line sit fully above the CTA dock.",
  "api": [
   "GET /v1/sessions/{id}"
  ],
  "local": [],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.paragraphs[4]",
   "session.article.paragraphs[5]",
   "session.article.paragraphs[6]",
   "session.article.paragraphs[7]",
   "session.article.paragraphs[8]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/read/read--dynamic-type-xl.en.html",
  "lang": "en",
  "group": "read",
  "screen": "read",
  "state": "dynamic-type-xl",
  "title": "Read · Dynamic Type AX1 (≈135%)",
  "description": "Same screen at Dynamic Type ≈ AX1. Meta row wraps; reading style scales from 18pt.",
  "api": [
   "GET /v1/sessions/{id}"
  ],
  "local": [],
  "data_binds": [
   "session.article.byline",
   "session.article.est_read_seconds",
   "session.article.length.label",
   "session.article.paragraphs[0]",
   "session.article.paragraphs[1]",
   "session.article.paragraphs[2]",
   "session.article.paragraphs[3]",
   "session.article.paragraphs[4]",
   "session.article.paragraphs[5]",
   "session.article.paragraphs[6]",
   "session.article.reading_hint",
   "session.article.source.name + version",
   "session.article.title",
   "session.article.topic.name",
   "session.kind",
   "session.language",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--idle.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "idle",
  "title": "Retell · round 1 · idle",
  "description": "Mic button ink, CTA disabled. Recording never starts without a tap (D6).",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--recording.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "recording",
  "title": "Retell · round 1 · recording",
  "description": "Coral button = pause. Timer counts effective (unpaused) time. Persistent in-app recording indicator + iOS orange dot. CTA enabled at ≥ 20 s.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--paused.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "paused",
  "title": "Retell · round 1 · paused",
  "description": "Paused time is not counted. Waveform freezes grey. Submit allowed if ≥ 20 s.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--autostop.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "autostop",
  "title": "Retell · round 1 · autostop",
  "description": "Reached max duration; capture stopped automatically, confirm sheet. Haptic: warning.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--too-short.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "too-short",
  "title": "Retell · round 1 · too-short",
  "description": "User tried to finish (or the level meter saw silence) under 20 s effective. Continue keeps the same file.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.min_valid_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--permission-denied.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "permission-denied",
  "title": "Retell · round 1 · permission-denied",
  "description": "Microphone denied. No attempt is created. Deep link to Settings; article and stage preserved.",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "UIApplication.openSettingsURLString",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/record/record--interrupted.en.html",
  "lang": "en",
  "group": "record",
  "screen": "record",
  "state": "interrupted",
  "title": "Retell · round 1 · interrupted",
  "description": "Call / Siri / route change paused capture and persisted the partial file. Resume or re-record (D2).",
  "api": [
   "GET /v1/sessions/{id} (stage = round1, max_round1_ms, min_valid_ms, previous_focus, language)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--uploading.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "uploading",
  "title": "Processing · uploading",
  "description": "Stage 1/3. Presigned PUT in flight. Leaving is safe — the file stays queued.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--transcribing.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "transcribing",
  "title": "Processing · transcribing",
  "description": "Stage 2/3. Poll GET /v1/attempts/{id} every 2 s (backoff to 5 s). ASR receives session.language as a hint.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--assessing.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "assessing",
  "title": "Processing · assessing",
  "description": "Stage 3/3. ETA from attempt.progress.eta_ms when present.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--failed.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "failed",
  "title": "Processing · failed",
  "description": "attempt.status = failed with a retryable error at stage assess. Retry re-runs the stage without re-upload.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "POST /v1/attempts/{id}/discard",
   "POST /v1/attempts/{id}/retry (attempt.error.retryable)",
   "attempt.error.code + attempt.error.stage",
   "attempt.error.message (Accept-Language)",
   "attempt.progress.stage / stage_index / stage_count",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--unreliable.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "unreliable",
  "title": "Processing · unreliable",
  "description": "attempt.status = unreliable (too_short; floor per language: 40 chars zh-Hans / 30 words en). No score, no XP; free re-record.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "POST /v1/attempts/{id}/discard → record idle",
   "attempt.transcript.length.label vs per-language floor (D2)",
   "attempt.unreliable_reason = too_short",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--long-wait.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "long-wait",
  "title": "Processing · long-wait",
  "description": "Poll exceeded 120 s (config.limits.poll_timeout_ms). Offer 'check later'; the result lands in Home resume.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/processing/processing--offline.en.html",
  "lang": "en",
  "group": "processing",
  "screen": "processing",
  "state": "offline",
  "title": "Processing · offline",
  "description": "Network lost during upload. File persisted; upload resumes automatically when reachable.",
  "api": [
   "GET /v1/attempts/{id} (status, progress, error, unreliable_reason, transcript.length)",
   "POST /v1/attempts/{id}/retry",
   "POST /v1/attempts/{id}/discard"
  ],
  "local": [
   "local:uploadQueue.state",
   "NWPathMonitor.status"
  ],
  "data_binds": [
   "attempt.progress.eta_ms",
   "attempt.progress.stage / stage_index / stage_count",
   "attempt.status",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--full.en.html",
  "lang": "en",
  "group": "feedback",
  "screen": "feedback",
  "state": "full",
  "title": "Feedback · full",
  "description": "Summary → 4 dimensions (each with evidence) → one action (ink) → evidence → key points → model answer (labelled reference) → helpful?",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--low-confidence.en.html",
  "lang": "en",
  "group": "feedback",
  "screen": "feedback",
  "state": "low-confidence",
  "title": "Feedback · low-confidence",
  "description": "confidence = low: warning banner with confidence_note; a dimension without evidence shows 'not enough evidence' and does not lower the score; one action still shown.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.confidence + confidence_note",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--achieved.en.html",
  "lang": "en",
  "group": "feedback",
  "screen": "feedback",
  "state": "achieved",
  "title": "Feedback · achieved",
  "description": "achieved_previous_action = true: 'You did it ✓' banner comes first; the new one-action moves to the next micro-skill.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action + previous_action",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.evidence[id=ev_a1].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--playback.en.html",
  "lang": "en",
  "group": "feedback",
  "screen": "feedback",
  "state": "playback",
  "title": "Feedback · playback",
  "description": "Evidence row tapped: signed audio URL fetched, playback seeks to start_ms and stops at end_ms; row highlighted.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/audio → url; local:player.positionMs",
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.duration_ms",
   "exemplar.label",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--exemplar-pending.en.html",
  "lang": "en",
  "group": "feedback",
  "screen": "feedback",
  "state": "exemplar-pending",
  "title": "Feedback · exemplar-pending",
  "description": "TTS is async (voice per article language): exemplar.status = pending → skeleton + spinner; poll until ready.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.label",
   "exemplar.status = pending",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/feedback/feedback--exemplar-failed.en.html",
  "lang": "en",
  "group": "feedback",
  "screen": "feedback",
  "state": "exemplar-failed",
  "title": "Feedback · exemplar-failed",
  "description": "exemplar.status = failed: text fallback + retry; never blocks the second take.",
  "api": [
   "GET /v1/attempts/{id} → assessment",
   "GET /v1/attempts/{id}/exemplar",
   "GET /v1/attempts/{id}/audio",
   "POST /v1/assessments/{id}/feedback {helpful, reason?}"
  ],
  "local": [
   "local:player.state",
   "local:player.positionMs"
  ],
  "data_binds": [
   "GET /v1/attempts/{id}/exemplar → status",
   "POST /v1/assessments/{id}/feedback",
   "assessment.achieved_previous_action = false",
   "assessment.band_label (Accept-Language)",
   "assessment.dimensions.efficiency.note",
   "assessment.dimensions.efficiency.score",
   "assessment.dimensions.fluency.note",
   "assessment.dimensions.fluency.score",
   "assessment.dimensions.key_info.note",
   "assessment.dimensions.key_info.score",
   "assessment.dimensions.structure.note",
   "assessment.dimensions.structure.score",
   "assessment.evidence[0].note",
   "assessment.evidence[0].start_ms",
   "assessment.evidence[0].transcript_excerpt",
   "assessment.evidence[1].note",
   "assessment.evidence[1].start_ms",
   "assessment.evidence[1].transcript_excerpt",
   "assessment.evidence[2].note",
   "assessment.evidence[2].start_ms",
   "assessment.evidence[2].transcript_excerpt",
   "assessment.evidence[3].note",
   "assessment.evidence[3].start_ms",
   "assessment.evidence[3].transcript_excerpt",
   "assessment.evidence[4].note",
   "assessment.evidence[4].start_ms",
   "assessment.evidence[4].transcript_excerpt",
   "assessment.evidence[id=ev_1].start_ms",
   "assessment.evidence[id=ev_2].start_ms",
   "assessment.evidence[id=ev_3].start_ms",
   "assessment.evidence[id=ev_5].start_ms",
   "assessment.key_point_coverage[0].text",
   "assessment.key_point_coverage[1].text",
   "assessment.key_point_coverage[2].text",
   "assessment.one_action.evidence_id → evidence.start_ms",
   "assessment.one_action.example_sentence",
   "assessment.one_action.instruction",
   "assessment.one_action.micro_skill_id",
   "assessment.one_action.title",
   "assessment.overall",
   "assessment.rubric_version + model_ref + confidence",
   "assessment.summary (article language)",
   "derived: count(key_point_coverage.covered)",
   "exemplar.label",
   "exemplar.status = failed",
   "exemplar.text",
   "session.stage = round2",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--idle.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "idle",
  "title": "Second take · round 2 · idle",
  "description": "Mic button ink, CTA disabled. Recording never starts without a tap (D6). Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--recording.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "recording",
  "title": "Second take · round 2 · recording",
  "description": "Coral button = pause. Timer counts effective (unpaused) time. Persistent in-app recording indicator + iOS orange dot. CTA enabled at ≥ 20 s. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--paused.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "paused",
  "title": "Second take · round 2 · paused",
  "description": "Paused time is not counted. Waveform freezes grey. Submit allowed if ≥ 20 s. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--autostop.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "autostop",
  "title": "Second take · round 2 · autostop",
  "description": "Reached max duration; capture stopped automatically, confirm sheet. Haptic: warning. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--too-short.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "too-short",
  "title": "Second take · round 2 · too-short",
  "description": "User tried to finish (or the level meter saw silence) under 20 s effective. Continue keeps the same file. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.min_valid_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--permission-denied.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "permission-denied",
  "title": "Second take · round 2 · permission-denied",
  "description": "Microphone denied. No attempt is created. Deep link to Settings; article and stage preserved. Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "UIApplication.openSettingsURLString",
   "session.attempts[round=1].duration_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/compress/compress--interrupted.en.html",
  "lang": "en",
  "group": "compress",
  "screen": "compress",
  "state": "interrupted",
  "title": "Second take · round 2 · interrupted",
  "description": "Call / Siri / route change paused capture and persisted the partial file. Resume or re-record (D2). Round 2 shows the single previous advice on top — never the article.",
  "api": [
   "GET /v1/sessions/{id} (stage = round2, round1_assessment.one_action, max_round2_ms)",
   "POST /v1/sessions/{id}/attempts {round, duration_ms, size_bytes, sha256, pause_count} → presigned PUT",
   "PUT upload.url",
   "POST /v1/attempts/{id}/submit"
  ],
  "local": [
   "local:recorder.state (idle|recording|paused|stopped)",
   "local:recorder.effectiveMs",
   "local:recorder.levelDb (waveform)",
   "local:recorder.fileURL (persisted partial)"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/complete/complete--improved.en.html",
  "lang": "en",
  "group": "complete",
  "screen": "complete",
  "state": "improved",
  "title": "Complete · improved",
  "description": "verdict improved: celebration, round1→round2 block, XP bar fills 240→295, ability change (+2) shown separately from the +11 session delta.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/complete/complete--same.en.html",
  "lang": "en",
  "group": "complete",
  "screen": "complete",
  "state": "same",
  "title": "Complete · same",
  "description": "verdict same: neutral icon, no rays, no improvement bonus (struck through); ability may drop by 1 — shown honestly.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp"
  ]
 },
 {
  "file": "screens/complete/complete--shorter-lost.en.html",
  "lang": "en",
  "group": "complete",
  "screen": "complete",
  "state": "shorter-lost",
  "title": "Complete · shorter-lost",
  "description": "verdict shorter_but_lost_points: no praise. Lists the lost key point; next suggestion = keep_constraints.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.key_points_lost[] (article language)",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp"
  ]
 },
 {
  "file": "screens/complete/complete--level-up.en.html",
  "lang": "en",
  "group": "complete",
  "screen": "complete",
  "state": "level-up",
  "title": "Complete · level-up",
  "description": "level_after.level > level_before.level: full-screen ink overlay with lime badge and new title.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.level_after.xp_total / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/complete/complete--achievement.en.html",
  "lang": "en",
  "group": "complete",
  "screen": "complete",
  "state": "achievement",
  "title": "Complete · achievement",
  "description": "First-ever session: achievements_unlocked[0] sheet; ability tile shows 'building baseline 1/3'.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_sample_count",
   "settlement.achievements_unlocked[0].description",
   "settlement.achievements_unlocked[0].title",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.plan_day_completed",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/complete/complete--replayed.en.html",
  "lang": "en",
  "group": "complete",
  "screen": "complete",
  "state": "replayed",
  "title": "Complete · replayed",
  "description": "settlement.replayed = true (reopened): no animations, no +1 flourish; banner explains rewards were already granted.",
  "api": [
   "POST /v1/sessions/{id}/complete (Idempotency-Key) → Settlement",
   "GET /v1/sessions/{id} (attempt durations)"
  ],
  "local": [],
  "data_binds": [
   "session.attempts[round=1].duration_ms",
   "session.attempts[round=2].duration_ms",
   "session.kind",
   "settlement.ability_after (− ability_before)",
   "settlement.comparison.duration_delta_ms",
   "settlement.comparison.overall_delta",
   "settlement.headline (Accept-Language)",
   "settlement.level_after.level + title",
   "settlement.level_after.xp_into_level / xp_for_next",
   "settlement.next_suggestion.instruction",
   "settlement.next_suggestion.title",
   "settlement.replayed + settled_at",
   "settlement.round1_overall",
   "settlement.round2_overall",
   "settlement.streak.current + streak_incremented",
   "settlement.subline (Accept-Language)",
   "settlement.weekly_quests.quests[0]",
   "settlement.weekly_quests.quests[1]",
   "settlement.weekly_quests.quests[2]",
   "settlement.xp.awarded",
   "settlement.xp.breakdown[0].type",
   "settlement.xp.breakdown[0].xp",
   "settlement.xp.breakdown[1].type",
   "settlement.xp.breakdown[1].xp",
   "settlement.xp.breakdown[2].type",
   "settlement.xp.breakdown[2].xp",
   "settlement.xp.breakdown[3].type",
   "settlement.xp.breakdown[3].xp"
  ]
 },
 {
  "file": "screens/growth/growth--empty.en.html",
  "lang": "en",
  "group": "growth",
  "screen": "growth",
  "state": "empty",
  "title": "Growth · empty",
  "description": "growth.state = empty (0 sessions).",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.state = empty"
  ]
 },
 {
  "file": "screens/growth/growth--baseline.en.html",
  "lang": "en",
  "group": "growth",
  "screen": "growth",
  "state": "baseline",
  "title": "Growth · baseline",
  "description": "growth.state = baseline (1–2 sessions): 'building baseline n/3'; no ability, no skills grid.",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.dimensions[0].delta_30d",
   "growth.dimensions[0].label (Accept-Language)",
   "growth.dimensions[0].score",
   "growth.dimensions[1].delta_30d",
   "growth.dimensions[1].label (Accept-Language)",
   "growth.dimensions[1].score",
   "growth.dimensions[2].delta_30d",
   "growth.dimensions[2].label (Accept-Language)",
   "growth.dimensions[2].score",
   "growth.dimensions[3].delta_30d",
   "growth.dimensions[3].label (Accept-Language)",
   "growth.dimensions[3].score",
   "growth.next_unlock (Accept-Language)",
   "growth.recent_sessions[0]",
   "growth.recent_sessions[1]",
   "growth.sample_count (ability = null)",
   "growth.series[].week_start / value / sample_n",
   "growth.trend"
  ]
 },
 {
  "file": "screens/growth/growth--ready.en.html",
  "lang": "en",
  "group": "growth",
  "screen": "growth",
  "state": "ready",
  "title": "Growth · ready",
  "description": "Ability (EMA, ability-v1) with 30-day delta, weekly trend, 4 dimensions, micro-skill mastery, achievements, the 'next point' hook, recent sessions. Language chips appear because growth.languages has >1 entry (zh persona).",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.ability",
   "growth.ability_delta_30d",
   "growth.achievements[0] (Accept-Language)",
   "growth.achievements[1] (Accept-Language)",
   "growth.achievements[2] (Accept-Language)",
   "growth.achievements[3] (Accept-Language)",
   "growth.achievements[4] (Accept-Language)",
   "growth.achievements[5] (Accept-Language)",
   "growth.achievements[6] (Accept-Language)",
   "growth.dimensions[0].delta_30d",
   "growth.dimensions[0].label (Accept-Language)",
   "growth.dimensions[0].score",
   "growth.dimensions[1].delta_30d",
   "growth.dimensions[1].label (Accept-Language)",
   "growth.dimensions[1].score",
   "growth.dimensions[2].delta_30d",
   "growth.dimensions[2].label (Accept-Language)",
   "growth.dimensions[2].score",
   "growth.dimensions[3].delta_30d",
   "growth.dimensions[3].label (Accept-Language)",
   "growth.dimensions[3].score",
   "growth.micro_skills[0] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[1] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[2] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[3] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[4] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[5] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[6] (mastery = achieved/suggested, last 10 sessions)",
   "growth.next_unlock (Accept-Language)",
   "growth.recent_sessions[0]",
   "growth.recent_sessions[1]",
   "growth.recent_sessions[2]",
   "growth.sample_count + algo_version",
   "growth.series[].week_start / value / sample_n",
   "growth.trend",
   "me.level.level"
  ]
 },
 {
  "file": "screens/growth/growth--gap.en.html",
  "lang": "en",
  "group": "growth",
  "screen": "growth",
  "state": "gap",
  "title": "Growth · gap",
  "description": "Weeks with no sessions are drawn as gaps with a hollow marker — never interpolated. Single language → no language chips.",
  "api": [
   "GET /v1/growth?range=8w&language= (Accept-Language)"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/growth?range=4w|8w|12w&language=",
   "growth.ability",
   "growth.ability_delta_30d",
   "growth.achievements[0] (Accept-Language)",
   "growth.achievements[1] (Accept-Language)",
   "growth.achievements[2] (Accept-Language)",
   "growth.achievements[3] (Accept-Language)",
   "growth.achievements[4] (Accept-Language)",
   "growth.achievements[5] (Accept-Language)",
   "growth.achievements[6] (Accept-Language)",
   "growth.dimensions[0].delta_30d",
   "growth.dimensions[0].label (Accept-Language)",
   "growth.dimensions[0].score",
   "growth.dimensions[1].delta_30d",
   "growth.dimensions[1].label (Accept-Language)",
   "growth.dimensions[1].score",
   "growth.dimensions[2].delta_30d",
   "growth.dimensions[2].label (Accept-Language)",
   "growth.dimensions[2].score",
   "growth.dimensions[3].delta_30d",
   "growth.dimensions[3].label (Accept-Language)",
   "growth.dimensions[3].score",
   "growth.micro_skills[0] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[1] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[2] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[3] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[4] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[5] (mastery = achieved/suggested, last 10 sessions)",
   "growth.micro_skills[6] (mastery = achieved/suggested, last 10 sessions)",
   "growth.next_unlock (Accept-Language)",
   "growth.recent_sessions[0]",
   "growth.recent_sessions[1]",
   "growth.recent_sessions[2]",
   "growth.sample_count + algo_version",
   "growth.series[].week_start / value / sample_n",
   "growth.trend",
   "me.level.level"
  ]
 },
 {
  "file": "screens/profile/profile--normal.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "profile",
  "state": "normal",
  "title": "Me · anonymous user",
  "description": "Stats from GET /v1/me (same source as home → no 12 vs 13 mismatch); weekly quests; languages row; SIWA card for anonymous users.",
  "api": [
   "GET /v1/me",
   "GET /v1/weekly-quests"
  ],
  "local": [],
  "data_binds": [
   "derived: first grapheme of me.display_name",
   "me.auth_provider = anonymous",
   "me.display_name",
   "me.entitlement.status / product_id / trial_ends_at",
   "me.level.title + level",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.stats.ability",
   "me.stats.completed_sessions",
   "me.streak.current",
   "weekly_quests.quests[0] (title: Accept-Language)",
   "weekly_quests.quests[1] (title: Accept-Language)",
   "weekly_quests.quests[2] (title: Accept-Language)",
   "weekly_quests.week_start / week_end"
  ]
 },
 {
  "file": "screens/profile/weekly-quests--default.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "weekly-quests",
  "state": "default",
  "title": "This week's quests",
  "description": "Week boundaries in user tz, counting rules, XP per quest.",
  "api": [
   "GET /v1/weekly-quests"
  ],
  "local": [],
  "data_binds": [
   "weekly_quests.quests[0]",
   "weekly_quests.quests[1]",
   "weekly_quests.quests[2]",
   "weekly_quests.week_start / week_end / timezone"
  ]
 },
 {
  "file": "screens/profile/settings--default.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "settings",
  "state": "default",
  "title": "Settings",
  "description": "App language, practice languages, reminders, retention 7/30/365, privacy, delete data/account, subscription.",
  "api": [
   "GET /v1/me",
   "PATCH /v1/me {settings}"
  ],
  "local": [],
  "data_binds": [
   "AppStore.showManageSubscriptions",
   "Bundle version + config.rubric_version",
   "DELETE /v1/me",
   "DELETE /v1/me/training-data",
   "me.settings.audio_retention_days → PATCH /v1/me",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.settings.reminders_enabled",
   "me.settings.streak_risk_alert",
   "me.settings.ui_locale (null = follow device) → Accept-Language"
  ]
 },
 {
  "file": "screens/profile/settings--language.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "settings",
  "state": "language",
  "title": "Settings · languages",
  "description": "UI language (follow device or override → settings.ui_locale; drives Accept-Language) is separate from practice languages (settings.practice_languages, first = primary).",
  "api": [
   "GET /v1/config → supported_ui_locales / supported_practice_languages",
   "PATCH /v1/me {settings.ui_locale, settings.practice_languages}"
  ],
  "local": [],
  "data_binds": [
   "PATCH /v1/me {settings.ui_locale: en}",
   "PATCH /v1/me {settings.ui_locale: zh-Hans}",
   "me.settings.practice_languages[] ∋ en (GET /v1/config → supported_practice_languages)",
   "me.settings.practice_languages[] ∋ zh-Hans (GET /v1/config → supported_practice_languages)",
   "me.settings.ui_locale = null"
  ]
 },
 {
  "file": "screens/profile/settings--delete-data-confirm.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "settings",
  "state": "delete-data-confirm",
  "title": "Settings · delete training data",
  "description": "Destructive confirm with exact consequences (counts from me.stats).",
  "api": [
   "DELETE /v1/me/training-data → 202 {job_id, purge_backups_by}"
  ],
  "local": [],
  "data_binds": [
   "AppStore.showManageSubscriptions",
   "Bundle version + config.rubric_version",
   "DELETE /v1/me",
   "DELETE /v1/me/training-data",
   "DELETE /v1/me/training-data → {job_id, purge_backups_by}",
   "me.settings.audio_retention_days → PATCH /v1/me",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.settings.reminders_enabled",
   "me.settings.streak_risk_alert",
   "me.settings.ui_locale (null = follow device) → Accept-Language",
   "me.stats.completed_sessions"
  ]
 },
 {
  "file": "screens/profile/settings--delete-account-confirm.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "settings",
  "state": "delete-account-confirm",
  "title": "Settings · delete account",
  "description": "Warns that the App Store subscription is not cancelled automatically.",
  "api": [
   "DELETE /v1/me → 204"
  ],
  "local": [],
  "data_binds": [
   "AppStore.showManageSubscriptions",
   "Bundle version + config.rubric_version",
   "DELETE /v1/me",
   "DELETE /v1/me → 204 → POST /v1/auth/anonymous (fresh)",
   "DELETE /v1/me/training-data",
   "me.settings.audio_retention_days → PATCH /v1/me",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.settings.reminders_enabled",
   "me.settings.streak_risk_alert",
   "me.settings.ui_locale (null = follow device) → Accept-Language"
  ]
 },
 {
  "file": "screens/profile/siwa--sheet.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "siwa",
  "state": "sheet",
  "title": "Sign in with Apple upgrade",
  "description": "Anonymous → Apple upgrade keeps the same account (D5). 409 conflict handled per contract.",
  "api": [
   "POST /v1/auth/apple {identity_token, authorization_code, install_id}"
  ],
  "local": [],
  "data_binds": [
   "ASAuthorizationAppleIDProvider → POST /v1/auth/apple",
   "derived: first grapheme of me.display_name",
   "me.auth_provider = anonymous",
   "me.display_name",
   "me.entitlement.status / product_id / trial_ends_at",
   "me.level.title + level",
   "me.settings.practice_languages",
   "me.settings.reminder_time",
   "me.stats.ability",
   "me.stats.completed_sessions",
   "me.streak.current",
   "weekly_quests.quests[0] (title: Accept-Language)",
   "weekly_quests.quests[1] (title: Accept-Language)",
   "weekly_quests.quests[2] (title: Accept-Language)",
   "weekly_quests.week_start / week_end"
  ]
 },
 {
  "file": "screens/profile/privacy--default.en.html",
  "lang": "en",
  "group": "profile",
  "screen": "privacy",
  "state": "default",
  "title": "Privacy & AI disclosure",
  "description": "Plain-language disclosure required by D6 (third-party AI, language hint, no training, retention, deletion, analytics, age).",
  "api": [
   "GET /v1/me (settings.audio_retention_days)",
   "GET /v1/config"
  ],
  "local": [],
  "data_binds": [
   "me.settings.audio_retention_days + config.rubric_version"
  ]
 },
 {
  "file": "screens/global/resume-session--sheet.en.html",
  "lang": "en",
  "group": "global",
  "screen": "resume-session",
  "state": "sheet",
  "title": "Resume session",
  "description": "On launch/home when home.resume_session != null (or today_quest in progress after a kill).",
  "api": [
   "GET /v1/home → resume_session",
   "POST /v1/sessions/{id}/abandon"
  ],
  "local": [],
  "data_binds": [
   "GET /v1/sessions/{id} → route by stage",
   "POST /v1/sessions {article_id, kind: daily}",
   "POST /v1/sessions/{id}/abandon",
   "home.display_name",
   "home.focus.title (Accept-Language)",
   "home.greeting (Accept-Language)",
   "home.level.level",
   "home.level.title (Accept-Language)",
   "home.level.xp_into_level / xp_for_next",
   "home.local_date_label (Accept-Language)",
   "home.quick_picks[0].article.id",
   "home.quick_picks[0].subtitle",
   "home.quick_picks[0].title",
   "home.quick_picks[0].topic.glyph / color_token",
   "home.resume_session.article.title",
   "home.resume_session.round1_overall",
   "home.resume_session.stage + created_at",
   "home.streak.current / today_done / at_risk",
   "home.today_quest.article.title",
   "home.today_quest.article.topic.name / length.label / est_read_seconds",
   "home.today_quest.steps[0].state + label",
   "home.today_quest.steps[1].state + label",
   "home.today_quest.steps[2].state + label",
   "home.today_quest.xp_reward",
   "home.topics[0].name",
   "home.topics[1].name",
   "home.topics[2].name",
   "home.topics[3].name",
   "home.topics[4].name"
  ]
 },
 {
  "file": "screens/global/leave-recording--alert.en.html",
  "lang": "en",
  "group": "global",
  "screen": "leave-recording",
  "state": "alert",
  "title": "Leave recording confirm",
  "description": "Back during capture: capture pauses immediately, partial file kept (D2/F10).",
  "api": [],
  "local": [
   "local:recorder.*"
  ],
  "data_binds": [
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local discard (or POST /v1/attempts/{id}/discard if registered)",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.max_round1_ms",
   "session.previous_focus.title",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/global/offline--toast.en.html",
  "lang": "en",
  "group": "global",
  "screen": "offline",
  "state": "toast",
  "title": "Offline toast",
  "description": "Connectivity lost: non-blocking toast; recording continues locally.",
  "api": [],
  "local": [
   "NWPathMonitor"
  ],
  "data_binds": [
   "NWPathMonitor.status = unsatisfied",
   "derived: recorder.effectiveMs ≥ session.min_valid_ms",
   "local:recorder.effectiveMs",
   "local:recorder.state",
   "session.attempts[round=1].duration_ms",
   "session.max_round2_ms",
   "session.round1_assessment.one_action.example_sentence",
   "session.round1_assessment.one_action.instruction",
   "session.stage → derived step"
  ]
 },
 {
  "file": "screens/global/error--generic.en.html",
  "lang": "en",
  "group": "global",
  "screen": "error",
  "state": "generic",
  "title": "Generic error",
  "description": "Non-retryable / unknown error. Shows error.code and request id (CCR-6).",
  "api": [
   "ErrorEnvelope {code, message (Accept-Language), retryable, stage, details}"
  ],
  "local": [],
  "data_binds": [
   "error.code",
   "error.message (Accept-Language)"
  ]
 },
 {
  "file": "screens/global/force-update--default.en.html",
  "lang": "en",
  "group": "global",
  "screen": "force-update",
  "state": "default",
  "title": "Force update",
  "description": "Bundle version < config.min_app_version. Blocks the app.",
  "api": [
   "GET /v1/config"
  ],
  "local": [],
  "data_binds": [
   "config.min_app_version vs Bundle.shortVersion"
  ]
 }
]