Commit Graph

452 Commits

Author SHA1 Message Date
null 2dd09d5d0d feat(restore): spare the requesting device its own "was this you?" alert (Future.md security #2)
The restore self-alerts fan out to every device the recipient owns — including
the new device that is doing the requesting. "Was this you?" sent to the asker
is noise; the copies that matter go to their partner and to any OTHER device the
real owner still holds (the phished-password-without-device-loss case), and
those are untouched. For a legit single-device owner the self-alert becomes a
clean no-op, which was the point.

The requesting device identifies itself: RestoreManager fetches its own FCM
token at request time and the doc carries it as a create-only, optional
requesterFcmToken. Doc-embedded on purpose — MainActivity's token registration
races the restore flow on a fresh device, so cross-referencing fcmTokens
server-side can't reliably name the requester. Strictly best-effort on the
client (runCatching → null → field omitted, never written hollow): a restore
must never block or fail over a notification nicety, pinned by test.

sendPushToUser gains optional excludeTokens (filtered after merge/dedupe;
excluding everything is a clean zero no-op via the existing empty-list guard),
threaded through the shared queueAndPush — the notification_queue record is
still written, so the in-app alert history stays complete — and applied to the
two self-alerts only; the partner "help them restore" push is deliberately
unfiltered. Rules: requesterFcmToken joins the create allowlist as an optional
plain string (opaque device identifier, no format to pin); partner-update and
status-flip rules are unaffected since the field is create-only. Old clients
never send it; the server reads it only if present — no deploy-order coupling.

Tests: 3 new push.test.ts cases (exclusion, exclude-all no-op, absent-list
unchanged) — mutation check on the filter kills exactly those; 2 new
RestoreManagerTest cases (token embedded; FCM failure never blocks the request).
Functions 101/101, tsc clean; Android suite + assembleDebug green.

Deploy (scoped): firebase deploy --only firestore:rules, then
--only functions:onRestoreRequested,functions:onRestoreFulfilled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:39:20 -05:00
null b2a04b7187 chore(auth): delete dead hasProfile() — it was C-AUTH-001 waiting to be called
Final sweep after the auth hardening: audited every remaining getUser caller for
the exists-but-empty trap. All are display-only (partner names/photos — a stale
read shows a blank label for a frame, writes nothing, routes nothing). One
finding: hasProfile() has zero callers and contains both halves of C-AUTH-001
verbatim — runCatching{...}.getOrDefault(false) turns a failed read into "has no
profile", and the cache-default get() means the post-sign-in stub document reads
as "no profile" too. Its name invites exactly the onboarding-routing use that
just burned us, so the next person reaching for it would reintroduce the bug
through the front door. Deleted from all three layers; git history keeps it if a
safe variant is ever wanted (it would need Source.SERVER + stub-awareness + a
Result return, at which point it's resolveDestination).

Compile clean, full unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:53:04 -05:00
null 18e6cf7b30 fix(auth): GoogleProfileMerger must not be able to kill a successful sign-in
Self-review of the last four commits against the codebase's own error-handling
conventions. Two real defects found, both in GoogleProfileMerger, both mine:

1. Its writes could crash sign-in. The class doc said "best-effort by design",
   but createUser/updateDisplayName/updatePhotoUrl were unwrapped, and merge()
   runs inside the view model's onSuccess block in viewModelScope — a throw
   there is an unhandled coroutine exception, after authentication has already
   succeeded. The words claimed a contract the code didn't deliver. merge() now
   never throws: the seed runs under runCatching and failures are recorded.
   (The pre-rework copies had the same unwrapped createUser, so this is older
   than my refactor — but I rewrote the class and kept the hole, and the doc
   claiming best-effort made it worse than inherited.)

2. It was the only file in domain/ using android.util.Log. The house pattern
   for use cases is an injected CrashReporter (DailyQuestionResolver is the
   model): recordException for failures, log() for breadcrumbs. Swapped over;
   a swallowed failure now actually surfaces in Crashlytics instead of dying
   in logcat on a device we'll never see.

Also aligned: the two Kotlin assert() calls in RecoveryPhraseNormalizationTest
were the only ones in the suite and silently depend on the JVM -ea flag — now
assertNotEquals like everything else; RecoveryScreen's OutlinedButton is
imported rather than fully qualified (the file's lone qualified call was the
anomaly, not the rule).

GoogleProfileMergerTest pins the contract: a failed read writes nothing and is
recorded (C-AUTH-001's conflation), a failed write is recorded not thrown, a
stub is not seeded over, a complete profile is untouched, and only blank fields
are filled. Mutation-checked by removing the runCatching — and that check also
caught a bug in the test itself: the "complete profile" fixture had defaulted
photoUrl to "", so the merger legitimately tried to fill it and the strict
mock's throw was swallowed by the very runCatching under test — a test passing
for the wrong reason. Fixture fixed; the mutation now kills exactly the one
test that asserts the contract.

Hilt graph validated via assembleDebug; full unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:37:16 -05:00
null 0955276024 fix(auth): stop isSignInStub from looping every new signup into profile setup
Caught on-device before it shipped, by doing the thing I'd been putting off:
creating a real account and signing in again. My own isSignInStub — added two
commits ago to fix C-AUTH-001 — sent every brand-new email/password user straight
back into profile setup on their next sign-in, forever. Name filled in, profile
finished, still asked "What should your partner call you?" every launch.

It tested email.isBlank() && createdAt == 0L && coupleId == null, on the reasoning
that every path creating a real user writes email and createdAt first, so their
absence must mean nothing is written. Both halves are false:

  - an email/password signup never writes `email` at all — CreateProfileViewModel
    builds its User without one;
  - it only writes `createdAt` through createUser, which it calls only when it
    reads no document — a race it normally LOSES to FCM registration, so it takes
    the targeted-update branch (updateDisplayName/updateSex) and writes neither.

So a finished profile — displayName and sex set, nothing else — read as a stub
forever, and the retry loop I added then dutifully classified it as a new user.
The predicate now asks "is anything set at all?", which is what "nothing has been
written here yet" actually means. Any one field is enough to trust the document.

Two things I got wrong and am correcting rather than leaving:

  - the previous commit claimed isSignInStub was "now shared rather than private to
    OnboardingViewModel". It wasn't. The private copy stayed and shadowed the new
    shared one, so the domain-model version was dead code and the narrow local one
    was what actually ran — which is why the first attempt at this fix changed
    nothing on device. The private copy is gone; there is one definition now. The
    duplicated-logic trap I'd just written a commit message about, walked into.
  - the manual's C-AUTH-001 entry advised requiring email/createdAt before believing
    a read. That advice would reproduce this exact bug, so it now says the opposite,
    with the reason.

Verified live on the throwaway, both directions, because they fail in opposite ways
and fixing one blindly breaks the other: a real signup (account created, profile
completed, re-signed-in) now lands on Home; the paired fixture still lands on
Recovery. The disposable account was deleted through the app's own flow afterwards.

Tests: aFinishedProfileWithNoEmailOrCreatedAtIsNotAStub pins the regression;
anyOneIdentifyingFieldIsEnoughToTrustTheDocument pins the rule. Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:25:25 -05:00
null 9448537b6b fix(auth): make a profile write unable to unpair a couple (C-AUTH-001 hardening)
The previous commit fixed the path that reached the hazard. This removes the
hazard: createUser was a whole-document set() of every field, so any caller
holding a partially-loaded User wrote coupleId = null over a paired user and
dropped coupleId/partnerId. The User that does this is not exotic — for ~200ms
after every sign-in, users/{uid} exists with no fields on it at all. Getting a
read slightly wrong should cost a no-op, not a relationship's history.

createUser now merges, and UserProfileWrite omits every null or blank value, so a
field is only ever written with a real value and a hollow User produces an empty
map. For a document that doesn't exist yet — the only case callers actually intend
— the result is byte-identical to before. Clearing a field stays the job of the
targeted update* methods, where the call site says so. It also carries the same
locked-placeholder require()s the targeted writers already had.

plan is deliberately no longer written: User.plan defaults to "free", so merging it
would silently downgrade a paying user whose document we read while it was hollow.
The server owns that field (RevenueCat), reads already default a missing plan to
"free", and no gate reads it — entitlements live in the server-only subdoc. Checked
against firestore.rules: create requires no fields, and update's hasOnly allowlist
still covers everything written.

Correcting my last commit message while I'm here: it claimed CreateProfile submit
calls createUser and overwrites the name. Only half true — it uses targeted merges
when it sees an existing doc, and only calls createUser when it reads null. The
path that actually reached the sharp edge was Google sign-in: mergeGoogleProfile
decided "new account" from runCatching { getUser(uid) }.getOrNull(), which is null
for a failed read as much as for a missing document — C-AUTH-001's conflation, in a
place that then wrote a whole document. It also existed as two verbatim copies, in
LoginViewModel and SignUpViewModel, so the same bug was there twice. Now one
GoogleProfileMerger: it does nothing on a failed read, and won't seed a name over a
stub (which would overwrite a returning user's real name with their Google one).
Both view models drop userRepository entirely as a result.

User.isSignInStub is now shared rather than private to OnboardingViewModel, since
two callers need the same question answered.

UserProfileWriteTest pins the rule; mutation-checked by reverting the omit rule,
which fails 3 of them. Full unit suite green. Sign-in verified live on the throwaway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:58:28 -05:00
null 6330bf98ca fix(auth): don't mistake the sign-in stub document for a new user (C-AUTH-001)
Verified on a throwaway emulator with a real paired account: signing in on a new
device sent a fully set-up user to CREATE_PROFILE instead of RECOVERY. Not a rare
race — it happened on every run.

The previous commit guessed at the cause and was wrong: it assumed the profile
read *failed*, and retried on exceptions. Instrumenting the decision showed the
read succeeds and returns a document that exists with no fields at all:

  DIAG null=false nameBlank=true sexBlank=true couple=false -> create_profile
  DIAG null=false nameBlank=true sexBlank=true couple=false -> create_profile
  DIAG null=false nameBlank=false sexBlank=false sexLen=33 couple=true -> home

The first two land within a millisecond of sign-in; the real document arrives
215ms later. Sign-in registers the FCM token, and that merge-write materialises
users/{uid} before the profile has synced. Reading from the server does not dodge
it — Source.SERVER returns the same field-less document — so the read has to be
*trusted* before it is acted on, not just sourced differently.

resolveDestination now treats a field-less document as "ask again" rather than
"new user", retrying until one with real identity on it arrives; only that decides.
A read that never succeeds routes Home, which is non-destructive and routes to
Recovery on its own. New users are unaffected: every path that creates a user
writes email and createdAt before onboarding, and a document that genuinely stays
empty still falls through to profile setup.

This mattered because createUser is a whole-document set(), not a merge, and the
CreateProfile submit calls it: tapping through the screen that should never have
been shown overwrites the real displayName with the locked placeholder and drops
coupleId/partnerId, unpairing the couple.

Keeps getUserFromServer for the decision so an offline read throws rather than
answering from cache. Landmine written up as C-AUTH-001 — the general trap is that
exists() == true does not mean populated, and blank fields right after sign-in mean
"not synced yet".

Verified live end to end on the throwaway: sign-in now lands on Recovery, and a
Title-Cased phrase (previously rejected) unlocks to Home, "Connected with Ben".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:48:49 -05:00
null 7ab80013df fix(recovery): stop the two ways a returning user loses their history
Recovering on a new phone was walked end to end (a fixture was wiped, then
restored through the app's own flow). The Recovery screen itself is good — the
app detects the missing key and routes there by itself, and the copy is honest.
Everything around it had two holes, one of them destructive.

1. Sign-in could send a returning user into new-user profile setup (P2, data
   loss). OnboardingViewModel treated "couldn't read the profile" and "has no
   profile" as the same thing, so a slow read right after sign-in — a real race,
   hit live — routed them to CREATE_PROFILE, which asks "What should your
   partner call you?" over a `🔒 Couldn't unlock on this device` value. Anyone
   tapping through it re-encrypts and overwrites the name we had just failed to
   read, and never reaches Recovery. The repository already distinguishes the
   cases (missing doc = success(null), failed read throws), so only a successful
   read may now route to profile setup; a read that never succeeds retries and
   then goes Home, which is non-destructive and routes to Recovery on its own.

2. A correct phrase was reported as wrong. It is Argon2id key material, so it is
   byte-exact, and only .trim() was applied — while the field allowed the
   keyboard to capitalise the first word and autocorrect the wordlist. Every
   generated phrase is lowercase a-z single-spaced (all 248 WORDLIST entries
   checked), so folding typed input to that canonical form is lossless and
   cannot weaken the KDF: it only removes failures that were never about the
   phrase being wrong. "That phrase doesn't match" now means it actually
   doesn't. Also sets KeyboardCapitalization.None + autoCorrectEnabled = false.

3. The escape hatch was styled as a footnote. Most people arriving here are on a
   new phone and never saved the phrase, so "ask my partner" — not the field —
   is their real way through, yet it was a bare TextButton under the one control
   they can't use. It is now a full-width OutlinedButton with plainer copy
   ("I don't have the phrase — ask my partner"). It stays below the field rather
   than replacing it: someone who does have the phrase pasted from a message is
   unlocked instantly and offline, while this path waits on the partner.

Adds RecoveryPhraseNormalizationTest (7 cases): auto-capitalisation, shouting,
double/tab/newline spacing and chat-pasted text all fold to the canonical phrase,
while a genuinely wrong phrase still differs. Unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:25:20 -05:00
null 5151698d56 fix(avatar): harden the cropper's error handling; unify with the codebase
Checking error handling on the crop sheet surfaced three real problems, two of
which I had introduced.

1. Crash on a high-megapixel photo. The picked image was decoded at full size
   and handed to a hardware Canvas. An 8000x8000 photo decodes to 256MB, which
   Android allocates but the canvas refuses to draw ("trying to draw too large
   bitmap") — thrown inside Compose's draw phase, where the runCatching around
   the decode can't see it. Proven live: an 8000x8000 pick killed the app.
   Fixed by subsampling at decode (inSampleSize from a bounds-only pass) so the
   working bitmap is capped at ~2048px / ~16MB. Native heap on that pick went
   256MB(attempted) -> 46MB.

2. The subsample fix then broke decoding for EVERY image. decodeStream returns
   null by design in inJustDecodeBounds mode, and I had `?: return null` on it —
   so loadOriented bailed right after the bounds pass, for all inputs. It only
   looked like "the huge photo failed"; a normal photo would have failed too. I
   never re-tested a small image after the change. The new AvatarCropSheetTest
   caught it. The stream is now what's guarded; the real check is the header size.

3. Errors were swallowed and off-standard. runCatching{}.getOrNull() dropped the
   cause and a failure silently dismissed the sheet — indistinguishable from a
   save that did nothing. Now unified with the screen's own pattern
   (EditProfileViewModel.save): the sheet reports the Throwable up via a new
   onError, the VM records it through the injected CrashReporter and surfaces
   the message through uiState.error -> the existing snackbar. The crop also
   moved off the main thread (withContext(IO)); inline, `saving` flipped within
   one frame and never showed.

Adds AvatarCropSheetTest (androidTest, 4 tests, on-device BitmapFactory/Canvas):
subsample math for 8000+/oversized, oversized decode stays bounded, garbage
returns null instead of throwing, crop output is square and bounded. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:58:38 -05:00
null 7b8fa652f6 feat(avatar): frame a picked photo, and fall back to initials not a heart
Initials, shared. Home's partner bubble and the Settings "Connected with" row
are two views of one person and gave different answers when there was no photo —
Home showed initials, Settings showed a heart. Settings now shows the partner's
initials in a 48dp circle, so the photo and no-photo states share one
silhouette. The helper is lifted to ui/components/Initials.kt rather than copied;
an identical copy in two files is exactly how packArtworkRes drifted. The
unpaired state keeps the heart — there is nobody to take initials from yet.

Framing. Picking a photo now opens a crop sheet: pinch to zoom, drag to move,
inside the real avatar circle. The avatar was a blind ContentScale.Crop, so a
non-square or off-centre photo was centre-cropped and could lose the subject —
Ava's 640x480 test photo cropped straight past her face. The crop is applied at
pick time and handed back as a normal Uri, so setPhotoUri → upload is untouched.
EXIF orientation is honoured (a portrait selfie would otherwise crop sideways),
output is a 512px JPEG, and the source aspect is respected.

Two bugs found by driving it live, both of which looked fine in code:
- Panning did nothing at 1x. The clamp was viewport*(scale-1)/2, which is 0 at
  1x — correct only for a square source. A cover-fit 640x480 photo already
  overflows horizontally at 1x, so that pinned it dead centre and made framing
  impossible: the whole point of the sheet. Now clamped to the picture's actual
  overflow, computed from the source aspect.
- Panning then revealed empty space at the circle's edge. ContentScale.Crop had
  already discarded the overflow and returned a viewport-sized node, so
  graphicsLayer was sliding an already-cropped square around — the pixels being
  panned to had been thrown away first. The preview now draws the bitmap itself
  at cover*scale with the same maths as the crop, so preview == output.

Adds androidx.exifinterface. Verified live on 5554: sheet opens, pan moves the
picture (pixel-probed), no empty edge. 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:19:37 -05:00
null 76d8746a9b fix(settings): the paired "Connected with" card rendered muddy and unpolished
Two defects, both only in the paired state — the unpaired state was already
styled correctly, which is why this hid.

1. The card was see-through. `partnerCardColor` used primaryContainer at 52%
   alpha, and the page behind it is a gradient brush, so the gradient bled up
   through the card: it read muddy against the crisp profile card directly above
   (which is 96%), and the card's own content bounds showed as a paler,
   sharp-edged band inside it — measured, not guessed: a 24-unit jump
   (218,209,226 -> 242,232,251 -> 217,208,225) across the Row's 18dp content
   inset. Now 96% like its sibling; the same scanline is flat (240,229,253 ->
   241,231,255). Content colours are the container's own on* pair rather than
   the page's ink, so contrast is correct in both themes.

2. The heart was a bare, outsized glyph. ProfileAvatar's no-photo fallback is a
   40dp Icon with no container, so the paired row showed a naked heart unlike
   anything else on the page — while the *unpaired* branch right below it, and
   every settings row, use a 48dp tile + 24dp glyph. The avatar is now used only
   when a real partner photo exists; otherwise it falls back to that same tile.

Verified live both fixtures (dark 5554 / light 5556) with pixel probes before
and after. theme-scan REVIEW 25 -> 24 (one hardcoded-colour hit removed);
CRITICAL/MAJOR unchanged. Unit tests green, 0 FATAL.

Filed alongside this round's Pass C results (R32) in ClaudeReport/ClaudeQACoverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:29:05 -05:00
null 089437e415 fix(nav): give the question pack library a way back
The pack library was a navigation dead end: it is not a bottom-nav tab and it
draws no header of its own, and it was missing from shellBackRoutes — so the
shell rendered neither an app bar nor a bottom bar. Once there, the system Back
gesture was the only way off the screen.

It is reached by drilling in from four places (Home "All packs", the Play hub,
the question composer's empty state, and the weekly recap), so it needs the same
shell back affordance its own detail page (QUESTION_CATEGORY) already had.

Audited the whole route table for the same hole rather than patching just this
one. Every other non-tab route either sits in shellBackRoutes or draws its own
back; the two remaining are PAIR_PROMPT and RECOVERY, which are self-contained
entry flows with their own CTAs, so they are intentionally left alone.

Verified live on both fixtures: Home -> Question Packs -> a pack -> back ->
Question Packs -> back -> Home, in dark and light. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:31:13 -05:00
null 66d38251ea fix(packs): show pack name and description in full on library cards
The library cards clipped the two things a card exists to communicate: the name
at maxLines=1 ("Communicati…") and the description at maxLines=2 ("…about
pers…"). Both are now uncapped and cards size to their content, matching the
pack detail page.

Removing the caps alone was not enough. The question-count pill shared the title
row, so it reserved width for the row's full height and squeezed the text into a
narrow column — the name then broke mid-word ("Communicatio/n") and the
description wrapped to a ragged 8 lines. The pill moves down to join the access
pill, which gives the text the card's full width and matches how the detail page
already groups its pills.

Verified live on both fixtures (dark 5554 / light 5556): full names on one line,
full descriptions across the card, pills grouped below. Unit tests green,
0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:24:26 -05:00
null 8aea4730cc perf(res): convert all artwork to WebP; unify + blend pack art
Converts every PNG in res to WebP (124 files, 104M -> 28M on disk, -73%).
Each file was encoded both lossless and lossy q95 and the smaller kept, but
lossy only where it measured visually lossless (PSNR >= 40 dB); dimensions and
alpha were verified per file before the PNG was removed, so 25 files that
compressed better losslessly stayed exact. No nine-patches exist to break, and
R.drawable refs are extension-agnostic (the only ".png" in code is a runtime
share-cache filename). Debug APK 141.9 -> 128.8 MB; pack art in the APK
26.4 -> 11.5 MB. The disk saving is larger than the APK saving because AAPT2
already crunched PNGs at build time.

Unifies pack artwork in a new PackArtwork.kt. packArtworkRes was duplicated
verbatim in the library and detail screens — which is exactly how the two
drifted before (one kept a grouped mapping that gave several packs the same
illustration). It now exists once, alongside the two art composables. This is
also where an imported pack's art would resolve.

Pack detail page fixes:
- Removed the second back arrow. The nav scaffold already supplies the
  "Question Pack" bar and its back affordance (AppRoute.kt), so the in-screen
  IconButton was a duplicate; onBack is now unused and gone.
- The description is shown in full. It was capped at maxLines=4 with an
  ellipsis, which truncated most packs mid-sentence — this is the page where
  someone decides whether to open the pack, so it should not be abridged.
- The hero art is blended instead of pasted on: it runs full-bleed (the list no
  longer pads horizontally; items pad themselves) and dissolves into the page.
  The dissolve is an alpha mask (BlendMode.DstIn), not a scrim in the
  background colour — the page behind is a gradient brush, so fading to any
  single colour left a visible pale band in light theme. Fading the image to
  transparent lets the real background through, correct in both themes.
  Library cards get a softer version of the same, into the card surface.

Verified live on both fixtures (dark 5554 / light 5556): single back arrow,
full description, art melting into the page with no hard edges, correct
per-theme art. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:52:14 -05:00
null 388b927437 feat(packs): add distinct pack artwork 2026-07-14 20:18:56 -05:00
null 1ac724bcde fix(content): give packs a pack_id, and stop icon_name leaking into the UI
Populates question.pack_id (build_db.py). The column was plumbed end to end
(question.pack_id -> QuestionEntity.packId -> Question.packId) but was NULL for
all 3811 rows, so content had no pack identity. Bundled packs now carry one —
the logical id a pack declares in metadata (daily ships as category
daily_fun_mc but is logically daily_single_choice_weekly_v1), else its category
id. This is the hook a purchased/imported pack needs: it becomes just another
pack_id in the same table rather than a special case. No schema or behaviour
change; identity hash unchanged.

Fixes two regressions the rebuilt db exposed, both latent for the same reason:
every category previously carried the placeholder icon_name "question", which
masked them. Packs now declare real Material icon names (shield, forum, paid).

1. Category glyphs. categoryGlyphStyle keyed on `iconName ?: categoryId`, so
   icon_name won. Material names are a different vocabulary from this file's
   keys, so 22 of 23 categories fell through to the default star. The curated
   per-category glyph now wins, with icon_name as the fallback — which is also
   what gives an imported pack a real glyph, since its category_id will not be
   listed here but its declared icon_name resolves. Added the Material aliases
   and the two unmapped categories (quality_time, daily_fun_mc).
2. Pack library chips. metadataLabels() rendered the raw icon_name as a
   user-facing tag, producing chips like "Chat Bubble Outline". It was only ever
   invisible because the list filtered the single value "Question". icon_name is
   a rendering detail, not a topic, so the chip is gone; access remains.

Verified live on both fixtures (dark 5554 / light 5556): distinct correct glyphs
(Boundaries warning, Communication chat, Rebuilding Trust shield, Sex & Desire
heart-outline), no leaked chips, and the new Quality Time pack browsable with
150 questions and a calendar glyph. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:10:37 -05:00
null 8e6dabd4a4 feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.

Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.

Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.

Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
null 757d4efdda test(home): robust assertion in render smoke's unanswered-state case
The unanswered case asserted a specific CTA ('Answer privately') that the preview's
demo state doesn't surface in the test viewport. Assert the stable header instead —
still exercises the UNANSWERED render path (setContent), just without a brittle text
match. Verified 3/3 green on a throwaway emulator (fixtures untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:54:17 -05:00
null ed0aa4d3b3 fix(test): HomeContentRenderSmokeTest nested-comment (components/* opened a block comment)
'ui/home/components/*' in the KDoc contains a /* sequence, which Kotlin treats as
a NESTED block-comment opener (Kotlin nests block comments) -> unclosed comment.
Reworded. androidTest now compiles. (App was never affected.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:49:06 -05:00
null 1eacf36d81 fix(test): HomeContentRenderSmokeTest unclosed-comment (emoji in KDoc broke Kotlin lexer)
The prior commit's androidTest didn't compile — a stop-sign emoji in the KDoc
tripped the Kotlin lexer ('Unclosed comment'). Replaced with plain text. App was
never affected (androidTest isn't in the APK); this restores connectedAndroidTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:47:59 -05:00
null f7eb03417b test(home): HomeActionMapperTest (JVM) + HomeContentRenderSmokeTest (instrumented)
Part 3 of the Home refactor. Adds the durable coverage the plan called for:
- HomeActionMapperTest: 12 JVM cases over the lifted pure mapper (refresh states,
  withHomeActions pairing/daily/loading/error paths, toHomeLabel mc-drop, secondary
  cap of 3, C-HOME-001 primary/pending dedup) — locks the extraction as faithful.
- HomeContentRenderSmokeTest: instrumented render net for Home (via the VM-free
  PairedHomePreviewScreen), light + dark — catches 'composes fine, crashes on
  first paint'. Runs on a THROWAWAY only (uninstalls app-under-test).

JVM test green; androidTest compiles. Fills the 'Home has no UI test' gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:46:16 -05:00
null 9e81c15350 perf(home): @Immutable on HomeAction/PendingActionCard/HomeCategorySummary
Part 2b (Compose stack advantage). Marks the three verified-deeply-immutable
card models @Immutable so their single-instance params get structural-equality
skipping (upgrade over the K2 reference-equality strong-skipping default).
HomeAnswerStats/HomeUiState left unannotated (LocalAnswer/Question/Set transitive
types not fully audited — a false @Immutable promise would cause stale UI).
Rendered output identical; only recomposition frequency drops. compile green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:04 -05:00
null 7be9a1bf15 refactor(home): lift pure action mapper -> HomeActionMapper.kt (VM 867->562)
Part 2b. Moves the ~305 lines of pure HomeUiState action-derivation extensions
(refreshDailyQuestionState/withHomeActions internal; toHomeAction/
buildDailyQuestionAction/buildPendingActions/has*/toHomeLabel private) out of
HomeViewModel verbatim (de-indented, byte-identical bodies). Verified pure — no
this@HomeViewModel/repo refs. VM call-sites unchanged (same-package extensions).
compile + full unit suite green. HomeViewModel now 562 lines (from 1030).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:40:31 -05:00
null 227d141221 refactor(home): extract data models -> HomeModels.kt (VM 1030->867)
Part 2a. Moves the Home data classes/enums + computeDailyQuestionState (kept
@VisibleForTesting internal) + gameRouteFor (widened private->internal, its only
caller loadHome is same-package) out of HomeViewModel into HomeModels.kt.
Same package -> no consumer import churn (AppNavigation, DailyQuestionStateTest
unchanged). Behavior-identical. compile + ui.home unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:38:29 -05:00
null 415f8d6866 refactor(home): extract StreakMilestoneDialog + drop dead MomentCueCard
Step 9/9 of the UI split. StreakMilestoneDialog -> components/ (kept internal;
ArtPreviewScreen call updated to the new package). MomentCueCard was dead (no
callers) — removed. Orphaned imports cleaned. compileDebugKotlin green.
HomeScreen.kt now ~620 lines (from 1921).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:34:37 -05:00
null 94360a5438 refactor(home): extract header + partner sheet -> components/HomeHeaderSection.kt
Step 8/9. Verbatim move (HomeHeader + PartnerQuickActionsSheet public, rest private;
@OptIn(ExperimentalMaterial3Api) + .semantics a11y + full state param preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:43:22 -05:00
null c13dbc5bfd refactor(home): extract primary/activation cards -> components/HomePrimaryCards.kt
Step 7/9. Verbatim move (activation + primary hero public, benefit-pill private).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:40:08 -05:00
null d324efe182 refactor(home): extract status strip -> components/HomeStatusStrip.kt
Step 6/9. Verbatim move (strip public, chip private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:54 -05:00
null 48197ffd1e refactor(home): extract action feed -> components/HomeActionFeed.kt
Step 5/9. Verbatim move (section public, card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:31:42 -05:00
null 88460922c3 refactor(home): extract pending cards -> components/HomePendingCards.kt
Step 4/9. Verbatim move (section public, card-view private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:30:25 -05:00
null 07c810c9a1 refactor(home): extract category preview -> components/HomeCategoryPreview.kt
Step 3/9. Verbatim move (grid public, mini-card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:29:06 -05:00
null 18ad31506e refactor(home): extract loading/error/empty -> components/HomeStateCards.kt
Step 2/9. Verbatim move, public entries. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:27:52 -05:00
null 5f39194c00 refactor(home): extract shared card kit -> components/HomeActionStyle.kt
Step 1/9 of the HomeScreen decomposition. Moves the 6 shared style decls
(homeActionGlyph, HomeGlyphIcon, homePrimaryArt, HomeActionColors,
HomeActionTone.actionColors, HomePill) verbatim into ui/home/components/, made
public per the components/ visibility idiom. No behavior change. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:26:08 -05:00
null a63bf350b1 fix(crash): LocalDate.ofInstant crashes on API<34 + add Lint/R8 CI gates
Adding Android Lint to CI immediately caught two real crash bugs invisible to
all emulator QA (the fixture emulators run API 34+):

- SettingsViewModel + YourProgressViewModel called LocalDate.ofInstant, which
  was only added in API 34 — but minSdk is 26. On every device running Android
  8-13 (the bulk of the install base) these throw NoSuchMethodError and crash
  the Settings and Your Progress screens. Fixed with the API-26-safe equivalent
  Instant.atZone(zone).toLocalDate() (same result).

The other two Lint errors were false positives (ProduceStateDoesNotAssignValue
on two EncryptedChatImage composables that DO assign value inside the producer —
the check misfires on a suspend/?.let RHS) — explicitly @Suppress'd with a note,
so Lint reaches 0 errors legitimately rather than via a blanket baseline.

CI (android-ci.yml) gains two jobs:
- android-lint: ./gradlew :app:lintDebug (fails on error-severity; 113 existing
  warnings are non-fatal and left for a separate burndown).
- release-build: first-ever R8 gate — builds :app:bundleRelease with a throwaway
  keystore + dummy RC_API_KEY (satisfies the release guards; AAB not distributed),
  so the minify/shrink/sign toolchain can never silently rot. Verified locally:
  bundleRelease SUCCEEDS today (95MB AAB). A green build proves the toolchain,
  not runtime survival of reflectively-loaded classes (Tink) — that stays a
  release-APK QA item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:41:04 -05:00
null 4e807cb088 fix(settings): Manage-subscription deep link used namespace, not applicationId
ExternalLinks.MANAGE_SUBSCRIPTION pointed Play's subscription manager at
package=app.closer (the code namespace); the Play package is the applicationId
closer.app. In production the Manage button would miss the app's subscription
entry. Invisible to all QA so far because no real subscription has ever existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:22:03 -05:00
null 6668a3c4b7 refactor(brand): in-app prose -> 'Closer Couples' (launcher + wordmark stay 'Closer')
Extends the store/marketing rename to in-app user-facing PROSE, per owner decision.
Every string where 'Closer' names the app in a sentence now reads 'Closer Couples'
(Android + iOS): settings, pairing, invite share, widget headlines, paywall
subhead/thank-you, age gate, privacy body.

Deliberately KEPT as 'Closer' (judgment calls, documented in docs/NameChange.md):
- launcher label / iOS CFBundleDisplayName (14 chars truncates under the icon)
- wordmark logos (onboarding, auth alt-text, widget header, share-card drawText,
  iOS onboarding) + their asserting tests
- 'Grow Closer' onboarding pun (intimacy, not the app name)
- entity/length-capped lines: privacy rotator 'so Closer cannot read them'
  (hard 64-char cap), iOS 'not even Closer's servers'
- iOS 'Settings > Closer' help path (must match the kept display name)
- 'Closer Premium' tier label (matches the RevenueCat entitlement display name)
- all technical identifiers (app.closer / closer.app / closer_premium / etc.)

TodayWidgetTest exact-match assertions updated. compileDebugKotlin + unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:07:31 -05:00
null 2c3c2df8c4 fix(copy): reconcile divergent premium benefit lists + drop inaccurate claim
The paywall and subscription screens showed two different premium benefit lists (centralizing
into CloserCopy surfaced the drift). Unified to a single CloserCopy.premiumBenefits used by both.

Also an accuracy fix: dropped "Exportable memories" — it directly contradicts the app's own
privacy copy ("Closer does not currently offer a data export", strings.xml privacy_no_export_body),
i.e. a benefit the app explicitly does not provide. Remaining items are all verified real features
(QuestionComposer, Connection Challenges, Desire Sync, Memory Lane, date planning, answer history).

compileDebugKotlin clean; single source of truth for both surfaces going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:50:29 -05:00
null 23cd213953 refactor(copy): Slice 1 — dedup age-gate copy into CloserCopy.AgeGate
The 18+ min-age error was triplicated verbatim (SignUpViewModel, CreateProfileViewModel ×2);
consolidated to CloserCopy.AgeGate.ageError(minAge). Also lifted the two disclaimer variants
(kept distinct: brief on sign-up, with-reason on the DOB step — not unified) and the duplicated
"Please enter your date of birth." prompt. 6 call sites now source from the catalog; copy is
byte-identical (no behavior change), compile clean.

Adds docs/CopyMigration.md — the migration plan, scoped (after a gap review) to the ~15-25
brand-voice/duplicated lines, NOT all ~281 UI literals: the bulk stays inline until the single
i18n → strings.xml pass, since double-migrating to a Kotlin catalog first is wasted work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:39:04 -05:00
null 0195564d25 docs(readme): refresh ready-state home screenshots 2026-07-09 00:30:27 -05:00
null 51efff3f78 refactor(copy): start CloserCopy catalog — migrate paywall + subscription voice copy
Establishes a typed Kotlin copy catalog (ui/brand/CloserCopy.kt), sibling to the existing
CloserBrandCopy (privacy rotator). Rationale over strings.xml: the app is English-only and
pre-launch, so a typed catalog gives one-place brand-voice review + compile-time safety
without Compose stringResource() friction or orphaned-string drift. strings.xml's real value
is the localization pipeline, which we migrate to in one pass when i18n is on the roadmap
(gate recorded in Future.md).

First slice (highest-value, monetization surface):
- PaywallScreen + SubscriptionScreen voice copy (benefit lists, headlines, value props,
  "Thank you for supporting Closer", couple-shared taglines) now source from CloserCopy.
- Generic chrome ("Continue", "Restore", "Manage subscription", error-retry) intentionally
  stays inline — it isn't brand voice.
- Surfaced real copy drift: the paywall and subscription benefit lists differ (swap two
  items + reorder) — co-located and flagged in CloserCopy.kt for a copy decision, left
  verbatim (not silently unified).

compileDebugKotlin clean; no behavior change (copy identical, just relocated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:26:15 -05:00
null 59e033692b chore(cleanup): purge 55 unused string resources
strings.xml had 127 entries but the app renders copy from hardcoded Compose literals
(only ~4 files use stringResource) — so ~half of strings.xml was a resource catalog that
was pre-created for the pairing/home/partner-home/settings-nav screens and never wired up.

Removed the 55 entries with zero R.string./@string references anywhere (kt/xml/manifest),
including whole dead sections (Settings nav labels, all Pairing subsections, Home screen,
Partner home). Kept everything actually referenced: app_name, today_widget_description,
common actions in use, and the Appearance/Notifications/Account/Privacy sections.
Resources compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:20:32 -05:00
null 21392ec2f3 chore(cleanup): remove verified dead files (Tier 1 dead-file sweep)
Removes files confirmed unreferenced by symbol-level git-grep and proven safe by a
clean :app:compileDebugKotlin + compileDebugUnitTestKotlin (no main/test references).

Dead code (every top-level type had 0 external references):
- core/notifications/NotificationHelper.kt — dead duplicate of the live
  NotificationChannelSetup (which is what CloserApp/AppMessagingService actually call)
- core/notifications/NotificationPermissionHelper.kt — unused permission helper
- notifications/PartnerNotificationScheduler.kt — superseded by PartnerNotificationManager
- data/questions/QuestionJsonParser.kt — superseded by the Room/asset-DB path
- data/repository/FakeQuestionRepository.kt — orphaned fake, no test/DI consumer
- ui/questions/QuestionDetailViewModel.kt — superseded VM, no composable binds it
- domain/model/{Entitlement,InviteStatus,QuestionSessionStatus}.kt — unused models/enums
  (entitlement state is read as Firestore booleans, not this model)

Stray artifacts:
- gitleaks-current.json / gitleaks-history.json — sanitized scan output (history = []),
  referenced by no CI/config; regenerable
- 19 stale .gitkeep placeholders in directories that now hold real files

Deliberately KEPT (flagged but not dead): WheelHistoryScreen/ViewModel.kt (named
"WheelHistory" but declare the live, nav-wired GameHistoryScreen/GameHistoryViewModel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:10:40 -05:00
null b47302d6b7 docs(readme): refresh daily question screenshots 2026-07-08 14:53:12 -05:00
null 53a821a80d fix(home): use people art for daily question card 2026-07-08 14:39:33 -05:00
null 6bd28d34e4 fix(home): use daily question art before answers 2026-07-08 14:29:12 -05:00
null b99a83388e fix(billing): link RevenueCat identity to Firebase auth + purchase UX
Critical fix: Purchases was never told the Firebase uid, so RevenueCat assigned its
own anonymous app_user_id. The revenueCatWebhook Cloud Function writes premium status
to users/{app_user_id}/entitlements/premium using that id — meaning a real purchase
would silently never unlock premium for the signed-in account, and CouplePremiumChecker's
partner-side read (same path, different uid) was broken by the same root cause.

RevenueCatBillingRepository now collects AuthRepository.authState and calls
Purchases.awaitLogIn(uid) on sign-in / awaitLogOut() on sign-out, guarded against
redundant calls and wrapped best-effort so a failed sync retries on the next auth event
instead of crashing the singleton.

Also:
- Bump com.revenuecat.purchases 8.20.0 -> 10.12.0 (verified: real published version,
  stable API surface across 8->10 per RevenueCat's own migration notes for the calls
  this app uses; confirmed resolved + full Hilt/KSP graph compiles clean).
- Purchase cancellation (user backs out of the Play billing sheet) is now distinguished
  from a real failure via PurchasesTransactionException.userCancelled, using a shared
  PURCHASE_CANCELLED_SENTINEL (same marker-constant idiom PaywallViewModel already uses
  for offering-load failures) so PaywallViewModel resets silently instead of surfacing
  the SDK's internal error text.
- PaywallScreen: genuine purchase errors (billing unavailable, network, etc.) now show
  a snackbar. Previously there was zero user-facing feedback on a real purchase failure
  beyond the loading spinner disappearing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 14:19:58 -05:00
null 694294c2e5 fix(ui): remove duplicate answer back button 2026-07-07 23:09:14 -05:00
null ca67187754 fix(ui): strip internal 'mc' token from the reveal-screen category chip too
The daily reveal chip used displayCategoryName() (a different surface than the
Home pill fixed earlier), so it still showed 'Daily Fun Mc'. Fixed centrally in
the shared helper. Verified live: reveal now shows 'Daily Fun'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:04:54 -05:00
null fda53175a9 feat(games): nudge + per-game copy + score a11y batch C4 from UX review
- Waiting-on-partner screen: 'Send a little nudge 💜' button (shown while the
  partner is still playing, not once it's your turn) reusing the generic
  thinking-of-you callable (10/day, quiet-hours-safe server-side); one-shot
  Toast on result incl. friendly rate-limit copy. Verified live: nudge →
  partner_activity push landed on the partner.
- Per-game banner copy: YOUR_TURN/RESULTS in-app banner now branches on gameType
  ('Your turn — guess their answers' for How Well, 'only mutual yeses ever show'
  for Desire Sync, etc.) instead of one generic line; mirrored in the Cloud
  Function's partner_completed_part push (yourTurnBody). Verified live.
- Accessibility: merged contentDescription on the This or That MatchScoreBadge
  ('You matched on N of M') and the How Well score ring Canvas ('You guessed N
  of M correctly') — both were split/Canvas visuals invisible to TalkBack.

Unit + functions typecheck green; assembleDebug clean. Server copy change needs
a functions deploy (bundled with the C1 finish-guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:50:39 -05:00
null cf8f82357e feat(games): game-loop retention batch C3 from UX review
- NextBeatCard: a self-contained 'what's next' card under every game's results
  CTAs so finishing a game doesn't dead-end — shows 'Tonight's question is ready
  →' when today's daily is unanswered, else a 'Day N together' streak note. Own
  @HiltViewModel (DailyQuestionResolver + LocalAnswerRepository + couple), so the
  four game screens only drop in the composable. Verified live on How Well results.
- How Well role swap: the guesser's results now lead with 'Your turn — let {name}
  guess you', starting a new round where they become the subject (Newlywed-style
  reversal; starter == subject). Verified live.
- Date Match: deterministic per-couple deck shuffle (kills 'every couple sees the
  same first card') + skip already-swiped ideas so the deck resumes past them
  instead of restarting at card 1. Verified live (swiped card no longer reappears).
- Desire Sync: remember served question ids per couple (SeenDesireQuestionStore on
  the settings DataStore) so back-to-back rounds don't repeat prompts; clears the
  record to cycle the pool when the unseen remainder can't fill a round.
- How Well pool purity: exclude daily_fun_mc novelty MCs from getQuestionsForPrediction
  (1869 real prediction questions remain); DAO method has no other caller.

Unit suite green; assembleDebug clean; live-verified on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:42:39 -05:00
null 9d0a0c7fdf fix(onboarding,challenges): funnel + gating batch C2 from P3/UX review
- DOB picker (sign-up + profile): swap the View-based android.app.DatePickerDialog
  (platform teal) for a shared Compose M3 DobPickerDialog that renders in the app
  palette in both themes; future dates unselectable. Verified live dark = purple.
- SignupHandoff: back the pending DOB with the app's Preferences DataStore keyed
  per-uid, instead of an in-memory singleton that died on process death and made
  CreateProfile re-ask for the birth date after any restart mid-onboarding
  (verified: am kill mid-profile → relaunch → no DOB re-ask). Local-only write,
  so the auth-token/Firestore-rules race the old comment guarded against doesn't
  apply; per-uid key prevents cross-account leakage.
- Connection Challenges: expose statusDay (calendar-actionable day) on
  ChallengeState; the day card shows a 'Day N unlocks tomorrow 🌙' teaser instead
  of spoiling the next prompt the moment today's step is marked done. +2
  ChallengeStateMachine tests.

Unit + functions suites green; verified live on the emulator pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:24:27 -05:00