feat(games): GameSessionManager cleanup, QuestionSessionRepositoryImpl fixes, HomeViewModel game-state wiring, QA docs

This commit is contained in:
null 2026-06-29 12:20:07 -05:00
parent fb85b0a1fa
commit b5b8ad8df9
11 changed files with 181 additions and 21 deletions

View File

@ -1,6 +1,7 @@
# Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> **R20 (2026-06-29) — fresh full ClaudeQAPlan run; found + FIXED 2 escaped bugs.** Build HEAD `62696a6` + R20 fixes (uncommitted: `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`). Cheap gates all green (unit **210** · fn **24** · theme-scan CRIT **0** · painter-xml **0** · wiring 🔴**0** · smoke **6/6 both**). Cornerstones A/B/D/E live-clean, 0 FATAL. **B-ABANDON-001 (P2)** — Quit/abandon on any game silently `PERMISSION_DENIED` (full `saveSession` set drops server-only flags → rule rejects removed `affectedKeys`) → stranded session; **fixed** (targeted `update(status,completedAt)`), verified live (quit→active=0→new game starts). **B-COPY-001 (P3)** — Home GAME_WAITING hero falsely claimed "partner already played their part"; **fixed** (neutral partner-named copy), verified live both devices. Both pending 1 confirm. See `ClaudeReport.md` R20 run-state.
> Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **Deploy status:** `functions/` + `firestore.rules` **DEPLOYED by user** (join push live; Tier-2 self-constraint **verified live** — member own-uid add 200, foreign-uid/removal 403). No remaining deploy gates. Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `<bitmap>` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.**
>
> **Scope expanded (plan review):** the playbook now has first-class passes **KO** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **Device/OS matrix = `blocked→needs-device` (pre-ship):** all per-round QA runs on two **identical** emulators (5554/5556, same API + screen) — minSdk/targetSdk · small/large screen · ≥1 physical device are NOT covered; don't claim "device matrix ✓".
@ -15,7 +16,7 @@
| Pass | Coverage | Status |
|---|---|---|
| A — Couple-shared premium | R18: re-verified live (Sam free) — Desire Sync + premium **Boundaries** pack → Paywall; Mixed pack free prompt opens (gate not over-broad); Free filter graceful empty. R13 A-201 (Date Match) holds. | ✅ pass (multi-surface gate re-confirmed R18) |
| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. | ✅ pass · finish-gate + submit-retry + abandon verified (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) |
| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. **R20: B-ABANDON-001 (P2) FIXED** — that abandon (+ ToT/HowWell Quit; all route through `abandonSession`) actually failed `PERMISSION_DENIED` server-side (full `saveSession` `doc.set()` dropped the server-only flags → session-update rule rejected the removed `affectedKeys`) so the session stayed stranded `active`; fix = targeted `update(status,completedAt)`; verified live (quit→active=0→a different game starts immediately). **R20: B-COPY-001 (P3) FIXED** — Home GAME_WAITING hero no longer falsely claims "partner already played their part." | ✅ pass · finish-gate + submit-retry + **abandon now actually works (B-ABANDON-001)** (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) |
| C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. R18b: **C-ORIENT-001 RESOLVED**`MainActivity` locked to portrait (no landscape design; verified `requestedOrientation=PORTRAIT` holds under forced rotation). C-DARKART-002 confirmed + pruned. | ✅ theme-scan CRIT 0 · C-DARKART-002 + C-ORIENT-001 resolved · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.md` |
| D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds |
| E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. **R18b: NEW `partner_joined_game` (joiner avatar) + standardized durable in-app game banner** — all foreground game pushes route through the themed banner (started/joined transient, your-turn/results persistent + tappable; foreground OS dupe suppressed, background OS unchanged); verified live per kind + Pass-E smoke 6/6 both emulators (join push pending functions+rules deploy). | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · banner standardized · Doze/battery = needs-device |

View File

@ -519,6 +519,14 @@ Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges
- **Different exit/resume styles** — finish normally; quit mid-game; background mid-game then resume; cold-kill
mid-game then reopen; "End their game"; re-open a completed session for the replay/results; play two games
back-to-back, and a *different* game type immediately after.
- **⛔ VERIFY QUIT/ABANDON ACTUALLY ENDS THE SESSION (server-side, by admin read — RETROSPECTIVE from B-ABANDON-001).**
"Quit" / "End their game" navigating away is **not** proof the session ended — the abandon write is best-effort and
**swallowed** (`runCatching{…}.onFailure{ Log.d }`), so a `PERMISSION_DENIED` looks like success in the UI. After any
quit/abandon, confirm the session is actually `completed` via an admin read (**0 active sessions**) AND that a *new/
different* game can then be started; watch `logcat` for `PERMISSION_DENIED` on the `sessions/{id}` doc during the quit.
A session that "won't clear" between rounds is a **bug to root-cause, not a test-data nuisance** — B-ABANDON-001 (the
full-`saveSession` `doc.set()` dropping server-only flags → rule rejects the removed `affectedKeys`) hid for several
rounds precisely because it was dismissed as cleanup difficulty. See the manual's [B-ABANDON-001 landmine](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes).
- **Edge inputs** — submit with nothing selected (should be blocked), rapid double-taps on answer/confirm/next,
spamming the start button, tapping during the reveal animation, switching tabs mid-game, receiving/tapping a
notification mid-game. None should crash, duplicate, or desync.

View File

@ -18,6 +18,7 @@
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current)
- **R20 (2026-06-29) — fresh full ClaudeQAPlan run from the start (user: "run the full ClaudeQAPlan") — found + FIXED 2 real escaped bugs (NOT a clean confirmation round).** Baseline: HEAD `62696a6` (R18b/R19 work committed; clean tree), both emulators paired + **free** (admin-confirmed), build reinstalled both. Cleared 1 stale ToT session by playing it through. **Cheap gates ALL GREEN:** unit **210** · functions **24** · `theme-scan` **CRITICAL 0** (9 MAJOR/23 REVIEW = intentional brand gradients) · `painter-xml-scan` **0** · `wiring-scan` **🔴0** · `entrypoint_smoke.sh` **6/6 on BOTH emulators (0 blocked)**. Discovery ritual: no drift (14 notif types + all fn triggers match coverage). **Cornerstones live-clean:** **A ✅** enforcement audit (every `isPremium`/`PremiumBadge` has a real `CouplePremiumChecker` gate — no badge-without-gate; A-201 class stays closed) + live both-free → Desire Sync → **Paywall** "Go deeper together" (graceful K-env "couldn't load plans", no crash). **B ✅** full 2-device This-or-That (QA joined via Home card → answered 10 → **first-finisher** → Sam got live **YOUR_TURN banner** → joined via banner → completion → **symmetric 5/10 "in sync" reveal** both devices). **D ✅** D1 at-rest `enc:v1:` (messages + lastMessagePreview + all 4 game answer-maps' per-uid values) · D2 rules static (Tier-2 self-constraint present, lines 361374) · D3 non-member couple/messages/capsules/desire_sync reads **403** · D5 self-grant entitlement **403**. **E ✅** cold-start smoke 6/6 both + live YOUR_TURN + persistent RESULTS banners + `partner_completed_part` first-finisher push delivered. **0 FATAL across the whole live session.** **TWO BUGS FOUND + FIXED + VERIFIED LIVE:** **(1) B-ABANDON-001 (P2)** — Quit/abandon on ANY game silently failed `PERMISSION_DENIED`: `abandonSession` round-tripped through `saveSession` (a full `doc.set()`) which **drops the server-only flags** (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`); the session-update rule counts those removed keys in `affectedKeys()` → denied, so the session stayed `active` (stranded → blocks new games), failure swallowed by `Log.d`. Proven via logcat (`Write failed at .../sessions/…: PERMISSION_DENIED` → `quit-abandon no-op`). **Fix:** targeted `update(status, completedAt)` mirroring `markUserComplete` (`affectedKeys == {status, completedAt}` ⊆ allowlist) in `QuestionSessionRepositoryImpl.abandonSession`; routed the latent-twin dead method `GameSessionManager.finishGame` (0 callers) through it too. **Verified live:** Quit → no denial → `active=0`, then **started a different game immediately** (lockout resolved). **(2) B-COPY-001 (P3)** — Home "GAME_WAITING" hero hardcoded *"Your partner already played their part — take your turn to reveal"* but fires on `uid !in completedByUsers` only (for async games `completedByUsers` stays empty until BOTH finish), so it falsely claims the partner finished the instant a game is merely *started*. **Fix:** neutral, partner-named, always-accurate copy ("Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up.") — the accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN banner. **Verified live both devices** (starter + joiner). Build + **210 unit + 24 functions green** after fixes. Remaining passes carry recent-round status (zero functional diff coming in; my fixes are HomeViewModel copy + session-completion writes only, no effect on C/F/G/H/I/J/L/M/N/P): **C** theme-scan CRIT 0; **L** chat at-rest `enc:v1:` (via D1); **K** money-path + **O** release + Doze = `blocked→needs-device`. **Verdict: R20 — cornerstones A/B/D/E live-clean, all cheap gates green, 0 FATAL; found + fixed B-ABANDON-001 (P2) + B-COPY-001 (P3) live. Board: 0 open P0/P1; 1 open P2 (O-AGE-001 pre-ship, user-blocked) + 1 P2 fixed-pending-confirm (B-ABANDON-001); 1 open P3 (BRAND-DARK-COVERAGE, user-blocked) + 1 P3 fixed-pending-confirm (B-COPY-001).** Uncommitted (user commits): `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `docs/Engineering_Reference_Manual.md`.
- **R19 (2026-06-28) — fresh full ClaudeQAPlan run from the start (user-directed, post-implementation).** Baseline reset clean: both emulators paired, both **free** (admin-confirmed premium=false), **0 active sessions** (cleared the one stale ToT by completing it 10/10). **Cheap gates ALL GREEN:** `./gradlew testDebugUnitTest` **210**, `cd functions && npm test` **24**, `scripts/theme-scan.sh` **CRITICAL 0** (11 MAJOR/25 REVIEW = intentional brand-purple/white in the wheel + banner + bubble colored-surface contexts), `scripts/painter-xml-scan.sh` **0**, `qa/entrypoint_smoke.sh` **6/6 on both emulators**. **Cornerstones live:** **A ✅** premium gate — both-free → Desire Sync → Paywall "Go deeper together" (graceful "couldn't load plans" K-env limit, no crash). **B ✅** This-or-That full 2-device → completed **10/10 "in sync"** reveal (+ this round's finish-gate / submit-retry / Quit-abandon). **D ✅** security cornerstone (raw-API): non-member couple/messages/capsules/desire_sync reads + self-grant entitlement **all DENIED 403**; messages at-rest `enc:v1:`; session/answer docs carry no plaintext. **E ✅** notifications: cold-start smoke 6/6 both + `partner_joined_game` live end-to-end (deployed) + standardized durable banner. **0 FATAL.** Remaining passes **carry their recent-round status** (per `ClaudeQACoverage.md`, code-stable — this round's diff is additive client + the deploy-gated rule): **C** theme-scan CRIT 0 + this-session dark spot-checks; **F** offline-cache (carried) + new Wheel submit-retry (unit-tested); **L** chat at-rest `enc:v1:` confirmed; **G/H/I/J/N/P** carried; **K** money-path + **O** release + Doze/battery = `blocked→needs-device` (pre-ship). **Verdict: R19 — cornerstones (A/B/D/E) live-clean, all cheap gates green, 0 FATAL; board 0 open P0/P1, 1 P2 (O-AGE-001 pre-ship), 1 P3 (BRAND-DARK-COVERAGE) — both user-blocked.** **Tier-2 rules DEPLOYED by user + VERIFIED LIVE (member-token raw-API):** own-uid add to `joinedByUsers` **ALLOWED 200** (legit path intact); foreign-uid add to `joinedByUsers`+`completedByUsers` **DENIED 403**; array removal **DENIED 403** — own-uid-only self-constraint holds without breaking markUserJoined/markUserComplete. (functions + rules all deployed; no remaining deploy gates.)
- **R18b (2026-06-28) — polish + error-handling/security/consolidation hardening (user: "look for gaps, error handling, security, what else can we improve, can we consolidate").** Bundled with the modern-feel polish. **Error handling:** **E1 (P2 bug) FIXED**`WheelSessionViewModel.submitAndFinish` used to swallow a submit failure and navigate to the reveal anyway (silent data loss + stuck session); now it surfaces a **retryable error** (`submitFailed`/`error` + a `SubmitErrorCard` "Retry", no false reveal), matching ToT/DesireSync/HowWell. **E3** — the silently-swallowed join-writes now `.onFailure { Log.w }` in all 4 games. **Consolidation (targeted):** deduped the 3 copies of the save-error string into `ui/games/GameCopy.SAVE_ANSWERS_ERROR` (used by all four); the per-game answer/reveal logic is intentionally NOT unified (premature abstraction). **Modern feel:** `GamePromptBanner` — soft **haptic on arrival** (reuses `CelebrationOverlay` `LocalHapticFeedback`), **spring overshoot** entrance, **live green presence dot** on the avatar for JOINED, **whole-card tap-to-act + swipe-up dismiss**, `liveRegion` + `contentDescription` a11y, and `GamePromptController` now **won't let a transient (started/joined) clobber a persistent (your-turn/results)** banner; **chat bubble** got the same arrival haptic for parity; warmer brand-voice copy (banner `styleFor` = client source of truth, cross-commented with the function). **Predictive back (2026):** `android:enableOnBackInvokedCallback="true"` (safe — 0 `onBackPressed`/`BackHandler` in the app). **Tier 3:** the Wheel active screen got a **"Quit game"** escape hatch (`abandon()`→`abandonSession`) so leaving mid-wheel no longer strands a session (mirrors ToT). **Verified:** build + **210 Android unit (+1 new Wheel submit-retry test) + 24 functions** green; **live (5554):** JOINED banner shows warm copy + green presence dot + real avatar; **Pass-E cold-start smoke 6/6 on BOTH emulators** (notification path regression-clean); 0 FATAL. **Tier 2 security — DEPLOYED + VERIFIED LIVE:** `firestore.rules` now constrains `completedByUsers`/`joinedByUsers` so a member can only add **their own** uid (no spoofing the partner's join/completion, no removals; `get(...,[])` tolerates old docs). Member-token raw-API test: own-uid add **200**, foreign-uid add (both arrays) **403**, removal **403**; markUserJoined/markUserComplete still pass. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `WheelSessionViewModelTest.kt`, `GamePromptBanner.kt`, `GamePromptController.kt`, `MessageBubbleOverlay.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `ui/games/GameCopy.kt`, `AndroidManifest.xml`, `firestore.rules`, `functions/src/games/onGameSessionUpdate.ts` (sender_name), `ClaudeReport.md`, `ClaudeQACoverage.md`. **NEXT: reset + full ClaudeQAPlan A→P fresh run (R19).**
- **R18b (2026-06-28) — FEATURE: "partner joined your game" push + standardized in-app game banner (user: "notification that your paired partner joined the game … w/ their icon … make that theme the standard").** Decision (discussed): **"Standardize, keep durable."** **New `partner_joined_game`:** the non-starter opening an active session writes their uid to `joinedByUsers` (new field; client `markUserJoined` mirrors `markUserComplete`; `firestore.rules` session allowlist += `joinedByUsers`, server-only `joinNotifiedAt` deliberately excluded); `onGameSessionUpdate` adds a branch that claims a one-time `joinNotifiedAt` and notifies the **starter** "<Name> joined your game" with the **joiner's avatar**. Wired the join-write into all 4 games' non-starter paths (ToT/DesireSync/HowWell `joinSession` + Wheel `load()` resume), best-effort + off the critical path (guarded to never fire for the starter). **Standardized banner:** generalized `GamePromptController`/`GamePromptBanner` with a `kind` (STARTED/JOINED/YOUR_TURN/RESULTS) — per-kind copy/action/avatar; **STARTED/JOINED transient (~9s), YOUR_TURN/RESULTS persistent** (stay until tapped/dismissed). `AppMessagingService` now routes all 4 game types to the banner in the **foreground** (suppressing the foreground OS duplicate; background OS unchanged — already shows the avatar large-icon); `AppNavigation` routes the banner action by kind (RESULTS→per-session results, else→the game). Added `sender_name` to the game push `data` so the banner names the partner. **Build + 209 Android unit + 24 functions tests green** (added a `PartnerNotificationTypeTest` case for the new type's mapping + routing). **Live (5554, foreground, real avatar+name):** STARTED "Sam started a game"+Join (avatar loaded), **JOINED "Sam joined your game"+View**, YOUR_TURN "Sam finished their part / Your turn"+Play, RESULTS "Sam finished / See your results"+View — **RESULTS still showing at 15s (persistent) while STARTED auto-dismissed by 12s (transient)**; **0 QA-SMOKE entries in the shade (no foreground OS dupe); 0 FATAL.** **Pass-E regression smoke 6/6 on BOTH emulators** (shared cold-start path clean). **⚠ DEPLOY REQUIRED (user):** the `partner_joined_game` push only fires once `functions/` + `firestore.rules` are deployed to `closer-app-22014` (I can't deploy; prior prod writes were classifier-denied) — until then the `joinedByUsers` client write is denied by the live rules (best-effort, swallowed, no crash). The banner-standardization for already-deployed types (started/your-turn/results) works immediately. Uncommitted (user commits): `QuestionSession.kt`, `QuestionSessionRepository.kt`(+Impl), `GameSessionManager.kt`, `ThisOrThatScreen.kt`, `DesireSyncScreen.kt`, `HowWellScreen.kt`, `WheelSessionViewModel.kt`, `GamePromptController.kt`, `GamePromptBanner.kt`, `AppMessagingService.kt`, `PartnerNotificationManager.kt`, `AppNavigation.kt`, `PartnerNotificationTypeTest.kt`, `functions/src/games/onGameSessionUpdate.ts`, `firestore.rules`, `Future.md`, `ClaudeReport.md`.
@ -57,8 +58,8 @@
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | 0 |
| P2 | **1** (O-AGE-001 pre-ship — needs product/legal) | 0 |
| P3 | **1** (BRAND-DARK-COVERAGE — needs dark art assets) | 0 |
| P2 | **1** (O-AGE-001 pre-ship — needs product/legal) | **1** (B-ABANDON-001 — fixed+verified live R20) |
| P3 | **1** (BRAND-DARK-COVERAGE — needs dark art assets) | **1** (B-COPY-001 — fixed+verified live R20) |
_R18b cleanup (2026-06-28): **pruned the entire confirmed backlog** (one-confirmation-round rule). Pruned to the
archived line: **O-ONBOARD-001** (P0, verified live R18b) · **C-DARKART-002** (verified live R18) · **C-THEME-001/002/
@ -129,6 +130,8 @@ routed correctly, so that was a test artifact, not a bug.)_
| ID | Sev | Area | Description | Suggested fix | Status |
|---|---|---|---|---|---|
| B-ABANDON-001 | P2 | Games lifecycle (Pass B) | **Quit/abandon on any game silently failed `PERMISSION_DENIED`, stranding the session `active`.** `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) round-tripped through `saveSession`, which does a **full `doc.set()`** of a fixed 13-field map — dropping the server-only flags the Cloud Function wrote (`startNotifiedAt`/`joinNotifiedAt`/`partFinishNotifiedAt`). The session-update rule's `affectedKeys().hasOnly([...])` counts those *removed* keys, so the write is denied; the session never flips to `completed` → stranded active (blocks starting new games), and the failure is swallowed by `Log.d`. Proven live (R20): `Write failed at couples/.../sessions/MWkzZOWWRLrLNNoSwM0n: PERMISSION_DENIED``ThisOrThatViewModel: quit-abandon no-op`. Affects ToT/HowWell/Wheel (all route through `abandonSession`). Escaped R19 (only the arrayUnion paths were rules-tested). | **FIXED (R20):** targeted `update(mapOf("status" to "completed","completedAt" to now))` in `abandonSession` so `affectedKeys == {status, completedAt}` ⊆ allowlist + monotonic active→completed; `finishGame` now delegates to it. Verified live: Quit → no denial → `active=0`, then a different game started immediately (lockout resolved). | **Fixed — pending 1 confirm** |
| B-COPY-001 | P3 | Content/copy (Pass P) · Home | **Home "GAME_WAITING" hero falsely claimed the partner already played.** Body was hardcoded *"Your partner already played their part — take your turn to reveal how you two line up,"* but the card fires on `getActiveSessionForCouple()?.takeIf { uid !in completedByUsers }` — and for async games `completedByUsers` stays empty until BOTH finish, so the card (and its false claim) shows the instant a game is merely *started*, even when neither partner has answered. Confirmed live (R20): with `completedByUsers=[]` + no answers, QA's Home showed the claim, then QA joined to a fresh Q1/10 (no reveal). Copy-vs-behavior mismatch. | **FIXED (R20):** neutral, partner-named, always-accurate copy — "Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up." The accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN `GamePromptBanner`. Verified live both devices (starter + joiner). | **Fixed — pending 1 confirm** |
| O-AGE-001 | P2 | Release / store readiness (Pass O) | **No age gate / age verification despite adult-intimacy content.** Sign-up collects only email+password+confirm; Create Profile collects name+gender; `domain/model/User.kt` has **no DOB/age field**; the only "birthday" in-app is the *partner's* relationship special-date (`SpecialDatesSection`), not age. Yet the app ships sexual/intimacy content (Desire Sync). Google Play content-rating + sexual-content policy generally require an accurate maturity rating and may require an age gate. _(Static finding — 2026-06-28 QA-plan gap review; confirm against current Play policy + intended content rating.)_ | Add an 18+/age-appropriate gate where required + complete the Play content/maturity questionnaire to match actual content. **Pre-ship gate** (does not block per-round flawless). | **Open (pre-ship)** |
| BRAND-DARK-COVERAGE | P3 | Art / theme | Most illustrations are **light-only** — only 12 of ~25 have a `drawable-night-nodpi/` dark variant. All `illustration_couple_*` heroes (paywall/subscription/onboarding/invite/history), `daily_question`, `partner_activation`, `tonight_partner_prompt`, `together_empty`, and **all 10 `pack_art_*` banners** show the **light/pink image on a dark screen** (feathered edges don't change the image colors). | Generate dark/aubergine-palette variants for each light-only asset → `drawable-night-nodpi/` (identical filename); `BrandIllustration` auto-selects per in-app theme. Re-run the decoupled-theme check. List in `ClaudeBrandingReview.md`. | **Open (P3)** |

View File

@ -295,12 +295,23 @@ class QuestionSessionRepositoryImpl @Inject constructor(
override suspend fun abandonSession(coupleId: String): Result<Unit> = runCatching {
val activeSession = getActiveSessionForCouple(coupleId) ?: return@runCatching
saveSession(
activeSession.copy(
completedAt = System.currentTimeMillis(),
status = "completed"
// Targeted update of ONLY the rule-allowlisted progress fields. A full saveSession() here
// does doc.set() of the whole document, which DROPS the server-only flags the Cloud Function
// wrote (startNotifiedAt / joinNotifiedAt / partFinishNotifiedAt). The session-update rule
// counts those removed keys in affectedKeys(), so the write is PERMISSION_DENIED and the
// session is stranded `active` (blocks new games; the failure is swallowed). Mirror
// markUserComplete's targeted update so affectedKeys == {status, completedAt}. (B-ABANDON-001)
firestore.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.SESSIONS)
.document(activeSession.id)
.update(
mapOf(
"status" to "completed",
"completedAt" to System.currentTimeMillis()
)
).map { }.getOrThrow()
)
.await()
}
override suspend fun getSessionById(coupleId: String, sessionId: String): QuestionSession? =

View File

@ -138,16 +138,13 @@ class GameSessionManager @Inject constructor(
throw Exception("Session ID mismatch")
}
val completedAt = System.currentTimeMillis()
val updatedSession = currentSession.copy(
completedAt = completedAt,
status = "completed"
)
// Propagate a failed write: saveSession returns Result.failure rather than
// throwing, so without getOrThrow() the outer runCatching would report success
// and leave the session stuck "active" (locking the couple out of new games).
sessionRepository.saveSession(updatedSession).map { }.getOrThrow()
// Complete via the targeted allowlisted update (status + completedAt only). Do NOT round-trip
// through saveSession() — its full doc.set() drops the server-only notification flags
// (startNotifiedAt/joinNotifiedAt/partFinishNotifiedAt), which the session-update rule counts
// as disallowed affectedKeys → PERMISSION_DENIED, stranding the session active and locking the
// couple out of new games. abandonSession performs the correct write. (B-ABANDON-001)
// getOrThrow propagates a failed write (otherwise the outer runCatching falsely reports success).
sessionRepository.abandonSession(coupleId).getOrThrow()
}
/**

View File

@ -625,10 +625,17 @@ class HomeViewModel @Inject constructor(
cta = "Answer to unlock reveal"
)
// NOTE: GAME_WAITING fires whenever there's an active session this user hasn't finished
// (for async games like This or That, completedByUsers stays empty until BOTH finish, so we
// cannot assume the partner has played their part here). Keep this copy accurate for every
// state — the real-time "X played their part, your turn" nudge is delivered separately by the
// push-driven YOUR_TURN GamePromptBanner, which knows the partner actually finished first.
Priority.GAME_WAITING -> HomeAction(
eyebrow = "Your turn",
title = "Your turn to play.",
body = "Your partner already played their part — take your turn to reveal how you two line up.",
eyebrow = "Game in progress",
title = "Pick up your game.",
body = partnerName?.let {
"Jump back in to finish your picks and see how you and $it line up."
} ?: "Jump back in to finish your picks and see how you two line up.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual,

View File

@ -1163,6 +1163,12 @@ SCRIPTS.md
These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now.
### B-ABANDON-001 — never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys
**Symptom (R20)**: tapping **Quit** on any game (This or That / How Well / Wheel) navigated away but **left the session `active` server-side** — stranded, blocking new games — with no user-visible error. Logcat showed `Write failed at couples/{id}/sessions/{sid}: PERMISSION_DENIED``ThisOrThatViewModel: quit-abandon no-op`. The failure was swallowed by `runCatching{…}.onFailure{ Log.d }`. This had been mistaken for a test-data cleanup nuisance for several rounds ("Quit doesn't cancel server-side") before being root-caused.
**Root cause**: `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) completed the session by calling `saveSession(activeSession.copy(status="completed", completedAt=now))`. `saveSession` does `doc.set(data)` where `data` is a **fixed 13-field map** — so it **overwrites the whole document and drops any field not in that map**, including the server-only notification flags the Cloud Function wrote via Admin SDK (`startNotifiedAt`, `joinNotifiedAt`, `partFinishNotifiedAt`, `finishNotifiedAt`). The session-update rule's `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status','completedAt','completedByUsers','joinedByUsers'])` counts a **removed** key as affected → those dropped flags fail `hasOnly` → the entire write is denied. (The normal both-finished completion path was unaffected because `markUserComplete` uses a **targeted `tx.update(docRef, {completedByUsers, status?, completedAt?})`**, never `saveSession`.)
**Fix (R20)**: `abandonSession` now does a targeted `firestore...document(activeSession.id).update(mapOf("status" to "completed","completedAt" to now))` so `affectedKeys == {status, completedAt}` ⊆ the allowlist (and the active→completed transition satisfies the monotonic-status clause); `finishGame` delegates to it. Verified live: Quit → no denial → session `active=0` → a different game starts immediately (lockout gone).
**Re-introduction risk**: **`saveSession()` is a CREATE primitive (full `doc.set`), not an UPDATE primitive.** Any code that mutates an *existing* session must use a targeted `update(...)` touching ONLY allowlisted keys (`status`/`completedAt`/`completedByUsers`/`joinedByUsers`) — a full `set()` silently strips server-written fields and the rule denies it. The hazard is invisible in unit tests (no rules) and is swallowed by best-effort `Log.d` callers, so **any new session-completion/abandon path must be exercised live against deployed rules** (watch logcat for `PERMISSION_DENIED` on the session doc), not just compiled. When you add a new server-only session field, it is dropped by `saveSession` for the same reason — prefer `update()` everywhere a session already exists.
### N-001 / N-002 — VMs that wait for the screen to push an id silently no-op if nothing pushes it
**Symptom (R15)**: the **Bucket List was entirely non-functional** — add/load/complete/delete all did nothing, no error, no logcat. **Root cause**: `BucketListViewModel` gated every operation on `if (coupleId.isEmpty()) return`, expecting the screen to call `setCoupleId(...)` — but `BucketListScreen` never did (the nav route passes no coupleId and there's no `LaunchedEffect`). So `coupleId` stayed `""` and every op returned early **silently**. Same class hit **Date Builder (N-002)**: `savePreference()` bailed on `dateIdeaId.isEmpty()` while **nothing ever calls `setDateIdeaId`**, the preference had an empty `coupleId`, and it wrote to `date_plan_preferences` — a collection **no screen reads**. So "Create Plan" silently saved nothing.
**Fix (R15, N-001)**: `BucketListViewModel` resolves the couple **itself** in `init` via `CoupleRepository.getCoupleForUser(uid)``setCoupleId``loadItems` (mirrors `MemoryLaneViewModel`/`YourProgressViewModel`, the correct pattern). Bucket items encrypt at rest (`enc:v1:`) once a real coupleId flows.

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

View File

@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500" viewBox="0 0 1024 500" role="img" aria-label="Closer private questions for couples feature graphic">
<defs>
<linearGradient id="iconGlow" x1="70" y1="74" x2="380" y2="384" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2A1138"/>
<stop offset="1" stop-color="#13061D"/>
</linearGradient>
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#050108" flood-opacity="0.38"/>
</filter>
<filter id="smallShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="7" stdDeviation="10" flood-color="#050108" flood-opacity="0.24"/>
</filter>
</defs>
<rect width="1024" height="500" fill="#15081E"/>
<path d="M0 0 L170 74 H398 L563 166 H816 C818 99 848 44 900 0 H0 Z" fill="#B98AF4"/>
<path d="M0 371 C139 310 288 315 430 346 C573 378 714 384 839 292 C851 363 845 430 821 500 H0 Z" fill="#0E0416"/>
<ellipse cx="1030" cy="192" rx="215" ry="310" fill="#F4C4DF"/>
<rect x="68" y="74" width="313" height="313" fill="#21102D" opacity="0.72"/>
<rect x="72" y="78" width="305" height="305" rx="64" fill="url(#iconGlow)" stroke="#4E2B61" stroke-width="1.3" filter="url(#softShadow)"/>
<image href="../../store/app-icon-512.png" x="96" y="99" width="258" height="258" preserveAspectRatio="xMidYMid meet"/>
<text x="410" y="143" fill="#FFFFFF" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="74" font-weight="800">Closer</text>
<text x="412" y="198" fill="#FFE6F5" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="28" font-weight="800">Private questions for two.</text>
<text x="412" y="237" fill="#F8EFFB" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="500">Answer prompts. Reveal together.</text>
<text x="412" y="267" fill="#F8EFFB" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="500">Encrypted before sync.</text>
<g filter="url(#smallShadow)">
<rect x="408" y="298" width="178" height="93" rx="21" fill="#FFF8FC"/>
<circle cx="497" cy="316" r="15" fill="#E8D8F9"/>
<rect x="486" y="308" width="22" height="17" rx="6" fill="#5E3479"/>
<rect x="491" y="313" width="12" height="2.4" rx="1.2" fill="#F7EFFB"/>
<rect x="491" y="318" width="9" height="2.4" rx="1.2" fill="#F7EFFB"/>
<text x="497" y="354" fill="#2A1635" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="800" text-anchor="middle">Daily prompts</text>
<text x="497" y="376" fill="#77647D" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="15" font-weight="500" text-anchor="middle">Small check-ins</text>
</g>
<g filter="url(#smallShadow)">
<rect x="604" y="298" width="184" height="93" rx="21" fill="#FFF8FC"/>
<circle cx="696" cy="316" r="15" fill="#F3D5E6"/>
<path d="M696 331 C688 324 682 319 682 312 C682 308 685 304 689 304 C692 304 695 307 696 309 C698 307 701 304 704 304 C708 304 711 308 711 312 C711 319 704 324 696 331 Z" fill="#A7195A"/>
<text x="696" y="354" fill="#2A1635" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="800" text-anchor="middle">Mutual reveal</text>
<text x="696" y="376" fill="#77647D" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="15" font-weight="500" text-anchor="middle">See together</text>
</g>
<g filter="url(#smallShadow)">
<rect x="806" y="298" width="144" height="93" rx="21" fill="#FFF8FC"/>
<circle cx="878" cy="316" r="15" fill="#E8D8F9"/>
<rect x="867" y="314" width="22" height="18" rx="6" fill="#5E3479"/>
<path d="M872 314 V310 C872 304 876 302 878 302 C880 302 884 304 884 310 V314" fill="none" stroke="#5E3479" stroke-width="4" stroke-linecap="round"/>
<text x="878" y="354" fill="#2A1635" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="19" font-weight="800" text-anchor="middle">Private sync</text>
<text x="878" y="376" fill="#77647D" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="13" font-weight="500" text-anchor="middle">Google can't decrypt</text>
</g>
<g>
<rect x="408" y="410" width="432" height="44" rx="22" fill="#7B568E"/>
<circle cx="432" cy="432" r="15" fill="#FFD3E9"/>
<path d="M432 420 L441 424 V434 C441 442 436 447 432 449 C428 447 423 442 423 434 V424 Z" fill="#6B3C82"/>
<text x="458" y="439" fill="#FFFFFF" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="18" font-weight="800">A private space for two</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

View File

@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500" viewBox="0 0 1024 500" role="img" aria-label="Closer end-to-end encryption feature graphic">
<defs>
<linearGradient id="iconGlow" x1="70" y1="74" x2="380" y2="384" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2A1138"/>
<stop offset="1" stop-color="#13061D"/>
</linearGradient>
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#050108" flood-opacity="0.38"/>
</filter>
<filter id="smallShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="7" stdDeviation="10" flood-color="#050108" flood-opacity="0.24"/>
</filter>
</defs>
<rect width="1024" height="500" fill="#15081E"/>
<path d="M0 0 L170 74 H398 L563 166 H816 C818 99 848 44 900 0 H0 Z" fill="#B98AF4"/>
<path d="M0 371 C139 310 288 315 430 346 C573 378 714 384 839 292 C851 363 845 430 821 500 H0 Z" fill="#0E0416"/>
<ellipse cx="1030" cy="192" rx="215" ry="310" fill="#F4C4DF"/>
<rect x="68" y="74" width="313" height="313" rx="0" fill="#21102D" opacity="0.72"/>
<rect x="72" y="78" width="305" height="305" rx="64" fill="url(#iconGlow)" stroke="#4E2B61" stroke-width="1.3" filter="url(#softShadow)"/>
<image href="../../store/app-icon-512.png" x="96" y="99" width="258" height="258" preserveAspectRatio="xMidYMid meet"/>
<text x="410" y="143" fill="#FFFFFF" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="74" font-weight="800">Closer</text>
<text x="412" y="198" fill="#FFE6F5" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="28" font-weight="800">End-to-end encrypted.</text>
<text x="412" y="237" fill="#F8EFFB" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="500">Answers lock on your device before sync.</text>
<text x="412" y="267" fill="#F8EFFB" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="500">Google can store them, not decrypt them.</text>
<g filter="url(#smallShadow)">
<rect x="408" y="298" width="178" height="93" rx="21" fill="#FFF8FC"/>
<circle cx="497" cy="318" r="16" fill="#E8D8F9"/>
<path d="M497 306 L508 311 V321 C508 330 502 336 497 339 C492 336 486 330 486 321 V311 Z" fill="#5E3479"/>
<text x="497" y="354" fill="#2A1635" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="800" text-anchor="middle">On device</text>
<text x="497" y="376" fill="#77647D" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="15" font-weight="500" text-anchor="middle">Encrypted first</text>
</g>
<g filter="url(#smallShadow)">
<rect x="604" y="298" width="184" height="93" rx="21" fill="#FFF8FC"/>
<circle cx="696" cy="318" r="16" fill="#F3D5E6"/>
<path d="M686 319 C686 313 690 309 696 309 C702 309 706 313 706 319 C706 325 702 329 696 329 C690 329 686 325 686 319 Z" fill="#A7195A"/>
<rect x="693" y="326" width="6" height="12" rx="3" fill="#A7195A"/>
<circle cx="699" cy="315" r="3" fill="#F3D5E6"/>
<text x="696" y="354" fill="#2A1635" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="800" text-anchor="middle">Your keys</text>
<text x="696" y="376" fill="#77647D" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="15" font-weight="500" text-anchor="middle">Stay with you</text>
</g>
<g filter="url(#smallShadow)">
<rect x="806" y="298" width="144" height="93" rx="21" fill="#FFF8FC"/>
<circle cx="878" cy="318" r="16" fill="#E8D8F9"/>
<rect x="865" y="310" width="26" height="19" rx="7" fill="#5E3479"/>
<circle cx="872" cy="320" r="2.6" fill="#F7EFFB"/>
<circle cx="878" cy="320" r="2.6" fill="#F7EFFB"/>
<circle cx="884" cy="320" r="2.6" fill="#F7EFFB"/>
<text x="878" y="354" fill="#2A1635" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="19" font-weight="800" text-anchor="middle">Google can't</text>
<text x="878" y="376" fill="#77647D" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="15" font-weight="500" text-anchor="middle">decrypt them</text>
</g>
<g>
<rect x="408" y="410" width="432" height="44" rx="22" fill="#7B568E"/>
<circle cx="432" cy="432" r="15" fill="#FFD3E9"/>
<path d="M432 420 L441 424 V434 C441 442 436 447 432 449 C428 447 423 442 423 434 V424 Z" fill="#6B3C82"/>
<text x="458" y="439" fill="#FFFFFF" font-family="Noto Sans, DejaVu Sans, Arial, sans-serif" font-size="18" font-weight="800">Only your devices can decrypt</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB