Closer/docs/CopyMigration.md

84 lines
5.2 KiB
Markdown
Raw Normal View History

# 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. ~1525 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 24: pending.