Venue surfaces UX audit — 2026-09-04
Status: Draft · proposals only — nothing here is built; companion to the mechanical fix pass of the same date
Scope: VenueFinder, VenueFilterBar, DirectoryVenueCard, ExpandableFilterBar, CourtsDirectoryScreen, DirectoryVenueDetailScreen, VenueCorrectionModal, and ko/venues.ts. Rubric: docs/canon/components.md, docs/canon/styling.md, docs/canon/wizards.md, docs/design/terminology-guide.md, CLAUDE.md's "would Toss ship this?" filter, and the standing memory rules (graphics over text, no CTAs in cards, card tones default/flat, Pressable-only clickables, 4px-grid tokens).
Every item below is grounded in the live component, not a wireframe or doc — file:line citations point at the exact code read for this audit (2026-09-04 checkout).
1. Directory card shows no price signal, though a sibling card already derives one from the same data
Screen: DirectoryVenueCard (nationwide search-result row). Today: packages/app/src/presentation/components/sessions/directory-venue-card.tsx:113-157 builds a fixed badge strip — facility kind, region/district, court count, indoor, surface, lighting, ball machine. There is no fee/price badge anywhere on the card. Why it fails the rubric: the audit prompt names price as one of the facts a user needs to pick a court. DirectoryVenue.feeSchedule is populated at LIST level (packages/app/src/domain/entities/directory-venue.entity.ts — feeSchedule carries no "detail-only" comment, unlike ratingValue/ratingCount/reviewCount which are explicitly findById-only). The sibling VenueCard (packages/app/src/presentation/components/venue-card.tsx:155) already parses the identical shape and renders vn.weekdayRateHint(formatKRW(venue.weekdayRate)) — the i18n string (weekdayRateHint, ko/venues.ts:58, "평일 {rate}원~") exists and is live, just not wired into this card. A user scanning ten directory rows currently has to open each one to learn it costs 3x more than the row next to it. Proposed change: parse the first scope: 'weekday' (or free) entry from feeSchedule the same way VenueCard does and append it as the LAST badge in the strip (or a small trailing price chip, since price is a decision-critical fact per the summary-card density canon's test #1 — "does this change the browse decision?"). Free venues get a 무료 badge instead of nothing, so "no price shown" never gets misread as "no price data." Wireframe: docs/twomore_design_system/wireframes/venue-finder.jsx (card density).
2. No distance signal or "가까운 순" sort, despite coordinates being on every row
Screen: VenueFinder browse list. Today: buildRows (packages/app/src/presentation/components/sessions/venue-finder.tsx:169-207) groups strictly by 시/도 text and sorts alphabetically within it; DirectoryVenue.latitude/longitude (directory-venue.entity.ts) are present on every row but never compared against the viewer's own location anywhere in this file or directory-venue-card.tsx. Why it fails the rubric: the prompt explicitly names distance/region as a decision input. Grouping by province is a fine BROWSE structure but does nothing for "what's near me right now" — the single most common court-search intent (Naver/Kakao Map both lead with distance). There's no 가까운 순 chip or sort affordance at all today; a user in 강남 scrolls a name-sorted list of every venue in 서울. Proposed change: once device location is available (it already is elsewhere in the app for weather/session cards), add a distance badge + an opt-in "가까운 순" sort toggle that swaps buildRows' province grouping for a flat distance-sorted list. This is a real feature, not a copy fix — needs an owner call on whether it's worth the location-permission ask on this screen specifically. Wireframe: docs/twomore_design_system/wireframes/venue-finder.jsx.
3. Region filter drops district disambiguation that its own sibling consumer already solved
Screen: VenueFilterBar's region/district dropdown pair. Today: packages/app/src/presentation/components/sessions/venue-filter-bar.tsx:148-159 calls RegionDistrictFilter without a districtOptionLabel override, so it falls back to defaultDistrictOptionLabel (packages/app/src/presentation/components/region-district-filter.tsx:73) — bare district name, no region prefix. The component's own doc comment (region-district-filter.tsx:25-29) states this is a KNOWN divergence: "pickup disambiguates district labels with the region name when 2+ regions are selected … the venue directory doesn't." Why it fails the rubric: many 시/도 share district names (중구 exists in Seoul, Busan, Daegu, Incheon, Ulsan; 서구 exists in several cities too). Select 서울+부산 in the venue filter and the district dropdown shows two bare "중구" rows with no way to tell which is which — a real ambiguity the sibling pickup-filters.tsx consumer of the exact same shared component already fixed. Proposed change: pass the same region-prefixed districtOptionLabel pickup uses whenever selectedRegionCount > 1 — a one-line prop addition, no new component. Low-risk enough that this could arguably move to the FIX pass on a future touch, but it's a two-region interaction that needs a quick visual check before shipping, hence proposed here. Wireframe: docs/twomore_design_system/wireframes/venue-filters.jsx.
4. Nav-chip counts render unformatted at 4 digits, and the codebase's own ICU ban makes the easy fix illegal
Screen: VenueFinder's 전체/코트/연습장/샵/즐겨찾기 chip row. Today: i.navChipCount (packages/app/src/config/i18n/ko/venues.ts:142) is (label, n) => ${label} ${n}``, called at venue-finder.tsx:654againstuseVenueViewCounts. The spine is documented at ~1,875 rows (venue-finder.tsx:162comment), so the 전체 chip can render "전체 1875" — no thousands separator. Why it fails the rubric: Toss-grade chip counts read as grouped numbers; a bare 4-digit run reads like a bug, not a stat. But the obvious fix —n.toLocaleString()— is banned by this codebase's own perf rule (Hermes routestoLocale\*through ICU and it's the same class of freezeno-localeCompare-sortsexists to prevent, per memoryfeedback_hermes_localecompare_perf). Proposed change: a tiny hand-rolled thousands-grouping helper (regex-based, no ICU) shared wherever a raw count renders — this is a small utility, not a design call, but it touches an existing perf-motivated lint rule so it's flagged here rather than silently added in the FIX pass. Wireframe: docs/twomore_design_system/wireframes/venue-finder.jsx.
5. Correction modal is one long scroll whose length swings 8→18 sections with no signal up front
Screen: VenueCorrectionModal. Today: every field in EDITABLE_FIELDS_BY_KIND[kind] (packages/app/src/domain/entities/venue-correction.entity.ts:185-194) renders as its own SectionBlock → Card in one ModalPanel.ScrollView (packages/features/sessions/src/venue-correction-modal.tsx:534-1021). full_court gets all 15 correction fields (ALL_CORRECTION_FIELDS, same file lines 132-150) plus the 폐업/이전 section, submit button, and report row — roughly 18 stacked cards in one sheet. shop gets 8 fields (15 minus SHOP_EXCLUDED_FIELDS, lines 154-162) — under half the length, in the exact same modal shell with no visual acknowledgment that it's shorter. Why it fails the rubric: CLAUDE.md's "would Toss ship this?" filter and "one task per screen, minimal density" sit uneasily with an 18-card single scroll with no progress indicator, no grouping, no "show more" — everything is visible at once regardless of confidence tier or how likely a user is to touch it. The wizards.md field grammar (AttachedPanel, collapsed-by-default patterns) exists precisely for this "long form, most fields untouched" shape, and this modal — despite reusing wizard-adjacent primitives (SelectionChip, Stepper, ActionButton) — doesn't use any of the field-grammar's progressive-disclosure tools; it's SectionBlock-and-Card end to end. Proposed change: worth a wireframe pass on whether high-confidence fields (already tier: 'high', i.e. the member is likely just confirming, not correcting) collapse into a single "정보가 정확해요" batch-confirm row up top, with only low/unconfirmed-tier fields expanded by default — cutting the common-case scroll length dramatically without removing any field's editability. Wireframe: none exists yet for this screen — would need a new one grounded in venue-correction-modal.tsx.
6. 미확인 trust is a single xs badge, easy to miss under the now-default "include unconfirmed" filter
Superseded (2026-09-06): docs/canon/venue-data.md's public-venue-identity canon (ratified 2026-09-03, reviewed 2026-09-06) retired the premise this finding assumes — ordinary venues display with NO trust badge, dimming, or source-count penalty at all, and 미확인/확인됨 no longer render anywhere, in either explore or pick intent (VENUE-RESIDUALS, decision log 2026-09-17). The proposed muted-thumbnail treatment below is void; nothing to build here.
Screen: DirectoryVenueCard. Today: emptyVenueFilters() (packages/app/src/presentation/components/sessions/venue-filter-bar.tsx:59-73) defaults verification: 'all' — the comment explains the recovered corpus sits at source_count=1 until the corroboration cron catches up, so 미확인 rows are now common in the DEFAULT view, not an opt-in edge case. Yet the only visual differentiator is VenueTrustBadge tier={trustTier} size="xs" (directory-venue-card.tsx:200) sitting inline next to the name — same card chrome, same thumbnail treatment, same badge strip as a fully corroborated venue. Why it fails the rubric: graphics-over-text and glance-scannability both argue for a differentiator a user catches WITHOUT reading a tiny badge label — the current design asks a scanning eye to parse 8pt text on every single row to know whether to trust it, right after the default filter was changed specifically because so many rows now carry this state. Proposed change: worth exploring a stronger but still calm treatment for unconfirmed — e.g. a muted/desaturated map thumbnail (parallels TERMINAL_MUTE_OPACITY's existing "recede, don't hide" pattern used for closed/cancelled/archived elsewhere) rather than relying solely on the badge. Needs an owner look since it's a cross-cutting visual change to the trust system, not a copy fix. Wireframe: docs/twomore_design_system/wireframes/venue-finder.jsx.
7. 샵 view carries no shop-specific facets — same capability filter, same empty copy as courts
Screen: VenueFilterBar + VenueFinder empty state, filtered to 샵. Today: the capability filter (venue-filter-bar.tsx:160-169) always offers full_court / ball_machine / screen / lessons — none of which a shop-kind venue can carry (FACILITY_KIND_BADGE_VARIANT's own comment at directory-venue-card.tsx:63 calls a shop "a real place, never a playable venue"). Switching to the 샵 nav chip doesn't hide or swap this filter for anything shop-relevant. The empty state is identical too: venue-finder.tsx:758-775 renders the same i.empty/i.emptyDescription ("검색 결과가 없어요" / "다른 지역이나 이름으로 검색해 보세요") for every view, including 샵. Why it fails the rubric: the prompt asks directly whether 샵 has any facets worth showing — today it has none; it inherits court-facet chrome that doesn't apply and generic copy that doesn't read as shop-aware. Proposed change: either (a) hide the capability filter entirely when view === 'shop' (cheapest fix, needs no new facets), or (b) if shop data eventually carries something facetable (brand, stringing service — venue-correction-modal.tsx's shop fields are currently just name/address/website/booking/amenities/parking, no shop-specific taxonomy), design real shop facets. Either way, tailor the empty-state copy per view at minimum. Wireframe: docs/twomore_design_system/wireframes/courts.jsx.
8. Detail screen's section order is identical for court/practice/shop — only suppression, no reordering
Screen: DirectoryVenueDetailScreen. Today: the section order is a fixed sequence (packages/features/sessions/src/directory-venue-detail-screen.tsx:1257-1615: 기본 정보 → 다른 이름 → 운영시간 → 이용료 → 리뷰 키워드 → 평점 → 편의시설 → 결제 수단 → 바로가기 → 블로그 → 사진 → 시설 → 코트 구성 → 호스트 확인 → 검토 대기 → CTA). For isShop, court-only sections (이용료, 코트 구성) are correctly suppressed — but 결제 수단/바로가기 (arguably MORE relevant to a shop visit than to a court booking) stay in the same position they'd occupy for a court, rather than moving up. Why it fails the rubric: this is a smaller miss than #7 — suppression already happens — but a true per-kind IA (the prompt's explicit ask) would prioritize by kind, not just hide court-only rows in an otherwise court-first order. Proposed change: lower priority than items 1-7; worth a pass only once shop data is richer than name/address/links (see #7). Note here so it isn't lost. Wireframe: docs/twomore_design_system/wireframes/venue-detail-redesign.jsx (closest existing grounding, though not one of the two files named in the task brief).
Not re-litigated here (already known/pending)
correctionModalTitle("코트 정보 수정") andhostConfirmTitleare explicitly flagged indocs/design/terminology-guide.md:28as pending an owner ruling on the 코트/테니스장 split for "umbrella" venue+court editing copy — not touched by this audit, not a new finding.- Loading/empty states elsewhere (browse skeleton rows, search skeleton cards) already follow canon (
SkeletonRow/SkeletonCard, never a flashedEmptyState) — no finding there beyond #7's shop-specific empty copy.
Entry-point ruling (2026-09-17, VENUE-RESIDUALS)
The 경기 tab's 테니스장 찾기 segment is THE in-app entry point for venue explore (ActivityScreen → VenueDirectoryContent, documented at docs/architecture/screen-blueprints/sessions.md:71). /courts remains the web/deep-link address of that exact same body — CourtsDirectoryScreen wraps the shared VenueDirectoryContent, one implementation with two mount points (docs/history/screen-blueprint-archive-2026-09-11.md:1094) — and stays as-is. This closes the "explore entry point's future home" open call left by the 2026-09-01 VenueFinder ship (docs/history/rebuild-log.md, "VenueFinder: one canonical venue-search surface"). Rejected: a second in-app entry to the same list (redundant navigation for one content set).