# Copy migration — brand-voice copy → `CloserCopy` > Incremental migration of **brand-voice** hardcoded copy into the typed `CloserCopy` catalog. > Builds on the paywall + subscription slice (commit `51efff3f`). Status tracked per slice below. ## Context The app renders copy from hardcoded Compose literals. A half-built `strings.xml` catalog was purged (`59e03369`) and the voice copy that benefits from centralizing is being lifted into typed Kotlin catalogs — one reviewable home for tone, dedup of repeated copy, compile-time safety. ## Scope — deliberately narrow (this is the key decision) There are **~281 hardcoded UI-copy literals** across the app (settings 64, wheel 42, dates 41, onboarding 27, home 26, questions 26, pairing 25, auth 17, recap 7, games 6). **This migration does NOT target all of them.** Most are functional chrome or screen-specific labels that gain little from a catalog — and critically, **at i18n time every user-facing string must move to `res/values/strings.xml` anyway**, so pre-moving the bulk into a Kotlin catalog now is wasted double-work. - **IN (migrate now):** the app-name ("Closer") lines, **duplicated** copy (drift risk), and shared value-props/taglines. ~15–25 lines total. These earn a catalog *today* — they're voice-critical, reused, or repeated. - **OUT (stays inline until i18n):** everything else — functional chrome, one-off screen labels, field/error text. No per-screen "voice copy" crusade. ## Catalog architecture Three **domain-scoped** catalogs, by design (not a smell to unify): - `CloserBrandCopy` — privacy-rotator lines. - `GameCopy` — game copy. - `CloserCopy` — general product voice; the umbrella for this migration, one nested `object` per area (`Paywall`, `Subscription` done; add `AgeGate`, `Onboarding`, `Pairing`, `Settings`, `Recap`, `Widget`). - **Interpolated copy → functions**, not consts (e.g. `fun ageError(minAge: Int) = "…$minAge…"`) — keeps the catalog dependency-free. ## Slices (each = one small, compile-gated, reviewable commit) - **Slice 1 — Age-gate (dedup, do first).** The min-age error is **triplicated verbatim** across `SignUpViewModel:62`, `CreateProfileViewModel:110` + `:150` → one `fun ageError(minAge)`. The 18+ disclaimer has **two genuinely-different variants** (`SignUpScreen` short "Closer is an 18+ app."; `CreateProfileScreen` "…We ask once to confirm your age.") → **keep both** as distinct consts (`disclaimer`, `disclaimerWithReason`); do NOT silently unify (that's a copy decision). - **Slice 2 — Onboarding + pairing brand lines.** `OnboardingScreen:142` "Grow Closer" + `:261` "Closer" wordmark; `PairPromptScreen:105` "Closer is built for two…". - **Slice 3 — Invite share text.** `InviteShareText.kt` (already a function w/ code+link) → `CloserCopy.Pairing`. - **Slice 4 — Settings, biometric, recap, widget.** `SettingsScreen:502/:537` subtitles; `MainActivity:319` `.setTitle("Unlock Closer")`; `WeeklyRecapShareCard:37` share string + `:87` `canvas.drawText("Closer")`; `TodayWidget:68/95/97`. - **Everything else → i18n pass.** No standalone "long tail" migration. The ~256 remaining literals move to `strings.xml` in the one mechanical localization pass, not into a Kotlin catalog first. ## Per-slice recipe 1. Grep target literals; confirm each is unique per file. 2. Add to the catalog section, verbatim (functions for interpolation). 3. Replace call sites with catalog refs — **covers non-Compose sites too**: `canvas.drawText(...)`, biometric `.setTitle(...)`, returned share strings, not just `text = "…"`. Chrome stays inline. 4. `./gradlew :app:compileDebugKotlin` — must stay green. 5. **Run affected unit tests** where the slice touches asserted copy — Slice 4 recap must pass `WeeklyRecapShareCardTest` (`./gradlew :app:testDebugUnitTest --tests "*WeeklyRecapShareCardTest"`). Byte-identical relocation keeps them green; running proves it. (`FirstRunRenderSmokeTest` is an instrumented androidTest asserting onboarding "Closer" — can't run headless, but relocation doesn't change the rendered text.) 6. Grep: no leftover local copy / old literal in the touched files. 7. Commit: "copy relocated verbatim, no behavior change." ## Guardrails - **No behavior change** — copy is byte-identical, only relocated (compile + tests + diff read confirm). - **Wordmarks relocated only** — the "Closer" wordmarks (widget, recap card, onboarding) keep the text "Closer" per the two-tier rename decision (`docs/NameChange.md`); no user-visible word changes. - **Check the working tree before each slice** (owner may have in-flight edits; it's clean as of this review). Skip any file with uncommitted owner edits until committed. - **i18n end-state:** when localization is scheduled, all copy catalogs + the ~256 still-inline literals migrate to `strings.xml` in one pass (gate recorded in `Future.md`). ## Status - **Slice 1 (age-gate): ✅ done.** Min-age error de-triplicated → `CloserCopy.AgeGate.ageError(minAge)`; both disclaimer variants + the duplicated DOB prompt consolidated. 6 call sites across SignUp{Screen,ViewModel} + CreateProfile{Screen,ViewModel} now source from the catalog. Compile clean. - Slices 2–4: pending.