Session Surface Conventions
Status: Active Last reviewed: 2026-07-18
How TwoMore renders session data across every surface — the location-first
SessionCard, the chip strip and status/RSVP chip logic, viewer-context threading, theSessionInfoModelprojection system, theSessionCompactRowcompact reference, and the session-detail screen IA. These are the canonical, single-source rules; feature code consumes the components and models below and never re-derives layout, chip order, or gate values ad-hoc. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.
SessionCard layout
SessionCard layout — location-first (GRADUATED to the production default 2026-07-18; experiment series 2026-06-24→30). The full-size (size="default") SessionCard leads with WHERE and renders, top to bottom inside SessionHeader layout="locationFirst":
- Status strip (live / cancelled / deadline / almost-full — one at a time, precedence-ordered) at the very top of the card, above the hero.
- Title row — the location TITLE (broad region, space-joined e.g.
서울 강남구; fallbacks: venue name → generatedsessionTitle) + the top-rightheaderAction(HostBadge for the host,SessionSaveButtonotherwise). - Venue hero (
VenueHero: static map → photo → surface tile) — the specific venue name + our booked court info (e.g.클레이 2면) in the hero's bottom overlay bar, the weather glance (SessionWeatherGlance) top-right. No-hero fallback: a session without a resolvablecourtVenueId(free-text location, fetch miss) renders the classicVenueLink+ weather row in the hero's place — the WHERE/conditions facts never disappear with the map. - WHEN band — a soft full-width
$surfaceSecondarystrip (44px, NOT a nested card): date left, time-range right, one naturally-spoken a11y node. Date/time live OUTSIDE the map, never as a hero overlay. - Two-tier chip strip (
chipRowTwoTier, below). - Optional
notechip, then the participation section.
Participation section (shared by both layouts): a thin <Divider /> separates metadata from participation. A borderless CapacityRing direction="column" (ring over the N/M count, NO box, 48px cell) leads participant faces (ParticipationCluster — the AvatarStack alone, no ring) with the status/RSVP chip pinned FAR RIGHT. Both the ring AND the faces render for recruiting/live AND completed (PARTICIPANTS_VISIBLE_STATUSES): the ring shows the FILL while recruiting and the FINAL attendance record ("8/8 played") on a completed card. FILL_VISIBLE_STATUSES only gates the recruiting-only "아직 참가자가 없어요" empty label. RSVPs are fetched ONCE at the SessionCard level and passed to BOTH the ring (count) and SessionMetaSection (faces) — one network call per card. The row is the expand/collapse toggle for the lazy match-summary well (SessionMatchDetail).
Classic layout (layout="default") — the status-colored DateTile (48px) beside the title/where/chips block — remains ONLY for compact (size="compact") cards, which render the meta bare full-width with no divider, rail, hero, or WHEN band. The venue fetch (useVenue) runs for every full-size card with a courtVenueId (the hero needs coords even when venueSurfaceType was prop-drilled); compact cards never fetch.
All session cards → use SessionCard from @twomore/app. Accepts optional footer?: React.ReactNode — rendered inside the card surface below SessionHeader; use it (e.g. LiveSessionCard) to inject match rows without nesting cards. Direct SessionHeader usage is only permitted for specialized hero/detail layouts.
Status / RSVP chip logic
The status/RSVP chip is NOT in the classification strip — it is pinned to the far right of the participation meta row (faces on left, status chip on right). Status-chip behavior depends on session.status and viewer participation:
session.status === 'in_progress'→SessionStatusBadge(진행 중, live red + PulseDot) always renders. LIVE is a temporal state independent of participation; if the viewer is also confirmed/waitlisted,RsvpStatusBadgerenders ADDITIVELY beside it. Same precedent as the payment chip — additive, never replaces.- Other statuses + viewer confirmed/waitlisted →
RsvpStatusBadgeREPLACESSessionStatusBadge.모집 중/마감됨is irrelevant to someone already in — show their state, not the room's. - Other statuses + viewer not participating → for open/locked (recruiting) sessions,
RecruitmentStatusBadgeviasessionRecruitmentState(the derived 모집 중 / 성사 대기 / 마감 임박 / 대기 모집 / 마감);SessionStatusBadgeonly for non-recruiting statuses (live/completed/cancelled). The payment chip (SessionPaymentStatusBadge) is ADDITIVE and renders only whenmyRsvp.status === 'confirmed'AND aSessionPaymentrow exists for the viewer — it goes in the classification strip, not the meta row.
SessionCard chip strip — the canonical two-tier flow
The full-size SessionCard renders the two-tier chipRowTwoTier, whose order is grounded in the SessionInfoModel gate taxonomy (eligibility → kind → cost; see SessionInfoModel projections). (Graduated from the sessionSummaryPolishMode experiment 2026-07-18; the single wrapping classic chipRow — format → style → tier → payment → approval — survives ONLY on compact cards. The legacy costTotal-split cost chip is retired; the fee chip below reads the canonical session.participationFee.)
Invariant layout — each row leads with explanatory metadata and ends with a decision GATE; both row ends ALWAYS carry a chip (left↔right symmetry):
- Row 1 — identity → eligibility gate:
[type] [format] ──spacer── [tier]type(번개/클럽,t().sessions.pickupBadge/clubBadge) ALWAYS leads — the eligibility frame, the most identity-defining chip. Null only for league/tournament.format(복식…) — explanatorykind.tierband — the skill GATE, right-pinned (always present).
- Row 2 — kind detail → cost gate:
[style] [rotation?] [approval?] [payment?] ──spacer── [fee]style(친선/경쟁) — the always-present left chip (moved here from Row 1 to guarantee a Row 2 left chip).rotation(로테이션 N분, whenrotationMinutes),approval,payment— explanatory / join details in the middle.fee— the cost GATE, ALWAYS right-pinned for pickup/club:참가비 N원when paid,무료when free.
Consistency over suppression: the type chip never moves and the fee chip never hides, so every session reads the same flow (what it is → what kind → the skill + cost gates) and symmetry holds in all states with no special cases. The two decision gates (tier, fee) sit at the right edges; everything else is explanatory on the left/middle.
Gating (번개 vs 클럽 × free/paid × member/guest), session-fields only (never a per-viewer membership query):
참가승인(approval) —자동 승인(auto → accent) /승인 필요(manual → neutral), frompublicInviteApproval. Shown whenorigin === 'pickup'OR (origin === 'club'ANDpublicInviteEnabled).publicInviteEnabledis the semantic gate: approval only exists when there's a public/guest join, so member-only club sessions hide it.참가비(fee) — always shown for pickup/club. A member-only free club session reads[클럽][복식]…[tier]/[친선]…[무료].
Adding a chip: place it by gate (eligibility/identity → Row 1 left · kind/logistics → Row 2 left/middle · a new decision gate → a right edge), keep both row ends filled, and gate visibility on session fields only.
Viewer context threading
SessionCard receives viewer context via the userId prop (optional only for unauthenticated/marketing surfaces). Internal derivation: useMyRsvp(userId, sessionId) + useMySessionPayment(sessionId, userId), both cache-shared with SessionMetaSection's useSessionRsvps so the card costs one network call per render, not two. Every authenticated render path threads userId={userId} from useAuth() — no exceptions.
SessionInfoModel projections
Session surfaces are projections of SessionInfoModel. Every session surface (summary card, compact row, detail page, share card) is a DIFFERENTLY-CONFIGURED PROJECTION of one canonical model — the same gate dimensions, in the same canonical order, expressed at a different depth. The single resolver is buildSessionInfoModel(input) (packages/app/src/domain/session-info-model.ts); surfaces consume its output and render their projection — they never re-derive status / spots / cost / eligibility ad-hoc. Gate taxonomy + canonical order (abort-risk descending, SESSION_GATE_ORDER): eligibility (GATE 0 — can I get in + where do I stand) → when (GATE 1a — date/time/duration) → where (GATE 1b — venue / surface) → conditions (GATE 1c — weather / rain verdict) → kind (GATE 2 — format / style / ELO-impact / tier band) → who (GATE 3 — host / roster / will-it-happen) → cost (GATE 4 — per-person cost + cancellation commitment) → urgency (GATE 5 — deadline / fill / FOMO) → action (surface-owned CTA, not in the model). SESSION_SURFACE_PROJECTION declares which gates each surface shows: card (tight projection — eligibility / when / where / conditions / kind / who / urgency / action); compact (minimal — eligibility / when / where / who / action); detail (full projection — all gates). The summary card is the tight projection: highest-signal item per gate, one row for cost (참가비 chip — the gate-4 gap that was previously missing). The detail page is the full projection ordered by the gate model — LOGISTICS surface first (when/kind/cost/conditions → venue → weather) then PEOPLE surface (host → who → matchups). Adding a new session surface: map it to a SessionSurface constant, declare its gates in SESSION_SURFACE_PROJECTION, and consume buildSessionInfoModel — never re-derive the gate values inline. Deferred: where.distanceKm (viewer→court distance) — the model has the slot (GAP-FILL) but the viewer's stored coordinates are not yet wired; populate once location permission is persisted.
Compact session reference
Compact session reference → SessionCompactRow carries the SessionCard minimum set even when squeezed inside another card. Two dateStyle modes: 'tile' (default — DateTile with the calendar-tile visual, for session-primary surfaces: 경기 lists, club-detail 다음 일정) vs 'dot' (SessionStatusDot + text date via t().common.monthDayWeekday, for surfaces where the session is auxiliary to the parent entity — club cards). Also carries: generated sessionTitle (format + type) + tier Badge + CapacityRing (participants) + time + VenueLink (location). The whole row is a Pressable → session detail when given a sessionId (nested under any embedding card; the inner VenueLink wins for the venue); surface-less with no sessionId. DateTile itself stays reserved for event-genre contexts — never use it inside a club-card where the session is supporting content (owner rule 2026-06-13: DateTile on a club card competes with the club's own identity for visual weight).
Session detail screen IA
Session/detail screens with a single contextual primary action → pin it in a BottomCtaBand (from @twomore/ui) at the bottom, not scattered down the scroll. When the screen uses DetailShell scroll={false} + its own <ScrollView style={{flex:1}}>, render {primary ? <BottomCtaBand>{primary}</BottomCtaBand> : null} as a SIBLING after the ScrollView (the band's fixed padding sits above DetailShell's bottom safe-area edge — no double inset, no DetailShell change). Compute exactly ONE action by state precedence (e.g. session detail: RSVP when open → GPS check-in when in_progress+confirmed → 매너 태그 when completed+confirmed → else null). Secondary/admin actions stay grouped in their own SectionBlock, never duplicated across the scroll. Canonical example: session-detail-screen.tsx (packages/features/sessions/src/session-detail-screen.tsx).
Detail-screen overview IA — gate-ordered, state-aware, two grouped surfaces. A detail screen is a DECISION/ACTION surface, not a glance — so it expands the summary card's data but does NOT just replicate the card. Session detail (updated 2026-06-19 — supersedes the prior "engagement-first / THREE stacked cards" framing): the page is TWO grouped surfaces rendered as iOS-grouped-list cards with internal hairline <Divider /> separators and NO outer SectionBlock titles; the gate ordering follows SESSION_GATE_ORDER (abort-risk descending, see the SessionInfoModel rule below). SURFACE 1 — "일정 정보" (LOGISTICS, leads): identity beat (generated title sessionTitle — format + style, never custom — + the status chip + the viewer's RSVP badge, weather/TMI cluster; then the classification chip strip format → style → tier → rotation; cost 참가비 chip; 공지/description when present) → 장소 venue tile (two-zone: static-map image on top → opens Naver Maps; info row with venue · surface · courts + chevron → in-app venue detail) → 날씨 weather beat (condition emoji + temp + rain-risk verdict via conditions.rainRisk). LOGISTICS leads because a user must know WHEN/WHERE/CONDITIONS before asking "who's in / do I want to participate." The status chip is shown here ONCE — no re-statement further down. The live PulseDot ($error red) lives in the sticky screen header (DetailShell/AppHeader titleLeading slot) so the "happening now" cue stays visible while scrolling. SURFACE 2 — "참여 · 경기" (PEOPLE): host card (all sessions — PickupHostCard for pickup, organizer identity row otherwise) → participation hero (the ONE display-size count + ProgressBar fill + expandable roster collapsed by default → full list friends-first on tap; per-player ELO tier badge in the roster; open: deadline countdown; locked: waitlist count) → matchups card (viewer's personal lens — see 3a below). There is exactly ONE hero per surface (the fill count in PEOPLE, no competing bars or numbers in LOGISTICS). Beats are typography-led with NO decorative lead icons (icons sparingly — see the icon rule); each follows a Lead → Body → Meta anatomy. EXCEPTION — the venue beat navigates elsewhere so it renders as a clearly-tappable tile (borderWidth/$borderSubtle bounded surface). The map image is a Naver Static Map (getStaticMapUrl → static-map edge proxy; NCP key gated to a Maps Application with Static Map enabled) and degrades photo → text on load error. (3a) Sibling cards, not in-overview sections — content that is its own entity (the session's matches) lives in a SEPARATE title-less card below the overview (session-matchups-card.tsx — packages/features/sessions/src/session-matchups-card.tsx), never as a titled SectionBlock inside the overview. The matchups card is the viewer's personal lens, NOT the full bracket — it shows ONLY the viewer's own match rows (tier badges via MatchTeams, mirroring the live scorecard) plus a single always-present "전체 대진표 보기 · N경기" link row that routes to the spectator scorecard for the complete interactive draw. Because every shown row is the viewer's, the per-row "내 경기" chip + $primary stripe are dropped (they no longer distinguish). Render cases: zero matches → render nothing; viewer has matches → their rows + the link; spectator / unassigned → just the link. Rendering every generated matchup on the detail screen (the old behavior) both buried the viewer's own match and added scorecard/detail render jank (fixed 2026-05-26). (3b) Non-CTA actions consolidate behind a header settings button — the single contextual CTA stays pinned in BottomCtaBand; share + all host/admin actions move into a Settings-icon → dropdown (session-actions-menu.tsx — packages/features/sessions/src/session-actions-menu.tsx, a Tamagui Popover anchored bottom-end), never a row of inline pills down the scroll. The menu is grouped by role (viewer → participant → host/admin) with destructive items last (tone: 'destructive', red); each host action navigates to a dedicated full SCREEN (NOT a modal panel) — edit / participants / attendance / match-rules / payments(정산) live at /sessions/[id]/{edit,participants,attendance,match-rules,payments} (routes.session{Edit,Participants,Attendance,MatchRules,Payments}, nested per the detail-route-placement rule, each a DetailShell screen that fetches by sessionId + gates on permission). Modal panels (ModalPanel) are reserved for the completed-state 매너 태그 CTA + the push soft-prompt; the match-rules EDITOR field set is a single shared controlled body — match-rules-editor.tsx (packages/features/sessions/src/components/match-rules-editor.tsx — MatchRulesEditor, value/onChange) — consumed by BOTH wrappers: match-rules-editor-sheet.tsx (packages/features/sessions/src/components/match-rules-editor-sheet.tsx — ModalPanel chrome for create-session-step5 + per-match scorecard override) and the full-screen match-rules-screen.tsx (packages/features/sessions/src/match-rules-screen.tsx — DetailShell + BottomCtaBand for the session-level menu entry), so the field set can never drift between sheet and screen. (3c) Supporting metrics are tap-to-explain, not silent — a derived score the user can't interpret (the weather TMI / 테니스 지수) is rendered as a quiet tappable cluster: a traffic-light emoji (🟢🟡🟠🔴 via playabilityEmoji(level), parallel to the weather condition emoji) on ONE line with weather emoji·temp + the score (neutral text — the emoji carries the level/color, the number is not re-colored) — no "테니스 지수" text label, no Info icon — that opens an explanation sheet (playability-info-sheet.tsx — packages/features/sessions/src/playability-info-sheet.tsx — what it is + how it's calculated), never a bare unexplained number. (3d) Social proof via ordering, not an extra row — the participation avatar stack sorts the viewer's friends to the front (within the cap) instead of adding a separate "친구 N명 참가" line. (4) State-aware content — the status chip, the live PulseDot, the elevated block's copy, and the pinned CTA all branch on status so the screen genuinely feels different (and alive) as the entity moves open → live → completed. The pinned BottomCtaBand holds EXACTLY ONE CTA per state (never two), computed by state precedence and state-aware (no dead buttons): open → RSVP/cancel (or a cooldown-gated disabled CTA); in_progress + confirmed participant → 기권하기 (forfeit — typically injury; an EXCUSED withdrawal server-side via forfeit_session, so NO no-show strike — the live scorecard is reached via the matchups card + header, so the band isn't spent on that redundant link); completed + confirmed → 매너 태그; else none. Forfeit is host-vs-participant-aware and auto-substitutes matches (migration 00310, supersedes the deferred 00206 "Phase 2/3"): forfeit_session pulls the withdrawing player out of every live + upcoming match one round at a time (bench-confirmed first, then a best-effort waitlist promotion), keeping the protected matches.participant_ids cache in sync — which is WHY it must be the SECDEF RPC and never a client .update() (the 00067 protect_matches_scoring trigger silently reverts a plain authenticated write to participant_ids). No sub + a nullable (player2) slot frees the player; a NOT NULL player1 slot is left for a manual host reshuffle. The RPC auto-detects the caller: a HOST (created_by) stepping out keeps running the event and the admin signal is suppressed — the live-utilities row renders it as a distinct neutral 경기에서 빠지기 CTA (not the participant's destructive 기권하기); a participant forfeit still signals admins, now excluding the actor. Both call the same RPC; isHost client-side only picks label/copy/success-toast, and the forfeit hook invalidates matchKeys.bySession so the roster + draw reflect the substitute immediately. Session-fee rails: in-app 결제하기 (PortOne) is NOT wired for real users yet (mock adapter + dev-gated dev_mock_pay_session confirm), so it renders only in local dev; real users see the fully-wired manual 송금 완료 rail (attest → host confirms receipt in 정산) as the sole, primary CTA — restore the pay button when the real PortOne adapter + verify swap land (Phase B). Secondary participant actions do NOT get a second band button — e.g. GPS check-in moves to the header actions menu (sessionActions) when relevant (live + confirmed participant + venue-with-coords + not-yet-checked-in; useSessionAttendance gives check-in state, gated to live, so the item self-removes once checked in). A CTA that ignores whether its action is still possible (GPS check-in shown after check-in, or with no venue coords so it silently errors), OR a band with two competing buttons, are the anti-patterns this precedence prevents (fixed 2026-05-26). Destructive-CTA warning rule (general): ANY CTA — band or otherwise — whose action is destructive or mostly-irreversible (forfeit, delete, end-session, cancel-mid-flight, leave-club) MUST route through a useConfirm warning (confirm.show({ ..., buttons: [{ style: 'cancel' }, { style: 'destructive', onPress }] })) before it fires the mutation — the button NEVER calls the mutation directly. Reversible/low-stakes CTAs (RSVP to an open session, which can be re-toggled with no penalty) do not need a confirm; the gate is "can the user undo this?". (5) Aliveness is data-driven — PulseDot, ticking countdowns (useDeadlineCountdown), fill momentum, social-proof avatars, the TMI playability hook — never decorative motion. Keep color neutral-first; the only accents are meaningful data (playability level color, the status chip + the header's live PulseDot ($error red), $primary progress, surface-type tint). This generalizes beyond sessions — the club detail screen (club-detail-screen.tsx — packages/features/clubs/src/club-detail-screen.tsx) follows the same shape: a hero card with ONE focal number (member count, displayMd) + capacity meta + classification chips, de-iconed (no accent/Users lead icons), reusing SessionCard for the next-session preview (no embedded SessionHeader). Club SUMMARY cards are single-sourced in packages/features/clubs/src/shared/ (my-club-card.tsx, discover-club-card.tsx), typography-led with no decorative lead icon — never duplicate a summary card inline per screen.
See also
- Screen Blueprint — where these surfaces appear on each screen.
- Status & Recruitment Conventions — status-badge intent palette, recruitment derivation, status-adaptive surfaces, and status dots/tiles.
- AGENTS.md — machine-enforced component constraints.
- CLAUDE.md — orchestration core + the pointer index back to this doc.