Skip to content

Corpan 0.18.1#298

Open
skyl wants to merge 56 commits into
mainfrom
app-store-prep
Open

Corpan 0.18.1#298
skyl wants to merge 56 commits into
mainfrom
app-store-prep

Conversation

@skyl

@skyl skyl commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

PR Type

Enhancement, Tests


Description

  • Add monetization gates and daily locks

  • Enable trials, codes, purchase analytics

  • Redesign immersive Corpán Plus paywall

  • Add streak and store tooling


Diagram Walkthrough

flowchart LR
  gate["Shared paywall gate"]
  host["App host overlays"]
  offer["Subscription offer"]
  store["Store/code backend"]
  analytics["Funnel analytics"]
  gate -- "request unlock or daily lock" --> host
  host -- "renders" --> offer
  offer -- "trials, codes, purchases" --> store
  offer -- "tracks events" --> analytics
Loading

File Walkthrough

Relevant files
Enhancement
15 files
purchase.ts
Add trials, codes, review, analytics hooks                             
+417/-24
SubscriptionOffer.tsx
Enable trial framing and code redemption                                 
+295/-77
PaywallSheet.tsx
Replace themed dialog with immersive paywall                         
+196/-118
paywallGate.ts
Implement shared metered paywall gate                                       
+396/-0 
DailyLockOverlay.tsx
Add universal daily quota lock overlay                                     
+305/-0 
App.tsx
Wire daily locks and gate analytics                                           
+56/-0   
UpdatePrompt.tsx
Adjust update backdrop for browser support                             
+2/-1     
types.ts
Define shared monetization gate contracts                               
+183/-0 
index.ts
Export shared monetization helpers                                             
+18/-0   
streak.ts
Add shared pack streak tracking                                                   
+123/-0 
StreakBadge.tsx
Display current pack streak progress                                         
+41/-0   
bestFit.ts
Add onboarding best-fit selection logic                                   
+88/-0   
paywall.ts
Integrate pronunciation coach paywall gating                         
+24/-0   
hostApi.ts
Expose entitlement and paywall host seams                               
+94/-0   
analytics.ts
Add monetization funnel tracking events                                   
+134/-0 
Tests
2 files
paywallGate.test.ts
Cover shared paywall gate behavior                                             
+533/-0 
streak.test.ts
Test shared streak calculations                                                   
+123/-0 
Configuration changes
3 files
asc_monetization.py
Add App Store monetization tooling                                             
+1328/-0
play_monetization.py
Add Play Store monetization tooling                                           
+777/-0 
load_seed.py
Add offer code seed loader                                                             
+174/-0 
Additional files
101 files
PIPELINE_STATE.md +3/-0     
BOOK_META.md +27/-0   
00-title.md +1/-0     
01-where-rainforests-grow.md +51/-0   
02-the-weather.md +37/-0   
03-the-land-and-water.md +39/-0   
04-the-plants.md +53/-0   
05-the-animals.md +65/-0   
06-the-people.md +45/-0   
07-the-rainforest-today.md +35/-0   
08-closing.md +11/-0   
manifest.json +13/-0   
narration.yaml +239/-0 
segments.json +2566/-0
generate_segments.py +175/-0 
post_generate_fixup.py +201/-0 
run_lang_pipeline.sh +146/-0 
CONVENTIONS.md +56/-0   
LANG_PITFALLS.md +5/-0     
series.yaml +25/-0   
LANGS_EP5.txt +6/-0     
book.yaml +37/-0   
00-script.md +281/-0 
manifest.json +23/-0   
narration.yaml +756/-0 
segments.json +1823/-0
apply_pacing.py +121/-0 
audio_gate.py +107/-0 
audit_translation.py +74/-0   
concat_with_pauses.py +60/-0   
drive_concept_update.sh +20/-0   
drive_remaining.sh +36/-0   
find_leading_silence.py +22/-0   
fixup_short_reactions.py +91/-0   
generate_cover.py +78/-0   
generate_dialog_segments.py +170/-0 
merge_concept.py +29/-0   
phonetics.py +380/-0 
recover_drops.sh +80/-0   
reset_segments.py +18/-0   
run_lang_pipeline.sh +116/-0 
update_concept_lang.sh +49/-0   
CLAUDE.md +7/-3     
CORPAN_PLUS_AGENT_BRIEFING.md +12/-7   
CHANGELOG.md +60/-0   
ANALYTICS_FUNNEL.md +107/-0 
common.json +15/-0   
ContentPackOverlay.tsx +11/-0   
MainExperience.tsx +69/-1   
OnboardingFinish.tsx +18/-4   
RatingPrompt.tsx +2/-1     
SettingsModal.tsx +2/-2     
HomeHub.tsx +2/-2     
PackCard.tsx +7/-1     
ContentPackHost.tsx +7/-13   
types.ts +96/-0   
OnboardingEngine.tsx +6/-0     
graph.ts +35/-6   
types.ts +6/-0     
paywall.ts +43/-5   
rating.ts +56/-1   
browser.ts +17/-0   
APPLE_OFFER_SETUP_BRIEF.md +106/-0 
PHASE3_CODES_CONTRACT.md +512/-0 
STORE_AUTOMATION.md +65/-0   
README.md +235/-0 
seed.json +23/-0   
generate-catalog-assets.py +20/-0   
patch-catalog.py +9/-0     
README.md +125/-0 
PRICING_RESEARCH.md +197/-0 
pricing-matrix.json +97/-0   
PHASE3_APPLY_BRIEF.md +69/-0   
codes.js +870/-0 
codes.test.js +544/-0 
package.json +5/-0     
verify_purchase.js +264/-38
main.tf +93/-0   
outputs.tf +10/-0   
presence_ec2.tf +6/-0     
README.md +73/-0   
analyze.py +122/-0 
battery.py +32/-0   
evaluate.py +168/-0 
judge_export.py +64/-0   
langs.py +116/-0 
metrics.py +310/-0 
prompts.py +65/-0   
report.py +117/-0 
requirements.txt +5/-0     
REPORT.md +102/-0 
recommendations.json +78/-0   
run.py +201/-0 
search.py +81/-0   
server.py +129/-0 
setup.sh +12/-0   
test_harness.py +93/-0   
App.tsx +19/-1   
Shell.tsx +26/-1   
tsconfig.json +5/-1     
Additional files not shown

skyl added 30 commits June 13, 2026 21:27
Two foundational primitives for the monetization overhaul (plan: get
people to pay via aha-moment onboarding, interaction-gated paywall
moments, free trial, codes).

- packs/shared/monetization: createPaywallGate — a reusable, versionless
  gate every pack uses to surface the paywall at natural interaction
  boundaries, never interrupting active use. Modes: action-count,
  daily-quota (DAU lever), time-armed (fires only on the next interaction
  after the interval). Subscriber = total no-op; soft/hard hardness;
  per-session backstop cap; localStorage-persisted, robust to bad storage.
  Folds in the tutomaton/teletron daily-quota shape. 20 vitest cases
  (run via corpan-city, the shared-test host).

