Skip to content

Session Surface Conventions

Status: Active Last reviewed: 2026-09-17

How TwoMore renders session data across every surface — the canonical LIST card SessionCardV2, the SessionAttention model, the location-first SessionCard (now a rollback artifact — see below), the chip strip and status/RSVP chip logic, viewer-context threading, the SessionInfoModel projection system, the SessionCompactRow compact 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.

SessionCardV2 — the canonical session LIST card

SessionCardV2 (packages/app/src/presentation/components/sessions/session-card-v2.tsx, exported via @twomore/app) is the canonical card for every session LIST surface — Home's 진행 중/오늘 일정/오늘 복기/이번 주·달 예정, Club Home Tab's next-session preview + upcoming list, Club Session List's 모집 중/진행 중/완료 panes, and the 경기 (Activity) tab — graduated 2026-07-27 (d21f1fba), replacing the location-first SessionCard documented below on every list render site (10 sites across 9 screen files). SessionCard remains in the tree only as a rollback artifact (zero live JSX render sites as of this graduation); see session-card-v2-decision.md for the migration/decision packet this shipped.

Zero-data-hooks projection. SessionCardV2 takes model: SessionInfoModel plus bulk-resolved venue / weatherLabel / saved props and renders them — it never fetches its own data (the one exception is VenueTile's local useState for image-load-failure, local UI state not a data hook). The canonical per-list props builder is useSessionCardModels(sessions, userId) (packages/app/src/presentation/hooks/use-session-card-models.ts): one bulk useVenuesByIds + useSessionsRsvps (confirmed roster) + useMyRsvps (viewer's own RSVP) + useSavedSessionIds call for the WHOLE list, then buildSessionInfoModel per session — the missing Protocol A sibling that fixed the incumbent SessionCard's per-card hook storm (6+ hooks/card in production, zero of 15 consumers passing the bulk props that already existed).

Composition (updated 2026-09-08, on top of flat-layout plan F1's 2026-09-07 anatomy — owner: "we are moving away from top strips for session summary cards," refined the following day: the closing-row chips must be real badges, a live session gets its band back, and the bookmark must be visible on every card): the entire card is one Pressable tap target → SessionLiveBand (session-strip-ui.tsx) across the top ONLY when the strip's lead chip is live — the fully saturated pre-F1 ribbon grammar (PulseDot + 진행 중, all content $textInverse on the palette's deep fill), the ONE exception to F1's bandless rule; every other state stays bandless → a 64×64 venue satellite tile (selectVenueHeroImage(..., 'thumb'), glyph fallback for no-coords/failed-load, 1px $borderSubtle hairline, bottom-right surface-accent CourtLinesTile corner chip on the map/photo branch) beside a VenueLine (venue name · weather · the bookmark — ALWAYS visible, outline $textTertiary unsaved / filled $primary saved, right of the weather; renders the real SessionSaveButton variant="card" toggle when the caller threads sessionId+viewerId, else a static non-tappable glyph) / WhenLine (date + time + fee) / ChipsLine (format/style/tier) stack → a closing row: SessionStateInline renders the resolved strip's LEAD chip AND the viewer's STANDING chip as canonical SessionStripChipBadges (status-badges.tsx) — never a hand-rolled colored word — with live's lead badge omitted here (the band above already said it, so a live card's closing row carries the standing badge only) and each lead's structured sub composed into the same badge only when it's a fact the label doesn't already say (payment_due's D-1, closing's countdown, waitlisted_standing's queue position render; starting_soon/rain's sub is dropped — their label already names the moment) — the standing badge stays a quiet surface variant when the lead is an attention chip and the role is not browse; absent when the strip has no chips — followed by RecruitmentMeter (session-compact-row.tsx, flex; its own showStatusWord only when there is no strip lead — the SAME meter SessionCompactRow uses, not a second implementation) filling the rest, present on every card. An optional note slot (2026-09-12) renders a caller-supplied post-game row INSIDE the card under the closing row — home's 오늘 복기 uses it for RecapNoteBadge + EloDeltaBadge; the card adds no hooks for it and every other list omits it.

Rendering since 2026-09-07 (F1): the strip MODEL is unchanged; the BAND is gone — EXCEPT live (owner ruling 2026-09-08 carves that one case back in). deriveSessionStrip still resolves role → lead chip → class → sub → bookmark; presentation renders the LEAD + STANDING chips as badges on the card's closing row (SessionStateInline, session-strip-ui.tsx, via the canonical SessionStripChipBadge) and, on the detail screen, as badges in the hero's badge row (same wrapper, status-badges.tsx) — terminal ended strips render a full-width Banner at the top of the detail scroll instead. A live lead is the sole exception to "no band": SessionLiveBand (session-strip-ui.tsx) revives the pre-F1 full-solid ribbon across the card's top edge, and the closing row falls back to the standing badge only for that one state. Everything below about ribbons, pale bands, STRIP_BAND_TINT, the band-derived bandFor visuals and the right-pole bookmark indicator (beyond the live exception above) describes the retired presentation and is kept as the model's history; bandFor still runs (the detail Banner keys on band === 'ended'), the bookmark token still feeds accessibility text, and the visual bookmark — ALWAYS visible now, not saved-only — lives on the venue line as the real save toggle.

SessionStrip is the role-first, ALL-CHIPS model (v2, shipped 2026-07-29 — ba8d0128 + 82b5b915; domain source of truth packages/app/src/domain/session-strip.ts; visual spec docs/twomore_design_system/wireframes/strip-map.jsx). The strip is a ROW OF CHIPS — no free text, no punctuation. It replaces the earlier two-pole "actionable lead LEFT / quiet status ledger RIGHT" model: v2 has no ledger of durable marks at all — a viewer's relationship to the session (host/confirmed/waitlisted) is now the ROLE the whole resolution runs under, not a right-pole badge repeated beside the lead. deriveSessionStrip(model, saved) resolves the strip on THREE marked axes, no hardcoding (owner directive):

  1. ROLE (resolveSessionRole, host > confirmed > waitlisted > browse) — messages are role-relevant, never universal. A host never sees recruitment status (they aren't applying); a confirmed viewer sees 참석 확정, not 모집 중; ONLY a browse viewer (no RSVP — including a session the viewer's relationship to is over, e.g. cancelled/no_show) sees recruitment state at all. sessionCompleted (eligibility.status === 'completed') lets presentation soften a confirmed viewer's standing copy to 참석 완료 (past tense) instead of 참석 확정 — attendance is a separate axis from RSVP, not a fifth role.
  2. KIND (StripChipKind, 14 values) — WHAT the chip says. Universal interrupts (cancelled · live) preempt every role's stack; each role's own ranked messages come from deriveSessionAttention's 9-kind precedence table (below), mapped 1:1 to a chip kind via CHIP_KIND_BY_ATTENTION; when nothing interrupts, the role's STANDING chip is the default — host_standing, confirmed_standing (or payment_verifying when the viewer's fee payment is submitted-awaiting-confirm), waitlisted_standing, or a browse viewer's recruitment_closed (or nothing at all, when the session is still openly recruiting — the meter already says 모집 중, so silence IS the signal).
  3. CLASS (STRIP_CHIP_CLASS, the ONE table in session-strip.ts) — every kind is classified on two orthogonal axes, and presentation derives ALL visuals from this classification alone, never a per-kind switch:
    • emphasis — the chip-color principle (owner 2026-07-29): urgent (a clock is running or it's happening now: live · payment_due · closing · starting_soon) and final (terminal: cancelled) render as a FULLY SATURATED RIBBON — the whole band takes the palette's deep badge-TEXT-token fill with $textInverse bare content (the Toss critical-banner / M3 container+on-container / airline gate-change pattern; bare-ribbon model, owner 2026-07-29 — no pill chrome anywhere on the strip, ever: a pale band + solid same-hue chip is tone-on-tone collapse and reads as background). warning (should-know, no clock: new_applications · rain · score_confirm) keeps a PALE tint band; progress (payment_verifying) and standing (host_standing · confirmed_standing · waitlisted_standing · recruitment_closed) sit on the calm $surfaceTertiary band — in all non-saturated cases content is bare text/icon whose semantics live in the color alone. RENDERER RULE: band 'act' is shared by urgent (payment_due/closing) and warning (rain/new_applications) chips — saturation derives from the lead chip's emphasis, never from strip.band directly. The BOOKMARK is a bare glyph INDICATOR always at the strip's end (filled = saved via strip.right, outline = unsaved; white on ribbons with the outline at ~0.7 opacity, $primary/$textSecondary on calm) — a bandless browse card carries no indicator (no-mark IS the unsaved default) and the TOGGLE lives solely in the session-detail title bar (SessionSaveButton variant="header"), never on the thumbnail.
    • palette — the semantic color family (error · warning · success · neutral), mapping 1:1 to the existing status-badge token pairs ($badgeErrorBg/Text, etc.) — never a bespoke hue.

Structured StripSub qualifiers render as a FUSED sub-segment inside the same chip — never a second chip, never label text. A countdown (time_left), a start-in (starts_in), a waitlist queue position (position), a rain probability (probability), or the waitlist-open fact (waitlist_open) is DATA, not copy — presentation formats it and renders it after a 1px hairline divider (content color at ~35% opacity) inside the SAME bare content run, at IDENTICAL text weight/size to the main label (B안, owner 2026-07-29 — there is no separate "sub" typographic treatment). The hairline alone is the separator: no punctuation, no middle dots, ever.

The band is DERIVED from the lead chip's class, never set by hand (bandFor in session-strip.ts): finalended; urgent → by palette (errornow, successsoon, else → act); warningact (a white chip still needs the band to carry the "act" signal, since the chip itself is quiet); progress/standingcalm. A browse viewer on an open, non-full session has no lead chip at all (standingChip returns null) and no bookmark — the strip is null and the band collapses to 0px.

bookmarked is the SOLE right-pole token (SessionStrip.right), added whenever the caller's saved flag is true, on every band — but it visually YIELDS (hides) on any non-calm band, EXCEPT cancelled: a dead session's other state is already terminal and 취소됨 is short, so there's still room, and unsaving stays a live, actionable choice there. The accessibility label always speaks the bookmark regardless of the visual yield (sessionStripAccessibilityText).

Vocabulary (natural-Korean audit, owner 2026-07-29 — packages/app/src/config/i18n/ko/home.ts strip block; vocabulary target updated by the 2026-07-29 vernacular migration, see below). States read ~중 (라이브, 예비, 입금 확인 중), results read ~됨/확정 (취소됨, 참석 확정), actions read ~필요 (입금 필요, 점수 확인 필요). new_applications reads 새 신청 N건 (never a bare count). Countdown subs read N 남음 (time remaining, time_left) vs N 뒤 (future start, starts_in) — never N 전 ("N ago"), which reads past-tense as a standalone segment (구어체 뒤, not the literary 후 — the Toss register). A full session with the waitlist open fuses 모집 마감 + the waitlist_open sub 예비 가능 into ONE chip, never two and never a middle dot — 접수 is the RECEIVING side's verb (the host's), so the viewer-facing chip uses the viewer's own verb, 예비 가능. waitlisted_standing reads 예비 (with a 예비 N번 sub carrying queue position) — 대기 is retired from the waitlist relationship entirely (community-vernacular alignment: 밴드 참석/불참/예비 culture) and now reserved for process-queuing only; payment queuing reads 입금 필요/입금 확인 중 (club-ledger 입금 vernacular, was 송금 필요/송금 확인 중), never 대기, closing the four-way 대기 overload (송금 대기 · 성사 대기 · 신청 대기 · 대기 접수) the pre-v2 copy had. 2026-07-29 vernacular migration: RSVP verb is 참석/불참 (참가 kept only for 참가비 + tournament entry), waitlist vocabulary is 예비, fee-transfer copy is 입금, match-gen CTA is 대진 짜기 — see docs/design/terminology-guide.md. packages/app/src/config/i18n/ko/home.ts carries this copy (migrated; the i18n terminology test bans the retired 송금 forms).

GATE-1c (rain) is rain-driven and outdoor-only, never playability-driven (fixed 2026-07-29, 82b5b915). ConditionsInfo.rainRisk (session-info-model.ts, feeding the rain chip) now fires ONLY on an actual precipitation signal for the session window — the forecast condition is rain/snow/thunderstorm, OR the precipitation probability is ≥ 60 (the "likely" line in both KMA's 강수확률 convention and the US NWS ladder) — AND the venue is not explicitly indoor (where.isOutdoor !== false; an unknown venue stays conservative and still gates). It no longer ORs in a poor/unplayable playabilityLevel, which used to surface the rain chip on a merely cold or windy day at a misleadingly low percentage; playability stays the weather glance's own signal, never a rain proxy.

SessionAttention feeds the strip's interrupt and ranked role-message chips (packages/app/src/domain/session-attention.ts, deriveSessionAttention) — a pure, config-table resolver mirroring deriveRecruitmentStatus's style but answering "what does THIS VIEWER need to know about THIS session right now," a viewer-scoped fact, not a session-wide one. 9 ranked, audience-gated kinds, fixed precedence (first match wins): cancelled (rank 0, everyone) → live (10, everyone, pulsing dot) → payment_hold (20, self with an unsubmitted fee hold) → score_confirm (30, participant with an unresolved score call) → host_action (40, host with pending applications) → closing (50, NOT-yet-joined viewers only — recruitment pressure isn't a roster member's information, the strip-v2 role principle that recruitment state is browse-only) → rain (60, participant, outdoor rain risk) → waitlist (70, waitlisted self — this IS the waitlisted role's standing chip, carrying the position sub) → starting_soon (80, confirmed self, within the 2h window shared with the session_starting_soon push cron, migrations 00385/00421). SessionInfoModel carries the resolved attention (+ viewerIsHost) so the card never re-derives it ad-hoc.

Type-budget addendum (2026-08-04, commits 311342d1/fa9aff95 — a fix batch that rode along with the club-card corners-law iteration). ChipsLine's format/style/tier-fallback chips moved to Badge variant="surface" — the theme $surfaceTertiary bg / $textSecondary text family (fill stepped up from $surfaceSecondary in the 2026-08-05 accessibility pass — the secondary tint was near-invisible against the white card body; TierRangeBadge matches the same fill), matching the club card's corners-law chips (the prior badge-palette gray clashed hue-wise against adjacent theme text). The tier-less fallback chip's label is 실력 무관 (t().sessionHeader.open, shared with the club card's corner chip — '오픈' was rejected: 오픈부 names the elite amateur division). The card's own type budget: cardTitle (16/24 600) is reserved for the venue name ONLY — the one heavy weight on the card; the weather label, date/time, and fee text all dropped their per-line fontWeight overrides and now render at cardMeta's own 500 default, so the ramp reads "one heavy title + role-weight everywhere else" — the same discipline docs/canon/clubs.md's corners-law entry documents for club cards. Body-row rhythm follows the club card's own $2 spacing law. The closing RecruitmentMeter IS gated on model.who.maxPlayers > 0 in SessionCardV2 (final fix batch 2026-08-05 — an unbounded session would otherwise render a meaningless 0-denominator bar), mirroring SessionCompactRow's own showCapacity gate; SessionCompactRow's ProgressBar math is additionally divide-by-zero guarded.

Known deferrals (2026-07-27, payment-status chip closed 2026-07-28 — input parity; score_confirm list/detail parity closed 2026-07-30). The list card shows no weather glance beyond the plain weatherLabel prop passed in (no per-card useSessionWeather fetch — no bulk weather source is bundled into useSessionCardModels yet). Payment-status GAP-FILL is now bundled (useMySessionPayments + useHostedPendingApplicationCounts, Protocol A) — see the SessionStrip contract section below. useSessionCardModels still passes DEFAULT_ELO for both singles/doubles (no consumer threads real per-viewer ELO into it yet — only session-detail does), so eligibility.viewerEligibleForTier is a known-coarse placeholder at list scope. viewer.scoreConfirmPending (the score_confirm attention's trigger, rank 30) is now wired on both surfaces. Detail (useSessionDetailModel) still bulk-fetches the session's active score calls via useSessionMatchScoreCalls(sessionId) (Protocol A, one query) and derives the viewer's pending-confirm boolean via the pure hasPendingScoreConfirm(matches, scoreCallsMap, userId) selector (domain/utils/score-confirm-selectors.ts) over the screen's already-loaded matches. List scope CANNOT run that same selector — cards never load a session's Match[] — so useSessionCardModels instead bundles useSessionsPendingScoreConfirm(sessionIds, userId) (presentation/hooks/queries/use-match-score-call.ts), a dedicated bulk hook backed by a new adapter query (MatchScoreCallRepositoryPort.findPendingConfirmSessionIds) that joins matchesmatch_score_calls server-side and returns the viewer's unresolved-call session ids directly, instead of loading every match. The hook is scoped to participatingSessionIds — the subset of the list where the viewer has a CONFIRMED rsvp (derived from the already-fetched myRsvpsMap, no extra query) — since a pending call can only exist for a session that reached in_progress/completed with the viewer in a generated match; this keeps the query cheap and mirrors how hostedSessionIds scopes useHostedPendingApplicationCounts above. A score_confirm attention now renders identically on card and detail for the same session.

The SessionStrip contract has ONE inline renderer, SessionStateInline (2026-09-07; SessionStripView survives only as a deprecated alias), and one badge wrapper for heroes, SessionStripChipBadge. (Historical text follows — size="card"/size="detail" chrome density no longer exists.) deriveSessionStrip(model, saved) (domain/session-strip.ts) resolves the strip; SessionStripView({ strip, model, size }) (presentation/components/sessions/session-strip-ui.tsx, exported via @twomore/app) renders it — SessionCardV2 calls it size="card", the detail screen calls it size="detail" with its own deriveSessionStrip(sessionModel, saved) — never a second hand-rolled host/payment/rsvp conditional, and never a second component. size (STRIP_SIZE_CONFIG) changes ONLY chrome density: $2 padding / cardMeta text role (card) vs $3 padding / cardBody text role (detail) — never color, chip classification, or copy. Renders null on BOTH surfaces when strip is null — the band's absence on an open, no-relationship session is itself the signal on both surfaces.

The shared label/tint vocabulary (STRIP_BAND_TINT, the private chipLabel/chipSubText i18n mappers, sessionStripAccessibilityText) lives alongside SessionStripView in session-strip-ui.tsx, so card and detail can never drift again. Input parity: useSessionCardModels (use-session-card-models.ts) bundles useMySessionPayments + useHostedPendingApplicationCounts + useSessionsPendingScoreConfirm (all bulk, Protocol A) to feed the SAME paymentStatus/paymentSubmittedAt/paymentHoldExpiresAt/pendingApplicationCount/scoreConfirmPending viewer GAP-FILL the detail screen threads from its own per-session queries — a payment_hold/score_confirm/host_action attention or a submitted-payment 입금 확인 중 (was 송금 확인 중) standing chip now resolves identically on card and detail for the same session.

SessionCard layout (HISTORICAL — deleted, no rollback path)

As of the SessionCardV2 graduation (2026-07-27, d21f1fba) this section documented the session-card.tsx subtree, initially kept only for rollback. That subtree — session-card.tsx, session-header.tsx, host-badge.tsx, session-top-banner.tsx, session-status-banner.tsx, session-meta-section.tsx, session-match-detail.tsx, session-weather-glance.tsx — has since been fully DELETED (2026-07-29); there is no code left to roll back to. SessionCardV2 above is canonical, and sole, for every list surface. The section below is left intact as pure historical prose — what the retired card used to do — not a spec for anything that exists in the tree today.

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":

  1. Status strip (live / cancelled / deadline / almost-full — one at a time, precedence-ordered) at the very top of the card, above the hero.
  2. Title row — the location TITLE (broad region, space-joined e.g. 서울 강남구; fallbacks: venue name → generated sessionTitle) + the top-right headerAction (HostBadge for the host, SessionSaveButton otherwise).
  3. 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 resolvable courtVenueId (free-text location, fetch miss) renders the classic VenueLink + weather row in the hero's place — the WHERE/conditions facts never disappear with the map.
  4. WHEN band — a soft full-width $surfaceSecondary strip (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.
  5. Two-tier chip strip (chipRowTwoTier, below).
  6. Optional note chip, 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.

(Historical — no live caller, file deleted.) SessionCard accepted an optional footer?: React.ReactNode — rendered inside the card surface below SessionHeader; used to inject match rows without nesting cards. SessionHeader itself is deleted too (2026-07-29) — there is no remaining direct-usage carve-out; any new hero/detail layout composes SessionCardV2/SessionStripView pieces instead.

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, RsvpStatusBadge renders ADDITIVELY beside it. Same precedent as the payment chip — additive, never replaces.
  • Other statuses + viewer confirmed/waitlisted → RsvpStatusBadge REPLACES SessionStatusBadge. 모집 중 / 마감됨 is irrelevant to someone already in — show their state, not the room's.
  • Other statuses + viewer not participating → for open/locked (recruiting) sessions, RecruitmentStatusBadge via sessionRecruitmentState (the derived 모집 중 / 성사 대기 / 마감 임박 / 대기 모집 / 마감); SessionStatusBadge only for non-recruiting statuses (live/completed/cancelled). The payment chip (SessionPaymentStatusBadge) is ADDITIVE and renders only when myRsvp.status === 'confirmed' AND a SessionPayment row exists for the viewer — it goes in the classification strip, not the meta row.

SessionCard chip strip — the canonical two-tier flow (HISTORICAL — the deleted SessionCard's layout)

This whole section describes the deleted SessionCard's two-tier chipRowTwoTier, not SessionCardV2. SessionCardV2's chip strip (ChipsLine, session-card-v2.tsx) is a SINGLE row of three chips — [format] [style] [tier] (Badge size="sm", gap="$2", nowrap, tier is the one shrinkable/ellipsizing element) — not the two-row, gate-symmetric layout below. What's still true on both: the ordering is grounded in the same SessionInfoModel gate taxonomy (eligibility → kind → cost; see SessionInfoModel projections), and the fee chip reads the canonical session.participationFee (the legacy costTotal-split cost chip stays retired). The two-tier mechanics below are left as historical reference only:

The full-size SessionCard renders the two-tier chipRowTwoTier, whose order is grounded in the SessionInfoModel gate taxonomy (eligibility → kind → cost). (Graduated from the sessionSummaryPolishMode experiment 2026-07-18; the single wrapping classic chipRowformat → 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 (복식 …) — explanatory kind.
    • tier band — 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분, when rotationMinutes), 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), from publicInviteApproval. Shown when origin === 'pickup' OR (origin === 'club' AND publicInviteEnabled). publicInviteEnabled is 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

HISTORICAL (deleted SessionCard): SessionCard used to receive viewer context via the userId prop and internally derive it — useMyRsvp(userId, sessionId) + useMySessionPayment(sessionId, userId), cache-shared with the (also deleted) SessionMetaSection's useSessionRsvps. Current reality: SessionCardV2 takes NO userId prop and does zero internal viewer-context derivation — it is a pure SessionInfoModel-projection component (see SessionCardV2 — the canonical session LIST card above); the viewer context is folded into the model by the CALLER's bulk builder (useSessionCardModels(sessions, userId)) before the card ever renders. Every authenticated list render path threads userId into that bulk builder 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 / cost / 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 field SessionCardV2 has always rendered; the table's card row omitted the 'cost' key until session-info-model.ts was corrected, 2026-07-31). The projection table is DESCRIPTIVE, not runtime-enforced — no surface reads it to filter which gates it renders, so it can drift silently; session-info-model.test.ts hand-lists the gates SessionCardV2 actually reads and asserts card is a superset, which is the only mechanical guard today. 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 session minimum set even when squeezed inside another card. v3 (owner direction 2026-07-20, replaces the date-tile layout — DateTile made the row tall and let the "time · venue" line wrap; the old dateStyle: 'tile' | 'dot' prop and SessionStatusDot are gone, no card uses DateTile inside this row anymore): THREE rows, no status dot at all — Row 1: title (truncates) + right-aligned tabular date·time; Row 2: VenueLink (venue name + surface glyph, self-hides with no location); Row 3: RecruitmentMeter (status word + fill bar + N/M count, self-hides when capacity is unknown — also exported for reuse by SessionCardV2's meter line, one implementation). The whole row is a Pressable → session detail when given a sessionId (nested under any embedding card; the inner VenueLink is a non-interactive tag, no nested tap); surface-less with no sessionId. DateTile (packages/ui/src/date-tile.tsx) still exists as a @twomore/ui export but has zero live JSX render sites in the app today (session-identity-card.tsx explicitly dropped it; club-vitals.ts's buildDateTileProps has no external caller) — it is simply no longer part of SessionCompactRow, and shouldn't be cited as an active pattern elsewhere without verifying a real call site first.

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 — ratified law, 2026-09-17 (decision log SD-IA — supersedes every prior "TWO grouped surfaces / iOS-grouped-list cards / header settings-button dropdown" framing below this line; none of that exists in the shipped screen — no Divider-separated grouped cards, no session-actions-menu.tsx, which was deleted). A detail screen is a DECISION/ACTION surface, not a glance. Session detail is ONE flat scroll (DetailShell ground="canvas", no boxed surfaces) rendered top to bottom in this exact order — verified against session-detail-screen.tsx (packages/features/sessions/src/session-detail-screen.tsx):

  1. SessionLiveAnchor — the page-level "진행 중" band (decided 2026-09-17, docs/twomore_design_system/wireframes/live-anchor.jsx; see also Live anchor band below) — the absolute first flow child, self-gated to null unless the session is in_progress.
  2. Terminal-state Banner tone="neutral" — only when the strip's lead is terminal (sessionStrip.band === 'ended', e.g. cancelled); mutually exclusive with the anchor above (a session is never both in_progress and cancelled).
  3. SessionHeroSection — full-bleed MediaHero (venue static map) / TypeHero fallback, carrying the strip's lead chip as an inline titleBadge beside the title (the viewer's own standing chip renders in the StatStrip below, not here).
  4. ONE FlatColumn, whose lead slot carries StatStrip (확정 인원 · 내 참가비 · 참석 마감 — a hero-zone block, never a section) and whose children render, in order: LiveParticipantBar (Banner tone="error") → RefundBannersNoticeSection (Well tone="primary", 공지) → PaymentGateBannerParticipationSection (참여) → MatchesSection (경기) → InfoSection (정보) → HostCalloutSection (host identity card, always renders) → HostToolsSection (호스트 도구) → DangerZoneSection (위험 구역, 일정 취소/삭제).
  5. Footer, outside the scroll body (DetailShell's footer slot): BottomCtaBand holding the ONE state-precedence CTA — see §"Session/detail screens with a single contextual primary action" above; this section does not restate that rule.

Section-gate rule. Every section in step 4 above, EXCEPT HostCalloutSection (which always renders a real SectionBlock and never returns null, so there is nothing to gate), is gated at the CALL SITE — showX ? <Section/> : null, booleans exposed from useSessionDetailModel/useSessionDetailActions (showNotice, showLiveParticipantBar, showRefundBanners, showPaymentGateBanner, showMatches, showHostTools, showDangerZone) — rather than relying on a section's own internal self-return. Why: FlatColumn's divider math sizes off the JSX element graph, which can't see a component that will internally return null — an unconditionally-passed self-gating child still counts as "1 item", producing an orphan/doubled hairline (seam-audit P1, docs/design/detail-pages-seam-audit-2026-09-06.md). MatchesSection was the one section that drifted from this rule — self-gated alone on session.status === 'cancelled', with no showMatches call-site mirror — until the 2026-09-17 drift fix added it. Every section keeps its own internal guard too, as defense-in-depth, but the screen never relies on that alone. Full behavioral detail this section doesn't restate (CTA state-precedence branches, forfeit, destructive-confirm, the matchups card's viewer-only lens) lives in session-detail-screen.tsx's own header comment and its ./session-detail/* split — read the code, not a paragraph here, for that volatile detail.

Live anchor band

A page-level "진행 중" band on the three live-session surfaces (decided 2026-09-17, docs/twomore_design_system/wireframes/live-anchor.jsx). SessionLiveAnchor (packages/app/src/presentation/components/sessions/session-live-anchor.tsx, exported via @twomore/app) promotes SessionLiveBand's full-solid ribbon grammar (session-strip-ui.tsxPulseDot + 진행 중 in $textInverse on the palette's deep fill) from CARD scope to PAGE scope, widened with a trailing slot carrying three facts on the same row, MetaBreak-divided: elapsed time since play started (ElapsedClock + formatElapsedDurationKo), the round currently being played of how many total (t().sessionLive.round(current, total)), and how many courts are live right now (t().common.courtCountText(n) — the same '코트 N면' formatter the hero's own booked-court-count fact uses, not a duplicate key). A fact renders only when its value resolves (non-null / non-zero); the label itself reuses common.live (already '진행 중') via the canonical live StripChip, so no new key duplicates that value. Protocol B, not a second fetch (fixed 2026-09-17, PF-01): a component reading the SAME composer as its parent screen accepts the composer's output (session/matches) as props instead of calling useSessionLiveFacts itself — every mount site already holds both from its own useSessionDetailModel/useMatchBoardData/useLiveSessionData call, so threading them down skips mounting useLiveSessionData's backfill effect + 10s poll interval a second time per screen. useSessionLiveFacts (still useLiveSessionData under Protocol A — TanStack Query dedupes the underlying fetch) remains only as the fallback for a standalone caller with no composer of its own; both paths resolve through the same pure deriveSessionLiveFacts. Mounted as the absolute first flow child on all three: session-detail-screen.tsx (above even the terminal banner and hero — the header's own titleLeading PulseDot is unchanged, since the collapsing bar still needs its own signal once scrolled past the band), match-board-screen.tsx (above TypeHero — its first live signal ever), and spectator-scorecard-screen.tsx (above SegmentedTabs, inside the screen's scroll={false} body — pinned for free, since only the active tab's own pane scrolls).

See also

Markdown remains the source of truth. Run yarn docs:check before handoff.