chore(repo): keep QA fixture docs local-only

This commit is contained in:
null 2026-07-07 23:28:45 -05:00
parent 694294c2e5
commit 6af9c15833
19 changed files with 33 additions and 2862 deletions

2
.gitignore vendored
View File

@ -16,6 +16,7 @@ iphone/.build/
iphone/Package.resolved iphone/Package.resolved
# Private project docs (agent-only, never commit) # Private project docs (agent-only, never commit)
Claude*.md
FUTURE.md FUTURE.md
HISTORY.md HISTORY.md
PROJECT.md PROJECT.md
@ -93,5 +94,6 @@ ClaudeReport.md
relationship-app.code-workspace relationship-app.code-workspace
# QA fixtures with test credentials — local only, never commit # QA fixtures with test credentials — local only, never commit
qa/README.md
qa/fixtures.local.md qa/fixtures.local.md
qa/*.local.md qa/*.local.md

View File

@ -1,335 +0,0 @@
# Closer — Branding & Artwork Review (Pass H)
Living status document for Closer's brand artwork pass. It records which illustrations/glyphs are live, which surfaces
should reuse existing assets, and which brand issues belong in implementation or QA rather than image generation.
**Asset ownership:** Codex is responsible for making all needed images, dark variants, and custom glyphs. Do not leave
image work as user-generated prompt handoff; backlog items here are Codex-owned assets to generate, add to the repo, and
verify.
**Current state (2026-06-28 re-audit against the code/resources — supersedes the 2026-06-27 audit):**
- **Backlog 1 (dark illustration variants) — essentially DONE.** `drawable-night-nodpi/` now holds a dark variant for
**every** non-transparent surface: all 22 `illustration_*` (incl. all `illustration_couple_*` heroes, `daily_question`,
`partner_activation`, `tonight_partner_prompt`, `together_empty`) **and all 10 `pack_art_*` banners**. Only **3**
transparent celebration assets have no dark variant — `illustration_premium_unlock`, `illustration_spin_wheel`,
`illustration_streak_milestone` — which rule 1 exempts *if* they read on both themes (verify, don't assume). Decoupled
in-app-Dark + system-Light now renders the dark art correctly app-wide after the **C-DARKART-002** fix (see below), so
the "light/pink art on a dark screen" defect class is closed for these surfaces.
- **Backlog 2 (custom glyphs) — ✅ DONE (2026-06-28).** Every generic Material icon has been replaced with a Closer
glyph: **0 `Icons.*` call sites and 0 `androidx.compose.material.icons` imports remain** (was 187 across ~49 distinct
icons). Wiring goes through a central `ui/components/CloserGlyphs.kt` accessor object (`@Composable` getter vals →
`ImageVector.vectorResource(R.drawable.glyph_*)`), used by 53 files; `CategoryGlyph.kt` resolves category glyphs the
same way. Build + 205 unit tests green; verified live both themes (back, check, close, lock, chat, heart, photo,
camera, mic, category badges all render with correct tint). **BRAND-ICON-CUSTOM is resolved.**
- **C-DARKART-002 (theming mechanism, fixed 2026-06-28):** `MainActivity` now drives `AppCompatDelegate.setDefaultNightMode`
from the in-app `ThemeMode`, so the real Configuration `uiMode` follows the app theme and **all** art resolution —
`painterResource` AND `BrandIllustration`, `drawable-night*` included — follows the in-app theme. This supersedes the
old "only `BrandIllustration` follows the theme" caveat.
- Done earlier: This-or-That redesign (C-DARK-UI-001) + Premium-unlock modal (A13).
> Branding **defects** (off-brand color, clipped/low-contrast art, a **light image on a dark screen**, a **generic
> Material icon**) → `ClaudeReport.md`. Pure "could be warmer / feature" ideas → `Future.md` `## QA`. **New art/glyphs to
> create** (dark variants, custom glyphs) → logged HERE as Codex-owned assets to make and verify.
---
## Brand standards — MUST hold every QA round (Pass C visual + Pass H branding own these)
Two non-negotiable brand rules. A violation of either is a **bug** (`ClaudeReport.md`) **and** the asset to create is
logged here as Codex-owned image/glyph work:
1. **Every image has a LIGHT and a DARK variant that matches the IN-APP theme.** No light/pink art on a dark screen, no
dark art on a light screen. `drawable-nodpi/` = light, `drawable-night-nodpi/` = dark. Since **C-DARKART-002**
(2026-06-28) the dark variant is auto-selected by the real Configuration `uiMode`, which `MainActivity` syncs to the
in-app `ThemeMode` via `AppCompatDelegate.setDefaultNightMode` — so **both** `painterResource` and `BrandIllustration`
resolve `-night` correctly even in the decoupled in-app-Dark + system-Light state. Transparent/celebration art that
genuinely reads on **both** themes is the only exemption — **verify it, don't assume.** A surface missing its variant = bug.
2. **Every icon/glyph is a custom Closer glyph — NO generic Material icons, no generic hearts.** Material `Icons.*`
(ArrowBack, Favorite, Person, Lock, Star, PlayArrow, …) are placeholders, not brand. Each in use is replaced by a
bespoke glyph in the Closer house style, logged in the icon backlog below for Codex to make + add (`glyph_*`).
**Image style lock:** all generated imagery must match the existing shipped flat 2D vector assets, especially
`illustration_couple_paywall`, `illustration_couple_subscription`, `illustration_couple_onboarding`,
`illustration_daily_question`, and the `pack_art_*` banners. Keep shapes simple and graphic, with pastel fills, minimal
facial/detail rendering, clean vector-like edges, and gentle gradients only. **Reject** painterly/storybook rendering,
3D-ish lighting, realistic texture, detailed hair/skin shading, dramatic cinematic glow, hard rectangular backdrops, or
anything that looks materially richer than the existing asset family. For any new batch, generate and review **one sample
first** against the existing contact sheet before saving more assets.
## Image theme-variant coverage (light + dark per surface) — ✅ essentially COMPLETE
> Re-verified against `res/` on 2026-06-28. Every non-transparent surface now has BOTH a light (`drawable-nodpi/`) and a
> dark (`drawable-night-nodpi/`) asset with the identical filename.
| Illustration | Light | Dark | Status |
|---|---|---|---|
| **All 22 `illustration_*`** — account_deletion_goodbye · answer_history_empty · bucket_list_empty · connection_challenges_header · couple_history · couple_invite · couple_onboarding · couple_paywall · couple_subscription · daily_question · date_match_empty · date_match_success · memory_lane_capsule · messages_empty · pairing_success · partner_activation · privacy_recovery · quiet_hours · reveal_celebration · together_empty · tonight_partner_prompt | ✅ | ✅ | **both exist** — dark variants added since the 2026-06-27 audit |
| **All 10 `pack_art_*`** (communication, deep_reflection, desire, family_commitment, fun_date, future_goals, home_life, intimacy, money_values, trust_repair) | ✅ | ✅ | **both exist**; pack banners verified live dark (C-DARKART-002 round) |
| illustration_premium_unlock · illustration_spin_wheel · illustration_streak_milestone | ✅ (transparent) | — | transparent/celebration — **exempt per rule 1 IF they read on both themes; verify** (only remaining "no dark" assets) |
**Remaining (small):** spot-check the freshly-added dark variants in Pass C (light + dark) for contrast/clipping — they
exist + resolve correctly via the C-DARKART-002 uiMode-sync (pack art + Today hero verified live across all 4 theme/art
states); the rest resolve by the same mechanism but haven't each been eyeballed. Then verify the 3 transparent celebration
assets read on dark; add a dark variant only if one doesn't. **Orphan:** `illustration_couple_history` has both
light+dark variants but **no code reference at all** (superseded by `illustration_answer_history_empty`) — dead asset,
removal candidate. **Debug-only now:** `illustration_quiet_hours` was removed from the Notification-settings UI
(2026-06-28, unprofessional mid-list art) and is now referenced **only** in the debug `ArtPreviewScreen` gallery.
> **⛔ CLAUDE — decoupled-theme art now works app-wide** (C-DARKART-002): raw `painterResource(R.drawable.illustration_*`/
> `pack_art_*)` follows the in-app theme via the Configuration `uiMode` sync, so it no longer *needs* `BrandIllustration`
> to get the right `-night` variant. `scripts/painter-xml-scan.sh` (crash guard) + Pass C's decoupled check (system-light +
> in-app-Dark) remain the per-round guards.
## Icon/glyph audit — ✅ COMPLETE (every Material icon is now a Closer glyph)
> Done 2026-06-28: all 187 `Icons.*` call sites (~49 distinct icons) were swapped to `glyph_*` via the central
> `CloserGlyphs` accessor; **0 `Icons.*` and 0 `androidx.compose.material.icons` imports remain.** The table below is kept
> for the historical mapping (Material icon → glyph) and as the reference for any *new* icon added later: never introduce a
> raw `Icons.*` — add a `CloserGlyphs` accessor + `glyph_*.xml` instead. A `scripts/` guard that fails on any `Icons.`
> call site would keep this at zero (recommended).
| Generic icon (≈remaining uses) | Used for | Wire to `glyph_*` (✅ asset exists) |
|---|---|---|
| `ArrowBack` (31) | every top-bar back | `glyph_back` (brand chevron) |
| `Favorite` / `FavoriteBorder` (17/5) | **generic hearts** — likes, love, daily | `glyph_heart` (the Closer two-equal-halves heart, filled + outline) |
| `Lock` / `LockOpen` (18/2) | premium-locked, security | `glyph_lock` / `glyph_lock_open` (keyhole motif from the mark) |
| `Person` / `People` (16/1) | avatars/partner fallback | `glyph_person` / `glyph_couple` |
| `Check` / `Done` / `CheckCircle` (11/1/1) | confirm/selected/sent | `glyph_check` |
| `ArrowForward` / `ArrowForwardIos` (10/4) | row chevrons, next | `glyph_forward` |
| `PlayArrow` (8) | Play tab / start | `glyph_play` |
| `Close` (7) | dismiss/close | `glyph_close` |
| `Star` (6) | premium/★ ideas | `glyph_star` (brand sparkle) |
| `Visibility` / `VisibilityOff` (5/2) | password reveal | `glyph_eye` / `glyph_eye_off` |
| `ContentCopy` (4) | copy invite code | `glyph_copy` |
| `Sync` (3) · `Image`/`PhotoLibrary`/`PhotoCamera`/`AddAPhoto` (3/2/2/2) · `Send` (3) · `Chat` (3) · `Delete` (3) | retry · media pickers · send · messages · delete | `glyph_sync` · `glyph_photo`/`glyph_camera` · `glyph_send` · `glyph_chat` · `glyph_trash` |
| `Home` (2) · `Settings` (1) · `Notifications`/`NotificationsNone` (2) | bottom nav + settings | `glyph_home` · `glyph_settings` · `glyph_bell` |
| `LocalFireDepartment` (2) | **streak flame** (generic) | `glyph_streak` (brand flame/spark) |
| `Mic`/`Pause` · `Timeline`/`TrendingUp`/`Psychology` · `Fingerprint`/`Key`/`Shield` · `Edit`/`Add`/`Share`/`Refresh`/`Warning`/`CalendarToday`/`Cake`/`CardGiftcard`/`AttachMoney`/`HourglassEmpty`/`QuestionAnswer`/`Palette`/`OpenInNew` (1 each) | voice · progress · security · misc | one bespoke `glyph_*` each, in house style |
Replace each `Icons.*` call site with `ImageVector.vectorResource(R.drawable.glyph_*)` + `Icon(tint=…)` (the existing
wiring pattern — see any wired glyph for the idiom). The glyphs now all exist, so a remaining `Icons.*` call site is an
**unfinished wiring = a logged brand defect**, not acceptable for ship. A `scripts/` scanner that greps `Icons\.` call
sites (excluding files that already wire the matching glyph) would make this measurable per round — currently ~196 remain.
---
## R10 brand walk (2026-06-26) — existing art integration clean, 0 defects
R10 visual sweep doubled as the Pass-H existing-art integration check: Today (paired-books daily-question art),
Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render
on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md`
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
+ PlayArrow glyph and read as intentional action banners, so no illustration is warranted there.
## This or That gameplay brand plan (Codex QA, 2026-06-27) — ✅ IMPLEMENTED + verified live R13 (2026-06-27)
> **Done (R13, working tree).** `ThisOrThatScreen.kt` `ChoicePromptBackdrop` replaced: the two-circle + diagonal-line
> "diagram" is gone — now a soft theme-aware glow + two faint paired-card silhouettes low in the frame, never crossing
> the prompt. `OptionCard` A/B map to `colorScheme.primary`/`secondary` with high-contrast `onSurface` body text, visible
> accent borders, and a rich filled selected state (`onPrimary`/`onSecondary`). `VersusBadge`, progress bar, the
> `N/total` + "This or That" pills, the mood number-circle, and `TotLengthChips` are all theme-aware (no fixed
> `CloserPalette` darks). Light/dark previews added. Verified live both themes (5554 dark / 5556 light), 0 FATAL — closes
> C-DARK-UI-001. Results screen left as-is (verified clean in R12 dark). The plan below is retained for history.
Live review used a dedicated QA launcher/device (`CloserCodexQA`) with a fresh admin-created test couple
(`codex-this-or-that-*` / `codex-partner-*`). Screenshots checked the mood picker and active gameplay in light and dark
mode. The visual issue is real: light-mode option buttons are directionally good, but dark mode makes the current prompt
backdrop feel like an accidental placeholder.
**Verdict:** The current `ChoicePromptBackdrop` two-circle + diagonal-line drawing should be treated as a branding defect
for active gameplay. In dark mode the diagonal line cuts through the question, the circles turn muddy, and the whole
motif reads like a technical diagram instead of a warm private ritual for two.
**Plan**
- Replace the two circles + line with a Closer-native "private choice" motif: two soft sealed answer cards, paired-card
silhouettes, or a subtle C-heart/keyhole accent. Keep it decorative and low-contrast behind the prompt; no line should
cross the question text.
- Keep the light-mode option card shape, spacing, and button feel. Make the colors theme-aware rather than reusing fixed
purple/pink values in both themes.
- Dark-mode option cards need stronger contrast: readable body text, visible borders, and selected states that feel rich
instead of dim. Disabled/other-selected states should still be legible.
- Keep `closerBackgroundBrush()` as the foundation, but consider one very subtle game-local glow or paired-card vignette
in brand colors. The background should support focus, not compete with the question or options.
- Re-check the mood picker after gameplay is fixed. The numbered mood circles are acceptable, but paired-card glyphs or
warmer step badges would feel more branded than plain numbered bubbles.
- Results can reuse `illustration_reveal_celebration` and existing heart/petal particles for high-match moments; the game
surface itself should stay code-native rather than needing a full raster illustration.
**Implementation map**
- Update `app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt`, especially `ChoicePromptBackdrop`,
`OptionCard`, and `VersusBadge`.
- Add local theme-aware helpers for This-or-That option A/B colors, selected colors, disabled colors, border colors, and
prompt-backdrop alphas. Validate against both `MaterialTheme.colorScheme.background` and `surface`.
- Prefer Compose `Canvas`/shape drawing for the prompt motif. Generate raster art only if the code-native motif cannot
carry the brand; if generated, use transparent PNG art with no readable text.
- Add or update light/dark Compose previews for mood selection, active prompt, selected answer, waiting/disabled, and
results.
**Acceptance checks**
- Light and dark screenshots show no decorative element crossing or competing with the prompt text.
- Long prompts, long options, selected, unselected, and disabled states stay readable.
- The light-mode cards retain the current friendly button feel.
- Dark mode feels intentionally Closer-branded: aubergine/lavender/pink, soft and private, not a placeholder diagram.
- Logs are checked after opening a game from notification/deep link before assuming the route worked.
## Generated Art Live
The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to
`app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState`
(rounded-tile, theme-safe) and a new `ui/components/BrandIllustration.kt` helper:
| Art | Screen wired | Verified |
|---|---|---|
| A1 `pairing_success` (transparent) | PairingSuccessScreen hero (replaced the keyhole chip; pulse/spring kept) | gallery (transparent floats) |
| A2 `answer_history_empty` | AnswerHistoryScreen empty (replaced generic couple_history) | shared EmptyState (Run-2 both-theme) |
| A3 `connection_challenges_header` (banner) | ConnectionChallengesScreen series-list header | gallery (16:9 banner) |
| A4 `memory_lane_capsule` | MemoryLaneScreen empty (replaced 📦 emoji) | shared tile |
| A5 `date_match_empty` / `date_match_success` | DateMatchesScreen empty / "It is a match!" modal | shared tile / gallery |
| A6 `bucket_list_empty` | BucketListScreen empty | **live dark + light** |
| A8 `messages_empty` | MessagesInboxScreen (new empty state added) | shared EmptyState |
| A9 `quiet_hours` | ~~NotificationSettings quiet-hours section~~ **REMOVED 2026-06-28** (out-of-place mid-list illustration → cleaner standalone toggle); asset now in debug Art-preview gallery only | n/a |
| A10 `past_games_empty` | WheelHistoryScreen ("Past Games") empty | shared tile |
| A11 `privacy_recovery` | SecurityScreen header | **live dark** |
| A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** |
| A13 `premium_unlock` (transparent) | **✅ Wired R13** — one-time Premium unlock modal (`PremiumUnlockOverlay`) | **live both themes** |
All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification. A7 pack art is
**N/A** because all 10 question packs already have `pack_art_*`.
A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data were
not all reachable on the baseline couple, but their render path is proven through the shared tile + gallery.
**Premium unlock modal — ✅ IMPLEMENTED + verified live R13 (2026-06-27).** `ui/components/PremiumUnlockOverlay.kt`
(`PremiumUnlockViewModel` + `PremiumUnlockOverlay`), hosted at the `AppNavigation` root next to `MessageBubbleOverlay`,
so it surfaces over any screen. It's driven off `CouplePremiumChecker.isPremium()` (which already OR-combines both
partners) — so it fires for **both** the purchaser (own entitlement active) **and** the partner (couple-shared Premium
turns on), without depending on the push route. One-time per activation via a new persisted `premiumUnlockCelebrated`
flag on `SettingsRepository`/`SettingsDataStore` (set on dismiss; auto-reset when Premium lapses so a re-activation
celebrates again — mirrors `lastCelebratedStreakMilestone`). The modal shows `illustration_premium_unlock` (transparent,
floats via `BrandIllustration(tile=false)`) + "Premium unlocked ✨" + "Start exploring". **Verified live:** admin toggle
QA premium ON → modal on BOTH 5554 (dark) and 5556 (light); dismiss → relaunch → no re-show (gate holds); 0 FATAL.
## Glyph Status
**~63 `glyph_*.xml` now exist** in `app/src/main/res/drawable-nodpi/` — the original G/G2 set PLUS the full Material-icon
replacement set (back, heart, heart_outline, lock, lock_open, person, couple, check, forward, play, close, star, eye,
eye_off, copy, sync, photo, camera, send, chat, trash, home, settings, bell, streak, mic, pause, timeline, trending_up,
psychology, fingerprint, key, shield, edit, add, share, refresh, warning, calendar, cake, card_giftcard, attach_money,
hourglass, question_answer, palette, open_in_new, …). Source SVGs: `docs/brand/generated-art/glyphs/source-svg/`;
Android vectors: `docs/brand/generated-art/glyphs/android-vector/`.
**Wired in code (23 distinct, verified 2026-06-28):** `paired_cards`, `how_well`, `sealed_answer`,
`connection_challenge`, `memory_capsule`, `date_card_heart`, `question_packs`, `bucket_list`, `past_games`, `spin_wheel`
(Play-hub cards + WaitingForPartner); `couple_premium`, `privacy_lock`, `delete_account`, `settings` (Settings); plus
`couple`, `daily_card`, `forward`, `home`, `lock`, `play`, `streak`, `chat`, `closer_heart_keyhole`.
**Created but NOT yet wired (~40):** every glyph in the icon-audit table whose Material equivalent still appears in code —
`back` (vs ArrowBack ×31), `heart`/`heart_outline` (vs Favorite ×12/×5), `person` (vs Person ×16), `check` (×11),
`close` (×7), `star` (×6), `eye`/`eye_off` (×5/×2), `copy` (×4), `send` (×3), `sync` (×3), `photo`/`camera`, `trash` (×3),
`bell`, `mic`, `pause`, `timeline`, `trending_up`, `psychology`, `fingerprint`, `key`, `shield`, `edit`, `add`, `share`,
`refresh`, `warning`, `calendar`, `cake`, `card_giftcard`, `attach_money`, `hourglass`, `question_answer`, `palette`,
`open_in_new`, `quiet_hours_moon`, `export_data`, `closer_mark`. **This is the open work** — see the icon-audit table.
White monochrome vectors are re-tinted by `Icon(tint = ...)` and loaded via `ImageVector.vectorResource(...)`.
---
## Prompt Style Reference
Use this only when a future pass creates a new prompt. Existing completed art should not be regenerated.
> Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**,
> gentle smooth gradients, calm and intimate. **Palette only:** aubergine `#24122F`, deep purple `#56306F`, lavender
> `#B98AF4`, soft pink `#F7C8E4`, soft lavender `#D9B8FF`, blush white `#FFF8FC`. Mood: **warm, quiet, equal,
> intentional — a private ritual for two.** Recurring motifs available: paired/sealed cards, floating hearts and
> petals, a small wooden table with mugs + a lit candle + a lavender sprig, moon/window for quiet hours, a
> calendar/date-card, a sealed capsule/box. If people appear: **two balanced, inclusive figures, simple friendly
> faces, no dominant partner.** Soft abstract floral/heart shapes in the background.
> **Do NOT include:** any readable text, answer/prompt/message content, invite codes, emails, app UI/buttons, generic
> dating-app clichés, stock-photo realism, other brands' logos/wordmarks/app-store badges,
> alarm/urgency/surveillance/fire imagery.
> **The Closer brand mark** (use ONLY where a prompt explicitly calls for it — otherwise keep art mark-free): a
> pink-to-lavender **"C"** whose upper arc is soft pink and lower sweep is lavender, curving around a **heart-shaped
> negative space** with a small **dark aubergine keyhole** at its center (the keyhole = trust/privacy; the heart =
> the couple). Keep it faithful — do not redraw it as a plain `C`, turn the keyhole into a heart, add a key/lock
> shackle, add faces or text. On dark/aubergine surfaces use a white keyhole; on light/blush use the aubergine keyhole.
**Aspect/format conventions**
- Hero/onboarding/paywall: portrait **4:5** (matches `illustration_couple_*`).
- Empty states: roughly **square 1:1**, generous padding, sits centered above text.
- Celebration: **1:1**, transparent or blush-white background.
- Glyphs: **single-color** simple vector, square, legible at 2032 dp.
- Pack/category art: **landscape ~16:9** banner, object-led (no people), like `pack_art_*`.
- Always readable on **both** blush-white (light) and aubergine (dark) surfaces — keep a soft self-contained vignette
rather than a hard rectangular background; export PNG with transparency where noted.
**Existing assets (reuse before generating):** the generated A1-A12 illustration set above, `illustration_couple_*`,
`illustration_daily_question`, `illustration_tonight_partner_prompt`, `illustration_partner_activation`,
`illustration_reveal_celebration`, `illustration_streak_milestone`, `illustration_together_empty`,
`illustration_premium_unlock`, all 10 `pack_art_*` assets, `particle_heart`, and `particle_petal`. These are Android
assets now; only generate new art when a future QA pass finds a specific missing surface or replacement-worthy defect.
---
## Screen-By-Screen Audit
Legend: ✅ on-brand / no art needed · reuse/wire existing art · 🔤 brand-copy/color/code touch
| Screen / surface | Current brand state | Opportunity |
|---|---|---|
| Onboarding carousel | ✅ couple art + warm copy | ✅ strong already |
| Welcome (Create / I have account) | heart mark + privacy line | ✅ on-brand; could rotate privacy messages 🔤 |
| Sign up / Login / Forgot password | plain form | 🔤 add small heart mark + one privacy line above the form (no big art — keep forms clean) |
| Create profile — name / sex / photo | plain steps | 🔤 light: small step glyphs; ✅ otherwise (forms stay clean) |
| Pair: invite (create code) | `illustration_couple_invite` wired | ✅ |
| Pair: accept code / pairing success | `illustration_pairing_success` wired | ✅ |
| Home (paired) | cards, warm copy | ✅ good |
| Home (unpaired "bring your person in") | couple art present | ✅ on-brand |
| Today / daily question | clean card | ✅; reveal moment is the place for art (below) |
| Answer reveal (mutual) | `illustration_reveal_celebration` wired | ✅ |
| Answer history | `illustration_answer_history_empty` wired | ✅ |
| Play hub | game glyphs wired | ✅ |
| This or That (setup/play) | light buttons good; dark prompt backdrop off-brand | 🔤 replace two-circle/line motif + theme option colors |
| This or That / How Well / Desire Sync **results** | score + rows | reuse `reveal_celebration`; particles on high match |
| How Well / Desire Sync intro | icon + copy | ✅ |
| Spin the Wheel | nice wheel art | ✅ wheel is on-brand |
| Wheel complete / results | text reveal | celebration header (reuse particles) |
| Connection Challenges (series list) | `illustration_connection_challenges_header` wired | ✅ |
| Connection Challenges (active day) | clean | 🔤 small streak/heart glyph; ✅ otherwise |
| Memory Lane (list) | `illustration_memory_lane_capsule` empty state wired | ✅ |
| Memory Lane (sealed capsule) | lock + date | optional: reuse capsule art on sealed-card detail, no new art |
| Date Match (deck) | clean cards | ✅ |
| Date Match (your matches / empty) | empty + success art wired | ✅ |
| Plan Date / Date Builder | form | 🔤 small date-card glyph; ✅ otherwise |
| Bucket List (empty) | `illustration_bucket_list_empty` wired | ✅ |
| Question Packs (library) | all 10 `pack_art_*` assets present | ✅ |
| Messages (inbox empty) | `illustration_messages_empty` wired | ✅ |
| Conversation | chat | ✅ keep clean; quiet-hours art is in settings |
| Past Games (empty/list) | `illustration_past_games_empty` wired | ✅ |
| Your Progress / Activity | stats | 🔤 brand-colored charts; reuse `streak_milestone` for milestones |
| Paywall / Subscription | couple art present | ✅ strong (couple illustration + “one subscription for both”) |
| Premium unlock modal | `illustration_premium_unlock` wired in `PremiumUnlockOverlay` (R13) | ✅ one-time modal for purchaser + partner; verified live both themes |
| WaitingForPartner | per-game glyphs + copy | ✅ |
| Settings + sub-pages | dense lists | ✅ keep clean — **no illustrations**; brand via section headers/color only 🔤 |
| Security / Recovery phrase | `illustration_privacy_recovery` wired | ✅ |
| Privacy & Terms | settings row uses `glyph_privacy_lock` | ✅; page can stay text-first |
| Delete account | `illustration_account_deletion_goodbye` wired | ✅ |
| Quiet hours (settings) | illustration REMOVED 2026-06-28 (looked unprofessional mid-list) — now a clean standalone toggle | ✅ matches "Settings stays clean — no illustrations" |
| Notifications (system) | `ic_notification_closer` used | ✅ no glyph swap needed |
---
## Generation Backlog
**Status (2026-06-28 re-audit) — the asset-generation backlogs are essentially DONE; the remaining work is CODE:**
1. **Dark illustration variants — ✅ DONE.** `drawable-night-nodpi/` has a dark variant for every non-transparent
surface (all 22 `illustration_*` + all 10 `pack_art_*`). Remaining: verify the 3 transparent celebration assets
(`premium_unlock`, `spin_wheel`, `streak_milestone`) read on dark, and Pass-C spot-check the new variants.
2. **Custom glyphs — ✅ DONE.** Assets created AND wired: all 187 `Icons.*` call sites swapped to `glyph_*` via the
central `CloserGlyphs` accessor; 0 Material icons / 0 material-icon imports remain. BRAND-ICON-CUSTOM resolved.
Do not regenerate **completed** illustration/glyph art unless a QA pass logs a specific defect.
---
## Notes for the asset hand-off
- Match filenames to the existing scheme: `illustration_<name>.png` (nodpi), `pack_art_<pack>.png`, `glyph_<name>.xml`,
`particle_<name>.png`. Provide @1x in `drawable-nodpi/` (illustrations) or density buckets where the existing asset has them.
- For the generated G-set, use the source SVGs in `docs/brand/generated-art/glyphs/source-svg/` for review and the
Android-ready vectors in `docs/brand/generated-art/glyphs/android-vector/` for app wiring.
- After adding art, re-run Pass C (visual, light + dark) on those screens to confirm contrast + no clipping, and
re-export store graphics per `docs/brand/visual-identity.md` if the palette/mark changed.

View File

@ -1,125 +0,0 @@
# ClaudeGamesReview — Full Games, Mechanics, Retention & UI Review
> **Session 2026-07-07 (Claude, senior-dev review round).** Scope: all 7 games verified live on a
> fresh 2-emulator couple, mechanics checked against their real-world inspirations (researched),
> retention assessment, full-app UI warmth sweep, code-quality audit + fix batches.
> Bugs are cross-referenced by ID; canonical defect state lives in `ClaudeReport.md`.
> Test couple: **Ava + Ben** (`j39xR7PVZLLCveWnmenU`), created through the real signup → invite-code
> → recovery-phrase pairing flow on `CloserQA2` (5554, light) + `CloserCodexQA` (5556, dark).
> Premium was granted via admin (user-authorized), verified (partner unlock modal + push), then
> revoked (locks confirmed restored). 0 active sessions at session end.
## 1 · Per-game verdicts (mechanics fidelity + live results)
| Game | Real-world analog | Fidelity | Live 2-device result |
|---|---|---|---|
| This or That | Rapid-fire preference quiz | ✅ Faithful | **PASS** — engineered answers → exactly 3/5 "in sync"; simultaneous symmetric reveal; warm framing ("Lots in common 💛", neutral "Differ" chips) |
| How Well Do You Know Me | The Newlywed Game | ✅ Core faithful; no round-2 role swap (see R-2) | **PASS** — subject/guesser roles correct on both intros; engineered 2/5 exact; near-miss scale renders amber "4 → 3" (isClose softening); low score framed "Getting there!" |
| Desire Sync | Mojo Upgrade-style mutual-desire matching | ✅ Canonical privacy contract, verbatim in intro copy | **PASS** — engineered intersection → exactly "2 shared desires / 3 stayed private"; non-mutual yeses never rendered on either device; count doesn't leak whose/which; answers `enc:v1:` at rest |
| Spin the Wheel | Conversation-deck wheel | ✅ Honest randomness (category picked during spin; decorative stop angle) | **PASS** — identical 10-question set both sides; blind until both answered; side-by-side reveal; completion gate blocks finish with unanswered prompts |
| Connection Challenges | 7-day couples habit challenge | ✅ Day-gating real (can't complete 2 days in one day) | **PASS** — premium challenge opened with entitlement; day-1 complete → "waiting for partner"; ⚠ day-2 prompt text visible a day early (spoiler, see U-13) |
| Memory Lane | Time capsule / letter to future selves | ✅ | **PASS** — sealed capsule shows only title + "Opens in 29 days"; title AND body `enc:v1:` at rest (admin-verified) |
| Date Match | Tinder swipe-to-match | ✅ incl. one-sided-like privacy | **PASS** — simultaneous mutual-LOVE race → exactly **1** match doc; celebration modal + "It's a match!" push on BOTH devices |
**Shared engine seams, verified live:** one-active-game gate converges the second starter into the
active session (Ben's How Well attempt → "Ava is playing a Wheel game / Join the game");
first-finisher `partner_completed_part` fires as an in-app banner (foreground) and real push
(killed app, cold-start tap opened the app cleanly, 0 FATAL); mid-game Quit abandons correctly
(admin: 0 active sessions after); "you both finished" results banner on both sides.
## 2 · Bugs found (canonical detail in ClaudeReport.md)
| ID | Sev | Summary | Status |
|---|---|---|---|
| DQ-MISMATCH-001 | **P1** | Partners can resolve **different daily questions** for the same day: server assignment is the unresolvable `q_default_daily` rollout fallback → each device falls back locally, and the fallback **pool depends on the viewer's premium state at that moment** (per-user entitlement, transient failures→false, mid-day flips). Reveal then cross-compares answers to different questions. Reproduced live (Ava: premium pool "tiny win" q; Ben: free pool "tiny debate" q; same clock/tz). | **FIXED** (paired fallback pinned to free pool — premium-independent) + follow-up filed to seed real server assignments |
| DQ-SLUG-001 | **P1** | Daily reveal renders the partner's answer as a raw option-id slug ("fake_awards_should_be_mandatory") when the id isn't in this device's question config (same class as HW-BREAKDOWN-001). | **FIXED** (humanize fallback in both reveal summaries) |
| GAME-HANG-001 (B1) | **P2** | ToT/HowWell/DesireSync answer listeners swallowed snapshot errors → game stuck on WAITING forever, no feedback (Wheel/Capsule already had the correct `close(err)` pattern). | **FIXED** (sources `close(err)` + VM `.catch` → retryable ERROR with "Try again" re-attach) |
| DST-001 (B2) | **P2** | `assignDailyQuestion.ts` hardcoded `CST_OFFSET_HOURS=-6`: wrong date labels + reveal times shifted 1h for the ~8 CDT months. | **FIXED** (Intl/America-Chicago helpers, matches streakReminder.ts pattern; 5 DST regression tests incl. spring-forward round-trip) |
| A-003b | P3 | Challenges catalog still shows 🔒 Premium badge after couple premium unlock (gate itself honors premium — opens fine). | Filed |
| BANNER-LIFE-001 | P3 | "You both finished · View" game banner persists across screens/games long after results were viewed (still up entering a NEW game's setup, and shown on top of the results screen it links to). | Filed |
| AUTH-TRUNC-001 | P3 | Welcome/splash privacy tagline clips mid-sentence without ellipsis ("Only you and your paired") — first-run first impression. | Filed |
| Copy/theming P3s | P3 | "Daily Fun Mc" internal category name as user-facing pill; unthemed teal date picker; DOB asked twice in signup→profile funnel; "It is a match!" vs push "It's a match!"; HW matched-row question text low contrast in dark; Date Match card-underlay text bleed; joiner screen titled "Waiting for X" when it's your turn. | Filed |
## 3 · Retention assessment (Hooked / Fogg / SDT lens)
**Strong foundations:** daily-question blind-reveal is a genuine daily anchor with a variable
reward (partner's answer) · streaks with repair + grace (aligned with Duolingo's leniency findings)
· Memory Lane = stored-value investment · Weekly Recap + Your Progress = progress evidence ·
first-finisher nudges close the async loop (verified: banner + push both fire) · notification suite
respects quiet hours.
**Prioritized recommendations:**
- **R-1 (P1): Fix the daily anchor supply chain.** `q_default_daily` fallback means the server
never really assigns; seed the Firestore `questions` pool (or make the callable assign a real id
from a synced catalog) so both devices always agree. (Client-side consistency fix landed this
session; assignment seeding is the durable fix.)
- **R-2 (P2): Role-swap rematch in How Well.** Newlywed Game's round structure swaps roles; the
results screen should offer "Now you guess — swap roles" (starter becomes guesser). Cheap, doubles
sessions per sitting, and answers "what do we do next?" at the emotional peak.
- **R-3 (P2): Return-tomorrow beat on results screens.** After every reveal the only CTAs are
"Play again / Back to Play". Add the next anchor: tomorrow's question teaser, challenge day N+1,
or streak state ("Day 2 tomorrow — keep it alive").
- **R-4 (P2): Repeat-avoidance for game pools.** Desire Sync's effective pool is 102 binary items
drawn `shuffled().take(n)` per session (no cross-session memory) and Date Match's deck is
deterministic (identical first card for every couple, every install). Persist seen-ids per couple.
- **R-5 (P3): Purify the How Well pool.** `getQuestionsForPrediction()` pulls all choice/scale
questions including 500 `daily_fun_mc` novelty MCs ("laugh-track move") — dilutes the
partner-knowledge premise. Exclude `daily_fun_mc` (or curate a prediction-worthy tag).
- **R-6 (P3): Challenges — hide tomorrow's prompt** (spoiler kills the daily ritual) and consider a
visible 7-day chain with both partners' marks (social accountability).
- **R-7 (P3): Analytics funnel gaps.** `RetentionEvent` has GameStarted/GameCompleted but no
first-answer, reveal-viewed, waiting-abandoned, or notification-tap-attribution events — the async
dead-end (biggest churn risk) is unmeasurable today.
- **Content notes:** 48/150 sexual_preferences items unreachable by Desire Sync (non-binary configs
— fine, Wheel uses them) · `daily_fun_mc` rows carry `sex='neutral'` (currently benign only because
of the binary filter; retag to avoid a future landmine) · This or That mood pools healthy (6075
per mood vs 15 max draw); `rebuilding_trust` (45 q) reachable only via "All topics".
## 4 · UI warmth sweep (vs docs/copy-guide.md)
Scores 15 per cluster (tone / emotional safety / welcome / consistency):
- **Games (all 7): 5/5//4.** Best-in-class results framing (no shaming anywhere: "Getting
there!", "Differ" not ✗, "Nothing in common this round" never blames). Consistency dinged for the
banner lifecycle + slug leak (fixed) + per-game accent divergence (DS magenta is intentional and
works).
- **Onboarding/pairing: 4//5/3.** "You're in / Now bring your person in", value-prop checklist,
recovery-phrase ceremony with word-confirmation are excellent. Dinged: tagline truncation, DOB
asked twice, unthemed date picker, dead-end Create-Profile when signed out.
- **Home/Today: 4/5/5/3.** Daily card + mode system (Tiny Win Tuesday live-verified) is a strong
anchor; "Daily Fun Mc" pill leak; Home-card question can disagree with Today tab (DQ-MISMATCH).
- **Empty states: 5.** Memory Lane's "No capsules yet" is the pattern to copy everywhere.
- Both themes render cleanly on every screen driven this session (fresh-couple run covered light
on 5554 + dark on 5556 throughout); HW matched-row contrast is the one dark-mode ding.
## 5 · Code-quality fixes landed this session
- **Batch 1 (correctness):** B1 observer `close(err)` ×3 + VM `.catch`→retryable ERROR ×3 (+
`GameCopy.SYNC_ERROR`); DQ paired-fallback premium-independence; reveal slug humanization ×2;
DST-safe Chicago date helpers + 5 regression tests.
- **Batch 2 (dead code / hygiene):** see ClaudeReport.md fix log.
- **Filed (structural, specced in Future.md):** generic session-game engine (ToT/HW/DS VMs are
~80% clones; ~250-line state machine ×4), generic replay VM/screen (3× ~60-line clones), shared
waiting/length-picker composables, premium-check invocation helper, `GameConstants` for divergent
`ADVANCE_DELAY_MS` (420/380), dispatcher injection, error-surfacing standardization for 6
silent-load VMs, `npm audit` 10 moderate (GCP transitive).
## 5.1 · R30 polish + experience follow-up (2026-07-07)
All Tier-A P3s and Tier-B UX gaps from the review shipped and were live-verified (detail: ClaudeReport R30). Highlights that change the player experience:
- **Daily question is fixed at the root.** The server pool was empty (every couple got the unresolvable `q_default_daily`); now Firestore `questions` is seeded with the free weekday pool and the server picks the same mode-aware, deterministic question the client would — so both partners get the identical themed question, even across time zones. Verified: server assigns `daily_single_choice_weekly_v1_072` (Tiny Win Tuesday), both devices resolve it, no slugs.
- **Games no longer dead-end:** NextBeatCard ("Tonight's question is ready →" / streak) under every results screen; How Well offers a Newlywed-style role swap.
- **Content freshness:** Desire Sync remembers served questions; Date Match shuffles per couple and skips already-swiped ideas (no more identical first card for everyone).
- **Waiting isn't passive:** a "Send a little nudge 💜" button on the waiting screen (reuses the quiet-hours-safe thinking-of-you callable).
- **Onboarding polish:** themed M3 date pickers; the birth date now survives an app restart mid-signup (no more double-ask).
- **A11y + copy:** score visuals carry TalkBack descriptions; banners speak per-game ("guess their answers"); the internal "Daily Fun Mc" label never faces users.
## 6 · Coverage notes
- Verified live: all 7 games happy path 2-device · result extremes (0-match impossible for DS
reveal? engineered 3/5, 2/5, 2-shared, 3/5-sync variants) · one-active gate · abandon · cold-start
push join · simultaneous-LOVE race · premium grant/unlock/revoke lifecycle · daily answer→reveal ·
day-0 check-in · pairing + recovery-phrase ceremony · deep-link scheme audit (only /join/ links
exist; game entries are notification-driven, all tested).
- Deferred (unchanged from plan): full Pass E matrix, Pass K money path, release build, monkey,
TalkBack full pass (spot: n/a this round), TodayWidget interactive smoke (code-read only),
am-kill mid-session resume (cold-start-from-push covered the recovery class).

View File

@ -1,170 +0,0 @@
# Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> **R30 (2026-07-07, polish + UX + seed):** all remaining P3s + 8 UX gaps FIXED & live-verified (see ClaudeReport R30 / ClaudeGamesReview §5). **Daily-question pool SEEDED + server made mode-aware/deterministic + deployed** — both devices resolve the same real server-pinned question (DQ-MISMATCH/DQ-SLUG closed at the root, verified live). Gates: unit pass · functions 58 · cold-start smoke run. Still deferred: TodayWidget interactive, TalkBack full pass, release/minify (O), device/OS matrix.
> **R29 (2026-07-07, games/mechanics/retention/UI review):** Pass B (all 7 games) **re-run live 2-device on a fresh couple = pass** (engineered-answer verification; see ClaudeGamesReview.md) · onboarding/signup/pairing/recovery-ceremony/day-0 check-in **pass (live, first full run since R24)** · daily answer→reveal loop **fail→DQ-MISMATCH-001/DQ-SLUG-001 → FIXED e5868bd, re-verified** · premium grant/unlock/revoke lifecycle **pass** · cold-start push open **pass (organic, killed app)** · one-active gate/abandon/simultaneous-match race **pass** · TodayWidget interactive + TalkBack spot + am-kill mid-session resume **todo (deferred; cold-start-from-push covered the recovery class)** · Pass D at-rest spot-check (game answers + capsule on new couple) **pass (`enc:v1:`)**.
> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.**
> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.**
> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b).
> **R25 (2026-06-30) — full fresh run on the new R24 E2EE backup/restore surface + cornerstone regression; 0 new defects.** Cheap gates green (unit **244** · fn **38** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** · cold-start smoke **6/6 both** · render smoke **4/4**). **Pass D CLEAN (deep on backup/restore):** at-rest manifest=pointers-only, Storage snapshot=`enc:v1:` (16KB, server-blind), restore_requests=0; rules member-scoped + keybox bound to other member + immutable pubkey; **D3 live negative all-denied** (backup manifest/chunks/restore_requests + create/write = 403/400; original couple/self-grant = 403); cross-user restore via **tokenized capability URL** verified (plain GET→200+`enc:v1:`); **R24 storage.rules deploy gap RESOLVED**. **Pass E:** `restore_requested` partner push **deployed + firing** (Sam queue: 3 today) → RESTORE_CONSENT; **R24-b functions NOT deployed** (`onRestoreFulfilled` absent, no `lastRestoreSelfAlertAt`, 0 `restore_self_alert`) → self-alert + completion alert = `blocked→deploy`. **Pass M:** new Security entries live-verified this session (recovery reveal on no-lock, Copy+`IS_SENSITIVE` mask, Help-my-partner-restore + back). **Cornerstone regression (Sam 5556):** A paywall gate ✓, B Play-hub cards+badges ✓, L inbox+thread fully decrypted no-leak ✓, N daily-Q decrypted+reveal ✓, 0 FATAL. **⚠️ PROCESS LANDMINE (I caused): `connectedDebugAndroidTest` UNINSTALLS+WIPES the app-under-test → wiped QA(5554) data → QA at fresh onboarding (O-ONBOARD-001 stays fixed). NEVER run instrumented tests on 5554/5556 fixtures — use throwaway 5558.** QA fixture recovery = `blocked→user` (password/re-auth needed). **User actions to close: (1) `firebase deploy --only functions` ✅ DONE (functions deployed by user; both self-alerts live-validated R25-c). (2) restore QA fixture ✅ DONE R25-b.**
> **R25-b (2026-06-30) — QA(5554) fixture RECOVERED; live 2-device partner-assisted restore verified end-to-end, 0 defects.** Password reset via admin (user-authorized) → QA signed in → **NEEDS_RECOVERY** → "Start restore" published request (code 592847) → **deployed `onRestoreRequested` fired LIVE** (Sam got "Help your partner restore 💜" push, id 40038) → Sam's **Change-1 consent live-verified** (email anchor + name **QA** decrypted locally + confirm checkbox; Approve gated on code(6) **AND** confirm) → approve → **QA auto-restored**: paired Home + "Sam/Revealed" + **full chat history decrypts**; fresh `restore_ok_R25` from restored QA **decrypts on Sam** = bidirectional round-trip = full R24 restore regression. **Deferred obs (not a defect):** warm-start restore-push tap opened Play hub not RESTORE_CONSENT (likely collapsed-notif-group artifact; cold-start routing smoke-green).
> **R25-c (2026-06-30) — LIVE-FIRE of deployed owner-alerts (Change 3): both restore self-alerts observed on QA's OWN device; last user-gate CLOSED; 0 defects.** User-authorized re-wipe QA(5554) → sign in → NEEDS_RECOVERY → Start restore (code 565429). **(1) Request self-alert fired LIVE** — QA **shade** (`closer.app` id 67945, `partner_activity`, imp 4) + durable `users/{QA}/notification_queue` (`restore_self_alert`, 23:17:38 “New device is restoring your history”) + partner push to Sam (“Help your partner restore 💜”) — all from one `onRestoreRequested`. **R25-b routing obs CLOSED:** tapping Sams *single* restore notif → RESTORE_CONSENT (not Play hub) ⇒ earlier artifact was the collapsed 2-item group header. Consent gate re-verified (code(6) alone Approve-disabled → +confirm enabled). Sam approve → **`onRestoreFulfilled` fired (status ok, 1319ms) on REQUESTED→READY** → **(2) completion self-alert** queued to QA (`restore_self_alert`, 23:19:50 “Your history was just restored”) — not on shade only because QA was foregrounded (auto-restored); push still reached live tokens. 132s apart (>~60s dedupe → no suppression). **Robustness live:** 1 stale token (`registration-token-not-registered`) failed but `Promise.allSettled` → function ok. QA auto-restored to paired Home + content decrypts, 0 FATAL → **fixture healthy**. **Minor follow-on (not defect):** prune `not-registered` FCM tokens.
> **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes R25-c follow-on); build + 47 fn tests green; NOT deployed (user-gated).** New `notifications/pruneTokens.ts` (`isDeadTokenError` prunes ONLY `registration-token-not-registered`/`invalid-registration-token` — never transient/`invalid-argument`, so a bug can't wipe all tokens; `pruneDeadTokens` best-effort, never throws, batch-deletes dead `fcmTokens` docs + legacy field, only on an actual dead token). Reuses each caller's `tokens`+`allSettled` results → 1 line wired into **all 19 push sites** (questions/dates/couples/games/notifications/billing/users/backup). `pruneTokens.test.ts` +9 tests (fn **38→47**). tsc verified every db/uid/tokens/results ref; `dist/notifications/pruneTokens.js` emitted. Takes effect on next `firebase deploy --only functions`.
> **R21 (2026-06-29) — brand-voice + Home-bubble polish, then full QA re-run; 0 new defects, 0 FATAL.** Copy: `prompt→question` (~26 strings) + clinical→Closer voice (Outcome/check-in feature, "Your Progress"→"Growing together", "Private sync"→"Just for two", Home eyebrow "Your daily question", paywall "…and growth"). Home partner bubble upgraded (Coil `SubcomposeAsyncImage` + gradient ring + a11y; partner photo verified live). Cheap gates all green (210 unit · 24 fn · theme-scan CRIT 0 · painter-xml 0 · smoke 6/6 both). **Reveal-when-answered verified LIVE end-to-end** (both answer → Home "Reveal is ready / Reveal together" → AnswerReveal shows both picks). Multi-angle nav verified (daily Q via Today+Home, reveal via Home card, Settings→Growing together, Play→Question Packs "250 questions"). Cornerstones A/B/D carry from R20 (no rules/crypto/games-logic change — diff is copy + Home-bubble UI); E re-verified (smoke). Also landed this session (uncommitted): recovery-UX partner-as-backup copy + change-phrase desync guard, `SECURITY.md`, first instrumented test `FirstRunRenderSmokeTest`. See `ClaudeReport.md` R21.
> **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 ✓".
>
> **First-run / cold-path = `blocked→fixture` (run the fresh-install lane):** the recurring emulators are **paired + signed-in + onboarding-complete**, so the recurring passes **cannot reach onboarding / sign-up / login / auth-logo / pairing / new-device-recovery / day-1 empty states** — this is the fixture blind-spot that hid **O-ONBOARD-001** (P0, every fresh install crashed). Cover it on a **throwaway** device (e.g. `emulator-5558` / fresh AVD — never `pm clear` 5554/5556) on any onboarding/auth/pairing/branding/`res/drawable` change + pre-ship. Don't claim "first-run ✓" off the logged-in fixtures. **R20 (2026-06-29) — FULL FRESH-PAIRING LANE RUN ✅ (user-requested), 0 FATAL:** two throwaways uninstalled→fresh-installed (5558=Avery + booted CloserCodexQA/5560=Riley). A: notif-permission → **onboarding all 3 slides** ("Answer honestly / No peeking / Grow Closer" — CtaSlide logo renders, O-ONBOARD-001 stays fixed) → auth landing (AuthLogoMark renders) → **sign-up****3-step profile** (name · inclusive gender Female/Male/Non-binary/Prefer-not-to-say · optional photo skip) → unpaired handoff → **invite code 47V-JCZ** + recovery phrase shown. B: **Skip onboarding path** verified (slide 1 → auth) → sign-up → profile → **Accept-instead → enter 47VJCZ → "Pair up"** → "You're connected / Riley & Avery". **New couple `3sRSEvky7HdSUOY9F1z0` (2 userIds, `encryptionVersion=2`) created server-side**; both devices flipped to paired in real-time; B's day-1 check-in modal + first daily Q render. No new bugs. Teardown: throwaways uninstalled, 5560 powered off, QA/Sam (5554/5556) untouched + clean (active=0). (Residual isolated throwaway Firestore couple left as disposable test data.)
>
> **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — contains architecture, security model, data model, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section that backs every fix-and-pruned ID below.
> Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is
> confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.)
## Status at a glance
| 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. **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 |
| F — Resilience | R12: concurrency (F-RACE-001 atomic-start code + R8 live) · process-death (smoke `am kill`×5 → push → cold-start recovered each) · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred |
| G — Account creation / fake-account | R10: abuse live via D3 (non-member denied, no self-grant) + invite rules; happy/validation R5-clean (unchanged) | ✅ pass |
| H — Branding & artwork | R13: ToT redesign + Premium-unlock modal done. **2026-06-27 brand audit opened 2 backlogs.** | ⚠️ **BRAND-DARK-COVERAGE** (light-only illustrations need dark variants) + **BRAND-ICON-CUSTOM** (~60 generic Material icons → bespoke `glyph_*`) — full asset lists in `ClaudeBrandingReview.md` |
| I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression |
| J — Accessibility | R13: **J-OBS FIXED + verified live** (composer media/voice/retry buttons → 48dp; measured 126px=48dp both axes); font scale 2.0 reflows + reduce-motion×7 (R10) hold | ✅ done · J-OBS fixed (pending 1 confirm) |
| K — Billing & subscription lifecycle | Gate (couple-shared unlock) verified via admin toggle (Pass A) + Premium-unlock modal + `onEntitlementChanged` push live (R13/R14). **Real money path (purchase/restore/cancel→expiry-relock/refund/plan-switch) NOT tested** — needs a real device + Play sandbox. | ⚠️ **todo** — money path `blocked→needs-device`; gate ✅ |
| L — Messaging & chat (E2E) | R15: conversation render driven live — decrypt **both dirs**, attribution, timestamps, **Seen** receipt, ❤️ **reaction**, ordering, day-separators, voice-note + image bubbles, E2E composer lock glyphs; inbox decrypted previews **no `enc:` leak**; live QA→Sam send delivered; at-rest `enc:v1:`. **R18 re-verified live round-trip** (Sam→QA: received + decrypted on partner, `enc:v1:`(79) at rest, marker absent from server docs = no leak, Seen receipt). Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — re-confirmed R18; 4 sub-items carry |
| M — Settings & account management | R15: **M-001 (quiet hours) FIXED + verified live** (server-side fail-open suppression); per-type notif toggle take-effect confirmed live (server-enforced; field flips in Firestore; toggle-off → 0 delivery); theme/DataStore persistence across relaunch ✅; biometric lock code-sound (cold-start re-lock; background-resume observation → Future.md). Remaining: edit-profile persist, unpair/delete-cascade (disruptive — deferred). **R18: M-001 re-confirmed** — toggling QH writes the client mirror (`quietHoursEnabled`/`StartMinutes 1320`/`EndMinutes 480`/`timezone`) to `users/{uid}` correctly; server suppression deployed + R15-verified. Recommend prune. | ✅ **pass (core)** — M-001 confirmed (prune next); unpair/delete deferred |
| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ✅ **pass** — N-001/N-002 fixed; **Date Memories/Reflection CLOSED (R26 2-device live: reflect→reveal, edit-before-reveal, notes, bg/fg deep-link, ready/opened pushes + read-failure/timeout hardening)** |
| O — Release build & store readiness | **Not started.** All QA to date is on the **debug** APK. Minified release build, signing/AAB, App Check enforcement, i18n/RTL, App-Links, Play Data-Safety = pre-ship gate, not yet run. | ❌ **todo (pre-ship gate)** |
| P — Content, copy & language | R15: UI-microcopy swept (warm/inclusive; debug rows `BuildConfig.DEBUG`-gated; friendly error fallbacks; on-brand privacy copy) + **question-bank audit live: 6103 Qs — 0 empty, 0 exact dupes, 0 placeholder tokens, complete/mutually-exclusive answer configs, good type variety, consent-framed sensitive content.** No typos/off-voice/non-inclusive copy found. **R18: found+fixed P-GRAMMAR-001** — in-game wheel surfaced a subject-verb agreement error; bank scan found **13 stress-Q** from one template family where plural subjects hit a singular "{x} is …" frame ("busy weeks/health worries/… is affecting you"); fixed the 13 rows in asset `app.db` (data-only); root fix belongs in the content generator. | ✅ **pass** — copy clean; P-GRAMMAR-001 fixed (asset) + grammar-audit recommended |
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **R28: HW-BREAKDOWN-001 FIXED+verified (humanize option-ID fallback) + BANNER-RESULTS-COPY-001 (P4) fixed + O-AGE-001 18+ gate IMPLEMENTED+live-verified → archived/mostly-closed.** **Open: 0 P2/P3 blockers.** Remaining non-blocking follow-ups: **O-AGE-001** rules DEPLOYED + birthDate persistence verified against the live rule → only the Play maturity questionnaire remains (product, not code); **BRAND-DARK-COVERAGE** effectively resolved (all 22 `illustration_*` have dark variants; only transparent celebration assets lack one and read fine on dark).
---
## Pass A — Couple-shared premium (neither / partner-only / self)
| Feature | neither→locked | partner→both unlock | self→unlock | Status |
|---|---|---|---|---|
| Chat media + reactions | pass | pass | pass | ✅ pass (reference pattern) |
| Play: Desire Sync | pass | pass | pass | ✅ pass |
| Play: Memory Lane | pass | pass | pass | ✅ pass |
| Play: Connection Challenges | pass | pass | pass | ✅ pass |
| Question Packs (premium) | pass | pass | pass | ✅ pass |
| Wheel: Category Picker / Spin / History | pass | pass | pass | ✅ pass |
| Date Match / Plan Date | pass | pass | pass | ✅ pass |
| Subscription screen (own status) | n/a | n/a | n/a | ✅ pass (by-design per-user) |
Verified live: neither→paywall ("Go deeper together"); partner→couple-shared unlock (Sam free entered Desire Sync + Memory Lane); self→unlock; premium badges hidden under premium / shown when free. (A-001 couple-shared gap + A-003 badge fixed & confirmed.)
## Pass B — Games lifecycle (start / play / finish + results, 2-device, real user-nav)
All 7 played one complete time through on both devices via the real in-app path; gameplay all PASS.
| Game | starts | plays | finishes/results | no crash | Evidence |
|---|---|---|---|---|---|
| 1. This or That | pass | pass | pass | pass | 5/5 via Play hub, answers synced, results match both ("Two peas in a pod"). |
| 2. How Well Do You Know Me | pass | pass | pass | pass | QA answered 5 (incl. 15 scale); Sam predicted via hub → 4/5, wrong one marked ✗ on both, scoring accurate. |
| 3. Desire Sync | pass | pass | pass | pass | QA(free) entered w/o paywall; both answered 5 Yes/No → exactly 3 mutual desires, mismatches hidden, match on both. |
| 4. Connection Challenges | pass | pass | pass | pass | Gratitude Week → both did Day 1 → 🔥1, advanced to Day 2 synced. (7-day series time-gated; per-day cycle verified.) |
| 5. Memory Lane | pass | pass (create+seal) | pass (sealed) | pass | Capsule sealed "Opens in 29 days", encrypted at rest (title+content `enc:v1:`), cross-device. Unlock future-dated. |
| 6. Spin the Wheel | pass | pass | pass | pass | Spun → category → both answered all 10, per-Q You/partner breakdown matches both, session synced. |
| 7. Date Match | pass | pass | pass | pass | Both swiped deck, 3 mutual likes → 3 `date_matches`, "It's a match!" modal + live push, "Your Matches" shows all 3. |
Note: exit each game via "Back to Play" between games so the session closes (B-001 auto-completion fix verified). F-RACE-001 (simultaneous start) fixed — see Pass F.
**R18b — FINISH-GATE (every question must be answered before a game can finish):** Grounding found only **Spin the Wheel** let a player finish with blanks (explicit Skip; `Next` advanced when empty; `End session` submitted the rest as "Skipped"; it's the only game with text boxes). Per user decision ("Hybrid"): Wheel now uses an index-keyed nullable answer store + an **`attemptFinish()` gate** — skip/blank is allowed mid-play, but Finish bounces to the first unanswered prompt with an a11y "N left to finish" banner and submits only when none are blank (enforces non-empty text + ≥1 choice; scale always has a value). The other three already require a pick to advance (ToT/Desire Sync auto-advance on tap; How Well's Continue is disabled until selected) — verified by code + Pass-B observation. **Live (both emulators):** full 2-player Spin-the-Wheel (all 10, mixed written/choice) → completed reveal with **no "Skipped"**; gate bounce + persistent "N left" banner + walk-forward confirmed; then a full 2-player This-or-That ("5/5 in sync"). 3 new `WheelSessionViewModelTest` cases (gaps→bounce/no-submit; all-answered→submit no "Skipped"; completion-walk). 0 FATAL.
## Pass C — Visual (light + dark), all ~50 routes
~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back).
- **Theme-scan execution tracking (MANDATORY):** every Pass C round must record the counts exactly as
`scripts/theme-scan.sh` reports them in its `## Summary` section, then file the appropriate items:
| Round | Scanner run | CRITICAL | MAJOR | REVIEW | CRITICAL filed to ClaudeReport.md | MAJOR filed (if any break themes) |
|---|---|---|---|---|---|---|
| R15 | ✅ | 9 | 8 | 32 | ✅ (C-THEME-001..009) | — |
| R16 | — | — | — | — | — | — |
R15 scanner: 9 CRITICAL hardcoded surface/background colors filed as C-THEME-001..009; 8 MAJOR (1 component
color override + 7 direct `painterResource` hits) and 31 REVIEW items deferred to the visual sweep. Update this
row if any MAJOR is promoted to a filed defect.
- **R10 re-sweep (both themes where relevant):** Messages inbox ✅ (dark+light: conversations, avatars, unread dot, previews decrypted, no `enc:` leak), Conversation ✅ (image/voice/text/reaction/read-receipt/date-sep, E2E lock glyphs, correct attribution both dirs), per-question Discussion thread ✅, Today/daily-question ✅ (dark+light, paired-books art on-brand), Activity/Together ✅ (dark). **5 P2 found:** C-HOME-001 (Home shows top pending action twice — `primaryAction` hero + `buildPendingActions` row), C-NAV-002 (wheel results→BACK re-enters finished play screen, no popUpTo), C-NAV-003 (Wheel History/Past Games + PartnerHome double app-bar — route in `shellBackRoutes` while screen owns a TopAppBar; C-CC-001 class), C-PW-001 (dark paywall "What's included" pills light-on-light, `BenefitPill` onSurface text), C-SEC-001 (accepter recovery copy). Premium-locked Wheel History state renders. Date Builder · Question Packs(gated→paywall) · Answer Reveal sealed = token-consistent, R9-clean.
- **R9 deferred sweep — 0 new issues:** Answer History, Together/Activity, Bucket List (empty state + FAB), Date Match deck, Date Matches all render cleanly in **dark** (good contrast, no clipping, no FATAL); Privacy & Terms + Home confirm **light** parity (shared Material3 tokens). Remaining standard list/detail (Wheel History · Date Builder · Past Games · Answer Reveal sealed · Question Packs[gated→paywall]) are token-consistent with the above; fresh-account auth/onboarding visual covered R3/R5. No C findings.
## Pass D — Security & encryption (D1D6) — clean, no P0/P1
- **D1 at-rest (admin ground-truth):** messages `text` + `lastMessagePreview`, all 4 game-answer collections (this_or_that/how_well/desire_sync/wheel, both users), capsule title+content, `date_swipes.actions` = `enc:v1:`; `wrappedCoupleKey` ciphertext (recovery-phrase-wrapped, **argon2id**); `encryptedRecoveryPhrase` server-blind + **wiped on acceptance**; plaintext `inviteCode` **not exploitable** (no code-encrypted secret persists; `/invites/{code}` readable only by inviter).
- **D2 rules:** no catch-all, no blanket `if true`; sessions update allowlist + immutable `startedByUserId` + monotonic status; `hasPremium` + entitlements server-only; ciphertext enforced on private fields; capsules/challenges member-scoped.
- **D3 raw-API negative (LIVE):** non-member ID token → Firestore REST on couple doc/conversation/messages/answers/session/capsules/partner-profile = **all 403**; non-member writes incl. real `users/{uid}/entitlements/premium` = **all 403 → no self-grant**. Member token reads 200 → **App Check not enforced on Firestore; rules are the sole gate and hold**.
- **D4/D5/D6:** wrapped couple key + KDF; App Check (client), gitignored SA JSONs, `allowBackup=false`; analytics metadata-only. Unchanged, hold.
- One hardening note → `Future.md` (App Check off on Firestore). _(R15 correction: `users/{uid}` update rule enforces a **field allowlist** — not arbitrary; extended R15 for `quietHours*`+`timezone`.)_
## Pass E — Notifications (type × {foreground / background / killed} + tap-to-open, both clients)
Full live two-device run (games + messages):
- **chat_message** ✅ end-to-end — channel `partner_activity`, title "Sam sent a message" (name, not private), body content-free, **text NOT in payload**; tap → exact conversation with content; white monochrome small icon.
- **partner_started_game** ✅ — channel `game_activity`, "QA is playing… Tap to join!" (content-free); tap → joins the active session.
- **partner_finished_game / results** ✅ — results push delivered to backgrounded partner, channel `game_activity`, content-free; tap → per-session results (per E-003 fix).
- **results-suppression** ✅ — partner foregrounded on the session received 0 pushes (ActiveGameSessionMonitor), while backgrounded partner got the results push. Delivery + suppression both confirmed.
- **New speculative types — `not implemented → Future.md` (R8 code check, 0 files each):** `join_game`/`game_invite`, `partner_joined_game`, `game_abandoned`/`game_ended`, `date_plan_update`, `memory_capsule_created`, `challenge_day_completed`, `subscription_entitlement_changed`. Worthwhile ones (couple-premium-unlock push; join/end pushes) logged to `Future.md` `## QA`. Not counted as pass.
- **partner_answered (couple-key reveal, 2026-06-26)** ✅ live both-client — `onAnswerWritten` fires on each answer; the second answer hits the **both-answered "Your answers are unlocked ✨"** copy (recipient already answered). `onAnswerRevealed` ✅ fires when `isRevealed` flips false→true → "opened your answers" push to the partner (witnessed live). Privacy gate (raw API): partner content **403** until both answer, **200** after; non-member **403** throughout. Reveal screen shows the partner's answer **both directions**. At-rest: content-free metadata + gated `enc:v1:` `secure/payload`.
- **Deferred (Round 9):** the full implemented-type × {fg/bg/killed} matrix isn't exhaustively re-run live — implemented types are routing-code-verified + centralized in `PartnerNotificationType`; chat/game start/finish/results + date_match verified live (R3/R5/R6).
- **R18b (2026-06-28) — full live re-run, 0 FATAL:**
- **Cold-start crash-triage smoke 6/6 on BOTH emulators** (`qa/entrypoint_smoke.sh`): launcher + `partner_started_game`/`partner_completed_part`/`partner_finished_game`/`chat_message`/`partner_answered` each killed (`am kill`) → real push → tapped from shade → **opens & stays** (0 fail, 0 blocked). This is the shared cold-start path (splash/onCreate) where the splash-exit crash class hid — clean.
- **Routing (background→tap, landed-screen verified, not just "opens"):** Sam received all 7 types; QA received 3 (both-client). chat_message(conv=main)→**exact conversation thread** (composer + Seen); partner_answered & daily_question→**Today/daily-question**; partner_started_game & partner_completed_part(tot)→**game screen**; partner_finished_game(wheel,session)→**per-session wheel results** ("Here's how you each answered", completed→results not a dead active session); date_match→**"Your Matches"**. Every tap: correct destination, app alive, **0 FATAL**.
- **Foreground in-app delivery (onMessageReceived intercepts; no OS tray):** `partner_started_game`→**in-app banner** "Your partner started a game · This or That" with Join/dismiss ✅; `chat_message`→**draggable chat-head bubble** ✅ (verified via real open-thread→back→Home→send flow + a distinct conv id; the `conversation_id=main` suppression seen on a process-death-restored back stack is **by-design read-suppression**, `ActiveThreadMonitor`, that clears on normal back-nav — not a defect).
- **Malformed / stale intents (all graceful, 0 FATAL):** unknown type→no nav, no crash; chat_message w/o `conversation_id`→**Messages inbox** (fallback); partner_started_game w/o `game_type`→**Play hub** (fallback); partner_finished_game w/ **deleted session id**→graceful "waiting" state w/ Back-to-Play/End escape (no crash, no dead-end).
- **Payload privacy (P0) — code audit of every sender + at-rest D1:** `onMessageWritten`, `onGameSessionUpdate` (+ part-finished), `onAnswerWritten`, `onAnswerRevealed`, `createDateMatch`, `onCoupleLeave` — each `data` block carries only routing IDs (type, couple_id, conversation/question/game/session id) + optional **public** avatar URL; titles use only the partner's display name; bodies are static. **No message/answer/date/swipe content, no keys/invite codes/recovery phrases.** At-rest cross-check: latest 6 `conversations/main/messages` all `enc:v1:` (server-blind source). Cross-checks D6.
- **Real `onMessageWritten` live re-drive NOT re-run this round** (UI-automation thrash on the composer send button) → carried from **R18 live** (exact copy "Sam sent a message"/"Tap to read and reply", no content) + this round's payload code audit + at-rest D1.
- **Doze / battery-optimization / App-Standby delivery → `blocked → needs-device`** (emulators never enter these states; the #1 real-world "notifications don't work" cause). Run on a physical device before any store push (`dumpsys deviceidle force-idle`, app Optimized→Restricted).
- **R18b — `partner_joined_game` + standardized durable in-app game banner (FEATURE):** new push when the non-starter opens an active session → the **starter** gets "<Name> joined your game" with the **joiner's avatar** (server-visible join via `joinedByUsers`; one-time `joinNotifiedAt`; `onGameSessionUpdate` branch). All four foreground game pushes now route through the themed `GamePromptBanner` with the partner's avatar + `sender_name`: **started/joined transient (~9s), your-turn/results persistent until tapped**; foreground OS duplicate suppressed; **background OS notification unchanged** (already shows the avatar large-icon — the purple banner is in-app only). **Live (5554):** all four kinds rendered correctly with avatar+name; **RESULTS still shown at 15s vs STARTED auto-dismissed by 12s**; 0 foreground OS dupes; 0 FATAL. **Pass-E cold-start smoke 6/6 on both emulators** (shared path regression-clean). `PartnerNotificationTypeTest` covers the new type's mapping + routing. **`blocked→deploy`: the join push only fires once `functions/` + `firestore.rules` are deployed** (the `joinedByUsers` client write is rule-gated; best-effort + swallowed until then). The banner standardization for already-deployed types works immediately.
## Pass F — Resilience / lifecycle / concurrency / time
- **Concurrency race:** F-RACE-001 (P1) fixed + **re-confirmed live (R8):** simultaneous mood-tap on both devices → **1 session** (was 2); race-loser landed on WaitingForPartner → **"Join the game"** → joined the winner's session at the **same Q1** (shared reveal preserved). Archived. *(Minor pre-existing note: loser can alternatively land on Play hub; not seen this run.)*
- **Offline:** airplane mode → Today renders from cache, no crash.
- **Lifecycle:** rotation/config-change → state preserved; ~6 cold restarts → clean to Home (auth persists).
- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
- **R9 network resilience:** airplane-mode on → Date Match + Messages render from cache, **no crash, no error dead-end**; reconnect → inbox refreshes, no stuck state, 0 FATAL (extends R3 offline-Today-from-cache).
- **Deferred (Round 10, low-risk):** time-travel-gated content (capsule unlock, challenge day-gating — needs clock manipulation); account-lifecycle deletion-cascade deep run (disruptive on the baseline couple). Minor note: race-loser can land on Play hub vs WaitingForPartner (no dup/crash; pre-existing routing).
## Pass G — Account creation, validation & fake-account abuse
Sign-up end-to-end (email/pw/confirm → 3-step profile → unpaired home) ✅; weak-password → friendly "at least 8 characters" ✅; fresh-account isolation (zero couple data) ✅; **duplicate-email → `auth/email-already-exists`** rejected ✅; invite single-use + 24h expiry, **bogus code → "Invite not found."** ✅; recovery phrase client-generated ✅; sign-out → onboarding → debug-token restore ✅. **No security findings.** (Non-member READ denial = live D3 above + app-level isolation.)
## Pass I — Performance & route efficiency (R8, build `23dd6a7`, emulator-5554, debug build)
Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` → drive route → read `gfxinfo`):
| Route / list | Jank / latency | Notes |
|---|---|---|
| Cold start → Home | 1253ms to first frame | acceptable (debug; release AOT-faster); no skipped frames, no ANR |
| Core tabs (Home/Today/Play/Messages/Settings) | 6.3% janky frames | smooth; no Choreographer-skip spam |
| Conversation (realtime listener) scroll | 90th 36ms / 95th 53ms | minor debug hitching; **no leak** |
| Play hub scroll | 90th 36ms / 95th 38ms | smooth |
- **Caching / lazy-load:** LazyColumn/Row/Grid in 17 files; Coil (AsyncImage) in 11; Room DAOs cache static question/category data locally — all in place, no load-all anti-patterns seen.
- **Leak check:** conversation open/close ×6 → ViewRootImpl=1, Activities=1, Views +2, PSS bounded after trim → no window/Activity/listener leak.
- **Redundant reads:** precise per-read counts need an instrumented/Perfetto build (Firestore success reads aren't in adb logcat); no failing-read spam **except I-001**; no leaked listeners.
- **Finding: I-001 (P1) — FIXED+VERIFIED** `getOutcomes()` bare-list query was rules-denied → fixed with `whereIn(documentId, dayKeys)`; 0 PERMISSION_DENIED after. **I-002 (P1) — FIXED+VERIFIED** (found fixing I-001): scores stored as int64 → read as Long → `Map<String,Int>` cast CCE → swallowed; fixed via `Number.toInt()`. Live: seeded day_0 → "Your Progress" shows "Baseline recorded". Both pending Round-9 confirm.
## Pass J — Accessibility (R8, emulator-5554)
- **Font scaling (font_scale 2.0, worst case):** Home, Paywall, Settings all **reflow + scroll, no clipped/hidden buttons** — meets the acceptance bar. Minor: long subtitles/email ellipsize, bottom-nav labels wrap ("Mess ages"). Restored to 1.0. ✅
- **TalkBack / semantics:** 0 `Icon()` calls without `contentDescription`; 111 explicit `null` (decorative silenced); meaningful labels on all key controls (Back ×26, Send, Close, Dismiss, photo actions, date-swipe Love/Maybe, capsule, edit/delete); loader uses `clearAndSetSemantics` + "Loading…" message. ✅
- **Touch targets:** most controls 48dp; **J-OBS (P3):** a few conversation icon-buttons measure ~4245dp wide (48dp tall) — single-axis marginal, fully operable; bump to 48dp.
- **Reduce-motion (animator_duration_scale 0):** nav sweep + screens work, no hang/unreachable content, 0 FATAL; honored in code across 7 surfaces (LoadingState, CelebrationOverlay, AnswerReveal, DesireSync, ThisOrThat, BrandMessageRotator, LocalQuestionContent). Restored to 1. ✅
- **Contrast:** covered by Pass C both themes (C-DS-001 dark-contrast fixed); precise WCAG ratios need a measurement tool — spot-checks clean, no new dim areas.
- **Keyboard/IME:** text fields validated functionally in Pass G (sign-up/profile); full hardware-keyboard tab-order **deferred** (emulator HW-keyboard harness).
- **Findings:** J-OBS (P3) only; no P0/P1/P2 a11y blockers.
## Pass H
- **H Branding** — deliverable in `ClaudeBrandingReview.md` (consumer brand walk → ready-to-paste art prompts).
---
## Round history (one line each)
- **R15** — gap-closing round (Passes L/M/N/P + regression smoke); **3 bugs found, 2 fixed.** **M-001 (P2 quiet hours)** — local-only window didn't suppress backgrounded/killed partner pushes; fixed via server-side fail-open `recipientInQuietHours()` in the 4 partner-action senders + client window/tz sync + rules allowlist; verified live (fn log suppress vs notify); deployed prod. **N-001 (P1) Bucket List fully non-functional** (coupleId never set → all CRUD no-ops) — FIXED (VM resolves couple in init) + verified live (add `enc:v1:`/complete/delete/render). **N-002 (P2) Date Builder "Create Plan" no-op** (wrote to unread prefs collection; ids never wired) — FIXED (re-pointed to create a PLANNED `DatePlan` via `savePlan` → Home "Date coming up") + verified live. L chat-core, P copy+question-bank (6103 Qs) clean; smoke 6/6 GREEN. Corrected stale "users/{uid} allows arbitrary fields" claim (there's an allowlist).
- **R14** — full fresh AJ ClaudeQAPlan run (pure QA, no code), FLAWLESS, 0 new findings: confirmation round on the R13 build — premium enforcement + couple-shared unlock + entitlement push (live); Desire Sync/How Well/Spin-the-Wheel full 2-device + first-finisher nudge; Memory Lane create+seal, CC resume, Date Match deck; decoupled-theme-art mandate; cornerstone live (403s + enc:v1:); offline + process-death; jank 5.25%; J-OBS 48dp holds. The 5 R13 fixes held → pruned (archived line).
- **R13** — open-backlog fix pass + full fresh AJ, FLAWLESS (0 open P0P3): fixed C-DARK-UI-001 (ToT dark redesign), C-DARK-UI-002 (check-in label), C-DARK-UI-003 (bottom insets), C-ART-EDGE-002 (8 opaque heroes feathered), J-OBS (48dp targets); confirmed A-201 live→pruned; shipped Premium-unlock modal (one-time, both partners, couple-shared, verified live). Pass D cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, at-rest enc:v1:). Diff UI-only → E/F/G carried. 0 FATAL both emulators.
- **R12** — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2): found+fixed **A-201** (P1 Date Match premium bypass — gated via CouplePremiumChecker→Paywall, verified live); 4 async games full 2-device E2E; security cornerstone live-clean (non-member 403 read+write, self-grant 403); smoke 6/6; jank 4.10%; new P3 C-ART-EDGE-002 (hero edges, deferred); C-DARKART-001+C-ART-EDGE-001 held→pruned; Pass A retrospective added (badge≠gate).
- **R11** — confirmation round, FLAWLESS (0 open P0P2): fixed C-DARKART-001 (P2, art follows in-app theme via `LocalAppInDarkTheme` + config-overridden context) + C-ART-EDGE-001 (P3, edge feathering) in shared `BrandIllustration`/`EmptyState`, verified live both decoupled theme directions (system-light+app-Dark→dark art · system-dark+app-Light→light art), 0 FATAL; re-confirmed + pruned the 5 R10 P2 fixes (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001); entrypoint smoke 6/6 green on fresh APK (launcher + 5 notif cold-starts open & stay). Art fixes in working tree; rest committed `2cd0af6`.
- **R10** — FULL run AJ + fix phase: 5 P2 found+fixed+verified-live (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dup app bar · C-PW-001 dark paywall · C-SEC-001 recovery wrong-store); E-GAME-002 confirmed live (start push+banner+Join) & pruned; concurrency double-start→1 session; security D1D7 clean; perf/a11y no regression. 0 open P0P2 (5×P2 pending 1 confirm).
- **R7** — security/concurrency deep dive (multi-angle): cornerstone clean; F-RACE-001 found+fixed+verified. 0 new open.
- **R6** — branding drop + Future.md backlog regression: 0 new open.
- **R5** — Cloud Functions deployed (E-OBS/E-003) + new Pass G clean: 0 open.
- **R2R4** — play-as-user game restart + fix phase; all P0P2 fixed + verified (archived IDs above).

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,140 +0,0 @@
# Claude iOS Plan — Part 2 (build to parity) + Part 3 (iOS QA)
> Program: **Part 1** = Android QA (`ClaudeQAPlan.md`, in progress) → **Part 2** = build the iOS app to Android's
> CURRENT state → **Part 3** = run the same QA passes on iOS. This doc covers Parts 2 & 3.
## Decisions (locked)
- **Approach:** native **SwiftUI** + Firebase iOS SDK (the `iphone/` scaffold already chose this: SwiftPM + XcodeGen,
feature dirs mirroring Android, `ARCHITECTURE_AUDIT.md`). Do NOT switch to KMP/Compose-MP.
- **E2EE:** **full Tink-compatible** crypto — iOS must byte-match Android's wire formats so an **iOS↔Android couple
decrypts each other**. (Overrides the audit's "skip E2EE for MVP" note — encryption is the cornerstone.)
- **Scope:** **working parity build** (Simulator + real device, TestFlight optional). **No App Store submission.**
## ⚠️ Hard constraint — macOS required (we are on Linux)
iOS build/run/QA needs **macOS + Xcode + iOS Simulator**; there is **no adb-equivalent from Linux**. Therefore:
- **On this Linux box** I can only: author/refresh Swift source, refresh the audit, write the plan/specs, and reason
about the code. **I cannot compile, run the Simulator, or do interactive/visual QA here.**
- **Everything that requires building or running is deferred to a Mac** (a Mac, cloud-Mac, or macOS CI runner).
- Part 2 done-on-Linux = "code authored + self-reviewed"; Part 2 *truly* done = builds green in Xcode and runs on
Simulator + device (on a Mac). **Part 3 (iOS QA) is entirely macOS-gated.**
## Current iOS state (as found)
`iphone/` = scaffold only: ~19 stub Swift files, `Crypto/` **empty**, most feature dirs empty, one `FirestoreService`.
`ARCHITECTURE_AUDIT.md` is thorough but generated from Android **v0.2.0** — it **predates** Messages/conversations,
chat media (photo/GIF/sticker/voice), reactions/typing/read-receipts/pagination, and couple-shared premium (A-001).
So this is a near-full build, not a top-up.
---
## Part 2 — Build iOS to current Android parity
### 2.0 Refresh the audit to CURRENT Android (do first)
Update `iphone/ARCHITECTURE_AUDIT.md` from the live Android code: add the **Messages** tab (conversations inbox +
conversation chat: text/photo/GIF/sticker/voice, reactions, delete/tombstone, typing, read-receipts, pagination,
chat-head bubble), the conversations Firestore model, the new notification types, and **couple-shared premium**
(`CouplePremiumChecker` — either partner premium unlocks all features for both). Re-tally screens/models/functions.
### 2.1 Project + dependencies (Mac)
XcodeGen/`project.yml`; SPM: Firebase Auth/Firestore/Functions/Messaging/Storage, RevenueCat (`purchases-ios`),
GoogleSignIn; `GoogleService-Info.plist`; entitlements (push, keychain); **App Check** via DeviceCheck/App Attest
(Android uses Play Integrity); APNs setup for FCM.
### 2.2 Core infrastructure
`AppDependencies` (manual DI), `AuthService` + `AuthRateLimiter` (mirror Android limits), `FirestoreService` +
per-domain services (User/Couple/Invite/Question/Answer/Game/Date/Conversation), `NotificationService`
(APNs+FCM + deep-link routing mirroring `PartnerNotificationType`), navigation (TabView + NavigationStack per tab,
`AppRoute` mirror, deep-link handler), `CloserTheme` (light+dark), reusable components.
### 2.3 E2EE — full Tink-compatible crypto (HIGHEST RISK; cornerstone)
Implement byte-compatible Swift crypto for every Android wire format:
- `SealedAnswerEncryptor` (AES-256-GCM, 96-bit IV, AAD `"{coupleId}|{questionId}|{userId}"`, `sealed:v1:`),
`FieldEncryptor` (`enc:v1:`, AAD=coupleId) — used by messages/previews/dates **and now profile metadata
(`displayName` + `sex` in `users/{uid}`)**,
`AnswerCommitment` (SHA-256, `sha256:`), `UserKeyManager` (ECIES P-256 HKDF-HMAC-SHA256 AES128-CTR-HMAC, keypair in
Keychain, `pub:v1:`), `ReleaseKeyEncryptor` (`keybox:v1:`), `RecoveryKeyManager` (**Argon2id m=46MiB, t=3, p=1**,
BIP39-style wordlist), `CoupleEncryptionManager`, Keychain-backed key stores.
- **Interop test harness (the acceptance gate):** (a) decrypt Android-produced fixtures (`sealed:v1:`/`enc:v1:`/
`keybox:v1:`) on iOS; (b) have Android decrypt iOS-produced ciphertext; (c) verify an Android-generated recovery
phrase unlocks on iOS and vice versa. Wire formats must match byte-for-byte. If a format can't be matched with
CryptoKit, use a vetted Swift lib (e.g. swift-sodium/SwiftArgon2) — never a non-interoperable shortcut.
- **Golden vectors** for the deterministic primitives (checked into both repos): `AnswerCommitment` SHA-256 and
`RecoveryKeyManager` Argon2id (fixed salt/params) must produce **identical bytes** on both platforms — assert in unit
tests on each side. AEAD/ECIES use random IVs so they can't be golden-matched; cover those with the **round-trip**
harness above. Generate the Android fixtures now (on Linux) so iOS has them ready.
- **⛔ Profile-metadata decrypt-on-read (REQUIRED before iOS launch — R22).** Android now encrypts `displayName` +
`sex` under the couple key. iOS reads these raw today ([HomeViews](iphone/Closer/Home/HomeViews.swift),
[SettingsViews](iphone/Closer/Settings/SettingsViews.swift)) and will show `enc:v1:…` until it decrypts them on
read (tolerant of legacy plaintext; show a 🔒 placeholder when the key is missing) — mirror Android's
`FirestoreUserDataSource` chokepoint + the pairing/legacy **migration** and **unpair-revert**. Until this ships,
iOS shows the locked placeholder for name/gender (acceptable in dev; **not** for release).
- **⛔ Date reflections (REQUIRED before iOS launch — R22).** The new Date Memories & Replay feature adds an E2EE
`couples/{id}/date_reflections/{dateId}/answers/{uid}` collection with the same couple-key + read-gated
`secure/payload` reveal as the daily question (`date_history` is plaintext). iOS must implement the matching
reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` /
`date_logged` notification types; until then iOS can't participate in date reflections.
- **⛔ Conversation backup + full partner-assisted restore (REQUIRED before iOS launch — R24).** Android now keeps a
couple-key-encrypted conversation backup (`couples/{id}/backup/manifest` + `.../chunks/{seq}` `enc:v1:`; snapshot
blob at Storage `users/{uid}/backups/{id}`) and a partner-assist flow (`couples/{id}/restore_requests/{uid}` with
a fresh `pub:v1:` + partner-written `keybox:v1:`; ECIES context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`;
6-digit OOB code = `truncate6(SHA-256(pubkey‖nonce))`). For **cross-platform** restore, iOS must byte-match the
`BackupCodec` JSON envelope, the couple-key wrap, and the keybox context/code so an iOS device can restore (self or
partner-assisted) from an Android partner and vice-versa — same class as the existing E2EE interop gate. Also wire
the `restore_requested` notification type. Until then iOS can't back up/restore or help a partner restore.
- **⛔ Partner-assist consent hardening (R24-b).** iOS's consent screen must mirror Android: resolve the recipient
via the user service and show their **email (plaintext anchor) + locally-decrypted display name**, and gate
Approve on **both the 6-digit code AND an explicit "I reached them" confirmation**. Mirror the two lifecycle
fixes — **delete any existing `restore_requests/{uid}` before re-creating** (a `set()` over an existing doc is a
rule-denied key-changing update) and **reject an expired request at fulfil**. Handle the new `restore_self_alert`
notification type (route to account security). The server (`onRestoreRequested`/`onRestoreFulfilled`) is shared,
so this is purely the iOS client half.
### 2.4 Screens & features to parity (~48 + new messaging)
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation
with full media/reactions/typing/read-receipts/pagination) and the games (full, not stubs), dates, wheel, settings,
paywall. Mirror the Android UX + the couple-shared premium gating (route gated features through the iOS
`EntitlementChecker` couple-shared equivalent).
### 2.5 Build & smoke (Mac)
Xcode build green; run on Simulator + a real device; smoke each major flow (auth → pair → home/today/play/messages/
settings) with no crashes. **This step + 2.1/2.5 runs on a Mac.**
---
## Part 3 — iOS QA (reuse `ClaudeQAPlan.md` passes AE on iOS)
Same methodology, severity scale (P0P3), report-only→fix→re-QA-until-flawless loop, continuity (use
`ClaudeReport-iOS.md` + `ClaudeQACoverage-iOS.md` + run-state header), and **autonomous run-to-completion** mode.
**macOS-gated.** Tooling adaptation from Android:
| Android (Part 1) | iOS (Part 3) |
|---|---|
| `adb` install/tap/screencap | `xcrun simctl` install/io screenshot; XCUITest or `idb` to drive taps |
| 2 emulators (5554/5556) | 2 iOS Simulators (or Simulator + device) for the two partners |
| `adb logcat` FATAL/ANR | `xcrun simctl spawn booted log stream` / Console crash logs |
| `cmd uimode night` for theme | Simulator Appearance (light/dark) per device |
| premium toggle via `set_premium.js` | same admin script (server-side) |
Run all passes on iOS:
- **A** couple-shared premium (gates + paywall), **B** each game one full playthrough on both, **C** visual
light+dark all screens + navigation-from-every-entry + back-stack/double-back, **D** security/encryption, **E**
notifications (APNs/FCM delivery + tap-to-open every type).
- **NEW — cross-platform pass (X):** a **mixed couple (Android + iOS)** — messaging, answers, dates, premium, and
notifications work cross-platform, and **E2EE decrypts both ways** (the Part 2 interop gate, verified live on real
paired devices). This is the make-or-break for the cornerstone.
- **iOS-native dimensions (add to the passes):** Dynamic Type (largest sizes), **VoiceOver**, safe-area / notch /
Dynamic Island, multiple device sizes (SE → Pro Max; iPad if supported), **edge-swipe-back gesture** + interactive
pop (the iOS "double-back"/nav-stack analog), `scenePhase` background/foreground, dark/light.
- **Real-device / sandbox needs:** **App Attest/DeviceCheck** and **APNs push** don't fully work on the Simulator —
use a real device (or `xcrun simctl push` with a payload for local notif routing); **RevenueCat IAP** needs a
StoreKit config file or a sandbox Apple ID. Plan Pass A/E around this.
## Definition of done
- **Part 2:** iOS builds green + runs on Simulator + device at feature parity with current Android; E2EE interop
harness passes (Android↔iOS decrypt both ways).
- **Part 3:** an iOS QA round is flawless (zero P0P2, D+E clean, every game fully played, nav/back verified) AND the
cross-platform pass X is clean.
## What can proceed now (Linux) vs needs a Mac
- **Now (Linux):** 2.0 audit refresh; author Swift for models/services/crypto/screens; write iOS QA tooling scripts;
prepare Android-produced crypto fixtures for the interop harness.
- **Needs a Mac:** 2.1 project/deps, all compiling, 2.5 build+run, and **all of Part 3**. Surface this as the blocker
when execution reaches it (per the autonomous-mode rule: do all non-gated work first, then flag the Mac requirement).

View File

@ -27,6 +27,14 @@
</sub> </sub>
</p> </p>
<p align="center">
<img alt="Android" src="https://img.shields.io/badge/Android-reference%20app-3DDC84?style=flat-square&logo=android&logoColor=white" />
<img alt="iOS" src="https://img.shields.io/badge/iOS-scaffold%20in%20progress-007AFF?style=flat-square&logo=apple&logoColor=white" />
<img alt="Firebase" src="https://img.shields.io/badge/Firebase-backend-FFCA28?style=flat-square&logo=firebase&logoColor=black" />
<img alt="RevenueCat" src="https://img.shields.io/badge/RevenueCat-billing-6D5DF6?style=flat-square" />
<img alt="License" src="https://img.shields.io/badge/license-private-E5484D?style=flat-square" />
</p>
--- ---
Closer is a native relationship app for couples who want a quieter way to check in with each other. Closer is a native relationship app for couples who want a quieter way to check in with each other.
@ -40,7 +48,7 @@ likes, followers, or infinite-scroll loops. The product loop is small on purpose
answer honestly -> reveal together -> keep the conversation going answer honestly -> reveal together -> keep the conversation going
``` ```
## Screenshots <h2 align="center">Screenshots</h2>
Fresh Android dark-mode captures from the current emulator build. Fresh Android dark-mode captures from the current emulator build.
@ -72,7 +80,7 @@ Fresh Android dark-mode captures from the current emulator build.
</tr> </tr>
</table> </table>
## What makes it different <h2 align="center">What makes it different</h2>
<table> <table>
<tr> <tr>
@ -87,7 +95,7 @@ Fresh Android dark-mode captures from the current emulator build.
</tr> </tr>
</table> </table>
## Product surface <h2 align="center">Product surface</h2>
| Area | Free | Premium | | Area | Free | Premium |
| --- | --- | --- | | --- | --- | --- |
@ -107,7 +115,7 @@ Fresh Android dark-mode captures from the current emulator build.
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export Data export is not currently offered. The in-app privacy copy intentionally says so until a real export
flow exists. flow exists.
## Platform status <h2 align="center">Platform status</h2>
| Platform | Status | Notes | | Platform | Status | Notes |
| --- | --- | --- | | --- | --- | --- |
@ -119,7 +127,7 @@ flow exists.
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration, Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration,
and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability. and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
## Architecture <h2 align="center">Architecture</h2>
```text ```text
Android (Kotlin/Compose) iOS (SwiftUI) Android (Kotlin/Compose) iOS (SwiftUI)
@ -141,7 +149,7 @@ Core rules:
- Server-verified premium. Clients observe entitlements; they do not self-grant access. - Server-verified premium. Clients observe entitlements; they do not self-grant access.
- No anonymous auth. Accounts use email/password or Google sign-in. - No anonymous auth. Accounts use email/password or Google sign-in.
## Security and privacy <h2 align="center">Security and privacy</h2>
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history - Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history
on-device before sync. on-device before sync.
@ -154,7 +162,7 @@ Core rules:
The canonical technical reference is The canonical technical reference is
[docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md). [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md).
## Local development <h2 align="center">Local development</h2>
<details open> <details open>
<summary><strong>Prerequisites</strong></summary> <summary><strong>Prerequisites</strong></summary>
@ -228,22 +236,21 @@ npm run serve
</details> </details>
## QA and release <h2 align="center">QA and release</h2>
The repo keeps repeatable QA checks close to the code: The repo keeps repeatable QA checks close to the code:
| Check | Purpose | | Check | Purpose |
| --- | --- | | --- | --- |
| [qa/README.md](qa/README.md) | Ava + Ben emulator fixtures and cold-start smoke notes |
| `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions | | `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions |
| `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks | | `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks |
| [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist | | [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist |
| [docs/release/](docs/release/) | Internal testing, store assets, and release prep | | [docs/release/](docs/release/) | Internal testing, store assets, and release prep |
Current Ava + Ben emulator sessions are documented in [qa/README.md](qa/README.md). Do not clear those Live emulator fixtures and related credentials are intentionally local-only. Keep them in gitignored
app sessions unless you are intentionally rebuilding the fixture. files under `qa/`, never in the tracked README or public docs.
## Repository map <h2 align="center">Repository map</h2>
```text ```text
app/ Android app app/ Android app
@ -252,12 +259,12 @@ functions/ Firebase Cloud Functions
server/ Optional Express service server/ Optional Express service
seed/ Question content and seed tooling seed/ Question content and seed tooling
scripts/ Static QA scanners scripts/ Static QA scanners
qa/ Re-runnable emulator QA notes and scripts qa/ Local-only emulator fixture notes and smoke helpers
docs/ Architecture, release, screenshots, and QA docs docs/ Architecture, release, screenshots, and QA docs
firestore.rules Firestore rules source of truth firestore.rules Firestore rules source of truth
``` ```
## Roadmap <h2 align="center">Roadmap</h2>
In progress: In progress:
@ -273,12 +280,11 @@ Out of scope for now:
- Wear OS / watchOS companions - Wear OS / watchOS companions
- Live video or voice sessions - Live video or voice sessions
## Project docs <h2 align="center">Project docs</h2>
| Doc | Purpose | | Doc | Purpose |
| --- | --- | | --- | --- |
| [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines | | [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines |
| [qa/README.md](qa/README.md) | Emulator fixtures and autonomous QA smokes |
| [docs/brand/](docs/brand/) | Visual identity, asset system, generated art | | [docs/brand/](docs/brand/) | Visual identity, asset system, generated art |
| [docs/release/](docs/release/) | Release prep and store assets | | [docs/release/](docs/release/) | Release prep and store assets |
| [docs/qa/](docs/qa/) | Manual QA checklist | | [docs/qa/](docs/qa/) | Manual QA checklist |
@ -286,6 +292,6 @@ Out of scope for now:
| [HISTORY.md](HISTORY.md) | Changelog and release notes | | [HISTORY.md](HISTORY.md) | Changelog and release notes |
| [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions | | [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions |
## License <h2 align="center">License</h2>
Private project. All rights reserved. Private project. All rights reserved.

View File

@ -1,40 +0,0 @@
# qa/ — re-runnable QA smokes (autonomous)
Durable, committed QA tooling so each round re-checks the fragile paths cheaply instead of by hand.
See `ClaudeQAPlan.md` (Pass E crash-triage, Pass F cold-start ownership, Definition of Done) for how these fit the run.
## entrypoint_smoke.sh — cold-start / entry-point launch integrity
The smoke that catches the **"app opens and immediately closes"** class (e.g. the splash-exit `iconView` NPE on a
notification cold-start). That class breaks **every** notification at once (shared cold-start path) and is **invisible to
`adb am start`** — only a REAL push tapped from the shade on a genuinely killed app reproduces it.
```bash
# both emulators; run after ANY change to MainActivity / splash / theme / manifest / nav / notifications, and every round
bash qa/entrypoint_smoke.sh emulator-5554 Y05AKO2IlTPMa0JQW1BiNIM0uzK2
bash qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2
```
Asserts each entry (launcher icon · each notification type tapped from a killed app · …) **opens AND stays** (process
alive, 0 FATAL, off the launcher). `PASS`/`FAIL`/`BLOCK`:
- **FAIL** = a real app crash (the bug). Pull the stack: `adb -s <serial> logcat -d | grep -E "FATAL EXCEPTION|getIconView"`.
- **BLOCK** = the push didn't reach the killed app (flaky emulator FCM / FcmRetry) — environmental, just rerun.
- Runtime ~37 min (FCM retries); run in the background and read the matrix. Exit 0 only if 0 FAIL.
## qa_push.js — send a real push (helper)
Sends a faithful `notification`+`data` FCM to a user's token so a killed app shows it and tapping cold-starts via the
real OS handover. **Use `am kill` (NOT `am force-stop`)** to kill — force-stopped apps are excluded from FCM.
```bash
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> partner_started_game game_type=this_or_that
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversation_id=main
```
## Test couple (current emulators)
- coupleId `Xal3Kw3gjSdn0niERYKJ`
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
- Admin SA JSON: `~/.config/closer/closer-admin-sa.json` (kept OUTSIDE the repo; override path via `SA_JSON`). For tools using Application Default Credentials: `export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json`.
## Fixture accounts & passwords
Live test-account emails, uids, AVD mapping, and passwords are kept OUT of this repo in the
gitignored `qa/fixtures.local.md` (dev-project throwaway creds). Ask the operator or read that
local file. Never commit credentials.

View File

@ -12,8 +12,8 @@
# Emulator FCM-to-killed-app is flaky (FcmRetry); undeliverable cases are reported BLOCKED, not FAIL — # Emulator FCM-to-killed-app is flaky (FcmRetry); undeliverable cases are reported BLOCKED, not FAIL —
# only an actual open-and-close / crash is a FAIL. # only an actual open-and-close / crash is a FAIL.
# #
# Usage: qa/entrypoint_smoke.sh <serial> [recipient_uid] # Usage: qa/entrypoint_smoke.sh <serial> <recipient_uid>
# qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2 # qa/entrypoint_smoke.sh emulator-5556 "$QA_RECIPIENT_UID"
# Env overrides: PKG, SA_JSON, COUPLE_ID. # Env overrides: PKG, SA_JSON, COUPLE_ID.
# Runtime: ~37 min (flaky emulator FCM adds retries). Run in the background and read the matrix. # Runtime: ~37 min (flaky emulator FCM adds retries). Run in the background and read the matrix.
# Exit 0 only if zero FAIL (BLOCK = undeliverable push, environmental — rerun, not an app bug). # Exit 0 only if zero FAIL (BLOCK = undeliverable push, environmental — rerun, not an app bug).

View File

@ -10,7 +10,7 @@
// node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session // node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
// node qa/qa_push.js <uid> chat_message conversation_id=main // node qa/qa_push.js <uid> chat_message conversation_id=main
// node qa/qa_push.js <uid> partner_answered // node qa/qa_push.js <uid> partner_answered
// Env overrides: SA_JSON, COUPLE_ID. // Env: SA_JSON, COUPLE_ID.
const admin = require('firebase-admin') const admin = require('firebase-admin')
const path = require('path') const path = require('path')
const os = require('os') const os = require('os')
@ -19,7 +19,11 @@ const os = require('os')
// working tree). Override with SA_JSON if yours is elsewhere. // working tree). Override with SA_JSON if yours is elsewhere.
const SA = process.env.SA_JSON || const SA = process.env.SA_JSON ||
path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json') path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' const COUPLE = process.env.COUPLE_ID
if (!COUPLE) {
console.error('COUPLE_ID is required. Keep fixture IDs in a local, gitignored env/file.')
process.exit(2)
}
admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore() const db = admin.firestore()

View File

@ -1,59 +0,0 @@
// D1 at-rest ciphertext verifier (READ-ONLY). Prints only field -> enc✓ / NOT-ENC + length.
// NEVER prints decrypted/plaintext content (per the QA logging rule).
const admin = require('firebase-admin')
const path = require('path')
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
const CIPHER = /^(enc:v1:|sealed:v1:)/
// fields that MUST be ciphertext if present
const PRIVATE_FIELDS = ['text','content','lastMessagePreview','title','description','duration','note','message','answer','body','preview']
function checkVal(v) {
if (typeof v !== 'string') return null
if (CIPHER.test(v)) return `enc✓(${v.length})`
// base64 Tink media or short metadata? flag anything that isn't enc/sealed
return `NOT-ENC?(len=${v.length})`
}
async function sampleColl(ref, label, limit = 3) {
try {
const snap = await ref.limit(limit).get()
if (snap.empty) { console.log(` [${label}] empty`); return }
let i = 0
snap.forEach(doc => {
if (i++ >= limit) return
const d = doc.data()
const hits = []
for (const f of PRIVATE_FIELDS) {
if (d[f] !== undefined) hits.push(`${f}=${checkVal(d[f])}`)
}
console.log(` [${label}] ${doc.id}: ${hits.length ? hits.join(' ') : '(no private fields)'}`)
})
} catch (e) { console.log(` [${label}] ERR ${e.code || e.message}`) }
}
(async () => {
console.log('=== D1 at-rest check, couple', COUPLE, '===')
// 1) couple doc subcollections
const coupleRef = db.collection('couples').doc(COUPLE)
const subs = await coupleRef.listCollections().catch(() => [])
console.log('couple subcollections:', subs.map(s => s.id).join(', ') || '(none)')
for (const s of subs) await sampleColl(s, `couples/${COUPLE}/${s.id}`)
// 2) common top-level collections keyed by coupleId
for (const c of ['messages','conversations','answers','bucket_list','date_plans','date_swipes','memory_capsules','capsules','date_plan_preferences','game_sessions']) {
const ref = db.collection(c).where('coupleId','==',COUPLE)
await sampleColl(ref, c)
}
// 3) conversations -> messages (nested)
try {
const convs = await db.collection('conversations').where('coupleId','==',COUPLE).limit(2).get()
for (const cv of convs.docs) {
await sampleColl(cv.ref.collection('messages'), `conversations/${cv.id}/messages`)
}
} catch (e) { console.log(' conv->messages ERR', e.code || e.message) }
console.log('=== done ===')
process.exit(0)
})().catch(e => { console.error('FATAL', e); process.exit(1) })

View File

@ -1,42 +0,0 @@
// D1 deep probe: dump FIELD NAMES + per-string enc-status/length (NEVER values).
const admin = require('firebase-admin')
const path = require('path')
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
const CIPHER = /^(enc:v1:|sealed:v1:)/
const MEDIA = /^(01|AAAA|CiQ|Ei)/ // tink/base64-ish prefixes seen for media
function fieldReport(d) {
return Object.entries(d).map(([k, v]) => {
if (typeof v === 'string') {
const tag = CIPHER.test(v) ? 'enc✓' : (v.length > 40 ? `RAWSTR?(${v.length})` : `meta(${v.length})`)
return `${k}:${tag}`
}
if (Array.isArray(v)) return `${k}:[${v.length}]${v.every(x => typeof x === 'string' && CIPHER.test(x)) && v.length ? 'enc✓' : ''}`
if (v && typeof v === 'object') return `${k}:{${Object.keys(v).join(',')}}`
return `${k}:${typeof v}`
}).join(' ')
}
async function dump(ref, label, n = 2) {
try {
const s = await ref.limit(n).get()
if (s.empty) return console.log(`[${label}] empty`)
s.docs.slice(0, n).forEach(doc => console.log(`[${label}] ${doc.id}\n ${fieldReport(doc.data())}`))
} catch (e) { console.log(`[${label}] ERR ${e.code || e.message}`) }
}
(async () => {
const c = db.collection('couples').doc(COUPLE)
// messages nested under each conversation
const convs = await c.collection('conversations').limit(3).get()
for (const cv of convs.docs) {
await dump(cv.ref.collection('messages'), `conv:${cv.id}/messages`, 2)
}
for (const g of ['this_or_that','desire_sync','how_well','wheel','date_swipes','sessions']) {
await dump(c.collection(g), g, 1)
}
process.exit(0)
})().catch(e => { console.error('FATAL', e); process.exit(1) })

View File

@ -1,33 +0,0 @@
// D1 nested-answer probe: for game answers map, report each uid's answer value enc-status (NEVER values).
const admin = require('firebase-admin')
const path = require('path')
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
const CIPHER = /^(enc:v1:|sealed:v1:)/
function classify(v) {
if (typeof v === 'string') return CIPHER.test(v) ? `enc✓(${v.length})` : `RAW?(${v.length})`
if (Array.isArray(v)) return `[${v.length}: ${v.map(classify).join(',')}]`
if (v && typeof v === 'object') return '{' + Object.entries(v).map(([k, x]) => `${k}=${classify(x)}`).join(' ') + '}'
return typeof v
}
async function probe(coll, mapField) {
const c = db.collection('couples').doc(COUPLE)
const s = await c.collection(coll).limit(1).get()
if (s.empty) return console.log(`[${coll}] empty`)
const d = s.docs[0].data()
const m = d[mapField]
if (!m) return console.log(`[${coll}] no ${mapField}`)
for (const [uid, val] of Object.entries(m)) {
console.log(`[${coll}] ${mapField}.${uid.slice(0, 6)}…: ${classify(val)}`)
}
}
(async () => {
for (const g of ['this_or_that', 'desire_sync', 'how_well', 'wheel']) await probe(g, 'answers')
await probe('date_swipes', 'actions')
process.exit(0)
})().catch(e => { console.error('FATAL', e); process.exit(1) })

View File

@ -1,53 +0,0 @@
// D3 negative access (LIVE): mint a NON-MEMBER token → raw Firestore REST → expect 403/PERMISSION_DENIED.
const admin = require('firebase-admin')
const path = require('path')
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const PROJECT = 'closer-app-22014'
const APIKEY = 'AIzaSyDAD7FnEYzhMsil41SzJ1XMjUNnJWmjie8'
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
const FAKE_UID = 'qa-nonmember-' + Date.now()
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const docBase = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents`
async function getIdToken() {
const custom = await admin.auth().createCustomToken(FAKE_UID)
const r = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${APIKEY}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: custom, returnSecureToken: true })
})
const j = await r.json()
if (!j.idToken) throw new Error('no idToken: ' + JSON.stringify(j))
return j.idToken
}
async function tryGet(token, p, label) {
const r = await fetch(`${docBase}/${p}`, { headers: { Authorization: `Bearer ${token}` } })
const ok = r.status === 200
const verdict = ok ? '❌❌ LEAK (200)' : `✅ DENIED (${r.status})`
console.log(` ${label}: ${verdict} [${p}]`)
return ok
}
async function trySelfGrant(token, uid) {
const r = await fetch(`${docBase}/users/${uid}/entitlements/premium?updateMask.fieldPaths=active`, {
method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ fields: { active: { booleanValue: true } } })
})
console.log(` self-grant premium: ${r.status === 200 ? '❌❌ ALLOWED (200)' : `✅ DENIED (${r.status})`}`)
return r.status === 200
}
(async () => {
console.log('=== D3 non-member negative access (uid', FAKE_UID, ') ===')
const tok = await getIdToken()
let leaks = 0
if (await tryGet(tok, `couples/${COUPLE}`, 'read couple doc')) leaks++
if (await tryGet(tok, `couples/${COUPLE}/conversations/main/messages`, 'list messages')) leaks++
if (await tryGet(tok, `couples/${COUPLE}/capsules`, 'list capsules')) leaks++
if (await tryGet(tok, `couples/${COUPLE}/desire_sync`, 'list desire_sync')) leaks++
if (await trySelfGrant(tok, FAKE_UID)) leaks++
// cleanup the throwaway auth user
await admin.auth().deleteUser(FAKE_UID).catch(() => {})
console.log(leaks === 0 ? '=== D3 PASS: all denied ===' : `=== D3 FAIL: ${leaks} LEAK(S) — P0 ===`)
process.exit(leaks === 0 ? 0 : 1)
})().catch(e => { console.error('FATAL', e.message); process.exit(2) })

View File

@ -1 +0,0 @@
L_R18_pass_1782681418

View File

@ -1,28 +0,0 @@
// Pass L / D1: read latest messages in couples/{COUPLE}/conversations/main/messages.
// Print ONLY metadata + enc-status (never decrypted/plaintext content). Also assert a plaintext
// marker does NOT appear anywhere (at-rest leak check).
const admin = require('firebase-admin')
const path = require('path')
const fs = require('fs')
const SA = path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
const MARKER = (fs.existsSync(path.join(__dirname,'last_marker.txt')) ? fs.readFileSync(path.join(__dirname,'last_marker.txt'),'utf8').trim() : '')
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
const CIPHER = /^(enc:v1:|sealed:v1:)/
const enc = v => typeof v==='string' ? (CIPHER.test(v)?`enc✓(${v.length})`:`RAW?(${v.length})`) : typeof v
;(async () => {
const col = db.collection('couples').doc(COUPLE).collection('conversations').doc('main').collection('messages')
const snap = await col.orderBy('createdAt','desc').limit(6).get()
console.log(`marker=${MARKER||'(none)'} latest ${snap.size} messages (newest first):`)
let leak = false
snap.forEach(d => {
const m = d.data()
const ts = m.createdAt && m.createdAt.toDate ? m.createdAt.toDate().toISOString() : m.createdAt
console.log(` ${ts} sender=${(m.senderId||'').slice(0,6)}… text=${enc(m.text)}`)
// leak check across the whole doc JSON
if (MARKER) { const blob = JSON.stringify(m); if (blob.includes(MARKER)) { leak = true; console.log(' ❌❌ PLAINTEXT MARKER LEAK in this doc') } }
})
if (MARKER) console.log(leak ? '=== LEAK: marker found in plaintext ===' : '=== OK: marker NOT present in plaintext (encrypted or not yet sent) ===')
process.exit(0)
})().catch(e=>{console.error('FATAL',e.message);process.exit(1)})

View File

@ -1,26 +0,0 @@
const admin = require('firebase-admin')
const path = require('path')
const SA = path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
function shallow(v){ if(typeof v==='string') return v.length>40?`str(${v.length})`:v; if(Array.isArray(v)) return `[${v.length}]`; if(v&&typeof v==='object') return '{'+Object.keys(v).join(',')+'}'; return v }
;(async () => {
const c = await db.collection('couples').doc(COUPLE).get()
const cd = c.data() || {}
console.log('=== couple top-level keys ===')
for (const k of Object.keys(cd)) console.log(` ${k} = ${shallow(cd[k])}`)
// find uid-like values
const uids = new Set()
for (const [k,v] of Object.entries(cd)) {
if (typeof v==='string' && /^[A-Za-z0-9]{20,}$/.test(v)) uids.add(v)
if (Array.isArray(v)) v.forEach(x=>{ if(typeof x==='string'&&/^[A-Za-z0-9]{20,}$/.test(x)) uids.add(x) })
if (v&&typeof v==='object'&&!Array.isArray(v)) Object.keys(v).forEach(x=>{ if(/^[A-Za-z0-9]{20,}$/.test(x)) uids.add(x) })
}
console.log('=== candidate member uids ===', [...uids].map(u=>u.slice(0,10)+'…'))
for (const uid of uids) {
const ent = await db.collection('users').doc(uid).collection('entitlements').doc('premium').get()
console.log(` ${uid.slice(0,10)}… entitlements/premium:`, ent.exists?JSON.stringify(ent.data()):'MISSING(free)')
}
process.exit(0)
})().catch(e=>{console.error('FATAL',e.message);process.exit(1)})

View File

@ -1,12 +0,0 @@
const admin = require('firebase-admin'); const path=require('path')
admin.initializeApp({credential:admin.credential.cert(require(path.join(__dirname,'..','closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')))})
const db=admin.firestore()
const QA='Y05AKO2IlTPMa0JQW1BiNIM0uzK2', SAM='imDjjOTTQv'
;(async()=>{
for(const uid of [QA]){
const d=await db.collection('users').doc(uid).get(); const x=d.data()||{}
const keys=Object.keys(x).filter(k=>/quiet|timezone|tz|notif/i.test(k))
console.log(uid.slice(0,8)+'… quiet/tz keys:', keys.length?JSON.stringify(Object.fromEntries(keys.map(k=>[k,x[k]]))):'(none present)')
}
process.exit(0)
})().catch(e=>{console.error(e.message);process.exit(1)})