- corpan-app host seam: hostApi.entitlement.{isSubscribed,snapshot,onChange},
  requestPaywall(context)->bool (reuses paywall store guards), and
  showRatingPrompt(). New per-pack PaywallSurface values. Purely additive —
  __CORPAN_PLUS/__CORPAN_ENTITLEMENT globals + corpan:request-unlock event
  untouched. SDK type mirror updated. tsc clean.
Free trial (the carrot):
- purchase.ts introOfferFromProduct() + periodLabelFromIso(): normalize
  iOS (offerDetails split) and Android (pricing-phase micros) intro/trial
  offers into { kind: free_trial|intro_price, periodLabel, cycles }.
- SubscriptionOffer renders premium trial framing ("7 days free, then
  $X · no payment due now", trial timeline, "Start Free Trial" CTA);
  degrades to today's plain-price UI when no offer is configured, so it
  lights up once store offers exist (Phase 0). handleSubscribe unchanged —
  the store applies the configured offer automatically.
- Known iOS native gap (tracked): plugin drops introOffer.paymentMode and
  hardcodes intro micros to 0, so iOS detection uses a price-string
  heuristic until the plugin is patched.

Onboarding aha-launch (value before paywall):
- New onboarding/bestFit.ts reuses Home's rankExperiences ranking
  (interests+userClass+language) to pick the top LAUNCHABLE experience;
  preview packs (corpan_city, teletron) blocklisted; falls back to the
  phrase experience, then Home.
- commitDraft sets the existing landing intent to {experience, packId};
  App.tsx's existing landing consumer launches via the same handleLaunchGame
  path Home uses. Exit → Home as normal. Visible "Explore on my own" escape.
- analytics: app_onboarding_launch{target}.

New strings added as t(key,{defaultValue}); locale JSON untouched (one x50
pass batches localization at end of Phase 1).
Reference integrations of @shared/monetization's createPaywallGate, one per
mode (alias registered per pack like @shared/moderation; gate uses defaults —
__CORPAN_PLUS globals + corpan:request-unlock):

- tutomaton (daily/hard, DAU lever): replaces the bespoke tutomaton.quota
  with a daily gate (limit 20, hard). 20 free messages/local day → blocked
  until tomorrow or subscribe; subscriber unlimited. isSubscribed wired to
  the pack's live `plus` (host injects Plus at mount, which the gate default
  wouldn't see). Old quota helpers removed; persistence key moves to
  corpan:gate:tutomaton:tutomaton_daily.
- beatlounge (timed/soft): one gate at rig build (5 min interval), disposed
  with the rig. Interaction funnel = a single capture-phase pointerdown on the
  shell root (NOT the command bus, which also carries AutoConductor/LLM
  dispatches — counting those would make it a quasi-timer). Never stops
  playback; the next tap after 5 min surfaces the paywall, dismiss → continue.
- hover-runner (action/soft): note() on each completed phrase (limit 5),
  onInteraction() on a window pointerdown. Every ~5 phrases the next tap
  surfaces the paywall, then play continues.

typecheck clean all three; beatlounge 1232/1232, hover-runner 16/16,
tutomaton tests green (1 pre-existing baseline failure unrelated).
Device-verify: beatlounge immersive pages + command palette register taps
inside .bl-shell. manifest devRevision (watcher artifact) left uncommitted.
Phase 0 store-setup tooling so the founder doesn't hand-click the consoles.

- infra/play/play_monetization.py: reuses the verify lambda's Google service
  account (AWS Secrets Manager corpan/content-packs/verify) to list
  subscriptions/base plans/offers (read-only), create+activate free-trial
  OFFERS (a base-plan offer, no Console/backward-compat needed), and flip the
  legacyCompatible flag (which gates promo codes). Writes are dry-run until
  --yes. Verified live: `list` works; the trial offer body validates. Blocked
  only on granting [email protected] the
  manage-monetization permission in Play Console (currently read-only).
  Promo CODE generation has no Google API — Console-only (path in README).
- infra/APPLE_OFFER_SETUP_BRIEF.md: complete brief for a browser agent to set
  up, on App Store Connect for monthly + annual: 7-day free trial (Introductory
  Offer = Free, 1 week), one-time-use FREE offer codes, and discount affiliate
  custom offer codes. Notes the App Store Connect API path if we'd rather script it.
Working SubscriptionOffer shape, dialed in against the live API:
- per-phase pricing: free in an anchor region (default US) + otherRegionsConfig.free
- offer-level availability: regionalConfigs[{anchor, newSubscriberAvailability}] +
  otherRegionsConfig.otherRegionsNewSubscriberAvailability=true (global, avoids
  enumerating non-billable regions like MN at regionsVersion 2022/02)
- acquisition scope: thisSubscription (new-subscriber trial; specificSubscriptionInApp
  is rejected for acquisition rules)
- fetches base-plan regions to choose a valid anchor; --anchor-region flag

7-day free trials now ACTIVE on corpan.sub.monthly + corpan.sub.annual.
Document driving Google Play + App Store Connect by API (creds in AWS
Secrets Manager, never the repo). Guard infra/asc against committing any
.p8 / AuthKey / venv. Secret-free (repo is open source).
Apple twin of infra/play. ES256-JWT auth (Key ID + Issuer ID + .p8 from the
appStoreConnect secret key; nothing hardcoded — open-source repo). Subcommands:
list / pricepoints / trial (FREE_TRIAL intro offer) / code-free (one-time-use
free offer codes + CSV) / code-discount (custom affiliate code). Endpoints
verified vs Apple's OpenAPI spec; per-territory intro-offer + price-point
discount nuances flagged for live verification. Dry-run until --yes.
Pending: populate the appStoreConnect secret (needs the Issuer ID), then run.
- code-free: FREE_TRIAL offer codes must NOT bind a subscriptionPricePoint
  (Apple 409s otherwise). Send territory-only price entries (null point).
  Verified live: created annual influencer (1yr) + giveaway (1mo) batches.
- code-discount: resolve the REAL per-territory price point nearest
  (1-pct/100)x base (Apple has no raw %-off). Verified live: USA 12.99 ->
  9.09 = exactly -30%, PAY_AS_YOU_GO. months/periods express '30% off for
  N months'. New read helpers: subscription_prices / price_points /
  nearest_point.
- gitignore *.csv (generated codes are secrets — never in the repo).
Apple rejects lowercase and <4-char custom codes (409). Uppercase and
validate up front so we never create an offer-code config then fail the
custom-code attach (leaving a dangling empty config).
…v-determined)

Creates a base-plan offer per affiliate code for Google: relativeDiscount
0.7 (=30% off), offerTags [code-<lowercased>], and NO targeting (omitted) =
developer-determined, so Play never auto-shows it — only applied when the app
passes the matching offerToken after the backend validates the typed code.
Attribution via subscriptionsv2.get -> lineItems[].offerDetails.offerId/Tags.
Annual: use --months 1 (recurrenceCount counts billing cycles). Verify live:
relativeDiscount accepted + empty-targeting=dev-determined.
Single source of truth for the affiliate/discount codes backend: exact
/code/resolve (4 branches, no fail-open) + /entitlement-token JSON, HMAC-JWT
resolutionToken signing, /verify-purchase additions (Apple JWS offer fields,
Google subscriptionsv2, idempotent PURCHASE# + first-verified-touch ATTRIBUTION
lock + LEDGER), ASSN-V2/RTDN renewal handlers, DynamoDB single-table corpan-iap
+ GSI1, the 8-code seed.json, and the WS-A..WS-E file-ownership map.
…16)

WS-D (Phase 3). New present_offer_code_redeem_sheet command wired end-to-end
(Rust command/mobile/lib + Swift AppStore.presentOfferCodeRedeemSheet iOS16
w/ SKPaymentQueue fallback + Android reject + desktop stubs + guest-js +
permissions). And get_products now surfaces the intro offer's paymentMode
(freeTrial/payAsYouGo/payUpFront) + real priceAmountMicros + period/cycles
(closes #16; additive — Android pricingPhases shape unchanged). cargo check
clean; 37 plugin tests pass. Swift needs an Xcode build to verify.
DynamoDB corpan-iap (PAY_PER_REQUEST, PITR) + GSI1 (obfHash) for the Google
renewal reverse-map; IAM GetItem/PutItem/UpdateItem/Query on table+GSI for the
verify lambda; 2 new routes POST /code/resolve + /entitlement-token on the
existing API (lambda routes by routeKey); DYNAMO_TABLE env; table outputs.
CRITICAL: added lifecycle ignore_changes=[secret_string] so apply can't wipe
the live secret (real values incl. codeSigning.hmacKey set out of band).
infra/codes/{seed.json (8 partners+8 codes), load_seed.py (idempotent
BatchWrite, dry-run until --yes)}. terraform validate Success; fmt clean.
Build-only — not applied.
resolveAffiliateCode -> resolveCode hitting POST /code/resolve; the dangerous
client FAIL-OPEN is deleted (any non-2xx/network/missing-token -> status:error,
never 'ok'). Thread resolutionToken into verifyPurchase. Android USE_OFFER_TOKEN:
re-read the live session-bound offerToken from getProducts by offerId; Apple
REDEEM_APPLE_SHEET: invoke plugin:iap|present_offer_code_redeem_sheet (guarded,
falls back to appleRedeemUrl). SubscriptionOffer: un-hide the code field after
the plan selector, debounced resolve, CTA branched by purchaseAction, render
discountLabel/partnerName, 'Couldn't check - Retry' on error (never 'valid').
New strings via t(defaultValue); locale JSON deferred to the x50 pass. tsc clean.
New codes.js: HS256 (hand-rolled via crypto, no new runtime dep) resolution +
entitlement tokens, code classification, DynamoDB registry/ledger access
(DocumentClient + GSI1 reverse-map), and the idempotent attribution flow
(PURCHASE# dedupe -> first-verified-touch ATTRIBUTION lock -> initial LEDGER
credit), plus per-subject/IP rate limiting.
verify_purchase.js (minimal additive): /code/resolve + /entitlement-token route
cases; capture Apple JWS offer fields; Google -> subscriptionsv2.get
(obfuscatedExternalAccountId + lineItems offerId/offerTags); validate
resolutionToken; ASSN-V2 JWS-verified DID_RENEW + RTDN OIDC-verified
SUBSCRIPTION_RENEWED renewal credits. Attribution is always non-fatal — never
blocks entitlement. 28/28 unit tests (mocked Dynamo/secret); node --check clean.
Deps: @aws-sdk/client-dynamodb + lib-dynamodb.
Tiered, Duolingo-benchmarked regional pricing (8 tiers, 166 territories,
iOS>=Android, annuals ~7x monthly) in infra/pricing/{pricing-matrix.json,
PRICING_RESEARCH.md}. Tools consume the matrix:
- play set-prices: convertRegionPrices (USD->local+tax) -> read-modify-write
  subscriptions.patch on only targeted regions' price. New purchases only.
- asc set-prices: USA-anchor rung -> Apple equalizations (Apple's own FX+.99
  rounding) -> POST subscriptionPrices per territory; preserveCurrentPrice ON
  by default (existing subs untouched). ISO2->ISO3 map added.
Both dry-run until --yes. Live application gated on founder review.
…tions)

The anchor territory (USA) isn't in its own equalizations response, so it was
skipped ('no equalized rung'). Use the USA anchor rung directly for USA.
Verified live dry-run: 6/6 territories resolve (USA $99.99 etc.).
…safety)

convertRegionPrices can return a currency the base plan doesn't expect at
regionsVersion 2022/02 (e.g. Bulgaria -> EUR vs expected BGN), and the
subscriptions.patch is atomic — one mismatch rejected all 148 regions. Now
skip + log any region whose converted currency != the base plan's existing
currency (keep its current price), so the rest apply.
…bust drops

The frozen 2022/02 version conflicts with Google's live catalog (Bulgaria->EUR,
Cote d'Ivoire->XOF currency migrations; MN billability), failing the atomic
patch. Use the LATEST version 2025/03 (the API reports it) so currencies match;
plus a retry loop that reprices EUR-pegged currencies and excludes genuinely
non-billable regions, so the patch always lands. Applied live: monthly+annual,
173 regions each. (Apple side: 174/174 monthly, 173/174 annual, effective 06-16.)
These are direction we believe in, not contractual absolutes to ship in
user-facing copy. Avoid "forever / no X ever / always" marketing claims.
New series scaffolding plus the first book — manuscript, pack config,
segment-generation scripts. Catalog metadata + cover-prompt entry added
to corpan infra so the book registers on next catalog regen.
Manuscript, dialog-pack scaffolding (vindy-ron-gemini-v1), and the
per-language pipeline scripts. PIPELINE_STATE logs Ep 5 status:
9 langs at v0.1.0, batch3 in flight, ja/ko dropped (translator/Gemini
issues), batches 4–10 not yet launched.
Universal paywall: PaywallSheet rewritten as ONE dark, full-screen immersive
paywall used on every surface (reader EOF, pack gates, library, settings,
onboarding) — the per-pack earthgate/stargate theming is gone. Corpán mark,
free-trial hero ("7 days free, then $X · no payment due now" + timeline),
squared plan selector w/ annual savings, the offer/affiliate code field below
the CTA, restore + cancel-anytime + terms, restrained motion (respects
prefers-reduced-motion), RTL-correct. SubscriptionOffer gains a `chromeless`
prop (re-housed in the dark shell; purchase/resolveCode/restore logic
untouched). `theme` still flows for caller compat but is ignored.

