Compare commits

..

33 Commits

Author SHA1 Message Date
null 5e7ef19b8f docs(brand): add asset-system.md, cross-link from visual-identity.md 2026-06-25 11:26:21 -05:00
null 3c4a4133be qa(r3): Pass C visual sweep + Pass D security re-audit clean
Pass C: ~14 screen-types in dark (Home, Play, all 7 games, paywall, Settings+Subscription+Appearance,
Today, Messages inbox, Conversation) render clean, no FATAL, no new contrast issues, 0 enc:v1 leaked
to UI. C-DS-001 holds. C-OBS: debug menu entries (verify BuildConfig.DEBUG-gated). Remaining standard
list/detail screens deferred (nav-drift).
Pass D: deployed rules re-audited (B-001 + D-001 fixes present, hasPremium/entitlements server-only,
ciphertext enforced, no catch-all); at-rest chat text + preview = enc:v1. D3 live deferred (3rd acct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:25:19 -05:00
null bb3b74f41e qa(r3): Pass A + Pass B fully re-verified live
Pass A: neither->paywall, partner->couple-shared unlock, self->unlock, A-003 badges both
directions. New A-OBS (P3): paywall plan-load shows raw 'credentials issue' (env: no RC sandbox).
Pass B: all 7 game areas played; B-001 holds across 4 async types (auto-complete); B-002 clean
case works; B-003 + C-DS-001 hold; Date Match deck + Wheel + How Well + Desire Sync + ToT all PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:18:07 -05:00
null 274fa5ca61 qa(r3): How Well verified (5/5, B-001 holds); log B-004 intermittent guesser-stuck on WaitingForPartner
How Well two-device playthrough PASS x2 (subject+guesser, reveal correct, session auto-closes).
B-002 clean case works (Play now -> guess INTRO when subject done).
NEW B-004 (P2, intermittent): guesser can land on generic WaitingForPartnerScreen for How Well
during a rapid game-to-game transition and get stuck (screen only exits on session end).
Not reproduced in clean case; escalate to P1 if deterministic. Report-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:06:02 -05:00
null eb4db1a321 qa(r3): re-QA round 3 — nav + premium + Desire Sync/This-or-That fixes re-verified live
Round 3 full re-QA in progress. Re-verified LIVE on both emulators (build ce7fc2e):
C-NAV-001 (back->launcher), C-CC-001 (single header), back-stack clean,
A-001 couple-shared (Sam free unlocks Desire Sync+Memory Lane), A-003 (0 badges),
D-001 (capsules load, no PERMISSION_DENIED), B-001 (2 consecutive games auto-close),
B-002 (Home Play-now resumes exact game), B-003 (coherent counts), C-DS-001 (dark contrast).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:46:16 -05:00
null ce7fc2edcb qa(fix-phase): all P0-P3 FIXED (P3x4: A-003/B-003/E-002/F-OBS); severity board all clear 2026-06-25 10:14:14 -05:00
null ab2ff8dbc7 fix(memorylane): propagate snapshot-listener errors so the screen doesn't hang (F-OBS P3)
observeCapsules swallowed listener errors (return@), so on PERMISSION_DENIED the flow never
emitted or closed and Memory Lane hung on its loading heart forever. Now close(err)s the
flow -> the ViewModel's existing onFailure -> ERROR state with Retry. (Root cause that
masked D-001.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:14 -05:00
null 497e641a90 fix(notifications): route partner_left/partner_deleted_account to Home (E-002 P3)
Added PARTNER_UNPAIRED type for the two real 'you are unpaired' pushes -> Home, where the
now-solo user gets the Invite CTA (matches body 'Tap to create a new invite'). Documented
that invite_created (server audit log, read:true) and spki (a crypto key-format string in
the RevenueCat webhook, not a notification) are false positives needing no routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:14 -05:00
null 652e3a7f4e fix(desiresync): clearer privacy counts on reveal (B-003 P3)
Per-person tiles showed '$total private' (e.g. '5 private'), contradicting the caption's
'N kept private' (e.g. '2 kept private'). Tiles now read just 'Private' (your individual
answers always stay private); the caption keeps the real shared/kept breakdown. Verified:
'You: Private / Sam: Private' + 'N shared, M kept private', no contradiction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:14 -05:00
null f1fdefb987 fix(play): hide Premium badge on Desire Sync/Memory Lane cards when couple has premium (A-003 P3)
Threaded showPremiumBadge=!hasPremium into DesireSyncCard/MemoryLaneCard and gated the
lock badge behind it. The feature was already accessible (A-001) — only the static badge
was misleading. Verified: with couple premium the Play hub shows no Premium badge on them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:14:14 -05:00
null bc8f519778 qa(fix-phase): mark B-002/C-CC-001/C-DS-001 (P2) FIXED+verified; P0-P2 all clear, P3x4 remain 2026-06-25 09:58:24 -05:00
null c3c5438bcc fix(home): 'Play now' resumes the waiting game, not the generic hub (B-002 P2)
Resolve the active session's gameType to its resume route (gameRouteFor) and carry it on
HomeAction.gameRoute / HomeUiState.waitingGameRoute; HomeActionTarget.Game now navigates
there (fallback Play hub). Each game screen auto-joins the couple's active session on open,
so the Home 'Play now' CTA drops the user straight into the actual waiting game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:58:24 -05:00
null e0d33ea85d fix(desiresync): theme-aware reveal text for dark mode (C-DS-001 P2)
DesireMatchCard used a hardcoded dark plum (Color(0xFF3D1F2E)) for the shared-desire
text -> readable on the light card in light mode, but dim/low-contrast on the dark-tinted
card in dark mode. Switched to MaterialTheme.colorScheme.onSurface so it adapts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:58:24 -05:00
null eb9facc2d5 fix(nav): drop Connection Challenges from shellBackRoutes (C-CC-001 P2)
The screen renders its own header (title + 'Pick a series…' subtitle + back) for both
the pick and active views, so the nav-scaffold app bar drew a SECOND identical header +
back arrow on top. Removed it from shellBackRoutes -> single header, single back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:58:24 -05:00
null f4a7019c3c qa(fix-phase): mark B-001 + C-NAV-001 (both P1) FIXED+verified; severity P1 0 open/4 fixed 2026-06-25 09:37:36 -05:00
null c1ef8d6630 fix(rules): allow completedByUsers on session update so finished games close (B-001 P1)
The sessions allow-update rule required affectedKeys().hasOnly(['status','completedAt']),
but the async-game completion path (markUserComplete) always writes completedByUsers, so
every 'I reached results' write was denied and the session stayed active forever -> the
couple was locked out of starting any new game (only the destructive 'End their game'
worked, since abandonSession only diffs status/completedAt). Rule now permits
['status','completedAt','completedByUsers'], lets any couple member record completion
progress, keeps startedByUserId immutable and status monotonic (active->completed).
Deployed + verified live: both finish a game -> session auto-completes (completedByUsers
=[both]) -> next game starts immediately (no 'Waiting for partner' block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:37:36 -05:00
null ebd3b2ed1f fix(nav): clear onboarding/auth back stack on entry->Home (C-NAV-001 P1)
Navigating to Home from any entry route (onboarding/profile/pair/login/signup/forgot)
now resets the stack (popUpTo(0) inclusive) so Home is the back-stack root. Previously
the graph start (ONBOARDING) lingered under Home, so system Back from Home walked
backward into the onboarding carousel -> welcome/login, making a signed-in user look
logged out. Verified: Back from Home now exits the app to the launcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:37:36 -05:00
null 4e49b92be2 qa(round2-B2): Date Match PASS (3 mutual matches + live It's-a-match push + Matches view); PASS B COMPLETE all 7 games; Sam reverted to free 2026-06-24 23:31:18 -05:00
null 01feee8321 qa(round2-B2): Spin the Wheel PASS (free partner enters; spin->Date Night->both answer 10->reveal matches both, no crash) 2026-06-24 23:22:25 -05:00
null f79b38c07c qa(round2-B2): Memory Lane PASS (capsule create+seal, encrypted at rest enc:v1:, cross-device sealed view, no crash); D1 capsule ciphertext verified live 2026-06-24 23:15:23 -05:00
null e76a84f5da qa(round2-B2): C-NAV-001 P1 CONFIRMED — back from Home resurfaces onboarding/auth (back stack not popped after login); clean cold-start repro 2026-06-24 23:07:28 -05:00
null 1303597d4a qa(round2-B2): Connection Challenges PASS (day-cycle synced both, streak ok, no crash); C-CC-001 P2 (duplicate header + double back) 2026-06-24 23:03:15 -05:00
null c71d858283 qa(round2-B2): Desire Sync PASS (free partner enters via A-001; 3 mutual desires revealed correctly, mismatches hidden, results match); B-003 P3 (confusing counts), C-DS-001 P2 (dark contrast) 2026-06-24 22:57:50 -05:00
null 3c9037d8e4 qa(round2-B2): How Well PASS (user-nav, predicted 4/5 w/ deliberate miss + scale, results match both, no crash); B-001 re-corroborated (Back to Play leaves session active) 2026-06-24 22:50:59 -05:00
null f8dc8119cb qa(round2-B2): This or That PASS (user-nav, 5/5, results match); B-001 escalated P3->P1 (Back to Play doesn't close finished session -> blocks next game); B-002 P2 (Play now lands on hub) 2026-06-24 22:42:18 -05:00
null 8fa922fb70 qa(round2): RESTART Pass B from game #1 (play-as-user) — coverage reset, build reinstalled both devices 2026-06-24 22:30:09 -05:00
null 21504098c2 qa(plan): Pass B — play-as-the-user mindset; report-first-then-workaround on any broken flow 2026-06-24 22:27:40 -05:00
null f9c6e42d92 qa(round2): Pass B — How Well full two-device playthrough PASS (5/5 predict, results match both) 2026-06-24 22:22:48 -05:00
null 60a6ce1dbf docs(qa): continue across auto-compaction without the user (file-state is authoritative)
Don't hand back when context fills: harness auto-summarizes + you continue from the committed
run-state + coverage. Can't self-invoke /compact and don't need to. Commit before interruptible
work; session-start ritual recovers stuck sessions. Only true blockers (denied gated action /
macOS) stop the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:13:48 -05:00
null 693ecd28ef qa(round2): Pass B — This or That full two-device playthrough PASS (5/5, results match both, no crash) 2026-06-24 22:10:15 -05:00
null e7073fc5f8 qa(round2): R2-1 done — A-001 re-verified all features + free-gate; D-001 fixed. Pass B next 2026-06-24 22:04:54 -05:00
null b05a72605e fix(rules): add capsules + challenges member rules (D-001 P1) — Memory Lane/Challenges were broken
couples/{id}/capsules and /challenges had NO rules -> default-deny -> Memory Lane hung on
loader, Connection Challenges couldn't load (live PERMISSION_DENIED). Added member-read +
ciphertext-enforcing capsules rule (title/content/promptUsed = enc:v1:) and a challenges
rule (catalog-referenced progress). Deployed + verified live: both features load, 0 perm
errors. Found during Round-2 re-verify of A-001 (Memory Lane couple-shared also confirmed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:02:40 -05:00
null efe0ddbf29 qa: record standing authorization (deploy firestore rules + admin access)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:51:45 -05:00
13 changed files with 558 additions and 85 deletions

View File

@ -16,19 +16,29 @@
| Subscription screen (own status) | n/a | n/a | n/a | pass (by-design per-user) |
Pass A: **complete** (1 systemic P1). **A-001 FIXED** (e8892a9) — couple-shared everywhere; re-verify each feature in re-QA. New cosmetic A-003 (P3, badge). Subscription screen by-design.
**R3 re-verified LIVE (2026-06-25):** neither→paywall ("Go deeper together"), partner→couple-shared unlock (Sam free entered Desire Sync + Memory Lane), self→unlock; A-003 badges hidden under premium / shown when free (count 0↔2). New A-OBS (P3): paywall plan-load shows raw "credentials issue" error (env: no RevenueCat sandbox).
## Pass B — Games lifecycle (start / play / finish + results)
**RESTARTED 2026-06-24 (R2-B2): full re-run from game #1 with the PLAY-AS-THE-USER mindset** (navigate only via the
real in-app path; report-first-then-workaround on any broken flow). Prior R2 This or That / How Well passes are
superseded — redo every game cleanly. (Prior result for reference: This or That 5/5 ✅, How Well 5/5 ✅.)
**✅ R2-B2 COMPLETE — all 7 games played one full time through on both devices via real user nav; gameplay all PASS.**
Findings surfaced by playing-as-user: **B-001 (P1)** finished session never closes → blocks next game; **C-NAV-001 (P1)**
back from Home resurfaces onboarding/auth; **B-002 (P2)** Home "Play now" → generic hub; **C-CC-001 (P2)** Connection
Challenges dup header/double-back; **C-DS-001 (P2)** Desire Sync dark-mode low contrast; **B-003 (P3)** confusing Desire
Sync counts. Sam reverted to free (baseline). `date_match` push verified live (Pass E bonus).
| Game | starts | plays | finishes/results | no crash | Status |
|---|---|---|---|---|---|
| This or That | pass (launch) | partial | todo | pass | launch ok |
| How Well Do You Know Me | pass (launch) | partial | todo | pass | launch ok |
| Desire Sync | n/a (premium) | todo | todo | todo | needs premium toggle |
| Connection Challenges | pass (launch) | partial | todo | pass | launch ok |
| Memory Lane | n/a (premium) | todo | todo | todo | needs premium toggle |
| Spin the Wheel | pass (launch) | partial | todo | pass | launch ok |
| Date Match | todo | todo | todo | todo | todo |
| 1. This or That | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: 5/5 via Play hub, answers synced, results match both (4/5 "Two peas in a pod", Q2 Differ correct), no crash ✅. Session-lifecycle bug B-001 (P1) hit on exit.** |
| 2. How Well Do You Know Me | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA answered 5 (incl. a 1-5 scale Q5); Sam predicted via Play hub — 3 correct + 1 deliberate miss (Kind tone vs Specific examples) + scale match → results show 4/5 "You really know each other" with the wrong one marked ✗ on BOTH devices, scoring accurate, no crash ✅** |
| 3. Desire Sync | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA(free) entered w/o paywall (A-001 live ✅); both answered 5 Yes/No → exactly 3 mutual desires revealed, mismatches hidden (privacy correct), results match both, no crash ✅. Findings: B-003 (P3 confusing counts), C-DS-001 (P2 dark-mode low contrast on revealed list).** |
| 4. Connection Challenges | pass | **pass (day-cycle, user-nav)** | **pass** | pass | **R2-B2: opened (D-001 rules hold ✅); started Gratitude Week → both completed Day 1 → day ✓, 🔥1 streak, advanced to Day 2 "Both of you showed up today", synced on both, no crash ✅. (7-day series is time-gated; full per-day cycle verified.) Finding: C-CC-001 (P2 duplicate header + double back). Minor: first partner's view shows next-day content + "waiting for partner" before the day is mutually done (self-resolves).** |
| 5. Memory Lane | pass | **pass (create+seal, user-nav)** | **pass (sealed)** | pass | **R2-B2: loads clean (D-001 ✅, no hung heart); QA wrote a capsule (title+body), picked "1 month" → sealed "Opens in 29 days"; **encrypted at rest** (title+content `enc:v1:`, `unlockAt`=+30d, status=sealed); Sam sees the same sealed capsule cross-device; no crash / no PERMISSION_DENIED ✅. Unlock/reveal is future-dated (can't test w/o time-travel). Single header (no C-CC-001 here).** |
| 6. Spin the Wheel | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA(free) entered (A-001 ✅); spun → "Date Night" category → both answered all 10 prompts (multi-select) → reveal "Here's how you each answered" with per-Q You/partner breakdown matching on BOTH devices, no crash ✅. Wheel session synced (Sam joined QA's active session). Dark answer text a bit dim (C-OBS pattern, readable).** |
| 7. Date Match | pass | **pass (full, user-nav)** | **pass** | pass | **R2-B2: QA(free) entered (A-001 ✅, in Play hub below Question Packs); both swiped date-idea deck (❌/⭐/💗); QA + Sam both liked the same 3 → 3 `date_matches` created (sunrise_hike/kayak/rock_climbing); Sam got "It is a match!" modal + LIVE "It's a match!" push notification; "Your Matches" shows all 3 "Mutual love"; no crash / no PERMISSION_DENIED ✅. (Premium-badged ideas accessible via couple premium.)** |
_Note: stale active session blocked games (B-001); cleared via in-app "End their game" (recovery verified)._
_Note: stale active session blocked games (B-001); cleared via in-app "End their game" (recovery verified). Exit each game via Back to Play between games so the session closes._
**REQUIREMENT (updated): each game must be played ONE COMPLETE time through on both devices (every step → finish/
reveal/results), not just launched.** All rows above are currently `launch ok / partial` only → **full playthrough
still owed for every game** in Round 2 (premium games need a premium toggle). A launch-only row counts as `partial`, not `pass`.

View File

@ -36,9 +36,15 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
findings are still logged and fixed in the fix phase.
- **"Once executed, complete it":** never declare done before the Definition of Done is met — keep cycling fix → re-QA
until flawless, then stop.
- **Context limits ≠ stopping.** If a context window fills, that's a checkpoint, not a stop: make sure the run-state
header + MD files are current and committed; the resume command continues automatically. Keep the loop alive across
sessions until flawless.
- **Context limits ≠ stopping — do NOT hand back to the user when context fills.** The harness auto-summarizes a long
conversation and continues in the next window; you continue **without the user**. (You cannot self-invoke `/compact`
— and you don't need to; auto-compaction handles it.) The **committed `ClaudeReport.md` run-state + `ClaudeQACoverage.md`
are the authoritative state** and survive any compaction — after a summary, **re-read them and continue at the next
chunk**. Never pause a run merely because context is getting long; only stop for a true blocker (a denied gated action
even with standing auth, or the macOS requirement for iOS).
- **Commit before anything interruptible** so a mid-chunk compaction never loses progress. Keep chunks atomic; if a
chunk is cut off mid-way (e.g., a game session left active), the **session-start ritual recovers it** (clear the stuck
session via in-app "End their game", then redo that chunk). Right-sized chunks (see Batch sizing) make this rare.
- **Don't pause for "by-design vs bug":** log the ambiguous finding and keep going (don't unilaterally rewrite
deliberate design — the log captures it). Never halt the run to ask.
- **Only true stop = a gated action you cannot perform.** Production deploys, admin Firestore writes/seeds, and
@ -131,6 +137,17 @@ Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSync
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through)
Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match.
- **PLAY AS THE USER (mandatory mindset for this pass):** drive every game **the way a real user would** — reach it
through the actual in-app navigation a person would tap (Play hub → the game's card → its buttons), **not** via
deep-links, admin pokes, forced state, or any shortcut a user doesn't have. **Expect what the user expects:** if a
tap/button/flow doesn't do the obvious thing, or a screen doesn't behave the way a normal user would assume, **that
itself is a finding** — log it.
- **When something doesn't work: REPORT FIRST, then a minimal workaround (in that order).** Do **not** silently
engineer around breakage by taking extra steps the user wouldn't take. The moment the natural user path fails:
(1) **log the issue** in `ClaudeReport.md` with severity + the exact user action that failed and what was expected;
(2) **only then** apply the smallest workaround needed to keep the pass moving. The workaround **never replaces**
the report — a flow that needs a workaround to proceed is, by definition, broken and must be filed to fix. If a
workaround is impossible, mark the game `fail→<id>` (blocked) and continue with the next.
- **A launch/crash check is NOT sufficient. Each game MUST be played one full way through, end-to-end, on BOTH
devices** — start → answer/interact through **every** step/round/question on each device → reach the
**finish/reveal/results** screen → confirm the result renders correctly for both partners. Verify each

View File

@ -1,8 +1,13 @@
# Claude QA Report — Full-App QA (living report)
> **RUN-STATE: Round 2 (re-QA + deferred coverage) NEXT | NEXT ACTION: re-verify A-001 + E-001 fixes; **play each game ONE complete time through on both devices** (Pass B was launch-only — full playthroughs still owed); then Pass C deep/stateful screens (reveal, wheel session, dates, bucket list, auth/onboarding) in both themes + **navigation from every entry point & back-stack/double-back checks**, full live notification matrix, D3 live non-member test; **Pass F (resilience/concurrency/lifecycle/time/migration)**; investigate **D-OBS** PERMISSION_DENIED on outcomes/challenges/capsules.**
> **RUN-STATE: Round 3 (full re-QA, started 2026-06-25) | Build == HEAD `ce7fc2e` (rebuilt+reinstalled on BOTH emulators this session). Baseline verified via admin: couple `Xal3Kw3gjSdn0niERYKJ`, Sam=free, QA=free(no entitlement), 0 active sessions, both apps cold-launch to Home no crash, 5554=Dark/5556=Light. NEXT ACTION: re-verify the 12 fixes hold (folded into the passes) then complete deferred coverage — Pass C deep/stateful + nav-from-every-entry + back-stack, Pass D3 live non-member, Pass E live notif matrix, Pass F resilience. Progress logged per-pass below + in ClaudeQACoverage.md.**
> _Round 2 result (carried): FIX PHASE COMPLETE — P1×2 (B-001 session rules, C-NAV-001 back-stack), P2×4 (A-001, B-002, C-CC-001, C-DS-001), P3×4 (A-003, B-003, E-002, F-OBS) all FIXED; D-001 (P1 rules) + E-001 (P2 routing) fixed earlier. All verified LIVE except E-002/F-OBS (code+build; live-trigger deferred). Rules deployed._
> Pass-B note: a finished game keeps its session active until a player exits the results (Back to Play); leaving both on results blocks the next game until "End their game". Exit cleanly between games.
> **Pass-B MINDSET (user, 2026-06-24): PLAY AS THE USER** — navigate only via the real in-app path a person would tap (no deep-links/admin pokes/shortcuts); expect what a user expects. When the natural path fails, **REPORT FIRST** (log issue + severity + the user action that failed & what was expected), **THEN** a minimal workaround to proceed — never silently engineer around breakage; a flow needing a workaround is broken and must be filed.
> R2-1 DONE: A-001 couple-shared re-verified live (Desire Sync/Memory Lane/Wheel enter when partner premium; free→paywall). **D-001 (P1) FIXED+DEPLOYED** (capsules/challenges rules; Memory Lane + Connection Challenges now load). Sam reverted to free (baseline).
> Round 1 complete (all 5 passes run report-only; P0P2 found were fixed in-line). Fixes: A-001 (e8892a9), E-001 (ce12abb). Open P3: A-003, B-001, E-002.
> **EXECUTION MODE: autonomous run-to-completion — do NOT stop; fix anything that blocks progress and continue; keep cycling fix→re-QA until a flawless round. Only a gated action (prod deploy / admin write / entitlement toggle) that's denied may be surfaced — do all other work first.**
> **EXECUTION MODE: autonomous run-to-completion — do NOT stop; fix blockers inline; keep cycling fix→re-QA until flawless. Do NOT hand back when context fills — the harness auto-compacts and you continue from THIS run-state (re-read it + coverage after any summary). Commit before interruptible work; recover stuck sessions via the session-start ritual.**
> **STANDING AUTHORIZATION (user, 2026-06-24): may `firebase deploy --only firestore:rules` + has admin access (Firestore reads/writes/seeds + entitlement toggles) — run these without pausing. Only the macOS requirement for iOS (Parts 2/3) remains a hard stop.**
> Playbook: `ClaudeQAPlan.md`. Coverage matrix: `ClaudeQACoverage.md`. Report-only during passes (no fixes until the fix phase).
> Devices: emulator-5554 (QA=`Y05AKO`) + emulator-5556 (Sam=`imDjjO`), paired (coupleId `Xal3Kw3gjSdn0niERYKJ`). Build == HEAD `64f0a7e`.
@ -10,20 +15,75 @@ _(Prior games/notifications QA from 2026-06-24 was completed + verified; superse
---
## Severity summary (Round 1)
## Severity summary (current — R1 fixed + R2 findings)
| Severity | Open | Fixed |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | 1 |
| P2 | 0 | 1 |
| P3 | 3 | 0 |
| P1 | 0 | 4 |
| P2 | 0 | 4 |
| P3 | 0 | 4 |
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2). Open = P3 cosmetics only
(A-003 badge, B-001 stale-session guard, E-002 informational notif routing). Deferred for a clean "flawless"
certification: exhaustive deep/stateful screens (Pass C), full live notification matrix + D3 live non-member test.
**ALL KNOWN ISSUES FIXED (P0P3).** P1×2 (B-001 session rules, C-NAV-001 back-stack) + P2×4 (A-001 + B-002, C-CC-001, C-DS-001) + P3×4 (A-003, B-003, E-002, F-OBS). All verified live except E-002/F-OBS (code+build-verified; live-trigger deferred — needs an unpair event / induced query failure).
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2).
**Round 2 (Pass B play-as-user restart) new/changed:** **B-001** escalated **P3→P1** (a finished game never closes its
session via any normal path — proven: both tapped "Back to Play", session stayed `active` 12s later — so every game
blocks the next, recoverable only via the destructive "End their game"; breaks the core loop); **B-002 (P2, new)** Home
"Play now / your partner is waiting to play" lands on the generic Play hub instead of resuming/indicating the waiting
game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informational notif routing), F-OBS
(load-fail handling). Deferred for "flawless": exhaustive deep/stateful screens (Pass C), full live notif matrix + D3.
---
## Round 3 re-QA log (2026-06-25, build `ce7fc2e`) — fix regression + deferred coverage
**Fixes re-verified LIVE this round:**
- **C-NAV-001 ✅** — cold start (logged in) → Home → system Back → focus = `NexusLauncherActivity` (app exits). No onboarding resurfacing.
- **C-CC-001 ✅** — Play hub → Connection Challenges (active Gratitude Week) → single header (`Back` desc count = 1), no duplicate title.
- **Back-stack ✅** — clean cold-start hierarchy: deep screen (challenge) → Back → Play hub → Back → Home → Back → launcher. No double-back, no dead-ends. (Earlier "double-back" suspicion was warm nav-state restoration of the last Play-tab destination, not a real defect — does not reproduce from cold start.)
- **A-001 ✅ (couple-shared)** — QA set premium, Sam left free. Sam (5556, partner-premium) Play hub → Desire Sync opens to "How long?" setup (no paywall) + Memory Lane opens (sealed capsule shown). QA (5554, self-premium) likewise unlocked.
- **A-003 ✅** — Play hub shows **0 "Premium" badges** on both 5554 (self-prem) and 5556 (partner-prem couple-shared).
- **D-001 ✅** — Sam opened Memory Lane → capsule list renders, **0 PERMISSION_DENIED** in logcat (capsules rule holds; no hung heart → F-OBS path healthy).
**Desire Sync two-device playthrough (premium ON; QA started, Sam joined):**
- **B-001 ✅** — both answered all 5 → admin read shows **active=0** (session auto-flipped to completed, no "End their game" needed). Core loop intact.
- **B-003 ✅** — reveal counts fully coherent: "3 shared desires / 2 answers stayed private" + tiles "You: Private / partner: Private" + caption "3 shared, 2 kept private". No contradicting "5 private".
- **C-DS-001 ✅** — 5554 (dark) reveal "You both said yes to" list renders crisp **white high-contrast** text (old dim muted-pink gone); 5556 (light) black-on-light. Both readable.
- Gameplay PASS — privacy logic correct (QA T,Y,Y,Y,T vs Sam T,Y,N,Y,F → exactly the 3 mutual-affirmative shown, 2 mismatches hidden), reveals match on both, no crash. Sam (free) joined QA's session = couple-shared join works.
**This or That two-device playthrough (immediately after Desire Sync — 2nd consecutive game):**
- **B-002 ✅** — QA started This or That → Sam's Home showed "Game waiting / Your partner is waiting to play" → Sam tapped **"Play now"** → landed directly in This or That **1/5** (the exact waiting game), NOT the generic hub.
- **B-001 ✅ (2nd consecutive game)** — both answered 5/5 → admin **active=0** again. Proven a couple can play two games back-to-back with no dangling/blocking session. Core loop solid.
- Gameplay PASS — both picked A on all 5 → Sam results "5/5 in sync — Two peas in a pod, matched on 5 of 5" with correct per-Q breakdown; consistent on both; **0 FATAL** in logcat.
**How Well Do You Know Me two-device playthrough (QA subject, Sam guesser):**
- Gameplay PASS (×2) — QA answered 5 about self (incl. two 1-5 scale Qs); Sam guessed via Play hub → reveal "5 of 5 — Perfect read / You guessed 5 of 5 about QA" with all-correct breakdown on both; scoring accurate; no crash.
- **B-001 ✅ (3rd game type)** — session auto-completed (active=0) once both submitted.
- **B-002 (clean case) ✅** — with the subject (QA) DONE first, Sam's Home "Play now" → How Well guess INTRO ("I'm ready") correctly (route gameRouteFor(how_well)=HOW_WELL → HowWellScreen.joinSession → INTRO).
- **B-004 (NEW, P2, intermittent) — guesser can get stuck on the generic "Waiting for Partner" screen for How Well.** Observed once: during a rapid This-or-That→How-Well transition (Sam had just finished This or That; QA then started How Well and was still mid-answer), Sam tapped Home "Play now" and landed on the generic `WaitingForPartnerScreen` ("Waiting for QA / QA is playing a How Well game"), which **only exits when the session ends** (its VM navigates away only on session==null) — it never routes the guesser into the guess flow. So the guesser is trapped there (recoverable only via "Back to Games" / "End their game"; re-entering How Well via the Play hub then works). NOT reproduced in the clean subject-done case (Play now → INTRO worked). Likely a stale `waitingGameRoute`/transition race sending the guesser to a non-How-Well game screen (which sees an active how_well session of a "different type" → WaitingForPartner) or directly to WAITING_FOR_PARTNER. **Repro is timing-dependent — needs a deterministic trigger; if it proves deterministic for "guesser taps Play now while subject is mid-answer", escalate to P1 (traps the user).** Report-only (logged, not fixed mid-pass).
**Spin the Wheel two-device playthrough:** QA spun → "Emotional Intimacy" (10 Qs) → Start session → Sam joined QA's active wheel session (1/10). Both answered all 10 → reveal "Complete / Here's how you each answered / Emotional Intimacy" with per-Q You/Sam breakdown on both; **session auto-closed (active=0) → B-001 holds (4th game type: desire_sync/this_or_that/how_well/wheel all auto-complete)**; **0 FATAL**. (Some rows "Skipped" = free-text prompts the automated driver doesn't type; not an app bug.)
**D-001/Memory Lane (re-confirmed R3):** Sam (partner-prem) opened Memory Lane → existing sealed capsule "Opens in 29 days" renders, no hung heart, 0 PERMISSION_DENIED.
**Connection Challenges (re-confirmed R3):** active "Gratitude Week / Day 2 of 7" loads with single header (C-CC-001), back returns to Play hub cleanly.
**Date Match (R3):** opens (single header, "Swiping with Sam"), deck advances through cards (Sunrise hike → Overnight camping…), premium date ideas accessible under couple premium, 3 existing matches badge, no FATAL. (Full mutual-match + live push verified R2-B2.)
**Pass A neither→locked ✅** — premium toggled OFF, both free → Play hub re-shows Premium badges (Memory Lane 🔒, Past Games 🔒; count back to 2, A-003 gating confirmed BOTH directions) → tapping Desire Sync opens the **paywall** ("Go deeper together / Unlock everything Closer has built for couples", What's-included list, Continue/Restore) — gate correctly blocks free users (does NOT enter the game). **Pass A fully re-verified: neither→paywall, partner→couple-shared unlock, self→unlock, A-003 badges both directions.**
- **A-OBS (P3/observe, likely env-only):** the paywall's plan list fails with "**Couldn't load plans — There was a credentials issue. Check the underlying error for more details.**" + disabled Continue. Expected in this emulator (no RevenueCat/Play-billing sandbox), so the gate itself is fine; but that **raw developer-ish error copy is user-facing** — in prod, a plan-load failure should show a friendlier message. Flag for copy review (not a gate bug).
**Pass B (R3) — all 7 game areas covered:** Desire Sync ✅, This or That ✅, How Well ✅ (+B-004 logged), Spin the Wheel ✅, Date Match ✅, Connection Challenges ✅ (loads/single-header/active Day 2), Memory Lane ✅ (loads/sealed capsule). **B-001 confirmed across 4 async game types (auto-complete, no stuck session). B-002 works (clean case). All fixes (B-001/B-002/B-003/C-DS-001) hold.**
**Pass C (R3) — deep-screen visual sweep (5554=Dark primary; several seen in Light on 5556 during A/B):**
Verified render cleanly, readable, **no FATAL, no new dark-mode contrast issues** — Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, **Settings** (+ **Subscription** "One subscription for both partners — no double billing", + **Appearance** Theme radios), **Today**/daily-question (incl. answer detail "Save privately / Discuss"), **Messages inbox** (avatars/timestamps), **Conversation** (image + voice + text msgs, ❤️ reaction, "Seen", input bar). **E2EE UI check: 0 `enc:v1` ciphertext leaked into the conversation UI** (messages decrypt for the user). C-DS-001 dark-contrast fix holds.
- **C-OBS (P3/observe):** Settings shows "**Art preview (debug)**" + "**Paired home (debug)**" entries — debug-only menu items (expected in this debug build; confirm they're `BuildConfig.DEBUG`-gated so they don't ship in release).
- _Deferred (nav-drift made per-screen capture slow; standard list/detail screens, lower risk): Question Packs detail, Bucket List, Past Games, Wheel History, Answer Reveal (sealed), Date Builder/Plan Date, and a fresh-account pass on auth/onboarding/pairing. No issues seen on the ~14 screen-types reached; the deferred set is standard Compose list/detail using the same theme tokens already verified._
**Pass D (R3) — re-audit clean, no P0/P1:**
- **D2 rules (deployed) re-audited ✓** — no catch-all `match /{document=**}`, no blanket `if true`; **sessions update (B-001 fix present)**: only `['status','completedAt','completedByUsers']`, `startedByUserId` immutable, status monotonic active→completed; **hasPremium server-only** (client write+diff blocked L172/174); **entitlements** owner+partner read (couple-shared) / write server-only; **capsules (D-001)** member-read + ciphertext-enforced (isCiphertext title+content) + authorId-bound + key allowlist + coupleEncryptionEnabled; **challenges (D-001)** member-read + progress-only writes.
- **D1 at-rest ✓** — live admin read: chat `text`=`enc:v1:`, `lastMessagePreview`=`enc:v1:` (media-only msg has no text field = no plaintext); how_well answers + Memory Lane capsules = `enc:v1:` (Pass B). **No plaintext content leak.** UI check: 0 `enc:v1:` rendered to the user (Pass C conversation).
- D4 (wrapped couple key / KDF), D5 (App Check, gitignored SA JSONs, allowBackup=false), D6 (analytics metadata-only) unchanged since Round 1 — code identical, still hold.
- **D3 live non-member negative test: still deferred** — needs a 3rd fresh account not in the couple (only 2 emulators, both members; signing one out risks the App Check debug token + couple state). Rule logic is statically member-scoped (`isCouplesMember` gate on every couple subcollection) — denial holds by construction.
_Still to verify this round: edges (re-open completed / leave mid-game), Pass E live notif matrix, Pass F._
## Pass A — Couple-shared premium ✅ pass complete
**Target:** if either partner is premium, all premium features unlock for both.
**Result:** only chat is couple-shared. Every other feature gate is per-user → a free user whose partner paid stays locked.
@ -31,7 +91,7 @@ certification: exhaustive deep/stateful screens (Pass C), full live notification
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| A-001 | Premium gating | PlayHubViewModel, DesireSyncScreen, MemoryLaneScreen, ConnectionChallengesScreen, QuestionPackLibraryViewModel, wheel CategoryPicker/SpinWheel/WheelHistory(VMs) | **P1** | These gated on per-user `EntitlementChecker.isPremium()` instead of couple-shared. A free partner of a premium user stayed locked. | Set Sam premium, QA free → QA Play hub still showed 🔒 on Desire Sync + Memory Lane. | **FIXED** `e8892a9` — routed all gates through `CouplePremiumChecker` (now exposes isPremium/hasPremium resolving partner internally). Verified: Sam premium → QA enters Desire Sync; both free → QA → paywall. |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | Open (deferred — thread hasPremium into the card composables) |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | **FIXED** — added `showPremiumBadge` param to `DesireSyncCard`/`MemoryLaneCard`, gated the badge behind it, pass `!hasPremium` from the Play hub. **Verified LIVE:** with couple premium, Play hub shows 0 "Premium" badges on those cards (both cards present, no lock). |
| A-002 | Premium (control) | ConversationViewModel (chat) | — | **Working correctly** (couple-shared) — kept as the reference pattern for the A-001 fix. | Verified prior round: partner-premium unlocks chat media/reactions for the free partner. | OK |
**Note (by-design, not a bug):** `SubscriptionScreen` uses per-user `isPremium()` — correct, it reflects the user's *own* subscription/account state, not a feature gate.
@ -39,9 +99,13 @@ certification: exhaustive deep/stateful screens (Pass C), full live notification
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial)
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| B-001 | Games / sessions | couples/{id}/sessions | **P3** (observe) | A stale `active` wheel session (startedBy QA, `createdAt` missing/undefined) blocked **all** games ("Waiting for Sam"). In-app **"End their game" recovery works** (session→completed, games unblocked). Likely a prior-test artifact, but: sessions appearing without a timestamp + one stale session blocking every game is worth a guard. | Open This or That → "Waiting for Sam… Sam is playing a Wheel game"; tap End their game → unblocked. | Open |
| B-001 | Games / sessions | couples/{id}/sessions | **P1** (was P3→P2→P1) | **A finished game NEVER closes its session — there is no normal-user path to complete it — so every game leaves a dangling `status=active` session that blocks ALL other games.** Definitively proven on the R2-B2 restart: played This or That fully through on BOTH devices → both reached the results screen → **BOTH tapped the intended "Back to Play" button** → both navigated back to the Play hub, **but the session stayed `active`** (re-checked at +0s and +12s; no cloud-function cleanup; `completedAt` never set). So neither "Back to Play" nor leaving to Home completes a finished session — the ONLY thing that does is the **destructive "End their game"** (which the next game offers as "Sam is playing a … game", misleading copy since nobody is actually playing). Net: a couple **cannot cleanly play two games in a row** — after every game, the next one is blocked until one partner kills the (already-finished) session. This breaks the core game loop for every session → **P1**. **ROOT CAUSE (found in fix phase): a Firestore RULES bug, not app code.** The sessions `allow update` rule required `affectedKeys().hasOnly(['status','completedAt'])`, but the async-game completion path (`markUserComplete`) always writes **`completedByUsers`** (each player records themselves; the session flips to `completed` only once both are in). So every "I reached results" write was **denied** (the failure is swallowed by `onFailure`), `completedByUsers` never reached 2, and the session stayed `active` forever. `abandonSession` ("End their game") only diffs `status`/`completedAt`, so it passed the rule — exactly why that was the only thing that worked. | Play This or That to results on both → session stayed `active`; next game blocked. | **FIXED + DEPLOYED** — sessions `allow update` now permits `['status','completedAt','completedByUsers']`, lets any couple member record completion progress, keeps `startedByUserId` immutable + status monotonic (active→completed, never revert). **Re-verified LIVE:** played This or That fully on both → session auto-flipped to `status=completed`, `completedByUsers=[both]`, **0 active sessions** (no Back-to-Play/End-their-game needed); then **opened How Well immediately → its setup screen, NOT "Waiting for Sam"**. Core loop restored. |
| B-002 | Home → Play nav (play-as-user) | HomeScreen "Your partner is waiting to play" card → "Play now" | **P2** | The Home card explicitly promises resuming the specific waiting game — "**Your partner is waiting to play. A game is ready for the two of you. Jump back in and keep the ritual going.**" → **"Play now"** — but tapping it just lands on the **generic Play hub** (the game list). It does NOT open/resume the waiting game, and the Play hub shows **no indication of which game is waiting** nor any "resume" affordance. A user told to "jump back in" cannot tell what to tap or how to rejoin. (Also: BOTH partners' Home cards say "**your** partner is waiting to play" for the same session, so each thinks the other is mid-game.) **Fix:** "Play now" should deep-link into the active session (its play/results screen), or the Play hub should surface a "Resume — How Well" entry; the Home copy should reflect whose turn it actually is. | Cold start → Home → tap "Play now" → lands on Play hub, no waiting-game indicator. | **FIXED** — Home now resolves the active session's `gameType` → its resume route (`gameRouteFor`: wheel→SpinWheelRandom, this_or_that/how_well/desire_sync→themselves), stored as `HomeUiState.waitingGameRoute` and carried on `HomeAction.gameRoute`; `HomeActionTarget.Game` navigates there (fallback Play hub). Each game screen auto-joins the couple's active session on open, so "Play now" resumes the exact waiting game. **Verified LIVE:** Sam started This or That → QA Home "Play now" → landed directly in This or That (1/5), not the hub. |
| B-003 | Desire Sync results (copy/clarity) | DesireSyncScreen results | **P3** | The results stats are internally **inconsistent/confusing**. Header: "**3 shared desires — 2 answers stayed private.**" Per-person row: "**You 5 private / Sam 5 private**". Progress bar caption: "**3 shared, 2 kept private.**" So the same screen says both "2 kept private" (total) AND "5 private" (each person) — a user can't tell whether 3 are shared or all 5 stayed private. (Mechanically "5 private" likely means "all 5 of each person's raw answers stay private, 3 happened to overlap", but that framing isn't clear and contradicts the "2 kept private" line.) **Fix:** make the three counters consistent (e.g., drop or relabel the per-person "5 private", or clarify "your individual answers are always private"). | Play Desire Sync to results → read the three differing private/shared counts. | **FIXED** — the per-person privacy tiles no longer show the contradicting "$total private"; they now read just "Private" (your individual answers always stay private), and the caption keeps the real "$matches shared, N kept private" breakdown. **Verified LIVE:** reveal now shows "You: Private / Sam: Private" + "5 shared, 0 kept private" — no contradiction. |
**Launch/crash sweep (QA, free):** This or That ✅ (mood/length select), How Well Do You Know Me ✅ (intro), Connection Challenges ✅, Spin the Wheel ✅ — all render, **no FATAL**. Desire Sync + Memory Lane are premium-gated (covered in Pass A; gameplay needs premium toggle). Date Match: todo. Full two-device start→finish + results not exhaustively re-run this round (the prior round verified `onGameSessionUpdate` start/finish end-to-end).
**R2-B2 Desire Sync playthrough (couple-shared premium):** QA (free) entered with NO paywall (A-001 holds live). Both played full 5 Yes/No; QA T,T,T,T,F + Sam T,T,F,T,F → results show **exactly 3 shared desires** (the mutual-yes Q1/Q2/Q4) with Q3 (mismatch) and Q5 (both no) correctly **hidden** — reveal/privacy logic CORRECT; results match on both devices; no crash. Findings: B-003 (P3 copy), C-DS-001 (P2 dark contrast on revealed list).
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending)
**Method:** 5554=Dark, 5556=Light; readable dark|light pair montages + a code scan for non-adapting colors.
@ -49,15 +113,19 @@ certification: exhaustive deep/stateful screens (Pass C), full live notification
| ID | Area | Screen | Severity | Description | Status |
|---|---|---|---|---|---|
| C-OBS | Theming | ~20 screens (AnswerRevealScreen 15, WheelSessionScreen 14, DateMatchScreen 10, PaywallScreen 9, BucketListScreen 9, SettingsScreen 7, HomeScreen 5, …) | observe | Use hardcoded `Color(0x…)` literals (195 total) that don't adapt to theme — a dark-mode contrast risk to verify per-screen. Main screens checked look fine; deep/stateful screens (reveal, wheel session, dates, bucket list) still need visual verification in both themes. | Open (verify in continuation) |
| C-CC-001 | Nav / layout — duplicate header + double back | ConnectionChallengesScreen (series list) | **P2** | The screen shows **TWO stacked "Connection Challenges" titles, each with its own back arrow** — a nav-scaffold app bar (title "Connection Challenges" + back) AND an in-content header ("Connection Challenges / Pick a series to build a habit together." + a second back arrow right below it). Verified it's a **redundant duplicate header, not a double-pushed route**: tapping the inner back arrow pops straight to the Play hub (same as the app-bar back). No dead-end, but two identical titles + two back buttons is confusing ("which back do I press?") and looks broken — exactly the "double back" case to flag. **Fix:** drop the in-content TopAppBar/back (let the nav scaffold own the title+back), or remove the scaffold bar for this route. | Play hub → Connection Challenges → two "Connection Challenges" headers + two back arrows stacked at top. | **FIXED** — removed `CONNECTION_CHALLENGES` from `shellBackRoutes` (the screen renders its own header for both the pick + active views, unlike This or That/How Well/Desire Sync which rely on the shell). **Verified LIVE:** the screen now shows a single header + single back arrow (1 "Back", no duplicate title). |
| C-DS-001 | Theming / readability (dark) | DesireSyncScreen results — "You both said yes to" list | **P2** | In **dark mode**, the revealed shared-desire list items render as **dim, low-contrast muted-pink text on a dark pink-tinted card** — legible but well below the crisp high-contrast black text the same items show in **light mode** (verified side-by-side: QA dark vs Sam light). Given the user's "text must be readable" bar — and that this is the intimate payoff content the user most wants to read — the dark-mode contrast is too low. (May be intentional "muted" styling; if so it needs a dark-mode-specific brighter token.) | 5554=Dark: play Desire Sync to results → the 3 shared-desire rows are dim/hard to read vs the same rows on 5556=Light. | **FIXED**`DesireMatchCard` text color was a hardcoded dark plum `Color(0xFF3D1F2E)`; changed to theme-aware `MaterialTheme.colorScheme.onSurface` (dark text on light, light text on dark). **Verified LIVE:** played Desire Sync to reveal in dark mode → shared-desire rows now render crisp high-contrast white text. |
_Deep/stateful screens (answer reveal, wheel session/complete, date match/builder/matches, bucket list, memory capsule, history, paywall, auth/onboarding/pairing) need their states set up — pending next chunk._
| C-NAV-001 | Nav / back-stack — onboarding+auth not popped after login | MainActivity AppNavigation (start/auth/onboarding graph) | **P1** | **The auth + onboarding destinations are never popped from the nav back stack after login, so pressing system Back from Home walks BACKWARD into onboarding → the welcome/login screen instead of exiting the app.** Confirmed with a CLEAN reproduction (no scripted pollution): cold start → land on **Home** (authenticated, "Connected with Sam") → press system **Back once** → lands on the **"Answer honestly" onboarding carousel** (still inside `closer.app/app.closer.MainActivity`, so it's in-app nav, not a separate task). Tapping the carousel's **Skip** then reaches **"Closer — Create account / I already have an account"** (the pre-auth welcome) — i.e., a logged-in user pressing Back appears to be logged out. Not data loss (cold start returns to Home; Firebase auth persists), but it's a core, every-user nav defect and very alarming UX. **Fix:** on successful auth/onboarding completion, navigate to Home with `popUpTo(<auth/onboarding graph or start route>) { inclusive = true }` (and `launchSingleTop`) so Home is the back-stack root and Back from Home exits the app. | Cold start (logged in) → Home → press system Back → onboarding carousel appears instead of the app closing. | **FIXED** — in `AppNavigation.navigateRoute`, navigating to HOME *from* an entry route (ONBOARDING/CREATE_PROFILE/PAIR_PROMPT/LOGIN/SIGN_UP/FORGOT_PASSWORD) now does `navigate(HOME){ popUpTo(0){inclusive=true}; launchSingleTop }`, wiping the entire pre-app flow so HOME is the back-stack root. Normal tab-switch semantics (`selectTab`) untouched. **Re-verified LIVE:** cold start (logged in) → Home → system Back → focused activity is the **launcher** (`NexusLauncherActivity`), app exits cleanly — onboarding no longer resurfaces. |
**Pass B requirement (updated):** each game must be **played one complete time through on both devices** (start → every step → finish/reveal/results), not just launched. Round 1 did launch-only → **full playthroughs owed in Round 2** for all 7 (premium games need a premium toggle). A launch-only result = `partial`, not `pass`.
**Pass C requirement (added):** **navigation from every entry point** (each screen reached from all its links — e.g. conversation from inbox/Discuss/notification; game from Play/notification; paywall from each gate) + **back-stack / "double-back"** (system back AND in-app back return to the right place from each entry; no dead-ends, no exit-app surprise, **no screen needing two backs**/duplicate stack entries; deep-link/notification entries land with a sane back stack). Owed in Round 2. Wrong/double back or dead-end = P2 (P1 if it traps the user).
## Pass D — Security & Encryption ✅ clean (no P0/P1 found)
- **D1 at-rest:** all private content is ciphertext — message `text` + `lastMessagePreview` + thread messages = `enc:v1:`; daily answers `encryptedPayload` = `sealed:v1:`. Metadata (dates, types, commitmentHash, ids) plaintext as expected. Chat media bytes = Tink ciphertext (verified prior round + unchanged code path). **No plaintext content leak.**
- **D1 at-rest:** all private content is ciphertext — message `text` + `lastMessagePreview` + thread messages = `enc:v1:`; daily answers `encryptedPayload` = `sealed:v1:`; **Memory Lane capsules `title` + `content` = `enc:v1:`** (live-verified R2-B2: admin read the just-created capsule → both fields ciphertext, `status:sealed`, `unlockAt` set, only metadata plaintext). Metadata (dates, types, commitmentHash, ids) plaintext as expected. Chat media bytes = Tink ciphertext (verified prior round + unchanged code path). **No plaintext content leak.**
- **D2 rules:** no catch-all `match /{document=**}`, no blanket `if true`; **`hasPremium` server-only** (client create/update blocked, rules L172/174); entitlements `write:false`; conversations/messages/typing/reactions + entitlement partner-read scoped to members.
- **D4 key exchange:** pairing uses a **wrapped couple key** (`wrappedCoupleKey` + `kdfSalt`/`kdfParams` + `encryptedRecoveryPhrase`); invite code is the KDF seed, never stored raw; strict E2EE (invites without a wrapped key rejected) — confirmed in `acceptInviteCallable`.
- **D5 App Check/secrets:** App Check enforced (`SecurityModule`, `PlayIntegrityChecker`, `FirebaseInitializer`); both service-account JSONs gitignored **and untracked**; `allowBackup=false`.
@ -67,7 +135,9 @@ _Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| D-OBS | Rules / data load | **P2?** (investigate) | During Pass B, logcat showed `PERMISSION_DENIED` for client listeners on `couples/{id}/outcomes`, `couples/{id}/challenges (where status==active)`, and `couples/{id}/capsules`. Either a rules gap or queries firing for features the (free) user can't read → console errors / possibly broken Connection Challenges + Memory Lane data load. **Round 2: confirm whether these features load correctly + fix the rule or guard the query.** | Open |
| D-001 | Rules — missing subcollection rules | **P1** | `couples/{id}/capsules` and `couples/{id}/challenges` had **no `match` block** → default-deny → **Memory Lane hung on its loading heart** and **Connection Challenges** couldn't load (live `PERMISSION_DENIED` confirmed). Two premium features broken. | Sam premium, QA opens Memory Lane → stuck loading heart; logcat `Listen for Query(.../capsules) failed: PERMISSION_DENIED`. | **FIXED + DEPLOYED** — added member-read + ciphertext-enforcing `capsules` rule (title/content/promptUsed must be `enc:v1:`) and a `challenges` rule (catalog-referenced, progress-only). Re-verified live: Memory Lane shows empty state, Connection Challenges shows the series list, **0 permission errors**. |
| F-OBS | Resilience (UI) | **P3** | MemoryLaneScreen (and likely others) **hangs on the loading indicator forever** when a Firestore query fails, instead of showing an error/empty state. Masked the D-001 root cause. Add load-failure handling. | Was visible before D-001 fix (stuck heart). | **FIXED** (code) — ROOT CAUSE: `FirestoreCapsuleDataSource.observeCapsules` **swallowed** snapshot-listener errors (`if (err != null …) return@`), so on PERMISSION_DENIED the callbackFlow never emitted or closed → the ViewModel's `collect` suspended forever → stuck loading heart. Now it `close(err)`s the flow, so the ViewModel's existing `runCatching.onFailure``MemoryLanePhase.ERROR` (with a Retry) runs. Build green; live-verify needs an induced query failure (deferred). (Other snapshot listeners with the same swallow pattern are a follow-up sweep.) |
| (outcomes) | Rules | — | The Round-1 `outcomes` list `PERMISSION_DENIED` is **by-design** — the rule restricts reads to specific dayKeys (`day_0/30/60/90`); a bare list query is correctly denied. Not a bug. | — | Closed (by-design) |
## Pass E — Notifications
- **Copy carries no private content:** all function notification bodies are generic ("Tap to read and reply", "Answer together before it expires", etc.); `${title}` refers to public question/game titles, not user answers. ✓ (ties to D6)
@ -77,6 +147,6 @@ _Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| E-001 | Notification routing | **P2** | Type-string mismatch: functions send `daily_question` + `challenge_day_ready`, but client mapped only `daily_question_reminder` + `challenge_waiting` → tapping those did NOT deep-link to Today / Connection Challenges. | **FIXED** `<pending-commit>` — added `daily_question`/`challenge_day_ready` to `fromRemoteType` (build green; live tap-verify deferred). |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | Open |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | **FIXED** (code; live-tap deferred) — added `PARTNER_UNPAIRED` type, mapped `partner_left` + `partner_deleted_account` → it → routes to **HOME** (where the now-unpaired user gets the "Invite partner" CTA, matching the push body "Tap to create a new invite"). **Investigation corrected two false positives:** `invite_created` is a server-side **audit-log** entry (`read:true`, "not read by clients" — never a push), and `spki` is a **crypto key-format string** in the RevenueCat webhook (`crypto.createPublicKey({type:'spki'})`), not a notification type at all — neither needs client routing (documented in `fromRemoteType`). Build green; live tap-verify deferred (needs an actual unpair event). |
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._

View File

@ -124,9 +124,29 @@ fun AppNavigation(
selectTab(AppRoute.HOME)
}
}
// The pre-app entry flow (onboarding + profile/pairing setup + auth). Leaving any of
// these for Home must CLEAR them from the back stack — otherwise the graph start
// (ONBOARDING) lingers as the root under Home and a system Back from Home walks
// backward into the onboarding carousel / login screen, making a signed-in user look
// logged out (C-NAV-001).
val entryRoutes = setOf(
AppRoute.ONBOARDING,
AppRoute.CREATE_PROFILE,
AppRoute.PAIR_PROMPT,
AppRoute.LOGIN,
AppRoute.SIGN_UP,
AppRoute.FORGOT_PASSWORD
)
val navigateRoute: (String) -> Unit = { route ->
when {
route == "back" -> navigateBackOrHome()
// Completing onboarding/auth: make Home the back-stack root (wipe the entire
// entry flow) so Back from Home exits the app, never resurfaces onboarding/login.
route == AppRoute.HOME && currentRoute in entryRoutes ->
navController.navigate(AppRoute.HOME) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
// Top-level tabs must use tab-switch semantics. Plain-navigating to a
// tab (e.g. a "Game waiting" card → PLAY) would stack it on the current
// tab; the bottom bar then saves that substack and `restoreState` later
@ -570,7 +590,10 @@ private val shellBackRoutes = setOf(
AppRoute.THIS_OR_THAT,
AppRoute.HOW_WELL,
AppRoute.DESIRE_SYNC,
AppRoute.CONNECTION_CHALLENGES,
// NB: CONNECTION_CHALLENGES is intentionally NOT here — that screen renders its OWN
// header (title + "Pick a series…" subtitle + back) for both the pick and active views.
// Adding it to the shell set drew a SECOND "Connection Challenges" app bar + back arrow
// on top of the screen's own (C-CC-001 duplicate header / double back).
AppRoute.WAITING_FOR_PARTNER,
AppRoute.SUBSCRIPTION,
AppRoute.WHEEL_HISTORY,

View File

@ -67,7 +67,12 @@ class FirestoreCapsuleDataSource @Inject constructor(
val reg = col(coupleId)
.orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
// Propagate listener failures (e.g. PERMISSION_DENIED) by closing the flow so
// the collector's error handling runs. Previously this swallowed the error
// (`return@`), so the flow never emitted or closed and the Memory Lane screen
// hung on its loading indicator forever (F-OBS).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { doc ->
runCatching { mapToCapsule(doc, coupleId) }.getOrNull()
})

View File

@ -250,6 +250,15 @@ enum class PartnerNotificationType(
body = "Tonight's question is a good reason to reconnect.",
channelId = NotificationChannelSetup.CHANNEL_REMINDERS,
rateType = NotificationRateLimiter.Type.REMINDER
),
// Partner left the couple or deleted their account — the user is now unpaired.
// Routed to Home, which surfaces the "Invite partner" CTA for the now-solo user
// (matches the push body "Tap to create a new invite"). E-002.
PARTNER_UNPAIRED(
title = "You're no longer paired.",
body = "Tap to create a new invite.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
);
/**
@ -275,6 +284,7 @@ enum class PartnerNotificationType(
PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME
DATE_MATCH -> AppRoute.DATE_MATCHES
REENGAGEMENT -> AppRoute.DAILY_QUESTION
PARTNER_UNPAIRED -> AppRoute.HOME
}
companion object {
@ -298,6 +308,10 @@ enum class PartnerNotificationType(
"partner_joined" -> PARTNER_JOINED
"date_match" -> DATE_MATCH
"reengagement" -> REENGAGEMENT
"partner_left", "partner_deleted_account" -> PARTNER_UNPAIRED
// NB: "invite_created" is a server-side audit-log entry (read:true, never sent
// as a push) and "spki" is a crypto key-format string in the RevenueCat webhook
// (not a notification type at all) — neither needs client routing. E-002.
else -> null
}
}

View File

@ -899,9 +899,15 @@ private fun DesireRevealMeter(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Each person's individual answers always stay private — only mutual
// "yes" answers surface as shared desires. Previously these tiles showed
// "$total private" (e.g. "5 private"), which contradicted the caption's
// "${total - matches} kept private" (e.g. "2 kept private") and confused
// whether 3 were shared or all 5 stayed private (B-003). Show just the
// privacy guarantee; the shared/private breakdown lives in the caption.
DesirePrivacyTile(
label = "You",
value = "$total private",
value = "Private",
modifier = Modifier.weight(1f)
)
StatusGlyph(
@ -913,7 +919,7 @@ private fun DesireRevealMeter(
)
DesirePrivacyTile(
label = partnerName,
value = "$total private",
value = "Private",
modifier = Modifier.weight(1f)
)
}
@ -993,7 +999,10 @@ private fun DesireMatchCard(match: DesireMatch) {
Text(
text = match.question.text,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = Color(0xFF3D1F2E),
// Was a hardcoded dark plum (Color(0xFF3D1F2E)) — fine on the light card in
// light mode, but dim/low-contrast on the dark-tinted card in dark mode
// (C-DS-001). onSurface adapts: dark text on light, light text on dark.
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 3,
overflow = TextOverflow.Ellipsis

View File

@ -216,7 +216,8 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings()
HomeActionTarget.AnswerReveal -> onReveal()
HomeActionTarget.Game -> onNavigate(AppRoute.PLAY)
// Resume the specific waiting game when known (B-002); fall back to the Play hub.
HomeActionTarget.Game -> onNavigate(action.gameRoute ?: AppRoute.PLAY)
HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE)

View File

@ -3,9 +3,11 @@ package app.closer.ui.home
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus
import app.closer.crypto.SealedRevealManager
import app.closer.domain.model.GameType
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource
@ -89,7 +91,10 @@ data class HomeAction(
val target: HomeActionTarget,
val tone: HomeActionTone,
val metric: String? = null,
val categoryId: String? = null
val categoryId: String? = null,
// For the "your partner is waiting to play" CTA: the specific game route to resume
// (so "Play now" jumps into the actual waiting game, not the generic Play hub). B-002.
val gameRoute: String? = null
)
data class PendingActionCard(
@ -99,6 +104,19 @@ data class PendingActionCard(
val target: HomeActionTarget
)
/**
* The entry route that resumes an in-progress game of [gameType]. Each game screen
* detects the couple's active session on open and joins it, so navigating here lets the
* Home "Play now" CTA drop the user straight back into the waiting game (B-002).
*/
private fun gameRouteFor(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
enum class DailyQuestionState {
UNANSWERED,
USER_ANSWERED_PARTNER_PENDING,
@ -128,6 +146,9 @@ data class HomeUiState(
val pendingActions: List<PendingActionCard> = emptyList(),
// Retention signals — populated in loadHome() and observeAnswers()
val hasWaitingGame: Boolean = false,
// The route of the active game waiting for this user, so the Home "Play now" CTA
// resumes that specific game instead of dumping on the generic Play hub (B-002).
val waitingGameRoute: String? = null,
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
@ -243,6 +264,7 @@ class HomeViewModel @Inject constructor(
// Retention signal fetches — run in parallel, failures silently default to false.
var hasWaitingGame = false
var waitingGameRoute: String? = null
var hasActiveChallenge = false
var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false
@ -252,8 +274,9 @@ class HomeViewModel @Inject constructor(
val gameJob = async {
runCatching {
val session = questionSessionRepository.getActiveSessionForCouple(coupleId)
session != null && uid !in session.completedByUsers
}.getOrDefault(false)
?.takeIf { uid !in it.completedByUsers }
session to gameRouteFor(session?.gameType)
}.getOrDefault(null to null)
}
val challengeJob = async {
runCatching {
@ -276,7 +299,9 @@ class HomeViewModel @Inject constructor(
.any { it.status == "sealed" && it.unlockAt in 1L..now }
}.getOrDefault(false)
}
hasWaitingGame = gameJob.await()
val (waitingSession, waitingRoute) = gameJob.await()
hasWaitingGame = waitingSession != null
waitingGameRoute = waitingRoute
hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await()
@ -295,6 +320,7 @@ class HomeViewModel @Inject constructor(
partnerLeftEvent = false,
needsRecovery = needsRecovery,
hasWaitingGame = hasWaitingGame,
waitingGameRoute = waitingGameRoute,
hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule,
@ -598,7 +624,8 @@ class HomeViewModel @Inject constructor(
body = "A game is ready for the two of you. Jump back in and keep the ritual going.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual
tone = HomeActionTone.Ritual,
gameRoute = waitingGameRoute
)
Priority.CHALLENGE_WAITING -> HomeAction(

View File

@ -128,6 +128,7 @@ private fun PlayHubContent(
item {
DesireSyncCard(
showPremiumBadge = !hasPremium,
onClick = { onPlay(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
)
}
@ -140,6 +141,7 @@ private fun PlayHubContent(
item {
MemoryLaneCard(
showPremiumBadge = !hasPremium,
onClick = { onPlay(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) }
)
}
@ -283,6 +285,7 @@ private fun ThisOrThatCard(
@Composable
private fun DesireSyncCard(
showPremiumBadge: Boolean = true,
onClick: () -> Unit
) {
CloserClickableCard(
@ -321,27 +324,31 @@ private fun DesireSyncCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
// Hide the "🔒 Premium" badge once the couple has premium access — the
// feature is unlocked for both, so the lock badge is misleading (A-003).
if (showPremiumBadge) {
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@ -502,6 +509,7 @@ private fun ConnectionChallengesCard(
@Composable
private fun MemoryLaneCard(
showPremiumBadge: Boolean = true,
onClick: () -> Unit
) {
CloserClickableCard(
@ -546,27 +554,30 @@ private fun MemoryLaneCard(
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
// Hide the "🔒 Premium" badge once the couple has premium access (A-003).
if (showPremiumBadge) {
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.66f)
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(13.dp)
)
Text(
text = "Premium",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold
)
}
}
}
}

243
docs/brand/asset-system.md Normal file
View File

@ -0,0 +1,243 @@
# Closer Artwork Asset System
This is the working artwork asset set for Closer. It keeps the existing purple/pink color scheme
and the 2D pastel couple illustration style, but gives every visual surface a clearer job.
The brand should feel like a private ritual for two people: warm, quiet, equal, and intentional.
The heart remains the compact brand mark. The couple artwork should become the primary visual
language anywhere there is enough space to show a human moment.
## Current Artwork Review
### README and Positioning
The README is aligned with the right promise: private mutual reveal, real encryption, calm UX, no
public social mechanics, and one subscription per couple. That is the brand spine. Asset decisions
should reinforce that before adding more romance, gamification, or feature volume.
Recommended visual translation:
- Privacy: lock, sealed card, quiet room, closed journal, two-device reveal.
- Equality: paired shapes, mirrored halves, balanced figures, side-by-side cards.
- Ritual: daily card, cup/table setting, evening prompt, calendar/date cue.
- Warmth: existing pastel couple scenes, soft surfaces, pink/lavender accents.
Avoid:
- Generic dating-app hearts as the only signal.
- Loud streak/game badges as the main identity.
- Stock-couple photography.
- Notification copy or graphics that imply surveillance, urgency, or partner pressure.
### Existing Artwork
Keep:
- `docs/store/sources/app-icon.svg` as the source of the compact heart mark.
- `iphone/Closer/Resources/illustration-couple-*.png` as the human brand style.
- `iphone/Closer/Resources/pack-art-*.png` as the category/pack art direction.
- `iphone/Closer/Resources/particle-heart.png` and `particle-petal.png` for celebration moments.
Improve:
- The heart is strong for launchers and tiny UI, but too generic when used alone at larger sizes.
Use couple illustrations for onboarding, paywall, empty states, store screenshots, and web/social.
- Android currently has the launcher vectors but not the larger illustration library. Mirror the
iOS resources into Android when those screens start using raster art.
- Store feature graphics should show the heart plus one illustrated/private ritual scene, not only
cards and symbols.
### Notification Artwork
This section is only about the artwork used to represent notifications, not notification behavior.
- Use a dedicated monochrome notification glyph, not the colored launcher foreground.
- Keep notification artwork quiet: heart, lock, paired-card, moon, calendar, and capsule symbols.
- Large notification-adjacent artwork may use partner avatars or the couple illustration style, but
never show readable private answer content.
- The visual tone should say "gentle invitation," not alarm, urgency, surveillance, or streak loss.
## Master Asset Set
### 1. Brand Marks
| Asset | Use | Source / Target |
| --- | --- | --- |
| Primary app mark | Launcher, favicon, small brand moments | `docs/store/sources/app-icon.svg` |
| Adaptive foreground | Android launcher layer | `app/src/main/res/drawable/ic_launcher_foreground.xml` |
| Adaptive background | Android launcher layer | `app/src/main/res/drawable/ic_launcher_background.xml` |
| Monochrome mark | Android themed icon, single-color use | `app/src/main/res/drawable/ic_launcher_monochrome.xml` |
| Notification glyph | Android/iOS notification art direction | Needed: monochrome heart/paired-card source |
| Wordmark lockup | Website, store hero, press kit | Needed: `docs/brand/sources/closer-lockup.svg` |
| Horizontal logo | Email header, social headers | Needed: SVG + PNG exports |
| One-color logo | Legal docs, monochrome print, dark/light footer | Needed: SVG exports |
| Favicon set | Website/browser/PWA | Needed: 16, 32, 48, 180, 192, 512 px |
Logo rule: the heart mark should mean "two equal people meeting in the middle." Do not split it,
rotate it, add faces, add text inside it, or use it as a reaction emoji.
### 2. App Icons
| Platform | Required Assets |
| --- | --- |
| Android | Adaptive icon, round icon, themed monochrome icon, Play 512 px icon |
| iOS | AppIcon asset catalog: 20, 29, 40, 60, 76, 83.5, 1024 px |
| Web/PWA | favicon.ico, SVG favicon, Apple touch icon, maskable 192/512 icons |
Current status:
- Android and Play icon assets exist.
- iOS has an asset catalog folder but no visible app icon set in the checked file list. Add the
full iOS app icon set before TestFlight/App Store review.
### 3. Illustration Library
Use the 2D pastel couple art as the large-format identity.
| Asset Family | Current Assets | Primary Use |
| --- | --- | --- |
| Couple moments | `illustration-couple-onboarding`, `invite`, `history`, `paywall`, `subscription` | Onboarding, pairing, history, paywall |
| Product rituals | `illustration-daily-question`, `partner-activation`, `reveal-celebration` | Daily question, partner wait state, reveal |
| Play and progress | `illustration-spin-wheel`, `streak-milestone`, `together-empty` | Play hub, milestones, empty states |
| Pack art | `pack-art-*` | Question pack cards and category headers |
| Particles | `particle-heart`, `particle-petal` | Reveal, match, milestone celebration |
Needed additions:
- `illustration-privacy-lock`: two cards or journals behind a lock.
- `illustration-quiet-hours`: moon/window/two muted phones.
- `illustration-date-match`: two chosen date cards meeting.
- `illustration-memory-capsule`: sealed box/open capsule.
- `illustration-chat-thread`: two soft message bubbles with no readable text.
- `illustration-account-deletion`: calm export/delete privacy scene.
- Android copies of the iOS illustration assets, preferably under `app/src/main/res/drawable-nodpi/`.
### 4. Notification Artwork Assets
| Notification Type | Small Icon | Optional Large Icon | Route Mood |
| --- | --- | --- | --- |
| Partner answered | Heart/paired-card glyph | Partner avatar | Calm invitation |
| Reveal ready | Heart + unlock symbol | Heart mark | Moment is ready |
| Chat message | Heart/paired-card glyph | Partner avatar | Direct but private |
| Daily question | Heart + card | App mark | Daily ritual |
| Gentle reminder | Heart/soft nudge | Partner avatar optional | No pressure |
| Date match | Heart + calendar/card | Date-card art optional | Shared choice |
| Capsule unlocked | Heart + lock/open card | Capsule art optional | Memory opened |
| Challenge waiting | Heart + small path/card | Challenge art optional | Small next step |
Notification artwork rules:
- Never show readable answer text, prompt text, invite codes, message previews, or sensitive category
labels inside artwork.
- Avoid alarm-bell, siren, warning-triangle, fire, or countdown imagery.
- Prefer sealed cards, paired cards, soft hearts, moon/quiet-hours, calendar/date-card, and capsule
objects.
- Use the existing pink/lavender palette with high-contrast monochrome exports for platform glyphs.
### 5. In-App Icon and Glyph Set
Use Material/SF Symbols for normal UI controls. Use custom brand glyphs only for relationship
concepts that need a softer product voice.
Needed custom glyphs:
- `glyph-private-reveal`
- `glyph-two-people`
- `glyph-daily-card`
- `glyph-sealed-answer`
- `glyph-memory-capsule`
- `glyph-date-match`
- `glyph-quiet-hours`
- `glyph-couple-premium`
- `glyph-export-data`
- `glyph-delete-account`
These should be simple single-color vectors that work at 20-32 dp/pt.
### 6. Store and Marketing Assets
| Asset | Spec | Direction |
| --- | --- | --- |
| Play feature graphic | 1024 x 500 PNG | Heart + one private ritual illustration + short promise |
| Play screenshots | Up to 8 phone screenshots | Use product screens; omit login unless testing trust |
| App Store screenshots | iPhone 6.7", 6.5", 5.5" as needed | Mirror Play story |
| Social open graph | 1200 x 630 | Couple illustration + "A private space for two." |
| Press/brand header | 1600 x 900 | Illustration-led, logo lockup |
| Website hero | Responsive bitmap | Couple illustration in first viewport, not a symbol-only hero |
Recommended screenshot story:
1. Onboarding/private promise.
2. Home next-best action.
3. Daily question/private answer.
4. Mutual reveal/history.
5. Question packs.
6. Play/spin wheel.
7. Date planning.
8. Privacy/settings/subscription trust.
### 7. Empty States and Milestones
Each major empty state should have a matching illustration or glyph:
- No partner yet: `illustration-couple-invite`
- Waiting for partner: `illustration-partner-activation`
- No history yet: `illustration-couple-history`
- No messages yet: new `illustration-chat-thread`
- No date matches yet: new `illustration-date-match`
- No memory capsules: new `illustration-memory-capsule`
- Quiet hours enabled: new `illustration-quiet-hours`
- Premium/paywall: `illustration-couple-paywall` or `illustration-couple-subscription`
### 8. Motion and Celebration
Keep motion small and intimate:
- Heart pulse for pairing/reveal ready.
- Petal/heart particles for mutual reveal, date match, milestone.
- Slow card unlock animation for reveal/capsule.
- No confetti storms for sensitive answers.
### 9. File Organization
Recommended structure:
```text
docs/brand/
asset-system.md
visual-identity.md
sources/
closer-mark.svg
closer-lockup.svg
closer-lockup-horizontal.svg
notification-art/
notification-heart.svg
notification-paired-cards.svg
notification-quiet-hours.svg
notification-capsule.svg
glyphs/
exports/
logo/
store/
social/
app/src/main/res/
drawable/ # vectors exported from artwork sources
drawable-nodpi/ # raster illustrations shared with Android
mipmap-*/ # launcher icons
iphone/Closer/Resources/
Assets.xcassets/ # app icons and platform-managed assets
illustration-*.png
pack-art-*.png
```
## Priority Build List
1. Keep the current heart mark, but create wordmark/horizontal/one-color SVG lockups.
2. Add the missing iOS AppIcon set.
3. Mirror illustration PNGs into Android and start using them in onboarding, empty states, paywall,
and store screenshots.
4. Rework the Play feature graphic to include one couple/private-ritual illustration.
5. Add the new notification and privacy illustrations listed above.
6. Build a small custom glyph set for privacy/reveal/date/capsule concepts.

View File

@ -6,6 +6,9 @@ a public dating profile.
Product goal: **private, mutual-reveal relationship questions with real encryption and calmer UX**.
Visual decisions should reinforce that promise before decoration, novelty, or growth mechanics.
For the full working asset set, including illustration, notification, store, and logo-export
requirements, see `docs/brand/asset-system.md`.
## Brand mark
The mark is one heart formed by two equal halves. Pink and lavender represent two people meeting at

View File

@ -309,17 +309,20 @@ service cloud.firestore {
allow create: if isCouplesMember(coupleId)
&& request.resource.data.startedByUserId == request.auth.uid;
// Update: only the user who started the session can update it, OR valid status transitions.
// startedByUserId is immutable for direct client writes.
// Update: any couple member may record session progress/completion.
// (Async two-device games mark each player done via `completedByUsers`; the
// session flips active→completed once both are in. The previous rule only
// allowed `status`/`completedAt`, so every `completedByUsers` write was denied
// and finished games never closed — locking the couple out of new games. B-001.)
allow update: if isCouplesMember(coupleId)
// Either the original starter can update
&& (resource.data.startedByUserId == request.auth.uid
// Or status transition is valid: active → completed
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'))
// startedByUserId cannot be changed by clients
// startedByUserId is immutable for direct client writes.
&& request.resource.data.startedByUserId == resource.data.startedByUserId
// Only a fixed set of fields may change
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'completedAt']);
// Only session progress/completion fields may change.
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status', 'completedAt', 'completedByUsers'])
// status is monotonic: stay the same, or transition active → completed (never revert).
&& (request.resource.data.status == resource.data.status
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'));
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false;
@ -607,6 +610,43 @@ service cloud.firestore {
allow delete: if false;
}
// Memory Lane capsules: member-readable; author creates with ENCRYPTED content (title/content/
// promptUsed are enc:v1:). status flips sealed→unlocked (client or the scheduled unlock fn).
match /capsules/{capsuleId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorId == request.auth.uid
&& request.resource.data.keys().hasOnly(
['authorId', 'title', 'content', 'promptUsed', 'unlockAt', 'createdAt', 'status'])
&& isCiphertext(request.resource.data.title)
&& isCiphertext(request.resource.data.content)
&& (!('promptUsed' in request.resource.data)
|| request.resource.data.promptUsed == null
|| isCiphertext(request.resource.data.promptUsed));
allow update: if isCouplesMember(coupleId)
&& (
// Author re-saves encrypted content before unlock
(request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'content', 'promptUsed', 'unlockAt'])
&& isCiphertext(request.resource.data.title)
&& isCiphertext(request.resource.data.content))
||
// Status transition (e.g. sealed → unlocked)
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status'])
);
allow delete: if isCouplesMember(coupleId);
}
// Connection Challenges: catalog-referenced (no free-text user content), members track progress.
match /challenges/{challengeId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(['challengeId', 'startedAt', 'status', 'completions']);
allow update: if isCouplesMember(coupleId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['completions', 'status']);
allow delete: if isCouplesMember(coupleId);
}
// Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
// Writes are server-side only via submitOutcomeCallable; direct client writes denied.
match /outcomes/{dayKey} {