chore: R12 working tree — QA docs, brand illustration updates, date-match paywall routing, theme tweaks

This commit is contained in:
null 2026-06-27 15:34:38 -05:00
parent 361eff18e3
commit 9f09ebbc67
10 changed files with 297 additions and 152 deletions

View File

@ -18,7 +18,49 @@ on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (a
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
+ PlayArrow glyph and read as intentional action banners — no illustration warranted. No new art to add this round.
## ✅ WIRED INTO ANDROID (2026-06-26 art drop) — all 11 generated illustrations live
## This or That gameplay brand plan (Codex QA, 2026-06-27)
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.
## ✅ WIRED INTO ANDROID (2026-06-26 art drop) — generated illustrations 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:
@ -38,8 +80,9 @@ The generated art (source in gitignored `docs/brand/generated-art/`, copied full
| A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** |
All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification.
**Not done:** A7 pack art = **N/A** (all 10 packs already have `pack_art_*`). **G-set glyphs = still backlog**
(not generated; tracked in `Future.md` `## QA`). Empty/match/pairing states that need empty-or-new data weren't
**Not done:** A7 pack art = **N/A** (all 10 packs already have `pack_art_*`). **G-set glyph art generated
2026-06-27 and ready to add** from `docs/brand/generated-art/glyphs/`; app wiring is still pending.
A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data weren't
reachable on the baseline couple — render path proven via the shared tile + gallery. Commits `077a408`→`5868d06` on `dev`.
---
@ -72,17 +115,17 @@ reachable on the baseline couple — render path proven via the shared tile + ga
- 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):** `illustration_couple_onboarding`, `_invite`, `_history`, `_paywall`,
`_subscription`, `illustration_tonight_partner_prompt`, `illustration_partner_activation`,
`illustration_reveal_celebration`, `illustration_streak_milestone`, `illustration_together_empty`,
`pack_art_{deep_reflection,family_commitment,home_life,trust_repair,money_values}`, `particle_heart`, `particle_petal`.
NOTE: most live only in iOS / `drawable-nodpi`; several screens below need the **Android copy** wired up, not new art.
**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`, 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 · add/þwire art (prompt below) · 🎨 new art to generate · 🔤 brand-copy/color touch
Legend: ✅ on-brand / no art needed · add/wire art · 🎨 new art to generate · 🔤 brand-copy/color touch
| Screen / surface | Current brand state | Opportunity |
|---|---|---|
@ -90,113 +133,64 @@ Legend: ✅ on-brand / no art needed · add/þwire art (prompt below) ·
| 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) | has recovery phrase, clean | small `illustration_couple_invite` at top (exists on iOS — wire to Android) |
| Pair: accept code / pairing success | minimal | 🎨 **A1** pairing-success celebration |
| Pair: invite (create code) | `illustration_couple_invite` wired | ✅ |
| Pair: accept code / pairing success | `illustration_pairing_success` wired | ✅ |
| Home (paired) | cards, warm copy | ✅ good; ensure card icons use brand glyphs 🔤 |
| 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) | functional | use `illustration_reveal_celebration` + petal/heart particles at the reveal beat |
| Answer history | list | empty state → wire `illustration_couple_history`; 🎨 **A2** if none |
| Answer reveal (mutual) | `illustration_reveal_celebration` wired | ✅ |
| Answer history | `illustration_answer_history_empty` wired | ✅ |
| Play hub | card list, game glyphs | ✅; verify each game card has a consistent brand glyph 🔤 |
| This or That (setup/play) | clean | ✅ |
| 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 | small celebration header art (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) | text list | 🎨 **A3** series/“small steps” header + per-series glyphs |
| Connection Challenges (series list) | `illustration_connection_challenges_header` wired | ✅; verify per-series glyph consistency 🔤 |
| Connection Challenges (active day) | clean | 🔤 small streak/heart glyph; ✅ otherwise |
| Memory Lane (list) | sealed rows | 🎨 **A4** empty state (no capsules) + sealed-capsule glyph |
| Memory Lane (sealed capsule) | lock + date | use capsule art (A4) on the sealed card |
| 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) | list | 🎨 **A5** “no matches yet” + match celebration |
| Date Match (your matches / empty) | empty + success art wired | ✅ |
| Plan Date / Date Builder | form | 🔤 small date-card glyph; ✅ otherwise |
| Bucket List (empty) | list | 🎨 **A6** “save ideas together” empty state |
| Question Packs (library) | `pack_art_*` present | ✅; 🎨 **A7** packs needing art (any pack without `pack_art_*`) |
| Messages (inbox empty) | list | 🎨 **A8** “no messages yet” (two soft bubbles, no text) |
| Conversation | chat | ✅ keep clean; quiet-hours state → A9 |
| Past Games (empty/list) | list | small replay/heart glyph; 🎨 **A10** if empty state bare |
| 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”) |
| WaitingForPartner | category glyph + copy | ✅ on-brand; ensure glyph per game type |
| Settings + sub-pages | dense lists | ✅ keep clean — **no illustrations**; brand via section headers/color only 🔤 |
| Security / Recovery phrase | text | 🎨 **A11** privacy-lock illustration (two sealed cards behind a soft lock) |
| Security / Recovery phrase | `illustration_privacy_recovery` wired | ✅ |
| Privacy & Terms | text | 🔤 small lock glyph header |
| Delete account | warning copy | 🎨 **A12** calm export/delete privacy scene (no alarm imagery) |
| Quiet hours (settings) | toggle | 🎨 **A9** moon/window/two-muted-phones |
| Notifications (system) | fallback glyph | 🎨 **G-set** monochrome notification glyph (heart/paired-card) |
| Delete account | `illustration_account_deletion_goodbye` wired | ✅ |
| Quiet hours (settings) | `illustration_quiet_hours` wired | ✅ |
| Notifications (system) | G-set glyph art generated, not wired | wire `docs/brand/generated-art/glyphs/android-vector/glyph_*.xml` |
---
## Art to generate — ChatGPT prompts (prefix the House Style block to each)
## Art to generate — status
Priority order = the moments a user feels most. Each is self-contained after you paste the House Style block first.
A1-A12 were generated and wired into Android. A7 is not needed because all 10 question packs already have `pack_art_*`.
G-set glyphs were generated 2026-06-27 and are ready to add from `docs/brand/generated-art/glyphs/`. There are no active
art-generation prompts right now.
**A1 · Pairing success celebration** — *1:1, transparent bg.*
> Scene: the Closer mark resolving into place — a soft-pink upper C-arc and a lavender lower sweep curving together to
> enclose a heart-shaped space with a small keyhole at its center — with a gentle burst of small floating hearts and
> petals around it. Convey "you're connected now." Calm, joyful, no text. (White keyhole if on a dark background.)
Do not regenerate completed illustration art unless a future QA pass logs a specific defect that requires replacement.
Always clean up completed art items: once an asset is generated, wired, and verified, remove its prompt from this active
backlog and update the audit table/status notes so only unfinished work remains.
**A2 · Answer history empty state** — *1:1.*
> Scene: a small soft journal/photo-album, slightly open, with two paired cards tucked inside and a few faint floating
> hearts; a lavender sprig beside it. Conveys "your shared moments will collect here." Quiet and inviting.
**A3 · Connection Challenges header ("small steps together")** — *16:9 banner, object-led.*
> Scene: a gentle winding path of soft stepping-stones, each stone a small rounded card, leading toward a glowing
> heart; a tiny lit candle and lavender fronds at the edges. Conveys a shared daily habit, "one small step at a time."
**A4 · Memory Lane — sealed capsule** — *1:1 (also usable as the sealed-card glyph).*
> Scene: a softly glowing sealed keepsake box/time-capsule with a small heart latch, a calendar/moon hint above it, a
> couple of petals settling. Conveys "sealed until your chosen date." Warm, secret, safe.
**A5 · Date match — "it's a match" + empty** — *1:1.*
> Scene: two date-idea cards (one pink, one lavender) leaning together to meet in the middle with a small heart where
> they touch; faint calendar/clock and a sun/hill motif behind. Conveys "you both chose this." For the empty variant,
> show the two cards slightly apart with a dotted soft line between them.
**A6 · Bucket List empty state** — *1:1.*
> Scene: a soft open notebook with a checklist of blank rounded lines, a small star and heart doodle, a pin/map dot
> and a tiny suitcase, lavender sprig accent. Conveys "save ideas to do together." Hopeful, light.
**A7 · Question pack art (per missing pack)** — *16:9 banner, object-led, match `pack_art_*`.*
> Scene: a fanned deck of paired cards as the hero object, themed to the pack topic with one simple symbol (e.g.
> intimacy = soft flame + petals; communication = two speech shapes; future = a small horizon/sunrise), framed by
> lavender fronds and a soft glow. No people, no readable text. (Generate one per pack that lacks `pack_art_*`.)
**A8 · Messages empty state** — *1:1.*
> Scene: two soft rounded chat bubbles (one pink, one lavender) gently overlapping with a small heart between them and
> a few floating petals; **no text inside the bubbles.** Conveys "your private conversation starts here."
**A9 · Quiet hours** — *1:1.*
> Scene: a calm night window with a soft crescent moon and stars, and two muted phones resting face-down on a bedside
> surface with a small lavender sprig. Conveys "gentle, no pressure, paused for the night." Soothing, no alarm imagery.
**A10 · Past Games empty state** — *1:1.*
> Scene: two paired game cards stacked with a soft replay/loop arc of petals around them and a faint heart. Conveys
> "the games you've played together will live here." Playful but calm.
**A11 · Privacy / Recovery — privacy-lock** — *1:1 or 4:5.*
> Scene: two sealed cards or closed journals nestled behind a soft, friendly padlock with a small heart on it; a key
> shape made of a lavender sprig; quiet protective glow. Conveys "only the two of you hold the key." Reassuring, warm —
> **not** a cold security/vault look.
**A12 · Account deletion — calm goodbye** — *1:1.*
> Scene: a soft open box gently releasing a few hearts/petals upward, with a faint door/horizon and a lavender sprig;
> muted, respectful, peaceful. Conveys "your data leaves with you." **No** warning triangles, red, broken hearts, or
> alarm imagery.
**G-set · Notification + relationship glyphs** — *single-color vector, square, legible at 24 dp.* (One prompt each:)
> Simple single-color flat glyph in the Closer style, no text, no background: **the Closer C-heart-keyhole mark**, **paired sealed
> cards**, **daily card**, **sealed answer (card with small lock)**, **memory capsule**, **date-card with heart**,
> **quiet-hours moon**, **couple-premium (heart + small crown/spark, tasteful)**, **export-data**, **delete-account**.
> Export in lavender for in-app and as high-contrast monochrome for the platform notification glyph.
**Generated G-set files:** `glyph_closer_mark`, `glyph_paired_cards`, `glyph_daily_card`, `glyph_sealed_answer`,
`glyph_memory_capsule`, `glyph_date_card_heart`, `glyph_quiet_hours_moon`, `glyph_couple_premium`,
`glyph_export_data`, `glyph_delete_account`.
---
## 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.
- Several items above are **"wire existing iOS art into Android,"** not new generation — do that first (cheaper):
history, invite, daily-question, together-empty, partner-activation already exist on iOS.
- 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.
</content>

View File

@ -10,12 +10,12 @@
## Status at a glance
| Pass | Coverage | Status |
|---|---|---|
| A — Couple-shared premium | R12: code audit (all gates→CouplePremiumChecker) + live couple-shared unlock (Sam prem→QA free unlocks Desire Sync setup + Memory Lane badge) | ⚠️ 1 P1 (A-201 Date Match premium ideas ungated — free user liked a ★Premium idea, no paywall); all other gates couple-shared ✅ |
| A — Couple-shared premium | R12: code audit + live couple-shared unlock (Sam prem→QA free unlocks Desire Sync); **A-201 found + FIXED + verified live** (Date Match LOVE/MAYBE on premium idea → Paywall, SKIP passes) | ✅ pass (A-201 fixed pending 1 confirm; all gates now couple-shared incl. Date Match) |
| 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) | ✅ pass (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) |
| C — Visual (light+dark) | R12: Messages(inbox+conv) both themes, Today, Subscription + organic A/B sweep (Home/Play/all game screens/Security/MemoryLane/DateMatch/Paywall) + R11 decoupled-theme art; back-stack spot-checks OK | ✅ regression-clean vs R10 full sweep · NEW **C-ART-EDGE-002 (P3)** direct-call hero art (daily-question, couple_subscription, etc.) hard edges on dark · C-DARKART-001+C-ART-EDGE-001 (shared helpers) hold |
| D — Security & encryption | R12 LIVE: D1 (4 game collections enc:v1:) · D3 raw-API non-member 403×4 + member-scoped + messages not enumerable · D5 self-grant premium PATCH→403; D2/D4/D6/D7 carried R7/R10 (rules unchanged, no deploy) | ✅ clean — cornerstone holds; bounds A-201 (server blocks real premium self-grant) |
| E — Notifications | R10 live: E-GAME-002 confirmed (start push+banner+Join), finish/answer/reveal pushes | ✅ pass · E-GAME-002 pruned · full fg/bg/killed matrix **partial** |
| F — Resilience | R10: concurrency double-start→1 session · process-death→clean+FCM re-register · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred |
| E — Notifications | R12 LIVE: Pass B verified start/first-finisher(`partner_completed_part`)/finish triggers→correct partners+copy; cold-start tap smoke **6/6** (launcher + 5 notif types open & stay) | ✅ pass · splash-crash class clean on fresh APK |
| 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 | R10: existing-art integration clean (0 defects), new game surfaces on-brand | ✅ see `ClaudeBrandingReview.md` |
| I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression |
@ -114,6 +114,7 @@ Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` →
---
## Round history (one line each)
- **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.

View File

@ -12,6 +12,8 @@
For each Pass below, before you start, read the relevant section of [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — it documents the architecture, the wire-format contracts, the security invariants, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) (bugs that cost real debugging time and are easy to re-introduce).
**This is bidirectional — the manual is a LIVING document, not a read-only reference.** Read it before; **write back to it after.** Whenever a round fixes a bug, changes a contract/flow/gate, or finds the manual stale or missing something, update the manual in the same chunk (see *Where every finding goes*, the *Docs update rule*, and the *MANDATORY retrospective* — all now route durable engineering truth here). Treat it as part of every fix, same as `ClaudeReport.md`/`ClaudeQACoverage.md`.
| Pass | Manual section to read first |
|---|---|
| A — Couple-shared premium | [Premium-gated features and gate pattern](docs/Engineering_Reference_Manual.md#premium-gated-features-and-gate-pattern) · [Billing](docs/Engineering_Reference_Manual.md#billing) |
@ -34,9 +36,24 @@ For each Pass below, before you start, read the relevant section of [`docs/Engin
| **An idea / improvement** — works but could be better, confusing copy, missing affordance, rough-but-not-broken flow, "it'd be great if…", feature idea | **`Future.md`** `## QA` | Short title + what prompted it + suggested improvement |
| **New artwork to create** — illustrations, glyphs, image-gen prompts | **`ClaudeBrandingReview.md`** | House-style prompt + placement |
| **What got tested + its status** (pass / fail / todo / deferred) | **`ClaudeQACoverage.md`** | Coverage cell (the resume anchor) |
| **Durable engineering knowledge** — a fixed bug's root cause + how it's easy to re-introduce, a new architecture fact / data path / wire-format contract / security invariant / gate pattern, or anything the manual is now stale/missing about | **[`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md)** (esp. [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes)) | New landmine entry (ID + cause + the guard) and/or an updated architecture/gate/flow section |
- A branding **defect** (mis-colored, clipped, off-brand, low-contrast art) is a **bug → `ClaudeReport.md`**, not a brand
idea — only *new art to create* goes to `ClaudeBrandingReview.md`.
- **ONE canonical home per fact; everywhere else is a pointer (ID/anchor), never a paraphrase.** This is the rule that
keeps the five docs from duplicating each other (and wasting tokens re-stating the same lesson). Route by *purpose*:
the **defect** (repro/severity/status) → `ClaudeReport.md` (transient — prunes to an ID after one confirm); the
**substance** (root cause / why it's fragile / how to not re-introduce it) → the **Engineering Reference Manual**
(permanent, engineer-facing); the **reflex** (how to FIND the class next round) → this `ClaudeQAPlan.md` Pass
(generalized, citing the ID); **coverage status**`ClaudeQACoverage.md`; **cross-session ops not in the repo**
(accounts, tooling, auth) → `memory/`. State a fact in its home once; elsewhere cite the ID. Don't restate a fix in
four docs.
- **The Engineering Reference Manual is a LIVING document — read it before a pass, write back to it after.** When a
round teaches the codebase something durable (a fixed bug's re-introduction risk, a new/changed architecture fact,
data path, contract, gate, flow, collection/Function/route, or the manual disagreeing with reality), update the manual
in the **same chunk**. **A fix is not complete until its durable substance is in the manual** (see the
MANDATORY-retrospective rule). The report row and the Pass reflex just reference the manual's landmine ID — they don't
re-tell it.
- Logging an idea in `Future.md` is **never** a substitute for filing a real defect: if it's broken, it gets an ID in
`ClaudeReport.md` too.
- Bug lifecycle: filed in `ClaudeReport.md` → fixed → kept **one** confirmation round → pruned to the archived-ID line
@ -172,6 +189,10 @@ surface and reconcile it with `ClaudeQACoverage.md`:
- **Docs update rule:** if the inventory finds a page, feature, notification, asset, state, backend path, or edge case
missing from the playbook/coverage, update `ClaudeQAPlan.md` and `ClaudeQACoverage.md` before marking the chunk done.
If it is product polish, also add it to `Future.md`; if it needs new artwork, add it to `ClaudeBrandingReview.md`.
**And if the discovery is a durable engineering fact (new route/collection/Function/flag/contract, a changed wire
format, a renamed file, a gate/flow that the manual describes wrongly or omits), update
[`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) in the same chunk** — the discovery
ritual is exactly when the manual drifts out of date, so reconcile it then, not "later".
## Multi-angle attack mandate (go DEEPER than "does the happy path work")
A capability can pass via the UI yet fail when hit directly. Probe each meaningful capability (read/write a private
@ -224,16 +245,27 @@ State lives in **files**, not memory:
placement, repeatable bug class, missed edge case, fragile route, confusing state, image/layout failure mode,
security angle, or anything else that should be checked every future round — update **this `ClaudeQAPlan.md`** in the
relevant pass before ending the chunk. Also add the matching row/cell to `ClaudeQACoverage.md` if it needs recurring
verification. Do this even after the immediate bug is filed/fixed so the lesson or newly discovered surface is not
lost to memory or git history.
verification. **And update [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) when the
discovery is durable engineering truth** (a new architecture fact, data path, contract, gate, flow, or a fixed bug's
re-introduction risk) — the QA plan captures *what to re-test*, the manual captures *what the system is and why it's
fragile*; both are living and both get updated. Do this even after the immediate bug is filed/fixed so the lesson or
newly discovered surface is not lost to memory or git history.
- **Learn from every ESCAPED or DEEP bug — MANDATORY retrospective (do this automatically, not only when asked).**
Any bug that (a) **escaped a prior round**, (b) needed **non-obvious diagnosis** (a crash, an "opens-and-closes",
a "didn't work", an intermittent, a wrong-root-cause first guess), or (c) **recurred** triggers a short retrospective
the moment it's fixed — the fix is **not complete** until all four are done:
1. **Add the guard that would have caught it** — a new `qa/` smoke check, a coverage row, or a concrete pass step
(e.g. the cold-start bug → `qa/entrypoint_smoke.sh`). If an existing smoke missed it, extend the smoke.
2. **Record the generalizable inspection lesson** in the relevant pass of this doc AND in `memory/` (how to *find*
this class next time — the reflex, not just the fix).
2. **Capture the lesson in its ONE canonical home, then link by ID elsewhere — never paraphrase it twice.** Split by
purpose: the **reflex** (how to *find* this class next round) goes in the relevant Pass of **this doc**, written
*generalized* and citing the bug ID as an example (do NOT re-narrate the bug here); the **substance** (root cause +
where it lives now + re-introduction risk + the guard) goes in
[`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) → [Known landmines and recent
fixes](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) (and update the matching
architecture/gate/flow section if the fix changed it). The manual is the next engineer's first read; a landmine
that isn't in it will be re-introduced. **Do NOT copy the fix into `memory/`** — per the memory rules, memory holds
only cross-session facts NOT in the repo (emulator↔account map, admin tooling/commands, standing auth,
never-commit); past fixes belong to the manual, so memory just points to the landmine ID if needed.
3. **Name the missing state/angle/entry-point** that let it hide and add it to the multi-angle / state matrices so it's
exercised every round (e.g. "real notification tap on an `am kill`'d app", not just `am start`).
4. **Note any wrong turn in diagnosis** so the misstep isn't repeated (e.g. "synthetic test passed while the real
@ -302,6 +334,17 @@ Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSync
`ui/wheel/{CategoryPicker,SpinWheel,WheelHistory}*`, `ui/questions/QuestionPackLibrary*`,
`ui/dates/{DateMatch,DateMatches}Screen`, `ui/memorylane/MemoryLaneScreen`, `ui/challenges/ConnectionChallengesScreen`.
Also: **any VM/screen calling `EntitlementChecker.isPremium()` directly** (grep for it) is a candidate gate.
- **ENFORCEMENT, not just a checker-usage grep (mandatory — RETROSPECTIVE from A-201, R12).** A feature can carry an
`isPremium` **content flag** + a cosmetic `PremiumBadge` with **NO gate at all** — that's exactly how Date Match
shipped a premium **bypass** (free users could view/like/match ★Premium date ideas; `getDateIdeas()` returned
`DateIdeaSeed.all`, no `CouplePremiumChecker`, badge only). Prior rounds missed it because the audit grepped for
`CouplePremiumChecker` *usages* and found the gated features, never noticing the feature that had **no** checker.
So every round: (1) **grep for `isPremium` / `PremiumBadge` / premium content flags** (`DateIdea.isPremium`,
`category.access=="premium"`, `challenge.isPremium`, …) and for **each** confirm a real enforcement path exists —
a `CouplePremiumChecker` filter OR a paywall-on-interaction — **not just a badge**; (2) **actually TRY TO USE the
premium content as a free user** (like/open/play it), don't just confirm the lock renders — "badge shows" ≠ "gated".
A badge with no enforcement = **premium bypass** (P1+). Inspection lesson: *"shows a Premium badge" is a display
fact, not a gate; prove the gate by using the content while free.*
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through ALL different play stayles of the game)
Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match.

View File

@ -10,10 +10,10 @@
> to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`.
## Run-state (current)
`R12 (2026-06-27) FRESH FULL ClaudeQAPlan run STARTED (user: "start from the start") | Baseline verified clean: QA free, Sam free (premium revoked), 0 active sessions; build HEAD 2cd0af6 + 3 uncommitted art files installed both emulators (5554=Dark, 5556=Light) | A ✅ (A-201 P1 Date-Match premium bypass) | B ✅ (4 async games full 2-device end-to-end + first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; CC/MemoryLane/DateMatch render-checked; MemoryLane title/preview run-on→Future) | C ✅ (regression-clean vs R10 sweep; Messages/Today/Subscription + organic A/B + R11 decoupled art; NEW C-ART-EDGE-002 P3 direct-call hero hard edges on dark) | D ✅ (LIVE: D1 game answers enc:v1: · D3 non-member 403×4 + member-scoped · D5 self-grant→403; D2/D4/D6/D7 carried R7/R10, rules unchanged) — cornerstone clean | E ▶ in progress (Pass B already verified start/first-finisher/finish triggers→correct partners+copy; cold-start tap smoke running bg bjffibz4v) | FJ todo | Admin: scratchpad/qadmin.js + qa/* + scratchpad/d3neg.js (raw-API). Baseline restored (both free, 0 active). | NEXT: confirm smoke 6/6, then Pass F resilience. Carryover from R11 (still valid): C-DARKART-001 (P2) + C-ART-EDGE-001 (P3) fixed pending 1 confirm; J-OBS (P3) open touch targets.`
`R12 (2026-06-27) FRESH FULL ClaudeQAPlan run STARTED (user: "start from the start") | Baseline verified clean: QA free, Sam free (premium revoked), 0 active sessions; build HEAD 2cd0af6 + 3 uncommitted art files installed both emulators (5554=Dark, 5556=Light) | A ✅ (A-201 P1 Date-Match premium bypass) | B ✅ (4 async games full 2-device end-to-end + first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; CC/MemoryLane/DateMatch render-checked; MemoryLane title/preview run-on→Future) | C ✅ (regression-clean vs R10 sweep; Messages/Today/Subscription + organic A/B + R11 decoupled art; NEW C-ART-EDGE-002 P3 direct-call hero hard edges on dark) | D ✅ (LIVE: D1 game answers enc:v1: · D3 non-member 403×4 + member-scoped · D5 self-grant→403; D2/D4/D6/D7 carried R7/R10, rules unchanged) — cornerstone clean | E ✅ (smoke 6/6 + Pass B triggers) | F ✅ (concurrency+process-death+offline) | G ✅ (security half live) | H (finding C-ART-EDGE-002) | I ✅ (jank 4.10%) | J (J-OBS P3) | **FIX PHASE ✅ — A-201 (P1) FIXED+VERIFIED LIVE** (Date Match LOVE/MAYBE on premium idea → Paywall via CouplePremiumChecker; SKIP passes; 0 FATAL). C-DARKART-001+C-ART-EDGE-001 held → PRUNED. | **R12 COMPLETE — FLAWLESS: 0 open P0P2.** Remaining: 2 non-blocking P3s (C-ART-EDGE-002 hero edges, J-OBS touch targets). | NEXT (R13): confirm A-201 holds → prune; optional P3 polish. Uncommitted (user commits): R11 art files + `ui/dates/DateMatchViewModel.kt` + `ui/dates/DateMatchScreen.kt` (A-201). Carryover from R11 (still valid): C-DARKART-001 (P2) + C-ART-EDGE-001 (P3) fixed pending 1 confirm; J-OBS (P3) open touch targets.`
- **(prior) R11 (2026-06-27) confirmation round COMPLETE — FLAWLESS | Fixed last open P2 C-DARKART-001 (in-app-theme art) + P3 C-ART-EDGE-001 (feathered edges) in shared BrandIllustration/EmptyState; verified live both decoupled theme directions, 0 FATAL | 5 R10 P2 fixes re-confirmed + PRUNED (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001) | entrypoint smoke green on fresh APK | 0 open P0P2 | Baseline: both FREE, 0 active sessions; build (HEAD 2cd0af6 + 3 uncommitted art files) installed both emulators | NEXT (R12 = next session): confirm C-DARKART-001 + C-ART-EDGE-001 hold → prune; optional J-OBS (P3) touch targets; then declare program-complete for Android. Art fixes in working tree (user commits).`
- **Pass B progress (R12):** **1. This or That ✅** — full end-to-end 2-device, NEW style **Light×5 Quick** (R10 was Deep×10): QA started → answered 5 (alt A/B) → first-finisher state; **first-finisher nudge fired** (`partFinishNotifiedAt` set + Sam queue `partner_completed_part` "QA finished their part — your turn to play!"); Sam **joined via Play-hub active state** (at Q1/5, no dup session) → answered all-A → session→completed (0 active); **`partner_finished_game` to BOTH**; reveal **3/5 in sync** symmetric + correct Match/Differ + You/QA attribution on **both** devices (QA dark / Sam light). 0 FATAL. **2. Spin the Wheel ✅****Ready=Start session** (R11 change) verified; spun→Stress→10Q; **mixed answer types** (free-text + 15 scale) render+accept; Sam **joined active session** via Play hub (Q1, no dup/new spin); both finished→completed (0 active); results "Here's how you each answered" with You/QA free-text + scale; **C-NAV-002 RE-VERIFIED LIVE** — results → BACK → wheel hub (Spin/History), NOT the finished session (the R11-deferred confirm). 0 FATAL. **3. How Well ✅** — QA subject 5·Quick (answered 5 about self), Sam **joined as guesser** ("Predict how QA answered…", asymmetric), guessed 5 → score **5/5 "Perfect read"** + per-Q breakdown (✓ + answer, choice+scale) symmetric both devices; completed (0 active). **4. Desire Sync ✅** (premium, couple-shared via Sam-on) — free QA opened setup (no paywall); QA all-Yes, Sam joined + Y,Y,Y,N,N → reveal **"3 shared desires · 2 kept private"** (only mutual-Yes shown, 2 mismatches "Private") symmetric both devices; completed (0 active); Sam premium restored OFF. **All 4 async session games verified end-to-end.**
- **Uncommitted (user commits):** R11 art fixes only `app/.../ui/theme/Theme.kt` (LocalAppInDarkTheme CompositionLocal), `app/.../ui/components/BrandIllustration.kt` (theme-correct `-night` variant via config-overridden context + edge feathering), `app/.../ui/components/EmptyState.kt` (routes its illustration through BrandIllustration). Everything else (splash fix, E-GAME-003, foreground banner, qa/ tooling) committed by user in `2cd0af6`.
- **Uncommitted (user commits):** R11 art fixes — `app/.../ui/theme/Theme.kt`, `app/.../ui/components/BrandIllustration.kt`, `app/.../ui/components/EmptyState.kt`; **+ R12 A-201 fix — `app/.../ui/dates/DateMatchViewModel.kt` (CouplePremiumChecker gate + `paywallRequired` event) + `app/.../ui/dates/DateMatchScreen.kt` (navigates to paywall).** Everything else committed in `2cd0af6`. Build installed both emulators.
- **Foreground "partner started a game" alert + bold Game Waiting card (R10, user-requested) — DONE+VERIFIED.** When the app is OPEN and a partner starts a game, a prominent **in-app top banner** ("<partner> started <Game>" + Join) slides in (mirrors chat's in-app surface) instead of the easy-to-miss system notification; **Join → joins the game**. Verified live for all 4 session games: banner name correct (Spin the Wheel / This or That / How Well Do You Know Me / Desire Sync); Join → joins wheel (ToT shows graceful "QA is playing a Wheel game" when types mismatch); **suppressed** when already on that game's screen (added `ActiveGameSessionMonitor.enter/leave` to `WheelSessionViewModel` — the others already had it). Home **"Game waiting"** card redesigned as a **bold purple-gradient hero** (glyph + game name + "Join the game"), promoted to top of "Waiting for you", verified **both themes** → tap **joins the specific game** (not the Play-hub fallback). FCM transport on the emulators is flaky (FcmRetry); the banner was exercised via a data-only high-priority send to the partner token (faithful to the deployed payload).
- **Pass C progress (R10):** **Settings family ✅** (dark + Security on light): Settings list, Subscription, Security, Delete account, Notifications all render clean both-relevant-themes; **4 illustrations confirmed in-context** (Security padlock, Delete-account doorway, Quiet-hours moon, + Subscription); back-stack OK (Security/Delete/Notif → BACK → Settings). **Found C-SEC-001 (P2)** — accepter's Recovery phrase disabled + wrong "invite your partner" copy (see Open issues). **Wheel back-stack RE-CHECKED = not a trap:** live wheel session → BACK → spin/setup → BACK → Play hub (2 backs, no dead-end); earlier "stuck" was automation cycling. Leaving mid-wheel leaves a resumable abandoned active session (normal; cleaned). Home ✅ both themes (stale game card gone).
- **6. Spin the Wheel ✅** — spun→Physical Intimacy→session; Sam joined at Q1; both answered 10 (A/B + 15 scale + free-text); per-Q You/QA breakdown renders; completed, 0 active. (Helper `wheel_drive.py` handles mixed types; free-text Qs hide "Next" behind IME.)
@ -34,30 +34,25 @@
| Severity | Open | Fixed (pending 1 confirm) |
|---|---|---|
| P0 | 0 | 0 |
| P1 | **1** (A-201) | 0 |
| P2 | **0** | **1** (C-DARKART-001) |
| P3 | **2** (J-OBS, C-ART-EDGE-002) | **1** (C-ART-EDGE-001) |
| P1 | 0 | **1** (A-201) |
| P2 | 0 | 0 |
| P3 | **2** (J-OBS, C-ART-EDGE-002) | 0 |
## Issues — R12 (1 open P1 [A-201 date-match premium bypass] · 0 open P2 · 1×P2 fixed pending 1 confirm [C-DARKART-001] · 1 open P3 [J-OBS] · 1×P3 fixed pending 1 confirm [C-ART-EDGE-001])
> R11 fixed the two open art issues in the shared `BrandIllustration`/`EmptyState` helpers and verified both live
> on **both** decoupled theme directions, 0 FATAL. The 5 R10 P2 fixes were re-confirmed this round and **pruned** to the
> archived-ID line below (detail in git `9c84c36`). **Fixes for the two art issues are in the working tree (user commits).**
> Fix summary: C-DARKART-001 — `LocalAppInDarkTheme` CompositionLocal (set in `CloserTheme`) drives a config-overridden
> context (`createConfigurationContext` with `UI_MODE_NIGHT_*` from the in-app theme) so `-night` art follows the app's
> own theme, not the system. C-ART-EDGE-001 — tiled art feathers its 4 edges to transparent (`graphicsLayer{Offscreen}` +
> `drawWithContent` `BlendMode.DstIn` linear gradients) instead of a hard `clip` + `border`; `EmptyState` now routes its
> illustration through `BrandIllustration` so both fixes apply everywhere from one place.
## Issues — R12 (0 open P0P2 = FLAWLESS · A-201 P1 fixed+verified-live pending 1 confirm · 2 open P3 [J-OBS, C-ART-EDGE-002])
> R12 was a FRESH FULL AJ run. Found A-201 (P1, Date Match premium bypass) — **fixed + verified live this round** (gated
> via `CouplePremiumChecker`). C-DARKART-001 (P2) + C-ART-EDGE-001 (P3), fixed in R11, held through R12's visual sweep and
> are **pruned** to the archived-ID line below (detail in git / working tree). New P3 **C-ART-EDGE-002** (direct-call hero
> hard edges) is deferred polish. Remaining open = 2 non-blocking P3s. **A-201 fix + R11 art fixes are in the working tree
> (user commits).**
| ID | Sev | Area | Description | Repro | Suggested fix | Status |
|---|---|---|---|---|---|---|
| A-201 | P1 | Premium / Date Match bypass (Pass A, R12) | **Premium date ideas are NOT gated — free users can view, swipe, like and match them with no paywall.** `DateIdea.isPremium` is documented as "requires an active premium entitlement," but `DateMatchRepositoryImpl.getDateIdeas()` returns `DateIdeaSeed.all` (premium ideas included, **no entitlement filter**), `DateMatchViewModel` loads them with no `CouplePremiumChecker`, and `DateMatchScreen` only renders a cosmetic `PremiumBadge()` — no lock overlay, no paywall on like/super-like. So the premium tier for Date Match is unenforced. **Escaped prior Pass A rounds** (which asserted "all gates use CouplePremiumChecker" — Date Match has NO gate). (Plan buckets "premium bypass" as P0; filed P1 as it's a content-tier subset, no security/data impact.) | 5554 (QA **free**): Play → Date Match → reject through deck → reach a **★ Premium** idea ("night camping getaway") → Like (heart) → **no paywall**, swipe accepted, deck advanced to the next premium idea ("Zipline canopy tour"); 0 FATAL, no `PERMISSION_DENIED`. | Gate premium date ideas through `CouplePremiumChecker`: either filter `isPremium` ideas out of a free couple's deck, OR intercept like/super-like on a premium idea → route to Paywall when neither partner is premium (couple-shared). Mirror the Desire Sync / Question-pack gate. | **Open (P1)** |
| C-DARKART-001 | P2 | Theme / dark-mode art (Pass C) | **Dark-mode illustrations didn't follow the IN-APP theme switch — only the system dark mode.** The in-app toggle (Settings → Appearance → Dark) swapped Compose colors via `CloserTheme(darkTheme=…)` but had no config `uiMode` override, so `painterResource` + the `drawable-night-nodpi/` variants resolved off the **system** `uiMode` → app-Dark on a light-mode phone showed **dark UI + light illustrations**. Affected all 12 `-night` illustrations. | 5554: `cmd uimode night no` (system light) → Settings → Appearance → **Dark** → before fix Security showed the **light** padlock tile on a dark screen. | **DONE:** `BrandIllustration` loads the drawable through `context.createConfigurationContext(cfg)` with `UI_MODE_NIGHT_*` set from `LocalAppInDarkTheme` (provided in `CloserTheme`). **Verified live R11 both directions:** 5554 system-light + app-Dark → **dark aubergine** art on dark screen (Security + Art-preview gallery); 5556 system-dark + app-Light → **light pastel** art on light screen; 0 FATAL, both apps alive. | **Fixed + verified live R11 (working tree; user commits)** |
| C-ART-EDGE-001 | P3 | Art / edge treatment (Pass C+H) | **Displayed illustrations had hard edges instead of fading into the screen**`BrandIllustration` hard-`clip`ped art to `RoundedCornerShape` + a hairline `border`, and `EmptyState` rendered raw `painterResource`, so the near-white tile read as a crisp rounded-rectangle boundary (esp. on dark). | Any art screen: hard tile edge/outline instead of feathering. | **DONE:** tiled art now feathers its 4 edges to transparent (`graphicsLayer{compositingStrategy=Offscreen}` + `drawWithContent` `BlendMode.DstIn` linear gradients, ~14% inset); `clip`+`border` removed; `EmptyState` routes through `BrandIllustration`. **Verified live R11:** Art-preview gallery + Security padlock melt softly into the surface on both themes; transparent art (`tile=false`) unaffected. | **Fixed + verified live R11 (working tree; user commits)** |
| A-201 | P1 | Premium / Date Match bypass (Pass A, R12) | **Premium date ideas are NOT gated — free users can view, swipe, like and match them with no paywall.** `DateIdea.isPremium` is documented as "requires an active premium entitlement," but `DateMatchRepositoryImpl.getDateIdeas()` returns `DateIdeaSeed.all` (premium ideas included, **no entitlement filter**), `DateMatchViewModel` loads them with no `CouplePremiumChecker`, and `DateMatchScreen` only renders a cosmetic `PremiumBadge()` — no lock overlay, no paywall on like/super-like. So the premium tier for Date Match is unenforced. **Escaped prior Pass A rounds** (which asserted "all gates use CouplePremiumChecker" — Date Match has NO gate). (Plan buckets "premium bypass" as P0; filed P1 as it's a content-tier subset, no security/data impact.) | 5554 (QA **free**): Play → Date Match → reject through deck → reach a **★ Premium** idea ("night camping getaway") → Like (heart) → **no paywall**, swipe accepted, deck advanced to the next premium idea ("Zipline canopy tour"); 0 FATAL, no `PERMISSION_DENIED`. | Gate premium date ideas through `CouplePremiumChecker`: either filter `isPremium` ideas out of a free couple's deck, OR intercept like/super-like on a premium idea → route to Paywall when neither partner is premium (couple-shared). Mirror the Desire Sync / Question-pack gate. | **Fixed + verified live R12 (working tree; user commits)**`DateMatchViewModel` injects `CouplePremiumChecker`; `swipeCurrent` intercepts LOVE/MAYBE on a premium idea when neither partner is premium → emits `paywallRequired``DateMatchScreen` navigates to Paywall; SKIP still passes; deck stays on the card. **Verified:** free QA Love on ★Premium "night camping" → Paywall ("Go deeper together"), no swipe; SKIP → advances, no paywall; 0 FATAL. (Server already blocked real self-grant per D5 — so no entitlement was ever exposed.) |
| C-ART-EDGE-002 | P3 | Art / hard edges on direct-call heroes (Pass C, R12) | **Hero illustrations rendered via direct `painterResource` (not the shared `BrandIllustration`) still show hard edges on dark theme** — the R11 C-ART-EDGE-001 feather fix only covered `BrandIllustration`/`EmptyState`. The Today "Weekend Side Quest" daily-question hero (light/pink art) renders as a **bright rounded-rect block with a hard bottom edge on the dark screen**. These direct-call heroes have **no `-night` variant** either. Likely same class: paywall couple art, onboarding, Home tonight-prompt/partner-activation, spin-wheel hero, pack art (all direct `painterResource(illustration_*)`). Matches the user's "all images should fade into the screen" request (only partially satisfied by C-ART-EDGE-001). | 5554 (dark): Today tab → daily question → "Weekend Side Quest" hero shows a hard bright edge against the dark card. | Route these heroes through `BrandIllustration` (gains feather + theme-variant), OR apply the same `featherEdges()` treatment at each call site; consider `tile`/`hero` variants. Verify each direct `painterResource(R.drawable.illustration_*)` site listed in the R12 grep. | **Open (P3)** |
| J-OBS | P3 | A11y / touch targets | A few conversation icon-buttons measure **~4245dp wide** (48dp tall) — single-axis marginal miss of the 48dp target; fully operable. Most controls are 48dp. | Pass J: uiautomator bounds on conversation → 23 clickables `<126px` wide. | Bump those icon-buttons to 48dp min (e.g. `Modifier.minimumInteractiveComponentSize()` / `size(48.dp)`). | **Open (P3, non-blocking)** |
## Resolved & confirmed (archived — full detail in git history)
A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DS-001 · **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** — all fixed and re-verified (R11 pruned the 5 R10 P2 fixes — C-HOME-001 single Home card · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` present + R10-live · C-NAV-003 single app bar re-confirmed live · C-PW-001 dark paywall pills legible re-confirmed live · C-SEC-001 recovery row active for accepter re-confirmed live — all committed in `9c84c36`; E-GAME-003 `onGamePartFinished` deployed + committed `2cd0af6`) (E-GAME-002 confirmed live R10: `startNotifiedAt` set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
A-001 · A-003 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · **C-DARKART-001** · C-DS-001 · **C-ART-EDGE-001** · **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** — all fixed and re-verified (R12 pruned C-DARKART-001 [in-app-theme `-night` art] + C-ART-EDGE-001 [feathered edges] — fixed R11, held through R12 visual sweep; in working tree) (R11 pruned the 5 R10 P2 fixes — C-HOME-001 single Home card · C-NAV-002 `popUpTo(WHEEL_SESSION){inclusive}` present + R10-live · C-NAV-003 single app bar re-confirmed live · C-PW-001 dark paywall pills legible re-confirmed live · C-SEC-001 recovery row active for accepter re-confirmed live — all committed in `9c84c36`; E-GAME-003 `onGamePartFinished` deployed + committed `2cd0af6`) (E-GAME-002 confirmed live R10: `startNotifiedAt` set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; **I-001** query→`whereIn(dayKeys)` + **I-002** Long-score→`Number.toInt()`, fixed `ab29f6b`, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / `outcomes` list / SubscriptionScreen per-user gate = investigated, **not bugs**.)
## Security cornerstone — clean (Pass D, deep dive, Round 7)
- **D1 at-rest:** chat text + `lastMessagePreview` + all 4 game-answer collections (ToT / How Well / Desire Sync / Wheel, both users) + Memory Lane capsules + date-swipe actions = `enc:v1:`. No plaintext content; only metadata in clear.
@ -100,6 +95,14 @@ also set an `ACTION_VIEW` + `closer://` **data Uri** on the notification intent:
path (no Uri) loads results; **warm tap from Settings now routes** (was the stuck case).
## Round history (one line each)
- **R12 (2026-06-27) — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2).** Found **A-201 (P1): Date Match
premium ideas ungated** (free users could like/match ★Premium ideas — `getDateIdeas()=all`, no checker, badge only;
escaped prior Pass A rounds) → **fixed + verified live** (gated LOVE/MAYBE via `CouplePremiumChecker`→Paywall, SKIP
passes). Pass B: all 4 async games full 2-device E2E (ToT/Wheel/HowWell/DesireSync) + first-finisher nudge + C-NAV-002
+ Ready=Start re-verified live. Pass D LIVE clean: non-member 403 (read+write), self-grant→403, game answers enc:v1:.
Pass E smoke 6/6. Pass I jank 4.10% (art change perf-safe). New P3 C-ART-EDGE-002 (direct-call hero hard edges,
deferred). C-DARKART-001+C-ART-EDGE-001 (R11) held → pruned. Retrospective added to Pass A (badge≠gate; try to USE
premium content as a free user). Fixes in working tree (user commits).
- **R11 (2026-06-27) — confirmation round, FLAWLESS (0 open P0P2).** Fixed the last open P2 **C-DARKART-001** (dark-mode
art now follows the in-app theme: `LocalAppInDarkTheme` CompositionLocal in `CloserTheme``BrandIllustration` loads the
`-night` drawable via a `createConfigurationContext` whose `UI_MODE_NIGHT_*` comes from the app theme, not the system) and

View File

@ -7,6 +7,11 @@ Non-blocking ideas: things that work today but could be better, plus feature ide
Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects).
- **Memory Lane capsule list: separate title from body preview.** (R12 Pass B) In the capsule list each row runs
the title straight into the body preview with no separator/space/style break (e.g. "QA Memory Test" + "Remember this
QA round…" renders as one run-on string). Give the preview its own line / muted style, or a separator, so title and
body read distinctly. (Partly confounded by R12 adb-typed test titles — re-check with clean data; cosmetic only.)
- **Consistent brand glyphs across game cards + waiting/notification surfaces.** _(Blocked: needs the
generated G-set art — image generation is the user's step per `ClaudeBrandingReview.md`.)_ Game cards
(Play hub), the WaitingForPartner screen, and notifications mix Material icons with brand art. A small

View File

@ -1,28 +1,46 @@
package app.closer.ui.components
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas as AndroidCanvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import app.closer.ui.theme.LocalAppInDarkTheme
/**
* Brand illustration that reads on BOTH the light and dark themes.
*
* Most of the generated empty-state / header illustrations ship with a soft
* near-white background; rendered raw on the dark (aubergine) theme that background
* would float as a pale block. Clipping to a generous rounded tile with a hairline
* outline turns it into an intentional, modern illustration card on either surface.
* Two things the raw `painterResource` path got wrong, both fixed here once for every screen:
*
* Transparent art (e.g. the pairing-success celebration) should pass [tile] = false
* so it floats freely with no card edge.
* 1. **Theme-correct variant (C-DARKART-001).** `painterResource` resolves `-night` drawables off the
* Android *system* `uiMode`, but the app has its own in-app theme toggle (Settings Appearance).
* When the two disagree (app set to Dark on a light-mode phone) the art used to stay light on a dark
* screen. We load the drawable through a config-overridden context driven by [LocalAppInDarkTheme]
* so the `-night` variant follows the IN-APP theme.
*
* 2. **Soft edges (C-ART-EDGE-001).** The generated illustrations ship with a soft near-white tile
* background; rendered as a hard-clipped rounded rectangle with a border they read as a floating
* card with a crisp edge. Tiled art now feathers its four edges to transparent so it melts into the
* surface on either theme.
*
* Transparent art (e.g. the pairing-success celebration) should pass [tile] = false so it floats
* freely with no feathering.
*/
@Composable
fun BrandIllustration(
@ -30,20 +48,61 @@ fun BrandIllustration(
contentDescription: String?,
modifier: Modifier = Modifier,
tile: Boolean = true,
cornerRadius: Dp = 28.dp,
contentScale: ContentScale = ContentScale.Fit,
) {
val shape = RoundedCornerShape(cornerRadius)
val shaped = if (tile) {
modifier
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.10f), shape)
} else {
modifier
val context = LocalContext.current
val dark = LocalAppInDarkTheme.current
// Resolve the drawable through a config-overridden context so the -night variant follows the
// IN-APP theme (Settings → Appearance), not the system uiMode (C-DARKART-001).
val painter = remember(res, dark) {
val cfg = Configuration(context.resources.configuration).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
val themed = context.createConfigurationContext(cfg)
val bitmap = ContextCompat.getDrawable(themed, res)?.toImageBitmap()
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).asImageBitmap()
BitmapPainter(bitmap)
}
val shaped = if (tile) modifier.featherEdges() else modifier
Image(
painter = painterResource(res),
painter = painter,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
contentScale = contentScale,
modifier = shaped,
)
}
/**
* Fade the four edges of tiled art to transparent so the illustration melts into the surface instead
* of showing a hard rounded-rectangle tile edge / border (C-ART-EDGE-001). Rendered offscreen so the
* `DstIn` alpha gradients blend against the art, not the screen.
*/
private fun Modifier.featherEdges(): Modifier = this
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
val fx = size.width * 0.14f
val fy = size.height * 0.14f
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White), 0f, fx), blendMode = BlendMode.DstIn)
drawRect(
Brush.horizontalGradient(listOf(Color.White, Color.Transparent), size.width - fx, size.width),
blendMode = BlendMode.DstIn,
)
drawRect(Brush.verticalGradient(listOf(Color.Transparent, Color.White), 0f, fy), blendMode = BlendMode.DstIn)
drawRect(
Brush.verticalGradient(listOf(Color.White, Color.Transparent), size.height - fy, size.height),
blendMode = BlendMode.DstIn,
)
}
private fun Drawable.toImageBitmap() = run {
if (this is BitmapDrawable) bitmap?.let { return@run it.asImageBitmap() }
val w = intrinsicWidth.coerceAtLeast(1)
val h = intrinsicHeight.coerceAtLeast(1)
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = AndroidCanvas(bmp)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
bmp.asImageBitmap()
}

