Brand and design · Specs

Data binding — every visible data point, per screen

Every visible data point to its API field or local state, and the contract change requests.

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

Notation

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

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

General fallbacks (unless a row says otherwise):

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

Contract change requests

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

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

Onboarding

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

Diagnostic

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

Plan

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

Paywall

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

Home

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

Library

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

Read

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

Record (round 1, round 2)

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

Processing

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

Feedback

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

Complete

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

Growth

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

Profile & settings

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

Global

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