Brand and design · Specs
Paywall — logic, projection, copy, honesty
Projection formula, calendar rules, honesty rules.
Screens: screens/paywall/paywall--{default,loading-products,purchasing,purchase-failed,restored,expired-return}[.en].html. The prototype is interactive: open paywall--default.html, drag the slider or tap 3/5/7, and switch plans. Logic lives in screens/paywall/paywall.js. It reads three embedded JSON blocks with the same shapes as GET /v1/paywall, StoreKit Product, and the paywall keys of strings.json.
Position in the flow (ADR-001 D8)
diagnostic result → 14-day plan → paywall (hard) → trial → home. There is no close button. Nothing past it works without an entitlement (trial or paid). "Restore" stays visible at the top and in the footer. The free diagnostic result is not lost: it is stored server-side and becomes the first point in Growth after subscribing.
Layout, top to bottom
- Your diagnostic:
diagnostic.overallin display type, the band label, and the weakest dimension (weakest_label, localized). - Title: "14 days to master “Conclusion first”" (
one_action_title). - Projection card: slider 3/5/7 (default = onboarding
weekly_days), the projection sentence, a start→day-14 bar, the Pilot data tag whenbenchmark_source = pilot_benchmark, and the disclaimer "Based on people with a similar start. Your results will differ. Not a promise." - 14-day calendar: Monday-first grid of
plan_preview.days. Practice days for K are outlined, rest days greyed, today is ink. Markers: streak milestones the schedule can actually reach, the billing day, and the day-14 re-test. Below it, a timeline: Today (trial starts) → reminder the day before trial ends → billing date with the StoreKit price → day-14 re-test. - Plans: annual (default,
badgefrom API, trial line, per-month equivalent) and monthly. Fine print, Restore, Terms, Privacy. - CTA dock: "Start 7-day free trial" / "Subscribe for $13.99/month", with a caption that states the date and amount of the first charge.
Projection formula
N = baseline score = paywall.diagnostic.overall (expired-return: me.stats.ability → CCR-11)
B = bucket(N) = "<50" | "50-59" | "60-69" | "70-79" | "80+"
K = selected weekly days ∈ {3, 5, 7}
row = benchmarks.find(weekly_days == K && baseline_bucket == B)
if row == null || row.sample_n < 30: → "not enough people… we won’t predict" (no number at all)
else:
M = row.day14_avg_overall, S = row.sample_n, Δw = row.weakest_dim_avg_delta
en: "People who started at {B} and practised {K} days a week averaged {M} after 14 days ({S} people)."
zh: "起点 {B}的用户,每周练 {K} 天,14 天后平均达到 {M}(样本 {S} 人)。"
+ "{weakest dimension} rose by {Δw} on average."
- No interpolation, no extrapolation. The server's nightly cohort job owns the numbers. The client only selects a row. The API description says "client interpolates by bucket"; the design deliberately does not blend buckets, because a blended number has no real sample behind it.
- The bucket is shown as a range ("60–69"), not as the user's exact score. The cohort is people who started in that range.
- Minimum sample is 30 (design rule,
PAYWALL_MIN_SAMPLE). In the fixtures, bucket80+at K=7 has n=18, so a user starting at 80+ who picks 7 days sees "not enough data". benchmark_source = pilot_benchmarkalways renders the localizedbenchmark_label("Pilot data" / 试点数据). Withcohortthe tag disappears, but the disclaimer stays.
Fixture values (pilot, docs/api/examples/*/paywall.json), bucket 60–69: K3 → 70 (n 86), K5 → 74 (n 212), K7 → 77 (n 64).
Calendar rules
- Practice pattern per K, by plan-day index within each 7-day block: K3 = {0, 2, 4}, K5 = {0, 1, 2, 4, 5}, K7 = all. Day 1 (today) is always a practice day.
- Streak milestones are computed from that pattern (3 and 7 consecutive practice days). K5 shows a "3" on days 3 and 10 and never a "7". K3 shows none. We never promise a streak the schedule can't produce.
- Billing day = day
trial_days + 1(indextrial_days) for products with an eligible intro offer; otherwise today. The date anddisplayPriceboth come from StoreKit. - Trial reminder = day before billing. It is a local notification scheduled at purchase if notifications are on, and the timeline says so.
Prices and products
- Prices, currencies, periods and trial length are only read from StoreKit (
displayPrice,price,subscriptionPeriod,introductoryOffer,isEligibleForIntroOffer). Copy uses placeholders, never amounts. The fixture uses ¥396/¥60 (zh storefront) and $79.99/$13.99 (US storefront) as samples only; these mirror the intended App Store Connect prices (doubled 2026-10-17). - The per-month equivalent for annual is
price / 12formatted with the product'spriceFormatStyle. It is labelled "about". products[].is_defaultselects the default card.badgeis server-localized ("Best value" / 最划算). No other badges.- Purchase:
Product.purchase()→ verify JWS server-side viaPOST /v1/subscriptions/apple/transactions→ trustEntitlementfrom the response. Restore:AppStore.sync()→ same endpoint per transaction.
States
| State | What changes |
|---|---|
| loading-products | Product cards are skeletons and the CTA is disabled ("Loading prices…"). The projection and calendar work, since they come from /v1/paywall. |
| purchasing | CTA spinner "Connecting to the App Store…", cards locked. The system sheet is on top. |
| purchase-failed | Red inline alert: "Purchase didn’t go through. You weren’t charged." CTA = Retry. A user cancel uses the same alert with cancel copy and no error tone. |
| restored | Toast "Subscription restored", CTA "Start today’s session". |
| expired-return | "Mia, your trial ended on Oct 24" plus the user's own stats (sessions, ability, focus). The projection uses current ability. No trial (not eligible), CTA "Subscribe for $79.99/year". |
What makes it honest (ADR-001 D10)
Used:
- The user's own diagnostic score and weakest area. Nothing generic.
- Cohort numbers with sample size, a pilot label, a minimum-sample cut-off, and a "not a promise" disclaimer.
- The exact first-charge date and amount under the CTA and in the timeline, plus a reminder before the trial ends.
- A calendar that only shows milestones the selected schedule can reach.
- Restore, Terms, Privacy and cancellation instructions always visible.
Not used:
- No countdown timers, "offer ends", or fake scarcity.
- No decoy tier, no crossed-out "original" prices, no "save 83 %" against an inflated reference. The per-month equivalent is plain arithmetic.
- No pre-selected add-ons, no hidden close button, no shame copy ("No thanks, I don't want to improve").
- No social proof unless real (
social_proof.*is null in pilot, so it's hidden). - No auto-start of a purchase. Every purchase needs the system sheet confirmation.
Analytics (D12)
paywall_view {baseline_bucket, benchmark_source} · paywall_interact {control: weekly_days|product, value} · trial_start / purchase {product_id} · failures carry error_code. No prices or content in events.