U7 — Auth/Profile/Settings + DM: Adversarial UI/UX Audit
Status: Archived Date: 2026-07-11 Scope: onboarding/login (packages/app/src/presentation/screens/auth/login-screen.tsx, packages/features/onboarding/src/**, packages/app/src/adapters/supabase/auth.supabase.ts, session-provider.tsx), profile + settings + consents + account lifecycle (packages/features/profile/src/**), DM (packages/features/messaging/src/**, packages/app/src/presentation/hooks/{queries/use-dm.ts,mutations/use-dm-mutations.ts,mutations/use-safety-mutations.ts}), push-permission soft prompt (packages/features/sessions/src/push-permission-soft-prompt-sheet.tsx). Method: 3 parallel evidence sweeps (auth/onboarding, profile/settings/consents/lifecycle, DM) — direct code reads + repo-wide greps + migration cross-reference (00107, 00220, 00226, 00228, 00294, 00184/00186) + canon diff (docs/canon/networking.md, docs/canon/notifications.md, docs/canon/korean-market.md) + docs cross-check (docs/external-blockers.md, docs/current-project-state.md). No code modified.
Executive-severity ranking
| # | Finding | Severity |
|---|---|---|
| 1 | OAuth is unreachable from the UI. LoginScreen is email/password-only with a "Kakao/Google 로그인은 곧 추가돼요" disclaimer; Kakao is code-complete but has no button; Google/Apple adapters are stubs that unconditionally throw | CRITICAL (launch gate) |
| 2 | signal_preferences (per-category in-app/push/minSeverity/quietHours) has ZERO settings UI. Route '/settings/signal-preferences' is defined but dead (no screen file, no push call); quiet-hours i18n keys scaffolded but unused; adapter updatePreference() has zero feature consumers. Alert-plan Phase D deferral confirmed accurate | CRITICAL (feature gap, deferred by design — now documented precisely) |
| 3 | The generic "푸시 알림" toggle silently grants/revokes the marketing_push PIPA consent purpose — a legal consent action hidden behind an unlabeled general toggle, inconsistent with the explicit consent-management screen writing the same purpose key | HIGH (compliance ambiguity) |
| 4 | OAuth cancel is silently swallowed and OAuth failure renders no UI — cancel resolves null → bare return; failure sets ctx.error which no screen reads (email path re-throws for surfacing; OAuth path never got the same treatment) | HIGH (latent — regression trap for Phase 8c) |
| 5 | 6 of 9 user_preferences notify booleans have no toggle anywhere (notifySessionCreated, notifyRsvpUpdates, notifyDuesReminder, notifyMatchResults, notifyBoardPosts, notifyAchievements — entity + i18n labels exist, zero Switch rows) | HIGH (settings gap) |
| 6 | DM inbox 15s-poll doc/code drift — canon + screen header + a hook docstring all claim a 15s inbox refetchInterval that does not exist in useDmThreads/useDmThreadsByStatus | MEDIUM (canon drift) |
| 7 | Login screen has raw inline-styled TextInputs + SafeAreaView — full manual style objects on the single most-trafficked screen, no justification comments | MEDIUM (canon) |
| 8 | Empty DM inbox has no actionable path to a 1:1 conversation — empty-state copy says "message a club member" but passes no onAction CTA, and the header "+" only creates group chats | MEDIUM (UX) |
| 9 | Deleted/unresolvable DM partner shows header "메시지" (generic inbox title) instead of the existing unknownUser ("알 수 없음") string used elsewhere in the same file; no "deactivated account" indicator during the 30-day soft-delete grace window | MEDIUM (edge case) |
| 10 | Change-password "current password" field is cosmetic — collected and required for the submit gate but, per the screen's own header comment, "not validated against current" — false-assurance pattern | MEDIUM (UX/security perception) |
| 11 | Expired session lands the user on the login screen with no "session expired" messaging — the screen has no state reflecting why it's showing | LOW-MEDIUM |
| 12 | No profile-completeness nudge — missing avatar/nickname get passive placeholders only, no "add a photo" CTA anywhere | LOW (UX) |
| 13 | Phone number: entity + UpdateProfileInput.phone + korean-phone.ts (libphonenumber-js wrapper) all exist, but there is zero phone-capture UI in onboarding/profile/settings — the KR-1 canon has nothing to apply to in this block | LOW (informational) |
| 14 | Verified-identity signup (KR 본인인증) is not implemented anywhere — nationality_code is a hardcoded 'KR' default with a forward-looking migration comment; production Kakao IdP itself is blocked on EXT-11 credentials | LOW (roadmap reality check) |
| 15 | AuthServicePort docstring claims "Universal OAuth sign-in via browser redirect" but the adapter uses per-provider native SDKs + signInWithIdToken (OIDC token exchange), not the PKCE browser-redirect flow — stale/aspirational port doc | LOW (doc drift) |
| 16 | DM: no typing indicators, no message editing (delete-only, 60s window, DB+client enforced) — absent features, not bugs; noting for expectation-setting | LOW (informational) |
What works well (evidence):
- Push-permission soft-prompt canon claim VERIFIED accurate. All three trigger sites are RSVP-mutation
onSuccesscallbacks insession-detail-screen.tsx:677-681,740-746,805-809, gated onnotifPermStatus === 'undetermined'; the OS prompt (Notifications.requestPermissionsAsync,notification.expo.ts:250) only fires from the sheet's accept CTA. Repo-wide grep confirms no earlier firing point (not onboarding, not_layout.tsx). And despite its filename,push-permission-soft-prompt-sheet.tsx:56-58usesModalPanel, notSheet. - User blocking is fully implemented, not a gap:
user_blockstable + RLS (00294_dm_block_and_report.sql:26-43),block_user/unblock_user/get_blocked_users/report_contentRPCs (00294:91-162), bidirectionalinitiate_dm_threadguard (00294:186-189),trg_dm_block_guardBEFORE INSERT send guard (00294:227-255),useBlockUser/useUnblockUser(use-safety-mutations.ts:22-44), thread-header block/report menu (dm-thread-screen.tsx:1404-1457), inbox filtering (dm-inbox-screen.tsx:243-255,323-335), group-thread message hiding (dm-thread-screen.tsx:1004-1008), plus a dedicatedsettings-blocked-users-screen.tsxwith unblock. dm_push_preview_enabledis fully wired end-to-end (NOT dead): column (00226_dm_message_push.sql:53-59, default false) → mapper (preferences.mapper.ts:28-31,54-55) → Switch row (settings-screen.tsx:533-543) → mutation (settings-screen.tsx:313-321) → edge-function gate (send-push/index.ts:198-226: preview truncated to 50 chars when true, generic '메시지를 보냈어요' when false — PII-safe default).- No Supabase Realtime channels anywhere in DM (grep for
.channel(/postgres_changes= zero hits); thread-screen 5s poll correctly focus-gated (use-dm.ts:142-143:refetchInterval: isFocused ? 5_000 : false). Registry discipline clean (use-dm.ts:4,use-dm-mutations.ts:15import from@/registry; zero direct supabase imports inpackages/features/messaging). - Consent architecture is genuinely strong: onboarding consent step renders per-jurisdiction purposes with required/optional prefixes + policy links (
onboarding-step-consent.tsx:44-61,139);settings-consent-management-screen.tsxgives PIPA §37 revocation (optional+granted only, confirm dialog, required-consent → delete-account pointer);consent-history-screen.tsxis a PIPA §39-7 audit trail. Logout usesuseConfirm(ModalPanel-basedconfirm-sheet.tsx) at both entry points (profile-screen.tsx:127-140,settings-screen.tsx:323-336). Account deletion exists with real friction: warning card → typed "탈퇴합니다" phrase gate → double-confirm → soft-delete + 30-day anonymization (delete-account-screen.tsx:1-12,52-79,89-109,153-158). - i18n coverage is clean across the whole block — zero hardcoded UI copy in onboarding, login, profile, settings, or DM JSX (only comments and the intentionally-hardcoded OWNER-owned legal pages
terms-screen.tsx/privacy-screen.tsx).Pressableused throughout DM (zeroTouchableOpacity). DM message deletion is defense-in-depth (client 60s checkdm-thread-screen.tsx:1347-1349+ RLS00220_dm_threads.sql:166-170). - Onboarding wizard styling is clean (only documented STYLE-1 SafeAreaView/pager escape hatches); all copy through
t().onboarding.*.
1. CRITICAL — OAuth is unreachable from the UI; Google/Apple are throw-stubs
Evidence:
packages/app/src/presentation/screens/auth/login-screen.tsxis the ONLY login screen (apps/mobile/app/_layout.tsx:178renders it forauthState === 'unauthenticated'). It is email/password-only; no Kakao/Google/Apple buttons exist in its JSX. Header comment (login-screen.tsx:4-8): "Phase 8b MVP … Future (Phase 8c): OAuth providers." The screen shows the disclaimeroauthComingSoon='Kakao/Google 로그인은 곧 추가돼요'(login-screen.tsx:155-159,i18n/ko/common.ts:97).- Adapter status (
packages/app/src/adapters/supabase/auth.supabase.ts): Kakao (50-77) implemented via native SDK →signInWithIdTokenbut has no UI entry point AND production credentials are blocked (EXT-11,docs/external-blockers.md:33). Google (87-100):throw new DomainError('Google 로그인은 아직 준비 중이에요', 'AUTH_GOOGLE_NOT_IMPLEMENTED'). Apple (110-132): throwsAUTH_APPLE_NOT_IMPLEMENTED(orAUTH_APPLE_IOS_ONLYfirst). - Dev email login is env-gated (
login-screen.tsx:37-39,118-139,EXPO_PUBLIC_DEV_EMAIL/_PASSWORD).
Impact: the live sign-in surface is exactly email/password + dev login. This directly contradicts the "Unified OAuth via Supabase" memory framing for the live product, and Apple's App Store guideline 4.8 (Sign in with Apple required when offering third-party login) becomes a hard gate the moment Kakao ships without Apple. Launch-blocking work, correctly staged as Phase 8c — but the audit must record that today's UI has no social login at all.
2. CRITICAL — signal_preferences per-category notification preferences have ZERO UI
Evidence:
- DB:
supabase/migrations/00107_signals_system.sql:213-311—public.signal_preferences(user_id, category ×13,in_app_enabled,push_enabled,min_severity,quiet_hours,allow_critical_bypass), per-user defaults seeded viasignal_preferences_seed_defaults()trigger (line 272, default INSERT line 311). - Adapter methods exist:
signal.supabase.ts:167-186(findPreferences,updatePreference). - Exhaustive grep (
useSignalPreferences|SignalPreferences|signalPreferencesacrosspackages apps): the ONLY feature consumer isnotification-center-screen.tsx:350,366(block U1) — read-only, used to filter the feed. NoupdatePreference()call exists in anypackages/features/**file. - Dead scaffolding:
routes.ts:111-112definessignalPreferences: '/settings/signal-preferences'but no screen file exists inapps/mobile/app/settingsorapps/web/app/settings, and noappRouter.push(routes.signalPreferences)exists anywhere. Quiet-hours i18n keys (ko/settings.ts:271-274,en/settings.ts:270-273) are defined and unused.
Verdict: the audit-plan line 22 deferral ("Phase D … deferred by design") is accurate — but the deferral's precise shape is: schema + resolver + read-path shipped; the write-path UI (screen, mutation wiring, route mount) is 100% absent while a route constant and i18n strings imply it was started. When Phase D lands it should claim the existing route + i18n scaffolding rather than minting new ones.
3. HIGH — Generic "푸시 알림" toggle silently writes marketing_push consent
Evidence: settings-screen.tsx:273-301 (handleTogglePush, bound to the row labeled rowPushNotifications: '푸시 알림', ko/settings.ts:766):
logConsent.mutate({ userId, category: 'system', channel: 'push', granted: next });
if (next) {
grantConsent.mutate({
purposeKey: 'marketing_push',
jurisdiction: 'KR',
channel: 'push',
collectionMethod: 'settings_toggle',
userId,
});
} else {
revokeConsent.mutate({ purposeKey: 'marketing_push', jurisdiction: 'KR', userId });
}The row's label says nothing about marketing/광고, yet toggling it ON grants the PIPA marketing_push purpose (and OFF revokes it). Meanwhile settings-consent-management-screen.tsx (lines 108-126, 147-167) manages the same purpose key with explicit "마케팅 푸시 알림" labeling and a confirmed 철회 flow.
Impact: (a) a user enabling general service pushes unknowingly opts into marketing — a consent captured without informed specificity is a PIPA §22 problem, not just a UX one; (b) two surfaces write the same purpose with inconsistent framing, so the consent-history audit trail records grants the user never knowingly made. The general toggle should control service push only (§50-exempt per canon) and never touch the marketing_push purpose; marketing consent belongs exclusively to the explicitly-labeled surfaces.
4. HIGH (latent) — OAuth cancel/failure produce zero user feedback
Evidence:
- Cancel:
auth.supabase.ts:150-173catchescode === 'CANCELLED' || 'AuthFailure'→ returnsnull;session-provider.tsx:390-394then bare-returns — no state change, no toast. - Failure: any other error is re-thrown, caught at
session-provider.tsx:402-409, which setsctx.error+ logs to Sentry — but no consumer in the auth scope readserrorfromuseAuth()(use-auth.ts:20,35).login-screen.tsxonly tracks its own localerrorMessagefrom the email path. - The asymmetry is deliberate-looking but one-sided:
signInWithEmail(session-provider.tsx:430-439) explicitly re-throws "so LoginScreen's catch can surface the error to the user" — the OAuth path was never given the same treatment.
Impact: currently dead code (no OAuth buttons — finding #1), which is exactly why it's dangerous: the moment Phase 8c wires a Kakao button, cancel = the screen sits inert, and real failures (no idToken because OIDC unchecked in Kakao Console — auth.supabase.ts:56-61 — network errors, SDK errors) are Sentry-only. Fix must ship WITH the button, not after.
5. HIGH — 6 of 9 notify booleans on user_preferences have no toggle
Evidence: preferences.entity.ts:44-54 defines notifySessionCreated, notifyRsvpUpdates, notifyDuesReminder, notifyMatchResults, notifyBoardPosts, notifyAchievements with i18n labels (ko/settings.ts:49-62 + en). settings-screen.tsx wires exactly 3 Switch rows: notificationsEnabled (509-519), notifySessionReminders (521-531), dmPushPreviewEnabled (533-543). The other 6 exist in DB/schema/i18n with zero UI.
Impact: independent of (and older than) the signal_preferences gap — two parallel half-finished preference systems (user_preferences booleans vs signal_preferences categories) now both lack UI, and any Phase D design must decide which one is canonical rather than adding a third. A user who wants dues reminders but not board-post pushes has no lever anywhere.
6. MEDIUM — DM inbox "15s poll" exists in three comments but not in code
Evidence:
- Claim ×3:
docs/canon/networking.md:25("thread screen 5s, inbox 15s"),dm-inbox-screen.tsx:8("useDmThreads (15 s native refetchInterval)"),use-dm.ts:152("covered by useDmThreads' 15 s poll"). - Reality:
useDmThreads(use-dm.ts:26-33) anduseDmThreadsByStatus(use-dm.ts:43-64) have norefetchInterval— and their own docstrings say so deliberately (use-dm.ts:23-24: "intentionally does not poll";40-41: infinite-page refetch trap). Inbox freshness = push invalidation + focus/mount refetch. - Thread screen is correct:
use-dm.ts:142-143refetchInterval: isFocused ? 5_000 : false.
Impact: the code's no-poll rationale is sound (refetching every loaded infinite page per tick is a real perf trap) — so this is a documentation fix: canon doc + screen comment + the useDmUnreadCount docstring must be rewritten to describe the actual freshness model, or an unread-count-only lightweight poll added if inbox staleness proves real on device. Never "fix" this by adding the interval to the infinite-page hooks as documented.
7. MEDIUM — Login screen raw inline-styled inputs (Tamagui canon)
Evidence: login-screen.tsx:65 (<SafeAreaView style={{ flex: 1, backgroundColor: bg }}> — variable in style), and login-screen.tsx:82-91,99-108 — two raw TextInputs with full manual style objects (backgroundColor/borderColor/borderWidth/borderRadius/padding/fontSize/color), no justification comments. Contrast: delete-account-screen.tsx:14,168-178 and change-password-screen.tsx:13,240-242 use raw RNTextInput + StyleSheet.create with inline justification ("Tamagui's compiler cannot extract styles applied directly to react-native TextInput") — the sanctioned pattern the login screen predates.
Impact: highest-traffic screen sits outside the theming system; also the three inputs across these screens should share one blessed RNTextInput wrapper rather than three hand-rolled style blocks (single-source-of-truth).
8. MEDIUM — Empty DM inbox: copy promises an action the UI doesn't offer
Evidence: dm-inbox-screen.tsx:290 — <EmptyState icon={MessageSquare} title={strings.noMessages} variant="compact" /> where noMessages = '아직 대화가 없어요. 클럽원이나 픽업 호스트에게 먼저 메시지를 보내보세요.' EmptyState supports actionLabel/onAction (packages/ui/src/empty-state.tsx:21-23,147-160) — not passed. The header "+" (dm-inbox-screen.tsx:417-419,427-432) routes to routes.dmCreateGroup() only; 1:1 threads are created exclusively from other surfaces (profile pages via useInitiateDmThread).
Impact: first-run DM user is told to message someone with no path to do it from where they're standing. Toss filter: the empty state should carry the task (e.g. CTA into club members list or a recipient picker).
9. MEDIUM — Deleted-partner thread header falls back to the wrong string
Evidence: dm-thread-screen.tsx:1151-1153:
const headerTitle = isGroup ? (thread?.title ?? strings.newGroup) : otherName || strings.inboxTitle;When otherParticipant is unresolvable (mapper null-safety: dm.mapper.ts:122-133 yields undefined), otherName is '' → header shows inboxTitle ('메시지'). The correct string unknownUser ('알 수 없음') exists in ko/dm.ts and is already used for message-sender fallback in the same file (dm-thread-screen.tsx:951). Additionally, during the 30-day soft-delete grace window (00186:67-72 sets deleted_at; anonymization to '탈퇴한 회원' runs later, 00184:78-93) the partner appears fully normal with no deactivated indicator.
10. MEDIUM — Change-password current-password field is decorative
Evidence: change-password-screen.tsx:5 (header comment): "현재 비밀번호 (shown for user reassurance; not validated against current)". The field gates isValid (line 96) and is passed to changePassword.mutate({ currentPassword, newPassword }) but nothing verifies it (Supabase updateUser doesn't take the old password). A wrong current password still succeeds — false-assurance UX; either verify via signInWithPassword re-auth before update, or drop the field and say "logged-in session required."
11-16. Lower-severity items (compact)
- #11 Expired session, no messaging: proactive refresh (60s interval, 5-min threshold, 3-failure force-signout,
session-provider.tsx:297-345) + reactive guard (351-378) both land on<LoginScreen />viaauthStatewith zero "세션이 만료되었어요" context. - #12 No completeness nudge: repo-wide grep for profile-completion prompts = zero. Missing nickname →
'이름 없음'fallback (profile-hero-card.tsx:163-164,ko/settings.ts:693; near-unreachable since display name is a hard onboarding gate, 2–20 chars,onboarding-step2-display-name.tsx:4-5,18-21). Missing avatar → initial-letterAvatarBubble(avatar-placeholder.tsx:8-10), no CTA. - #13 Phone:
Profile.phone+UpdateProfileInput.phoneexist;korean-phone.ts+no-direct-libphonenumberESLint rule enforce KR-1 at tooling level; zero capture UI in onboarding (no phone step inpackages/features/onboarding/src/) or profile/settings. Daum postcode: N/A — no address field exists in this block (sole "Daum" hit is legal text,privacy-screen.tsx:9,136). - #14 Verified identity:
00228_profiles_nationality.sql:18-22—nationality_code NOT NULL DEFAULT 'KR', comment defers population to future verified-IdP onboarding; no picker by design (UpdateProfileInputhas nonationalityCode). Club affiliation as a profile attribute exists nowhere (UI or DB) — matches "flat nationality now, groups held" (docs/architecture/foundational-schema-decisions.md:66). - #15 Port doc drift:
auth.service.port.ts:20-21claims browser-redirect universal OAuth; adapter (auth.supabase.ts:150-173) dispatches to native SDKs +signInWithIdToken(OIDC token exchange). PKCEflowTypeon the client (client.ts:81,client.web.ts:57) governs email/magic-link/web flows, not this path. - #16 DM absent features: no typing indicators (grep-confirmed), no message editing — delete-only within 60s, enforced client (
dm-thread-screen.tsx:1347-1349) + RLS (00220:166-170). Reactions exist (6-emoji quick row,dm-thread-screen.tsx:100,111-137). Read receipts: per-message 읽음/전송됨/전송 중 on sender's last message (dm-thread-screen.tsx:1019-1026) + unread dot per row (dm-inbox-screen.tsx:161-170). - Onboarding friction note (not filed as broken): optional 3-card value-prop pager (skippable,
onboarding-value-props.tsx:230-232;showValuePropsis unpersisted local state so it reappears on every pre-completion mount) + 6 hard-gated wizard steps (onboarding-wizard-screen.tsx:62-68): welcome → birth year → consent (under-14 gate has no forward path by design) → display name (mandatory) → play style (mandatory) → done. Six mandatory steps is heavy by Toss standards, but each gate is load-bearing (age/PIPA/identity); candidate trim: fold play-style into a post-onboarding prompt. - Near-miss (not a violation):
use-dm-mutations.ts:20importsisAlreadyAcceptedErrorfrom@/adapters/supabase/idempotencyinside presentation — a pure error classifier, not a client instance, but it is an adapter-layer import in presentation worth normalizing (move to a shared util or re-export via registry).
Adversarial probes run
- Traced the full OAuth call chain UI →
useAuth→session-provider→AuthServicePort→auth.supabase.tsper provider, specifically hunting the cancel/failure feedback paths; diffed the email path's deliberate re-throw against the OAuth path's swallow-into-context. - Grepped
requestPermissionsAsync/PushPermissionSoftPromptSheetrepo-wide to verify the "fires only after first RSVP success" canon claim — confirmed exactly 3 trigger sites, all RSVPonSuccess, no earlier firing point (onboarding, layout, app start all clean). - Exhaustive grep for
signal_preferencesconsumers acrosspackages apps(hooks, screens, mutations, routes, i18n) to prove the zero-UI claim definitively rather than assert deferral from the plan doc; separately diffeduser_preferencesentity fields against rendered Switch rows to find the 6 orphaned booleans. - Cross-read both writers of the
marketing_pushpurpose key (settings-screen.tsxgeneric toggle vssettings-consent-management-screen.tsx) and checked the row label i18n for any marketing disclosure — none. - Diffed the DM freshness canon (
docs/canon/networking.md:25) against actualrefetchIntervaloptions in everyuse-dm.tshook; grepped for.channel(/postgres_changesin all DM files; verified the thread-screen poll's focus gate. - Traced
dm_push_preview_enabledend-to-end (migration → mapper → toggle → mutation →send-pushedge function body gating) to rule out a dead toggle. - Simulated a deleted-account DM partner through the soft-delete/anonymization migrations (00184/00186) → dm mapper LEFT-JOIN null path → thread-screen header fallback expression.
- Checked blocked-users existence beyond DM (table, RLS, RPCs, trigger guard, settings screen) before accepting "blocking exists" as a works-well.
- Swept
style={{/StyleSheet/className/TouchableOpacity/raw-Korean-literals across all three sub-scopes, classifying every hit as sanctioned escape hatch (justified comment, static RN-primitive literal) vs violation. - Verified the verified-identity/nationality claims against docs + migrations rather than project-memory assertions (
external-blockers.mdEXT-11,current-project-state.md:47,00228comments,foundational-schema-decisions.md).
Canon violations (summary)
- Tamagui-only (STYLE) —
login-screen.tsx:65,82-91,99-108: variable-bearing inline styles- two fully hand-styled raw
TextInputs, no exemption comments (violation). All other raw-style hits in the block are sanctioned: staticflex:1on RN primitives, commented palette-hex swatches (settings-screen.tsx:155-171), justifiedRNTextInputStyleSheets (delete-account/change-password).
- two fully hand-styled raw
- Networking canon drift —
docs/canon/networking.md:25describes a DM inbox 15s poll that code intentionally does not implement (use-dm.ts:23-24,40-41); two in-code comments repeat the phantom claim. Realtime-channel ban: fully honored. - PIPA / marketing-consent canon — the generic 푸시 알림 toggle writing
marketing_push(finding #3) undercuts the "active, informed marketing consent" requirement (docs/canon/notifications.md,docs/canon/korean-market.md); the (광고) title prefix itself is a server-side send-push concern (out of U7 scope, flag to B7/U1). - Single source of truth —
marketing_pushwritten from two inconsistently-framed surfaces; three divergent hand-styled password/text inputs; two parallel preference systems (user_preferencesbooleans vssignal_preferences) both half-surfaced. - ModalPanel-not-Sheet, Pressable, i18n, registry ports — all clean across the block (verified, not assumed).
Improvement candidates (design-spec seeds for the synthesis phase)
- Phase 8c login screen (with #1/#4/#7): Kakao-primary button + Apple (App Store 4.8) + Google, error/cancel states designed in from day one (cancel = stay put silently is fine IF intentional; failure = inline error band using the same surface as the email path), inputs on a blessed
RNTextInputwrapper. Wireframe before build per wireframe-first rule. - Notification preferences screen (Phase D, with #2/#5): one canonical surface claiming the existing
/settings/signal-preferencesroute + quiet-hours i18n keys; decidesignal_preferences(13 categories × in-app/push/severity/quiet-hours) as the model and migrate/retire the 6 orphaneduser_preferencesbooleans rather than surfacing both. - Consent hygiene (with #3): strip
marketing_pushwrites from the generic push toggle; add a separately-labeled 마케팅 알림 row (광고 framing) whose grant flows through the consent ledger withcollectionMethod: 'settings_toggle'. - DM inbox first-run (with #8):
EmptyStateonAction→ recipient picker (club members) or route to 클럽 멤버 list; header "+" gains a 1:1 path (or the create-group screen generalizes to "new message" with 1 recipient = 1:1). - Small fixes batch: #9 header fallback to
strings.unknownUser+ deactivated-partner indicator; #10 re-auth or drop the cosmetic field; #11 session-expired reason banner on LoginScreen; #6 rewrite the three phantom-poll comments + canon line.