Closer/docs/CopyMigration.md

5.9 KiB
Raw Blame History

Copy migration — brand-voice copy → CloserCopy [CLOSED]

CLOSED (2026-07-11) — stopped by owner decision after Slice 1. The ROI review concluded pure copy-relocation wasn't worth the churn; effort went to higher-value work instead (server/ removal, benefit-list reconciliation). Slices 24 are CANCELLED, not pending. The catalog's scope is frozen at paywall + subscription + age-gate (ui/brand/CloserCopy.kt). Also note: the 2026-07-11 "Closer Couples" in-app prose rename REWROTE many strings quoted below (e.g. the age-gate disclaimer is now "Closer Couples is an 18+ app.") — treat quoted copy as historical. Rename record: docs/NameChange.md.

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: cancelled (2026-07-11 close-out — see header). Several of their target strings were since edited in place by the "Closer Couples" prose rename instead.