U6 — Discovery: Adversarial UI/UX Audit
Status: Archived Date: 2026-07-11 Scope: 번개 찾기 (pickup discovery — packages/features/activity/src/{activity-pickups-screen,shared/pickup-filters,shared/apply-pickup-filters,shared/use-pickup-filters-default,shared/courts-header-action}.tsx), club discovery (packages/features/clubs/src/{clubs-discover-screen,shared/discover-filters,shared/discover-filter-model,shared/discover-club-card,shared/use-viewer-discover-clubs,shared/club-card-model}.tsx), the nationwide court/venue directory + detail (packages/app/src/presentation/components/sessions/{venue-directory-content,venue-filter-bar,directory-venue-card,venue-confidence,venue-hero}.tsx, packages/features/sessions/src/{directory-venue-detail-screen,venue-correction-modal,venue-hours-composer,venue-fee-composer}.tsx), the venue-directory adapter/mapper (packages/app/src/adapters/supabase/venue-directory.supabase.ts), and the pickup discovery → guest-application entry point (packages/features/sessions/src/public-pickup-preview-screen.tsx, packages/app/src/navigation/onboarding-intent.ts, apps/mobile/app/_layout.tsx). Method: direct code read of every file above + targeted grep sweeps for localeCompare, react-native-webview, MapView, duplicate district-filter encodings, and dead-data cross-references (business_hours/review_keywords) + a git-ancestry check against apps/mobile/ota-compatibility-baseline.json to verify a previously-flagged native-module risk against the actual pinned OTA-compatible binary. No code modified.
Executive-severity ranking
| # | Finding | Severity |
|---|---|---|
| 1 | New-user guest-application funnel leak: resolveOnboardingDestination has no route rule for /sessions/[id]/preview (the pickup-discovery entry point), so a brand-new user who taps a pickup card, signs up to apply, and completes onboarding is dropped on Home, losing the exact pickup they came to join | HIGH |
| 2 | The "미확인 포함" corroboration toggle defeats its own purpose: DirectoryVenueCard only ever renders a trust badge for official/host_verified venues — corroborated-but-unofficial and gov-only/unconfirmed venues render identically once the toggle surfaces them, so a user can't tell which cards in the mixed list are actually unverified | HIGH |
| 3 | Club-discovery sort reintroduces the exact ICU localeCompare anti-pattern that caused the 3–4s directory-freeze regression this block's sibling screen (venue-directory-content.tsx) explicitly documents and works around | MEDIUM |
| 4 | Directory empty state is one static string regardless of cause — a sparse/unconfirmed-only region (e.g. 강원) reads identically to "genuinely zero courts anywhere," with no hint that toggling 미확인 포함 would reveal results | MEDIUM |
| 5 | Three independent hand-rolled region→district cascading filter bars (pickup, venue directory, club discovery) with diverged encoding format (region:district vs region-district vs none) and diverged cardinality (multi/multi/single) — a single-source-of-truth violation with live drift, not just risk | MEDIUM (canon) |
| 6 | naver_place deep observation fields (business_hours, review_keywords) are scraped/stored offline (scripts/scrape-naver-place.mjs, scripts/lib/providers.mjs) but never read by any product code — dormant data, zero UI surface | LOW (dead data) |
| 7 | appRouter.push(routes.login) in the pickup preview's unauthenticated CTA is a no-op in practice — AuthGate renders LoginScreen directly off authState, ignoring the Stack/pathname, so the push never has an observable effect | LOW (dead code path) |
| 8 | Verified NOT a bug (resolves a previously-flagged risk): VenueCorrectionModal's VenueLocationPicker/DaumPostcodeModal (both react-native-webview-backed, a native module) are safely reachable in the currently-pinned OTA runtime — see verification below. The "NEEDS EAS BUILD" caution in project memory is stale for the binary actually live today. | INFO |
What works well (evidence):
- The corroboration gate is correctly implemented as the documented AND, and its negation as the documented OR (De Morgan) —
venue-directory.supabase.ts:135-139:verification==='corroborated'→.gte('source_count', 2).neq('verification_status', 'unverified');'unconfirmed'→.or('source_count.lt.2,verification_status.eq.unverified'). The code comment (lines 124-133) correctly explains whysource_countalone isn't sufficient (the 00375 Overpass-polygonunverifiedcohort), citing concrete numbers (7 of 13 passsource_count>=2yet are still unidentifiable). VenueDirectoryContentalready carries the scar tissue from the exact class of bug flagged in finding #3:buildRows(lines 96-127) explicitly uses a plain string compare, notlocaleCompare, with an inline comment citing the "3–4s JS-thread freeze on device" regression and routing the ~1,875-venue directory through a header/cardFlatRowunion onFeedList/FlashList instead of RNSectionList(which "processes ALL ~1,875 items synchronously... froze the JS thread for 2.5–3.5s"). This screen is airtight on the pattern; the club-discovery screen (finding #3) is not.- The multi-source confidence UI (
venue-confidence.tsx) is a clean, DRY, single-source primitive:ConfidenceBars/VenueTrustRing/SourceChips+ pure helpers (getVenueTrust,getFactTier,getCoordSources) are shared verbatim between the directory card, the detail screen's stat tiles, and the correction modal's per-field headers — confidence is always DERIVED fromfieldConfidence/coordConfidence, never hand-set anywhere audited. VenueCorrectionModalis a thorough, fully-structured (no free text) per-field editor: each field independently firesuseCorrectVenueFact, offers a "맞아요" confirm affordance on already-high-confidence fields, and surfaces pick-a-source chips when 2+ providers disagree (venue-correction-modal.tsx:391-403,460-470).VenueHerodegrades gracefully through photo → Naver static map →CourtLinesTilecourt-surface tile (venue-hero.tsx:61-91), andDirectoryVenueCard/DirectoryVenueDetailScreenboth handle missing coords with a plainMapPinicon fallback instead of a broken image.- Pickup discovery correctly reuses the canonical
SessionCardunmodified (activity-pickups-screen.tsx:78-89) — zero drift between pickup cards and session cards, satisfying the consistency dimension outright. - Discovery exclusion (not "host-first sort," which is a different, correctly-scoped feature living in
use-my-participating-sessions.ts/session-sort.tsfor the My Sessions list) is implemented server-side and pagination-safe:session.supabase.ts:44-48—.neq('created_by', options.excludeCreatedBy)insidefindOpenPickupsPageImpl, so a host's own pickups never consume a discovery page slot. No drift against the CLAUDE.md session-host-role-backlog note. - No filter surface in this block (pickup, venue directory, club discovery) uses horizontal scroll — all three panels lay
DropdownChip/SelectionChiprows out withflexWrap="wrap"inside a bounded$cardpanel, honoring the owner's absolute no-horizontal-scroll rule. DirectoryVenueCardisReact.memo'd with an explicitly-documented stableonPresscontract (directory-venue-card.tsx:182-185) so a nationwide list of hundreds of cards doesn't re-render wholesale on every filter/toggle tick.
1. HIGH — Guest-application funnel leak: new users lose the pickup they signed up to join
Evidence:
packages/features/sessions/src/public-pickup-preview-screen.tsx:362-373— the unauthenticated CTA isappRouter.push(routes.login as never).apps/mobile/app/_layout.tsx:157-183(AuthGate) —authState === 'unauthenticated'renders<LoginScreen />directly (a component swap, not a route navigation);authState === 'needs_onboarding'renders<OnboardingWizardScreen postOnboardingDestination={onboardingDestination.destination} />, whereonboardingDestination = resolveOnboardingDestination({ pathname, params: routeParams })is computed from the currentusePathname()/useGlobalSearchParams()— i.e. whatever route the user was actually on before AuthGate intercepted (this correctly captures/sessions/[id]/previewsince the URL never changes, only the rendered tree).packages/app/src/navigation/onboarding-intent.ts:73-87(inferIntentFromPath) recognizes exactly 5 path shapes: public-share links (/s|m|p|lb/...),joinClub,createClub,discoverClubs/clubs, andcreate-session?clubId=pickup./sessions/[id]/previewis not one of them.onboarding-intent.ts:136-153— whenpathIntentisnull(which it is for the pickup-preview path), the function falls through to the final default:{ intent: 'new_player', destination: routes.home, source: 'default' }.
Reproduction (traced, not run): unauthenticated user → discovery → taps a pickup card → PublicPickupPreviewScreen at /sessions/{id}/preview → taps "로그인하고 신청하기" → signs up (new account) → authState flips to needs_onboarding → OnboardingWizardScreen is handed postOnboardingDestination = routes.home (the pathname-inference miss) → onboarding completes → user lands on Home, not back on the pickup they wanted to apply to. They must re-search discovery from scratch, and any filter state on the original pickup screen (there is none needed here, but the intent is lost) is gone. This is exactly the "returnto_link" case the OnboardingIntent type already models ('return_to_link' exists as a value) — the type system anticipated this class of path, inferIntentFromPath just never got a rule for it. Note: an _existing user who is merely signing back in is unaffected — authState goes straight to ready without an onboarding hop, AuthGate never navigates away from the current pathname, and the Stack resumes on /sessions/[id]/preview correctly. The leak is specific to brand-new signups, which is precisely the growth-critical path discovery exists to serve. Fix shape (not implemented): add a rule to inferIntentFromPath for /sessions/[id]/preview (and likely /clubs/[id]/preview if one exists) that returns { intent: 'return_to_link', destination: pathname }, mirroring the existing public-share-path special case at onboarding-intent.ts:141-143.
2. HIGH — The 미확인 포함 toggle surfaces unverified venues with zero visual distinction
Evidence:
venue-filter-bar.tsx:70-73,274-281—verification: 'corroborated' | 'all'toggle, default'corroborated'; flipping to'all'widens the server query via.or('source_count.lt.2,verification_status.eq.unverified')(adapter,venue-directory.supabase.ts:137-139) to include the gov-only/unidentifiable cohort.directory-venue-card.tsx:80-81,158-167— the ONLY trust signal the card renders is:isVerified = venue.verificationStatus === 'official' || venue.verificationStatus === 'host_verified'→ an "인증됨" badge if true, nothing at all otherwise. There is no rendering path in this card that readssource_count,verification_status === 'unverified', or anyfieldConfidence/coordConfidencevalue (the file's own doc comment at lines 11-14 confirms this was an intentional redesign: "No trust-tier chip / meta-line / hours-fee parsing here").- Consequence: once a user opts into 미확인 포함, the resulting list mixes (a) corroborated-but-not-"official" venues, (b) single-source venues, and (c) the
verification_status='unverified'Overpass-polygon cohort that "no POI source can name" (per the adapter's own comment) — all rendering as visually identical cards. The one surface that DOES explain confidence honestly (VenueTrustRing+summaryLow/summaryMedium/summaryUnknowncopy,directory-venue-detail-screen.tsx:536-555, strings atko/venues.ts:220-224) only exists on the detail screen, one tap deeper — the list itself, which is where the toggle lives and where the trust decision actually needs to be made, says nothing. Fix shape (not implemented): surface at minimum a lightweight "미확인" badge on the list card whenverification === 'all'is active and the venue fails the corroborated gate (mirrors theisVerifiedbadge's existing badge-strip slot), or reuseConfidenceBarscompactly.
3. MEDIUM — Club discovery reintroduces the banned localeCompare sort pattern
Evidence:
packages/features/clubs/src/shared/club-card-model.ts:389-401(sortClubCardModelsForDiscovery, called directly fromclubs-discover-screen.tsx:52-64on every render of the discovery feed):tsconst sessionDiff = a.nextSessionSortKey.localeCompare(b.nextSessionSortKey); ... return [a.regionText, a.title].join('|').localeCompare([b.regionText, b.title].join('|'));- Contrast with
venue-directory-content.tsx:110-118, which explicitly bans this on the sibling directory screen: "a PLAIN string compare, neverlocaleCompare('ko')— Hermes routes that through ICU and it is ~1000× the cost. (Sorting 1,875 venue names withlocaleComparewas the 3–4s JS-thread freeze on device.)" — and CLAUDE.md / project memory (feedback_hermes_localecompare_perf) generalizes this as a standing rule ("Never localeCompare-sort large lists on Hermes... a tiny query timing multi-seconds = thread starvation"). - Same file also has two more instances in the adjacent
club-card-summary.ts:30,55,59(used by the My Clubs surface, technically U2 territory, but sharing this exact module — flagging here since it's the same root cause and an agent fixing #3 should sweep both call sites in one pass). - Scale today is bounded by the discovery page size (infinite-scroll pages, not the full corpus
VenueDirectoryContentloads), so this is not yet the same order-of-magnitude freeze — but it is the identical anti-pattern the team already paid a multi-second production regression to learn to avoid, reintroduced in a sibling discovery surface with no plain-compare fallback.
4. MEDIUM — Directory empty state can't distinguish "no courts" from "filtered out by the trust gate"
Evidence:
venue-directory-content.tsx:245-262— the empty state always rendersi.empty/i.emptyDescription.ko/venues.ts:120-121—empty: '검색 결과가 없어요',emptyDescription: '다른 지역이나 이름으로 검색해 보세요'(static, not conditioned onfilters.verification).- Because the default
verification: 'corroborated'filter (venue-filter-bar.tsx:83) requires 2+ sources AND a non-unverifiedstatus, a genuinely sparse region (강원, 세종, etc., per the pilot data noted in project memory — "rich metadata ceiling = ~15-20% public courts only") can have real Overpass/gov-sourced courts that are simply gated out by default, and the user sees the exact same "다른 지역이나 이름으로 검색해 보세요" they'd see if the region had zero courts in the database at all. The copy doesn't mention the 미확인 포함 toggle that would actually resolve the empty state.
5. MEDIUM (canon) — Three diverged, hand-rolled region→district filter implementations
Evidence — three independent encodings for the same "시/도 + 시/군/구" cascading-filter concept:
- Pickup:
packages/features/activity/src/shared/pickup-filter-values.ts(buildPickupDistrictFilterValue/parsePickupDistrictFilterValue) — multi-select regions, multi-select districts, per its own doc comment formatregion:district-shaped (colon-adjacent parsing, distinct module). - Venue directory:
venue-filter-bar.tsx:50-56(buildDistrictValue/districtBelongsToRegion) — multi-select regions, multi-select districts, format${regionSlug}-${구}(hyphen-joined, and the file comment explicitly notes "Region slugs never contain a hyphen... so splitting on the first '-' is unambiguous" — a DIFFERENT parsing assumption than the pickup module). - Club discovery:
discover-filters.tsx/discover-filter-model.ts— single-select region, single-select district (filters.region/filters.district, not arrays) — no filter-value encoding at all, a structurally different data shape from the other two. - All three read from the same canonical
REGIONS/getSigunguOptions(@/config/regions) for the option lists themselves — that part is correctly shared — but the SELECTION MODEL and DISTRICT-VALUE ENCODING around those options were each reinvented per-screen. This is a live drift, not a hypothetical one: a future bug fix to "how region+district filtering is encoded" (e.g. the kind of Korea district-name-collision bug the pickup module's own comment calls out — "district names are not globally unique in Korea") would have to be applied in up to 3 places, and nothing enforces that it will be. Violates the CLAUDE.md "Single source of truth — one implementation per pattern... extend don't bypass" rule.
6. LOW — naver_place deep observation fields are dormant
Evidence: grep for business_hours/review_keywords/businessHours/reviewKeyword across the entire repo (excluding node_modules) returns hits ONLY in scripts/scrape-naver-place.mjs and scripts/lib/providers.mjs — the offline enrichment tooling. Zero hits anywhere under packages/app/src (domain entities, mappers, adapters, hooks) or packages/features. The venue detail screen's "운영 시간" section (directory-venue-detail-screen.tsx:602-623) reads a generic operating_hours JSONB field populated by the gov/multi-source resolver, not Naver's review-derived business hours or keywords. This confirms the audit brief's suspicion: this data is collected but has no product-facing consumer.
7. LOW — Dead login-navigation call
Evidence: public-pickup-preview-screen.tsx:362-373 calls appRouter.push(routes.login as never), but AuthGate (apps/mobile/app/_layout.tsx:177-179) renders <LoginScreen /> unconditionally whenever authState === 'unauthenticated', regardless of the Stack's current route — so this specific push call has no observable effect (the user was already looking at LoginScreen via the AuthGate swap, or would be regardless of the push target). Harmless today, but it's a landmine for a future engineer who assumes routes.login is a live, reachable route.
8. INFO — WebView-backed venue-correction pickers verified SAFE in the current OTA runtime
Evidence (verifying a risk flagged in project memory, not a new finding):
packages/ui/src/venue-location-picker.tsxandpackages/ui/src/daum-postcode-modal.tsxboth importreact-native-webview(a native module) at module scope, and both are reachable directly from the always-rendered "코트 정보 수정" row onDirectoryVenueDetailScreen(directory-venue-detail-screen.tsx:727-748,775-781) wheneveruserId != null— i.e. every logged-in user hitting a venue detail page has this native-backed modal one tap away.apps/mobile/ota-compatibility-baseline.jsonpinslastCompatibleSourceCommit: 770277d4a0a7ba2c6922ec698e2068f6b3289506for the currently-livepreviewchannel binary.git merge-base --is-ancestor a6d75185 770277d4a0a7ba2c6922ec698e2068f6b3289506→ true (a6d75185=feat(ui): daum postcode address picker (native WebView + web sibling), the commit that addedreact-native-webviewtoapps/mobile/package.json).git diff 770277d4a0a7ba2c6922ec698e2068f6b3289506..HEAD -- <all 10 nativeSensitivePaths from the baseline>→ empty (no native-sensitive drift since the pinned build).- Conclusion: the native
react-native-webviewmodule is already linked in the binary thepreviewOTA channel currently targets. The correction modal is safe to exercise today; the "NEEDS EAS BUILD" caution recorded in project memory (project_venue_user_corrections.md) describes a state that has since been resolved by a build that already landed — that memory note is now stale and should be updated.
Adversarial probes run
- Traced the full unauthenticated → sign-up → onboarding path for the pickup guest-apply CTA through
AuthGate/resolveOnboardingDestination/inferIntentFromPath, not just the CTA's ownonPress(finding #1). - Cross-checked the corroboration-gate SQL (
ANDvsOR) against its own inline justification comment and the migration numbers it cites (00347/00362/00375) for internal consistency — passed. - Grepped for every
localeComparecall site across the entire discovery block (not just the one file with the known-good fix) to check whether the fix had been applied consistently — it had not (finding #3). - Ran a git-ancestry + diff check against the actual pinned OTA baseline for the WebView native-module risk instead of trusting the "needs EAS build" note at face value (finding #8 — the multi-modal/runtime-verification mandate from
research-methodology.mdapplied literally). - Grepped the whole repo (not just
supabase/functions) for thenaver_placedeep-data field names to confirm dormancy rather than assuming from the scrape script alone. - Diffed the three region/district filter modules line-by-line for encoding-format and cardinality parity instead of assuming "same options source = same behavior."
Canon violations
- CLAUDE.md "Single source of truth" — finding #5 (3 diverged region/district filter encodings).
feedback_hermes_localecompare_perf/ CLAUDE.md ICU-freeze precedent — finding #3 (club-discoverylocaleComparesort).- No Tamagui-only, bounded-variant,
FeedList, or horizontal-scroll violations found in this block — all filter panels, cards, and lists audited comply.