Funnel analytics: full taxonomy (pack_enter/exit, gate_hit{pack,surface,mode},
paywall_shown/dismissed/converted, paywall_cta_tapped, code_field_opened,
code_resolved/redeemed, trial_started, subscription_purchased{plan,platform,
code}, subscription_restored) wired across ContentPackOverlay (dwell time),
App.tsx (gate_hit from the request-unlock listener), store/paywall.ts,
purchase.ts, + an optional onFire hook on the shared gate. Anonymous + opt-in
(same emit()/consent gate as existing events; session-UUID only, no PII).
Docs: corpan-app/docs/ANALYTICS_FUNNEL.md. Strings via defaultValue (loc later).
tsc clean; gate tests green.
Gate helper: dailyLimit (hard per-local-day cap, resets at local midnight — the
DAU model) + softNagEvery (dismissible request-unlock nags before the cap =
"soft, soft, hard") + unitLabel. At the cap, dispatches corpan:daily-locked
{packId,surface,doneToday,limit,resetAt,unitLabel} once/day; isBlocked() true
(daily cap is inherently hard) until next local day or subscribe. New resetAt()
helper; onFire observes the lock; subscriber = no-op. 29 gate tests (20 + 9).

Host: new DailyLockOverlay — universal, matches the dark premium paywall +
Corpán mark; green spring-in checkmark (accomplishment, not error), live
countdown to reset, "Go further, faster with Corpán Plus" CTA → the universal
paywall; quiet "Maybe later" (stays locked till reset/subscribe). App.tsx
renders one instance off the corpan:daily-locked event (subscriber-guarded).
analytics: daily_lock_shown / daily_lock_upgrade_tapped (anon/opt-in). Strings
via defaultValue. Per-pack config = pack-release-tunable. tsc clean.
Apply the daily-quota model (hard per-local-day cap + "soft, soft, hard" nags
→ the accomplishment lock) across packs. Per-pack limit in one named constant
(pack-release-tunable). Subscribers = no-op.
- tutomaton: migrated to v2 daily (20 messages/day, nag@5).
- hover-runner: soft-action → daily (20 phrases/day, nag@5).
- juice-squeeze: wired (20 phrases/day, nag@5).
- pronunciation-coach/parlometron: wired (15 rounds/day, nag@5; solo + MP share
  one daily count via the surface).
- phrase-flip (corpan-app MainExperience): wired (20 phrases/day, nag@5; counts
  forward handleNext only, backward review free).
- hanzipan (JS pack): wired (20 characters/day, nag@5; counts on stroke-complete).
- beatlounge left timed-soft (daily TIME cap = a follow-up gate mode);
  world-radio/readers untouched.
typecheck clean across packs + corpan-app; pack tests green (tutomaton's 1
pre-existing prompts.test failure is unrelated).
The presence host's hostname encodes its public IP
(presence.3-142-26-37.sslip.io) and serves long-lived WebSockets, so it must
only be replaced on purpose. The AL2023 ARM64 `data "aws_ami"` lookup ships
a new AMI roughly weekly, which was triggering a force-replace on every
plan. Add `lifecycle { ignore_changes = [ami] }` so the live AMI is treated
as ground truth; AMI rolls become an explicit, scheduled operation instead
of a side effect of unrelated applies.
skyl added 3 commits June 15, 2026 00:54
…s tutomaton-only)

note() counted but only tutomaton checked isBlocked(), so the lock overlay showed
once and the user continued unbounded. Add gate.requestDailyLock() (re-show the
DailyLockOverlay on a blocked tap, bypassing the once-per-day note guard) and guard
each action boundary: core phrase-flip handleNext + Random, hover-runner round
transition, juice-squeeze reload, pronunciation-coach solo+multiplayer startRecording
(mic disabled 'Done for today'), hanzipan goNext (fresh chars only). Subscribers
bypass via isBlocked()->isSubscribed(); exactly dailyLimit actions then blocked. 31/31.
…ishment lock copy

New packs/shared/streak primitive (recordPackVisit/getPackStreak, consecutive
local-day logic, per-pack localStorage, corpan:streak-changed). Host records a
visit on pack-enter + phrase-flip; hostApi.getStreak() exposes it; StreakBadge
shows it on Home tiles for everyone (>=2 days), never gated. DailyLockOverlay
reworded to accomplishment + streak: 'Your {count} {unit} for today' / '{days}-day
streak — come back tomorrow to keep it going' / 'Continue now with Corpán Plus'.
EN source only (localization deferred). 12/12 streak tests, app tsc clean.
Evidence-based pass over all 55 advertised teaching languages using the new
infra/tutomaton-eval harness (faithful llama.cpp replication of the plugin's
ChatML + sampler chain; programmatic gate + native-level Claude fluency judging
at the best parameters).

- Retune global model defaults to the shipped Qwen3-4B Q4_K_M: temperature
  0.6->0.3, topP 0.95->0.9, minP 0->0.05, repeatPenalty 1.0->1.1. Lower-temp/
  tightened sampling cut fabrication + repetition loops in weak languages
  (rescued Marathi) and was neutral-to-positive on strong ones.
- Drop 5 languages the model cannot teach acceptably even at best params:
  Telugu (fabricated vocab), Swahili (word-salad/loops), Sundanese + Javanese
  (answers in Indonesian), Punjabi-Shahmukhi (answers in Urdu). 55 -> 50.
- Fix already-stale prompts.test count; bump manifest+pkg to 0.6.0; changelog.

Harness + report: infra/tutomaton-eval (REPORT.md, recommendations.json).
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Trial Detection

introOfferFromProduct only treats a zero-micros phase as a free trial when the formatted price has no digits. If Play Billing returns a free-trial phase as priceAmountMicros: 0 with a localized numeric display like $0.00, the offer is not classified as a free trial, so the trial CTA/framing is skipped even though the store offer exists.

