Closer/docs/CopyMigration.md

84 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.