View File

@ -2,20 +2,16 @@ package app.closer.ui.components
import app.closer.ui.theme.closerCardColor
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -39,13 +35,11 @@ fun EmptyState(
verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md)
) {
illustrationResId?.let { resId ->
Image(
painter = painterResource(resId),
BrandIllustration(
res = resId,
contentDescription = null,
modifier = Modifier.size(illustrationSize),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(illustrationSize)
.clip(RoundedCornerShape(CloserRadii.Tile))
)
}
Text(

View File

@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -77,6 +78,11 @@ fun DateMatchScreen(
) {
val state by viewModel.uiState.collectAsState()
// A-201: a free couple expressing interest in a premium idea routes to the paywall (couple-shared).
LaunchedEffect(Unit) {
viewModel.paywallRequired.collect { onNavigate("paywall") }
}
DateMatchContent(
state = state,
onLove = viewModel::loveCurrent,

View File

@ -3,6 +3,7 @@ package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.CouplePremiumChecker
import app.closer.domain.model.DateIdea
import app.closer.domain.model.DateMatch
import app.closer.domain.model.DateSwipe
@ -14,8 +15,11 @@ import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
@ -32,7 +36,8 @@ data class DateMatchUiState(
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
val matches: List<DateMatch> = emptyList(),
val currentIndex: Int = 0,
val justMatched: DateMatch? = null
val justMatched: DateMatch? = null,
val hasPremium: Boolean = false
) {
val currentIdea: DateIdea? get() = dateIdeas.getOrNull(currentIndex)
val nextIdea: DateIdea? get() = dateIdeas.getOrNull(currentIndex + 1)
@ -46,14 +51,25 @@ class DateMatchViewModel @Inject constructor(
private val repository: DateMatchRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(DateMatchUiState())
val uiState: StateFlow<DateMatchUiState> = _uiState.asStateFlow()
// One-shot: emitted when a free couple tries to LOVE/MAYBE a premium date idea, so the screen
// routes to the paywall instead of recording the swipe (A-201 — couple-shared premium gate).
private val _paywallRequired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val paywallRequired: SharedFlow<Unit> = _paywallRequired.asSharedFlow()
init {
loadDateMatch()
viewModelScope.launch {
premiumChecker.isPremium().collect { premium ->
_uiState.update { it.copy(hasPremium = premium) }
}
}
}
private fun loadDateMatch() {
@ -121,6 +137,17 @@ class DateMatchViewModel @Inject constructor(
val userId = _uiState.value.currentUserId ?: return
viewModelScope.launch {
// Couple-shared premium gate (A-201): expressing positive interest (LOVE/MAYBE) in a
// premium idea requires premium for EITHER partner; SKIP always passes. Checked at decision
// time so a freshly-loaded deck can't race the entitlement flow. The deck does NOT advance
// when gated — the user lands on the paywall and the same premium card is still there on return.
if (action != SwipeAction.SKIP && current.isPremium) {
val premium = runCatching { premiumChecker.hasPremium() }.getOrDefault(false)
if (!premium) {
_paywallRequired.tryEmit(Unit)
return@launch
}
}
_uiState.update { it.copy(justMatched = null) }
val result = repository.recordSwipe(coupleId, userId, current.id, action)
result.fold(

View File

@ -6,8 +6,19 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* The app's OWN dark-theme state (from Settings Appearance), independent of the Android system
* `uiMode`. `CloserTheme(darkTheme)` only swaps the Compose color scheme, while `painterResource`
* and the `drawable-night*` variants resolve off the system `uiMode` so when the in-app theme and
* the system disagree, art mismatches the UI. Art helpers read this to load the correct `-night`
* variant per the app's own setting (C-DARKART-001).
*/
val LocalAppInDarkTheme = staticCompositionLocalOf { false }
@Composable
fun CloserTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
@ -15,11 +26,13 @@ fun CloserTheme(
) {
val colors = if (darkTheme) darkColors else lightColors
MaterialTheme(
colorScheme = colors,
typography = Typography,
content = content
)
CompositionLocalProvider(LocalAppInDarkTheme provides darkTheme) {
MaterialTheme(
colorScheme = colors,
typography = Typography,
content = content
)
}
}
// Brand palette. Keep accent color in containers and controls; large surfaces stay quiet.