// Free trial: explicit zero micros (Android) OR a no-digit display price
// when micros are unreliable (iOS, which sends 0 for every phase).
const isFreeByMicros = microsKnown && micros === 0 && isFreeDisplayPrice(phase.formattedPrice)
const isFreeByDisplay = isFreeDisplayPrice(phase.formattedPrice)
if (isFreeByMicros || isFreeByDisplay) {
  return {
    kind: "free_trial",
    periodLabel: periodLabelFromIso(phase.billingPeriod),
    cycles: phase.billingCycleCount ?? 1,
  }
Retry No-op

The retry button trims the current code, but when the value is already trimmed React receives the same state value and the resolve effect does not rerun. After a transient /code/resolve failure, tapping Retry can therefore do nothing unless the user edits the field.

<button
  type="button"
  onClick={() => setAffiliateCode((c) => c.trim())}
  className="underline underline-offset-2 hover:text-foreground"

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix free-trial detection

Android free trials are commonly formatted as "$0.00", which contains digits, so
isFreeByMicros currently rejects the authoritative zero-micros signal. Use the
recurring paid phase to distinguish Android zero-priced trials from the iOS plugin's
all-zero micros behavior.

corpan/corpan-app/src/contentPacks/purchase.ts [412-420]

-const isFreeByMicros = microsKnown && micros === 0 && isFreeDisplayPrice(phase.formattedPrice)
+const isFreeByMicros =
+  microsKnown &&
+  micros === 0 &&
+  typeof recurringMicros === "number" &&
+  recurringMicros > 0
 const isFreeByDisplay = isFreeDisplayPrice(phase.formattedPrice)
 if (isFreeByMicros || isFreeByDisplay) {
   return {
     kind: "free_trial",
     periodLabel: periodLabelFromIso(phase.billingPeriod),
     cycles: phase.billingCycleCount ?? 1,
   }
 }
Suggestion importance[1-10]: 8

__

Why: This correctly identifies a real bug: Android zero-priced trial phases may have formatted prices like "$0.00", so requiring isFreeDisplayPrice() defeats the authoritative priceAmountMicros === 0 signal. The proposed recurringMicros > 0 guard also preserves the intended protection against the iOS plugin's all-zero-micros behavior.

Medium
Prevent reload quota bypass

Do not exempt the initial load from paywallGate.isBlocked(). A user who is already
capped can reload the pack and receive a fresh solvable phrase, allowing them to
exceed JUICE_DAILY_LIMIT.

corpan/packs/juice-squeeze/src/game.ts [2186-2874]

-const createWordBlocks = async (opts?: { initial?: boolean }) => {
+const createWordBlocks = async () => {
   // Hard daily cap: loading a NEW phrase to solve is the metered action.
   // Once the free user has reached JUICE_DAILY_LIMIT completed phrases they
   // get EXACTLY that many — re-show the accomplishment-lock overlay instead
-  // of loading another. The initial mount load is exempt (so a returning,
-  // already-capped user still sees the board), and subscribers never block.
-  if (!opts?.initial && paywallGate.isBlocked()) {
+  // of loading another. Subscribers never block.
+  if (paywallGate.isBlocked()) {
     paywallGate.requestDailyLock()
     return
   }
 ...
-createWordBlocks({ initial: true }).catch((err) => {
+createWordBlocks().catch((err) => {
   console.error("[juice-squeeze] Failed to load utterances:", err)
 })
Suggestion importance[1-10]: 8

__

Why: The concern is valid because exempting the initial createWordBlocks call can load a fresh solvable phrase for a user who is already capped, allowing one extra completion past JUICE_DAILY_LIMIT. Removing the initial exemption better enforces the hard daily quota.

Medium
Avoid deleting regional configs

Do not remove a rejected region's regionalConfigs entry from the patch body. With
updateMask="basePlans", deleting that entry can unintentionally make the base plan
unavailable or clear its regional configuration; abort or restore the original
config instead.

corpan/infra/play/play_monetization.py [696-698]

-રconfigs[:] = [r for r in rconfigs if r.get("regionCode") != bad]
-by_region.pop(bad, None)
-dropped.append(bad)
+if bad in by_region:
+    if orig_price.get(bad) is None:
+        by_region[bad].pop("price", None)
+    else:
+        by_region[bad]["price"] = orig_price[bad]
+raise SystemExit(
+    f"set-prices: store rejected region {bad}; aborting to avoid removing its regionalConfig. "
+    "Fix or skip it explicitly before retrying."
+)
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies that removing a regionalConfigs entry while patching with updateMask="basePlans" may unintentionally remove regional availability/configuration. The proposed fail-safe behavior is conservative but appropriate for avoiding destructive store configuration changes.

Medium
Re-show daily lock correctly

Use quotaGate.requestDailyLock() here instead of quotaGate.onInteraction(). For
dailyLimit hard caps, onInteraction() is the legacy paywall seam and may not
re-dispatch the corpan:daily-locked overlay for an already-blocked user.

corpan/packs/tutomaton/src/chat.ts [1531-1537]

 if (quotaBlocked()) {
-  // Hard daily cap reached: surface the paywall (the gate fires it when
-  // armed) and don't send. Free messages return tomorrow, or with Plus.
-  quotaGate.onInteraction()
+  // Hard daily cap reached: re-show the accomplishment-lock overlay and
+  // don't send. Free messages return tomorrow, or with Plus.
+  quotaGate.requestDailyLock()
   syncSendEnabled()
   systemNote(t("quotaEmptyNote"))
   return
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion is accurate: for a dailyLimit hard cap, quotaGate.requestDailyLock() is the API intended to re-dispatch the daily lock overlay, while onInteraction() is not guaranteed to do that for an already-blocked user. This fixes an important blocked-state UX/functionality issue in send.

Medium
Validate discount percentage

Validate args.percent_off before resolving price points. Values outside (0, 100) can
produce a zero/negative target or a higher-than-base price, causing the script to
create an invalid or non-discount offer.

corpan/infra/asc/asc_monetization.py [934]

+if not (0 < args.percent_off < 100):
+    sys.exit(f"--percent-off must be in (0,100); got {args.percent_off}.")
 duration = _duration_from_months(args.months) if args.months else "ONE_MONTH"
Suggestion importance[1-10]: 7

__

Why: cmd_code_discount currently accepts invalid --percent-off values that can produce non-discount or nonsensical price-point targets. This is a valid input-validation fix with meaningful correctness impact, though it is not a critical runtime bug.

Medium
General
Notify entitlement snapshot changes

This only observes subscription object identity, so changes to subjectId,
entitlementToken, or lastRefreshed will not notify packs despite being part of the
snapshot. Compare the actual snapshot fields that buildEntitlementSnapshot()
returns.

corpan/corpan-app/src/contentPacks/hostApi.ts [1153-1165]

 onChange: (cb) => {
-  let prev = useEntitlementStore.getState().subscription
+  let prev = buildEntitlementSnapshot()
   // Fire on any change to the subscription / subject / token so packs
   // see purchases, restores, and refreshes. Compares the fields that
   // feed the snapshot to avoid spurious callbacks on unrelated writes.
-  return useEntitlementStore.subscribe((state) => {
-    const next = state.subscription
-    if (next !== prev) {
+  return useEntitlementStore.subscribe(() => {
+    const next = buildEntitlementSnapshot()
+    if (
+      next.plus !== prev.plus ||
+      next.subjectId !== prev.subjectId ||
+      next.entitlementToken !== prev.entitlementToken ||
+      next.checkedAt !== prev.checkedAt ||
+      next.subscription.active !== prev.subscription.active ||
+      next.subscription.plan !== prev.subscription.plan ||
+      next.subscription.expiresAt !== prev.subscription.expiresAt ||
+      next.subscription.autoRenew !== prev.subscription.autoRenew
+    ) {
       prev = next
-      cb(buildEntitlementSnapshot())
+      cb(next)
     }
   })
 },
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that onChange only tracks subscription identity despite buildEntitlementSnapshot() also including subjectId, entitlementToken, and checkedAt. Comparing the snapshot fields makes the documented entitlement change notification behavior reliable.

Medium
Make retry actually retry

The Retry button is a no-op when affiliateCode is already trimmed, so failed code
checks cannot be retried unless the user edits the input. Add an explicit retry
nonce and include it in the resolver effect dependencies.

corpan/corpan-app/src/components/packs/SubscriptionOffer.tsx [105-697]

 const [codeStatus, setCodeStatus] = useState<
   | { kind: "idle" }
   | { kind: "checking"; code: string }
   | { kind: "resolved"; code: string; response: Extract<CodeResolveResponse, { status: "ok" }> }
   | { kind: "error"; code: string; error: string }
 >({ kind: "idle" })
+const [codeRetryNonce, setCodeRetryNonce] = useState(0)
 
 ...
 
 useEffect(() => {
   ...
-}, [affiliateCode, selectedProductId, t])
+}, [affiliateCode, selectedProductId, t, codeRetryNonce])
 
 ...
 
 <button
   type="button"
-  onClick={() => setAffiliateCode((c) => c.trim())}
+  onClick={() => setCodeRetryNonce((n) => n + 1)}
   className="underline underline-offset-2 hover:text-foreground"
 >
   {t("code.retry", "Retry")}
 </button>
Suggestion importance[1-10]: 6

__

Why: The current retry handler can be a no-op when affiliateCode is already trimmed, so failed resolveCode() calls may not be retried without editing the input. Adding a codeRetryNonce is a sound, localized fix for this UX issue.

Low
Avoid scroll-lock race

Opening the paywall before closing the daily lock can race the two body scroll-lock
effects: the daily-lock cleanup may restore document.body.style.overflow after the
paywall has set it to "hidden". Close the lock first, then open the paywall so the
paywall owns the final scroll-lock state.

corpan/corpan-app/src/components/paywall/DailyLockOverlay.tsx [134-142]

 const upgrade = () => {
   trackDailyLockUpgradeTapped(context.packId)
-  // Hand off to the ONE universal paywall.
+  onClose()
+  // Hand off to the ONE universal paywall after releasing this overlay.
   usePaywallStore.getState().openPaywall({
     surface: context.surface,
     packId: context.packId,
   })
-  onClose()
 }
Suggestion importance[1-10]: 4

__

Why: The concern is plausible because both DailyLockOverlay and PaywallSheet manipulate document.body.style.overflow, and unmount cleanup could interfere with the paywall's scroll lock. The impact is limited to a transient UI behavior, and React batching may already avoid some cases, but the proposed ordering is a reasonable defensive improvement.

Low
Security
Restrict generated code files

Generated one-time offer codes are bearer-like secrets, but open() uses the process
umask and may leave the CSV group/world-readable. Create or truncate the file with
0600 permissions before writing the codes.

corpan/infra/asc/asc_monetization.py [871-872]

-with open(out_path, "w", newline="") as f:
-    f.write(r.text)
+fd = os.open(out_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+try:
+    os.fchmod(fd, 0o600)
+    with os.fdopen(fd, "w", newline="") as f:
+        f.write(r.text)
+except Exception:
+    os.close(fd)
+    raise
Suggestion importance[1-10]: 7

__

Why: The generated one-time-use codes are sensitive, so writing them with explicit 0600 permissions is a relevant security improvement. The proposed cleanup code could mask an exception if fdopen already closed the descriptor, but the main permission-hardening change is valid.

Medium

@skyl

skyl commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Requesting changes. I would not ship this PR as-is.

Blockers:

  1. Build is red. corpan-app · tsc + build fails in npm run build at check:i18n (corpan/corpan-app/package.json runs that before Vite). Locally, npm run check:i18n reports 23 static t() keys absent from public/locales/en/common.json (code.*, trial/intro subscription strings, paywall.dismiss, onboarding.engage.explore) plus dailyLock.* and streakBadge.label missing from every non-English locale.

  2. Apple offer-code redemption does not attribute purchases. The REDEEM_APPLE_SHEET branch in corpan/corpan-app/src/components/packs/SubscriptionOffer.tsx:233 presents StoreKit and returns after refresh(), so the resolutionToken never reaches /verify-purchase. The iOS plugin emits purchaseUpdated from Transaction.updates, but the app does not listen to it; local refresh only checks ownership. Result: Apple offer-code conversions can unlock locally but never write PURCHASE#, ATTRIBUTION, or initial LEDGER rows.

  3. /entitlement-token cannot work for normal verified purchases. codes.readLatestEntitlement() requires PURCHASE# rows with a future expiresAt (corpan/infra/terraform/lambda/codes.js:799), but attributePurchase() writes its PURCHASE# row without expiresAt (corpan/infra/terraform/lambda/codes.js:447) and only when a resolution token exists. Non-code subscriptions do not persist any such row. refreshEntitlementToken() can therefore clear the token despite an active local subscription.

  4. Initial affiliate ledger rows lose payout percentage. attributePurchase() writes revenueSharePct: claims.revenueSharePct (corpan/infra/terraform/lambda/codes.js:495), but real tokens minted by /code/resolve do not include revenueSharePct (corpan/infra/terraform/lambda/codes.js:155, corpan/infra/terraform/lambda/codes.js:692). The tests inject that field manually via verifiedClaims(), so this escapes coverage and payout exports get null pct.

Checks run:

  • PASS: cd corpan/corpan-app && npm run tsc
  • FAIL: cd corpan/corpan-app && npm run check:i18n
  • PASS: cd corpan/infra/terraform/lambda && npm test
  • PASS: npm run --prefix corpan/packs/corpan-city test:run (760 tests)

skyl added 3 commits June 15, 2026 11:27
The build's i18n gate (check-i18n.mjs) requires every t() key in en + every
locale. The monetization/streak UI shipped EN-only: 23 keys (code.*, subscription
trial.*, paywall.dismiss, onboarding.engage.explore) were used via inline
defaultValue and never added to en/common.json, and dailyLock.*/streakBadge.* were
EN-only. Added the 23 to en, then translated all 35 into the 53 non-en locales via
OpenAI (brand terms + {{placeholders}} preserved; only-missing merge). Removed an
empty stray 'hostApi.ts corpan/' dir artifact. Build green: 717 keys x 54 locales.
@skyl

skyl commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Review — Corpán 0.18.1 (monetization overhaul)

Verdict: ready for on-device testing. CI is green, the production build passes, the backend is live and smoke-tested, and the diff is clean of secrets. The remaining unknowns are all things only a real device build can validate (native StoreKit/Play bits) — that's exactly the next step.

Scope

250 files / 36 commits. Delivers the full monetization program: universal dark paywall · free trials (live both stores) · daily-quota gates with the accomplishment-lock + per-pack visit streaks · server-authoritative affiliate/discount codes · anonymous opt-in funnel analytics · onboarding aha auto-launch · regional pricing (applied live) · plus low-end-Android hardening (GPU-blur ANR, tutor OOM floor) and three new languages. Localized across all 54 locales.

Verified ✅

  • CI green (run after the i18n fix). npm run build passes: i18n gate 717 keys × 54 locales in sync, tsc clean, vite build OK.
  • Tests: monetization gate 31/31, streak 12/12, hover-runner 16/16.
  • Backend live + smoke-tested: /code/resolve returns REDEEM_APPLE_SHEET (iOS) / USE_OFFER_TOKEN (Android) for IAN30, and fail-closed ATTRIBUTE_UNVERIFIED for unknown codes (no fail-open). HMAC signing works; 8-partner/8-code registry seeded.
  • Stores live: Apple — 7-day trial (175 territories) + 8 affiliate codes ×2000 + giveaway batches. Google — 7-day trial + 8 affiliate offers (created this round; fixed a missing-duration bug in the Play tool). Regional pricing applied both platforms.
  • Daily wall now actually enforces on every metered surface (was tutomaton-only — phrase-flip + 4 packs were counting but never blocking; also caught the "Random sentence" bypass).
  • Secret-clean: no .p8/keys/service-account JSON/.env in the diff (only docs + placeholders).

Must test on device (native paths can't be CI-verified)

  1. tauri-plugin-iap new request_review command (iOS SKStoreReviewController / Android Play ReviewManager + the new Gradle dep) — needs an Android + iOS build to compile.
  2. Free-trial rendering from real StoreKit sandbox / Play license tester.
  3. Code redemption end-to-end: IAN30 → Apple offer sheet (iOS) / discounted Play purchase (Android); bogus code denied.
  4. The daily wall + accomplishment lock + streak badge, and subscriber-bypass.
    (Full step-by-step device checklist available — say the word and I'll drop it in.)

Known follow-ups (non-blocking, tracked)

  • Minor paywall polish: trial badge per plan tile (monthly-only trial hidden until selected); iOS empty periodLabel can suppress the trial panel; trial_started analytics undercounts the plain intro-trial path. All minor; tune after you feel the copy.
  • Renewal attribution: the Lambda handles ASSN V2 + Google RTDN already; the store/cloud wiring (secret fields + GCP Pub/Sub + 2 Console settings) is documented in infra/RENEWAL_ATTRIBUTION_SETUP.md. Not release-blocking (first-purchase attribution works). I'll run the scriptable parts on your go.
  • Remote-config for paywall A/B — recommended as a separate endpoint (fail-safe defaults), Phase-4 fast-follow, not in this release.

Behavior changes to feel & tune

The daily limits are now real walls: phrase-flip/hover/juice/hanzipan 20/day, pronunciation-coach 15, tutomaton 20, soft nag every 5. These are the knobs — try them and we dial.

Recommend: build on Android + iPad/iOS, run the device checklist, then TestFlight. I'm standing by to fix anything testing surfaces.

skyl added 2 commits June 15, 2026 12:53
2) Apple offer-code redemption now attributes: app subscribes to the iOS plugin
   purchaseUpdated (Transaction.updates) and POSTs the pending resolutionToken to
   /verify-purchase after the redeem sheet, so PURCHASE#/ATTRIBUTION/LEDGER rows
   are written (Android already did this). App-side; needs a device build.
3) /entitlement-token works for normal verified subs: attributePurchase persists
   expiresAt on the PURCHASE# row + new recordEntitlementPurchase() writes the
   entitlement row for any verified active sub (no code required).
4) Ledger payout pct no longer null: resolution token now carries revenueSharePct
   (registry-derived); tests mint REAL tokens (was masking the bug). 34/34 lambda.

Lambda fixes (3,4) need a redeploy; #2 needs an iOS build.
…on-gating)

Packs ship OTA to pre-0.18.1 apps that have no DailyLockOverlay + don't listen
for corpan:daily-locked. Hard-blocking there froze users behind an invisible
wall. The gate now hard-blocks ONLY when the host advertises support via
globalThis.__CORPAN_HOST_CAPS.dailyLock (set by ContentPackHost in >=0.18.1);
absent => isBlocked() returns false and the gate degrades to the legacy soft
corpan:request-unlock nag every host already renders. Subscriber bypass + soft
nags unchanged. +3 tests (old-host soft, new-host hard). 34/34, app tsc clean.
@skyl

skyl commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Blockers addressed (commits up to 390d531c)

1. Red build / i18n — fixed. The 23 t() keys missing from en/common.json (code.*, subscription trial/intro, paywall.dismiss, onboarding.engage.explore) are now in en, and dailyLock.* + streakBadge.label + all the above are translated across all 53 non-English locales. npm run check:i18n passes (717 keys × 54 locales in sync); CI is green.

2. Apple offer-code redemption now attributes. The app subscribes to the iOS plugin's purchaseUpdated (Transaction.updates) and POSTs the pending resolutionToken to /verify-purchase after the redeem sheet, so PURCHASE#/ATTRIBUTION/LEDGER rows are written (mirrors the Android path; idempotent). App-side only — the plugin already emitted the event. Needs an iOS build to exercise.

3. /entitlement-token works for normal verified subs. attributePurchase now persists expiresAt on the PURCHASE# row, and a new recordEntitlementPurchase() writes the entitlement row for any verified active sub (no code required), so readLatestEntitlement finds it. Regression test added (fails without the fix).

4. Ledger payout pct no longer null. The resolution token now carries revenueSharePct (registry-derived at mint), and the masking test was rewritten to mint a real token instead of hand-injecting the field. New end-to-end test asserts a non-null pct from /code/resolveattributePurchase. 34/34 lambda tests.

Blockers 3 & 4 are in codes.js / verify_purchase.js → need a Lambda redeploy to take effect live (spark/infra owns terraform/lambda). Blocker 2 needs an iOS build.

Also added — backwards compatibility (raised separately)

Packs ship OTA to pre-0.18.1 apps. The gate-v2 daily wall now hard-blocks only when the host advertises __CORPAN_HOST_CAPS.dailyLock (set by ≥0.18.1); in an older host the same pack degrades to the dismissible soft corpan:request-unlock nag every host already renders — never a frozen, unexplained wall, and no pack is version-gated. +3 tests.

Ready for re-review.

skyl added 16 commits June 15, 2026 13:58
…urface

- __CORPAN_HOST_CAPS.dailyLock now set app-wide in main.tsx (was only in
  ContentPackHost, which doesn't mount for the core phrase-flip) so the daily
  hard cap actually engages outside content packs.
- Removed the standalone native review on corpan:exit; the in-app RatingPrompt
  is the single surface and its 5-star button now pops the native review widget
  (mobile) / store URL (desktop) — fixes the double rating popup.
…) — was uncommitted, broke CI

App.tsx + the changelog for the catalog 'zombie' fix (timeout + jitter + bounded
retry + conditional headers) were committed earlier, but the new catalogFetch
module and the catalog/phrasePack store edits that back them were never git-added,
so CI failed on 'Cannot find module @/contentPacks/catalogFetch'. Landing them
makes the tree whole; full build green (tsc + i18n + vite).
…te behind ref guard)

createPaywallGate was built in render behind `ref === null`, and the effect
cleanup dispose()s it. React StrictMode (dev) runs mount→cleanup→mount, so the
gate got disposed and the guard refused to rebuild → the ref held a DISPOSED gate
and note()/isBlocked() silently no-op'd forever (no nag/lock no matter how many
phrases you flip). Now built inside the effect so each run gets a fresh gate;
cleanup disposes + clears the ref. Works in dev + robust to any remount.

Also: store-unreachable copy is platform-neutral ('the store', not 'App Store')
since it shows on Android; + DEV-only window.__corpanDebug monetization helpers.
…xit→Home

The cap now meters ONLY new-phrase acquisition (Random / Next-on-newest) via a
single acquireNewPhrase() seam: at the cap it shows the dismissible accomplishment
lock and does NOT fetch. Navigating back/forward through already-seen phrases is
always free + uncounted (handleNext short-circuits the in-history case before the
gate; handlePrev never gated). So a free user gets 20 NEW phrases/day and can
review them freely. Exiting phrase-flip just goes Home (onExit clears any open
lock) — never a paywall on exit. 34/34 gate tests, tsc clean.
…erlay (z-60 -> z-1400)

THE phrase-flip 'lock never shows' bug: the experience overlay is z-[1100]
(opaque bg) and chrome z-[1110], but the lock/paywall roots were z-[60] — so when
fired from inside an experience they painted BEHIND it (invisible), and only
appeared on exit when the experience unmounted ('exit blocks me with the
paywall'). Now z-[1400], above experience/chrome/drawers. Verified via on-device
CDP screenshot that the DOM-present overlay wasn't painting.
…was a no-op)

A gate-v2 daily gate's onInteraction() only fires the legacy countArmed paywall —
a no-op here — so once at the cap, a re-send showed only the in-chat systemNote,
never the accomplishment lock. Use requestDailyLock() so a blocked send re-shows
the lock (consistent with phrase-flip). Crossing the cap (20th msg) already worked
via note()'s fireDailyLock.
…OM-crashes

The tutor shipped one model (Qwen3-4B) that silently OOM-crashed low-RAM phones
(and even a 6 GB Android phone). It now ships three Qwen3 sizes and picks the
biggest that runs safely for the device's RAM, with a user-overridable picker.

- modelTiering.ts: registry + pure selectTier(totalRamMb) → recommended size +
  per-size state (recommended/available/try-anyway/disabled). 4B recommended
  >=7GB, try-anyway at 6GB, disabled below. Boundary-tested.
- plugin: total RAM on status() (cross-platform: /proc/meminfo + sysctl
  hw.memsize); blunt 4GB gate replaced by a per-model footprint backstop so
  small models load on small devices while 4B is refused where it would OOM.
- ModelManager: reads RAM, auto-picks recommended (persisted, overridable),
  model-aware context length, graceful 'unsupported' fallback.
- Picker UI in the setup gate (disabled-greyed reads as the device's limit),
  localized into 46 languages.
- Non-thinking output for hybrid 0.6B/1.7B: noThink prefill (plugin) + streaming
  thinkFilter (strips <think> from transcript AND speech) + model-lab Thinking
  toggle. 4B is native Instruct-2507.

Small GGUFs hosted in prod (CloudFront + catalog). Per-size language eval-gating
is the remaining quality step (every size offers all languages until then).
…pronunciation-coach mic re-pop

DailyLockOverlay title -> 'That's your {count} {unit} for today — nicely done'
(accomplishment, not error). pronunciation-coach: the capped 'Done for today' mic
was disabled and swallowed its own taps so a re-tap did nothing — capture-phase
listener now re-pops the shared lock (matches multiplayer + first cap-cross).

NOTE: en dailyLock.title drifted from ~53 locales (re-gen pending). The tutomaton
half (de-red chat.css, quota strings, composer re-pop) is in files the model-tiering
agent owns — left in the working tree for that commit.
Lets the harness evaluate the smaller tiers in their real shipping config:
- TUTO_EVAL_MODEL=/path.gguf  — point llama-server at the 0.6B/1.7B GGUF
- TUTO_EVAL_NOTHINK=1         — seed the empty <think></think> prefill (matches
  the plugin's non-thinking mode for hybrid Qwen3)
- TUTO_EVAL_RESULTS=dir       — isolate a run's rows.jsonl (the rid isn't keyed
  by model, so a shared dir would reuse the wrong model's cached generations)
…ty on poison row)

A single llama-server 500 (seen ~1700 generations into the 0.6B sweep) aborted
the whole multi-hour run. Now server.complete retries 4x and restarts the server
from the 2nd attempt (recovers a wedged/dead server); eval_config records an
empty reply on persistent failure so the sweep continues and resume won't re-hit
it. rows.jsonl resumability means a relaunch picks up where it left off.
…a + live debug API

ONE standard for daily quotas across packs. New packs/shared/monetization:
src/quotas.ts (central QUOTAS registry = single source of truth for limit/nag/unit
per surface, remote-config-ready via getQuota), src/dailyQuota.ts (createDailyQuota
(surface) = the one construction pattern), legacy-key migration (<packId>.quota ->
standard corpan:gate key, one-time), a globalThis.__corpanGates registry, and
QUOTA_STANDARD.md. Converted phrase-flip/hover/juice/hanzipan/pronunciation to
createDailyQuota (same keys/limits/nag). DEV __corpanDebug.quota.{list,set,reset,
clearAll} drives live gates both directions (no reload). Presentation standard =
the shared DailyLockOverlay (z-1400, green-check, no per-pack red). tutomaton
conversion documented for its agent. 46/46 monetization tests, tsc clean.
…tations) + working infinite-load

- Phone layout (narrow portrait <=767px OR short landscape <=600px) now drops
  the iPad viewport-fit contract: the page scrolls and the examples panel
  expands to full height instead of being crushed. Fixes landscape-phone being
  unusable (it fell into the iPad fit-layout, cramming everything into ~400px).
- 'Scroll for more' examples now load on phones: infinite-load moved off a
  scroll listener on the (internally-scrolling-only-on-wide) examples list to
  an IntersectionObserver on the footer sentinel, which fires whether the page
  or the panel is the scroller. Also reset page scroll on character change.

iPad portrait/landscape keep the exact viewport fit.
…age gating

Phase 4/5/6 completion + a premium model UI:
- Premium 'Tutor model' sheet (choose/download/switch): cards per size with
  Active/Recommended/Use/Download+progress/disabled states; one-tap switch
  (ModelManager.useModel + installStates); reachable from the action menu.
- Phase 6a: Q8_0 KV cache on Apple/Metal (flash attn AUTO) — ~half KV RAM.
- Phase 6b: background unload on low-RAM (<6GB) devices + reload on resume,
  so the OS can't OOMKill the app under memory pressure.
- Phase 5: per-size language gating mechanism — unsupported langs shown disabled
  ('needs a larger model'); activates once each size's evaluated list is set.
- Model-lab per-language Thinking toggle (ModelTuning.think).
- New UI strings localized into 46 languages (insertions-only).
…fferToken

trial_started fired only on `options.offerToken` (the Android affiliate DISCOUNT
token) — so it MISSED every standard trial (iOS StoreKit intro, Play base-plan
trial carry no offerToken) and MIS-counted 30%-off code buys as trials. Now it
fires when the purchased product's normalized introOffer.kind === 'free_trial'
AND it's a new subscription (wasSubscribed captured pre-purchase). subscription_
purchased was already complete, so only the trial dimension was wrong.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant