Skip to content

Surface System Unification — Clubs + Sessions (info-models, projections, canonical bindings)

Status: Active Approved: 2026-07-18 (owner amendments folded in through v19) Build status (2026-07-19): ⬜ NOT STARTED — the next R3 design-led track (see the Project Cockpit, the single cross-project tracker). The "Build status" and "Phase plan" summaries are directly below; the spec (§0–§5) is the deep reference. Sibling: the Engineering Soundness Masterfile. Visual reference of record: the TwoMore Surface System artifact 0c120711 — the multi-page design reference (shared rules · primitive sample gallery where every element carries its source-extracted spec line · session cards/pages · club cards/pages · all 10 domain pages rendered as FULL top-to-bottom phone frames (36 complete screens, header chrome to tab bar/CTA band) · coverage map · roadmap). Spec values were extracted from shipped source by a 3-auditor pass 2026-07-18 (77 components: text roles, Badge/Button/chip metrics, DateTile/CapacityRing/podium/donut dimensions, shell chrome). Working artifacts during design: club 8068762b, sessions 4d4aa381 (superseded by the system artifact for acceptance).

Build status

Start here. The Project Cockpit is the live cross-project tracker; this spec (§0–§5) is the deep reference for the surface-system build. Full phase detail: §4 Build phases.

FieldValue
Build status⬜ NOT STARTED
Sequenced afterUX-audit residuals settle
Visual acceptance referenceartifact 0c120711 (36 rendered phone frames) + working artifact 8068762b

2026-07-20 update: the DiscoverClubCard leg of Phase 2 (item 7) shipped ahead of the rest of this spec (cd51677a, bad94d6d, 2c23b40e, 5a11f517), then the owner reviewed it and ordered a further re-split to a lean bait card the same day (661cb825, OTA 74989e41) — the card had grown into a mini-profile. See §3.9m for the shipped composition and the updated discoverCard row in §3.1's table. The rest of this spec (session family, other club surfaces, the four panes) remains unbuilt — "NOT STARTED" above still holds for those.

Phase plan

(each phase: targeted jest → yarn check → lint:strict → commit → OTA preview → baseline advance → owner on-device QA)

PhaseStatusScopeOTA-visible
1 — model spine⬜ Not startedClubInfoModel + projections; ship docs/canon/surface-system.md, rewrite clubs/sessions canon; a migration for the discovery-stats RPC. No visual changeno
2 — approved surfaces⬜ Not startedCompose the canonical club + session cards/wells against the model (wireframe 8068762b)yes
2b — the four panes⬜ Not startedClub detail IA: 홈 · 게시판 · 랭킹 · 관리 (renames + tappable people strip for roster reachability)yes
2c / 2d — session family + CTA⬜ Not startedWire the model into card/compact/share; BottomCtaBand sublabel slot; bounded-exception canon; retire the legacy costTotal pathyes
3 — mechanical batch⬜ Not startedNet-new audit catches (items 16–18) + stale-doc hygiene, riding alongyes

The all-domain audit pass (§4) is DONE — every domain page of artifact 0c120711 is filled with wireframes from extracted source beats; net-new catches folded into Phase 3.


0. The shared surface-system rules (feature-agnostic — both families obey ALL of these)

RuleStatementElaborated
Info-model per domainOne pure-data domain model per entity family (SessionInfoModel existing, ClubInfoModel new), i18n-free, in packages/app domain, resolved by one builder. Surfaces read model gates, never raw entity fields, for gate values.§2.1, §5.2
Declared projectionsEvery surface that renders the entity is a DECLARED, named projection (a gate array). No undeclared surfaces. Projections are order-preserving subsequences of their canonical order (clubs: per audience arc; sessions: the single abort-risk order).§2.1, §3.9d, §5.2
Fact-ownershipEvery fact is owned by exactly one gate; it renders only inside its owning gate's section; omitting a gate makes its facts absent, never relocated. Enforced by the per-gate model interfaces.§3.9d
Component–gate alignmentComponents map 1:1 to gates with declared densities (e.g. nextSession → Well hero | SessionCompactRow compact). A surface never introduces a sibling variant; projections choose presence and order, never appearance.§3.9i/j, §5.2
Single-occurrenceEach fact renders once per surface. Two named exceptions: the action-point CTA recap sublabel, and cross-surface bait→payoff repetition.§3.9c
Canonical bindings onlyEvery element binds to a source-verified canonical primitive with $ tokens — no raw values, no hand-rolled duplicates. The bindings tables (§3.9h/l) are the build contract.§3.9h, §3.9l
The rulerRow heights {44 bordered/touchable, 36 inline}; paddings frame $3 / row $2; gaps $2 row / $1 icon+chip; section rhythm 12/8; one left content edge, one right trailing edge; SectionEyebrow as the one section-label vocabulary.§3.9k
Status-adaptive, status-invariantState changes what appears (banners, muting, chips), never how a component looks. Domain status renders only via canonical XxxStatusBadge wrappers.§3.9c, §5.2
CTA canonCTAs live in exactly two places: the pinned BottomCtaBand (one action per state; sublabel recap slot; binary-fork named exception; destructive never pins) or sibling-of-card compact rows; bounded in-card exception = per-item list-row ≤2 compact mark-done buttons.§5.3

Canon distribution at Phase 1: these shared rules ship as a new docs/canon/surface-system.md; docs/canon/clubs.md (rewritten) and docs/canon/sessions.md (amended) become the per-family applications referencing it — one rule set, stated once. Grounding: 5-reader research pass over live HEAD (card anatomy · detail anatomy · SessionInfoModel precedent · adversarial fact-by-fact drift diff · UX walk + audit-digest harvest); every claim cited to file:line. Owner design decisions locked in §3.


1. Findings (what actually runs)

1.1 The four surfaces and their one half-shared model

buildClubCardModel (packages/features/clubs/src/shared/club-card-model.ts:271-388) genuinely feeds all four club surfaces — DiscoverClubCard (discover-club-card.tsx:64), MyClubCard (my-club-card.tsx:75), club-detail's 홈 tab (club-detail/home-tab.tsx), and PublicClubProfileScreen — so the core derivation is single-sourced. But it fails the codebase's own canonical bar (SessionInfoModel, packages/app/src/domain/session-info-model.ts) on three axes:

  1. Wrong layer. It is feature-package-local (packages/features/clubs/src/shared/). ArchUnitTS ARCH-2 blocks every other feature (home, sessions, profile, records) from importing it. session-info-model.ts lives in packages/app domain precisely so all features reach it via the @twomore/app barrel — that placement is load-bearing.
  2. i18n in the model layer. It bakes t()-produced Korean strings into the model (formatLabel, capacityLabel, peopleText, recruitLabel, chipGroups). SessionInfoModel is deliberately i18n-free — labels are surface-layer concerns.
  3. No projection discipline. There is no CLUB_SURFACE_PROJECTION; surfaces consume the model degraded and re-derive around it:
    • 홈 tab calls buildClubCardModel without stats, silently nulling memberCount/openSlotCount/avatars/daytimeLabels — then re-derives member count from raw members.length (bypassing the server aggregate the cards use) and computes tierText twice.
    • Public profile recomputes capacityRatio inline below the model call that provides it, renders the raw region slug instead of the model's regionText, and imports 3 symbols via the internal @/ alias instead of @twomore/app (the boundary bypass that enabled the schedule drift).

1.2 The cards themselves are canonical; the canon doc is not

The shipped card is a 5-zone composition (banner+overhangs · identity · tagline · ClubNextSessionWell · ClubScheduleBlock+chips) — docs/canon/clubs.md still describes the retired pre-2026-07-06 four-zone design (capacity-ring banner pill, titled 정기 일정 block, Divider+SessionCompactRow). Neither detail screen uses any of the card's three signature primitives (ClubNextSessionWell, ClubScheduleBlock, ParticipationCluster) — each hand-rolls different hero/schedule/member-count widgets, which is why card→detail breaks visual continuity despite the shared model function underneath.

1.3 Drift inventory (ranked)

#KindFactDisagreementEvidence
1BUGRegionPublic profile renders raw English slug ("seoul"), bypassing its own cardModel.regionTextpublic-club-profile-screen.tsx:315-319
2BUG · 3-way다음 일정 identityDetail hero query excludes in_progress; detail's own list below it AND the cards' RPC include it — hero can contradict the list on the same screenclub-detail-screen.tsx:76 vs home-tab.tsx:108 vs RPC 00256
3TRUNCATION정기 모임 rowsRPC caps at 3 rows (rn <= 3, migration 00267); public profile's separate useClubRegularMeets query is uncapped — a 4-meet club shows different schedules on card vs profile00267 vs profile query
4DUP · latentMember countCards+profile use stats.activeMemberCount (RPC); detail recomputes from raw members.length — no shared code keeps them matchedhome-tab.tsx member block
5LABEL · 3-wayLevel/tierCards: NTRP band (formatSkillBand); detail + profile: ELO tier names (formatTierRange, two call sites) — same minTier/maxTierclub-detail-screen.tsx:96 + profile chipGroups
6DUPcapacityRatioRecomputed inline on profile below the model callprofile
7ARCHImport boundaryProfile imports via internal @/ alias, not @twomore/appprofile imports

Verified not drifted: banner/hero media precedence, avatar-privacy filtering (single RPC / single model field).

1.4 UX gaps (data-exists unless noted)

  • Price (duesAmount/defaultGuestFee) and distance (useUserLocation + haversineMeters + ClubDiscoveryStats.regularMeets[].latitude/longitude, whose field comment literally says "for the viewer→court distance signal") are top-tier join-decision factors, fully plumbed, rendered nowhere.
  • Public profile — the join-decision screen — has the plainest next-session block of the four surfaces.
  • 홈 tab leads with a non-actionable identity Card (home-tab.tsx:200-271) restating the AppHeader's club name (club-detail-screen.tsx:204) ahead of dues/next-session.
  • Dead non-member join CTA on member detail (club-detail-screen.tsx:271-281 — unreachable past the :90-94 bounce).
  • 총무 has no dues-amount edit path in club settings despite the field existing end-to-end.
  • Hardcoded English "Strike" (club-attendance-screen.tsx:231); dead self-demote guard (club-members-screen.tsx:784-791); missing join-policy WizardReviewRow in create-club-step7.tsx; no pull-to-refresh on members/dues screens (siblings have it).
  • Tags reality: a free-form club tags field was deliberately retired (club-card-model.ts ~202-204: it duplicated focus/composition/schedule and overflowed the chip strip). The live tag vocabulary is derived: focus 균형형/친목형/경쟁형/성장형, composition 남성만/여성만/혼성, format, daytime 오전/오후/저녁, tier band — plus tagline (80-char free text) and proofSignals. chipGroups.play is currently unrendered on the discover card.

2. Target architecture — ClubInfoModel

Structural clone of the session precedent (session-info-model.ts is the only info-model in the repo; grep confirms).

2.1 The model

packages/app/src/domain/club-info-model.ts:

  • Gate taxonomy + TWO canonical arcs (v6 — the semantic recomposition, research-grounded; supersedes the single-order taxonomy). One gate vocabulary (ClubGateKey): identity · character · rhythm (정기 모임) · conditions (가입 안내: 승인제/회비/자리) · people · proof · nextSession · plus member-scope obligations (my dues state, unread board). Over it, TWO canonical orders:
    • CLUB_ACQUAINTANCE_ORDER (guest surfaces — the six-beat acquaintance arc from organizational-socialization/trust research × the real page orders of Meetup/Strava/당근모임/Heylo): identity → character → rhythm → conditions → proof → nextSession(invitation) → action. (v10, owner: the people gate is MERGED INTO identityIdentityInfo owns name/logo/banner/region/district/tagline-position PLUS memberCount/maxMembers/capacityRatio/composition/memberAvatarUrls. The identity BAND renders people facts at per-surface density: card = a compact demographics line under the location line [혼성 · 20~40대 · 멤버 N/M명], profile = the capacity-donut line, 클럽 홈 = the tappable roster strip inside the identity band. This restores demographics to the discover card with fact-ownership intact — one owner, one section, densities differ. proof keeps vitals + the join-gated roster tease [access, not identity]; the full roster grid stays the deepest block.) Conditions sit adjacent to the preview (the Heylo/당근 "requirements never buried" canon; honest realistic preview builds trust — Wanous RJP); nextSession is the personal invitation beat ("come THIS Saturday"), last before action; the CTA carries a conditions recap sublabel.
    • CLUB_MEMBER_ORDER (member surfaces — the urgency-ordered member loop: belonging → obligations → participation): identity → obligations → nextSession. (v10: the roster strip is part of the identity band — belonging includes my people.) (v7: clubmates' faces ARE the belonging beat — the tappable 멤버 strip [also the roster entry] sits directly under the identity strip. v8: rhythm DROPPED from member surfaces — members know their cadence, the 다가오는 일정 instance rows carry the same MeetAgendaRow visual language, and the cadence stays one tap away on the profile; revisit if new-member QA misses it. Standing/growth live in the 랭킹/관리 tabs.)
    • The subset rule, restored per arc: every surface's projection is an order-preserving SUBSEQUENCE of its arc — discoverCard ⊆ publicProfile (acquaintance), myClubCard ⊆ detail(홈) (member). This is the session precedent's core structural invariant, re-established at arc granularity; the per-audience arc split is the ONE named deviation from sessions' single order (a session means the same thing to every viewer; a club means two different things to an outsider and a member) — the canon rewrite must state this deviation explicitly rather than claiming a full structural clone.
    • Session-precedent hygiene to carry over: projections are audited against rendered JSX, not hand-authored (the session model's own card projection drifted — it omits cost while the shipped card renders a cost badge; fix that drift when touching session-info-model, and don't reintroduce the gap in the club model).
  • Per-gate interfaces, values only (enums/booleans/numbers/Dates — ZERO t() calls):
    • IdentityInfo
    • KindInfo { format, focus, memberComposition, minTier, maxTier } — the "what is this club about" gate
    • ScheduleInfo
    • PeopleInfo
    • JoinabilityInfo
    • ProofInfo
    • NextSessionInfo — mirrors ClubDiscoveryStats['nextSession'] (venue/coords/photo, date/time, confirmedCount/maxPlayers, avatarUrls)
  • Input: { club: Club; media?: ClubMedia[]; stats?: ClubDiscoveryStats | null; nextSession?: Session | null; upcomingCount?: number; now?: Date } — same precedence as today (stats wins).
  • One next-session status source: the model consumes stats.nextSession (RPC 00256 statuses) as THE definition; closes drift #2 by construction.
  • Projections: ClubSurface = 'discoverCard' | 'myClubCard' | 'detail' | 'publicProfile' with CLUB_SURFACE_PROJECTION: Record<ClubSurface, readonly ClubGateKey[]> (orders in §3.1). No speculative compact projection — added only when a real consumer lands (session precedent).
  • Audience axis (owner directive, 2026-07-18 — the bait→payoff contract, §3.4): ClubAudience = 'guest' | 'member'. Every model gate is guest-safe (identity · kind · schedule · people-aggregate incl. privacy-filtered ≤5 avatars · joinability · proof · nextSession); member-scope content (my dues state, board, rankings, full role-grouped roster, admin) is deliberately NOT in the model — it comes from member-scoped queries and may only render on member surfaces (myClubCard, detail). Guest surfaces (discoverCard, publicProfile) may render ONLY model gates, and the public profile advertises the member-scope content's existence without leaking it (the tease row: "멤버 전체와 게시판은 가입 후 볼 수 있어요"). This is the UI contract mirroring what RLS already enforces underneath — document it as a rule in the canon rewrite.
  • Exports: via domain/index.ts (~354-376 block) + index.ts (~455-477 block), same two-barrel edit sessions made.
  • Tests: club-info-model.test.ts mirroring session-info-model.test.ts's fixture-only shape (no t() mock possible — the model can't call it).

2.2 Migration path (zero consumer churn)

club-card-model.ts stays in packages/features/clubs/src/shared/ but buildClubCardModel() is rewritten as a thin adapter: buildClubInfoModel() for all pure values, then the existing t()-driven label layer (chip strings, capacityLabel/peopleText/recruitLabel, discoveryScore ranking — ranking is a feature concern, stays here) on top. ClubDiscoverySummary return shape preserved; both card call sites untouched. This is the exact split session-card.tsx performs over SessionInfoModel.

2.3 Enforcement + canon

  • Rewrite docs/canon/clubs.md: current 5-zone card reality + a "ClubInfoModel projections" section paralleling docs/canon/sessions.md ("every club surface is a differently-configured projection … never re-derives gate values ad-hoc").
  • CLAUDE.md Rules: extend the club-conventions pointer line to name buildClubInfoModel as the club sibling of buildSessionInfoModel.
  • ArchUnit: the new domain file must pass platform-purity (no react/RN/tamagui/@twomore/ui/t() imports) — the existing fitness rules cover it once it's in domain/.

3. Owner-locked design decisions

3.1 Join-seekers read regular meets, not upcoming meets (owner insight, 2026-07-18)

"People would be looking at regular meets, not upcoming meets, as they look for a club to join … remove or push [next session] down to the bottom of the card. Instead we should put the club tags so people can get a feel for what the club is about."

Encoded as per-surface projection order — this is exactly what the projection model exists for:

SurfaceProjection orderNext-session treatment
discoverCardSHIPPED 2026-07-20 (§3.9m), supersedes this row: identity (incl. people demographics + 📅 frequency glyph) → character (tagline + style chips) → joinability (one quiet terms line)Removed entirely — no schedule rows, no next-session line or Well render on the card at all; both are now profile-exclusive (the payoff surface, §3.3).
publicProfileidentity → kind (tags) → schedule (uncapped meets) → people → proof → nextSession (Well) → action (join CTA)Well survives here, demoted below schedule — a prospect one step from joining deserves the rich venue/faces proof before the CTA.
myClubCardidentity → nextSession (Well) → schedule → peopleMembers DO care about upcoming — the Well stays the hero. The split IS the insight.
detail (홈)identity (strip) → nextSession (Well) → dues → schedule → boardMember daily-return ordering (W2-A).

3.2 The tags/chip block (discover card — SHIPPED 2026-07-20 as a leaner set; see §3.9m)

Supersedes the price+distance+composition+daytime chip plan originally speculated here (never shipped in that form). What shipped (model.styleChips, club-card-model.ts:109,364): tagline (the club's own voice, up to 2 lines, rendered as ink above the chips, not a chip) + focus (친목형/경쟁형/성장형 — omit 균형형 default) + format (복식 위주 …) chips ONLY. Composition (혼성 …) moved to the identity band's demographics line and meet cadence to the new 📅 frequency glyph line (fact-ownership, §3.9d) instead of the chip row — the card's chip strip is style/vibe only. The 월 회비 chip did NOT ship as a chip — dues renders on the card's quiet terms line instead (weighted 700, alongside the join-policy label). The distance chip did NOT ship at all — nearest-venue distance remains a fast-follow item (§4 item 11, ClubDiscoveryControls). No new free-form tags field — it was retired for overflow/duplication reasons that still hold; if richer "vibe" tags are ever wanted, that is a new-data owner decision made against that history.

3.3 The bait→payoff consistency contract (owner, 2026-07-18)

"The club card should feel like, not just a summary, but a bait to lure people in. Club detail should expand on the information presented on the card so that users' curiosity is met. That means we have separation between members-only information and guests-only information."

Two journeys, one rule set:

  • Guest journey: DiscoverClubCard (bait) → PublicClubProfileScreen (payoff). Every card fact expands: tagline → full 클럽 소개 prose (description); merged week strip → uncapped agenda rows; ≤5 faces → capacity donut + composition + activity vitals; 월 3만원 chip → full fee/guest-fee policy. The profile shows the edges of member-scope content (roster count, board activity vitals) with an explicit join-gated tease row — the conversion device.
  • Member journey: MyClubCard (bait) → 클럽 홈 (payoff). Member-scope gates (내 회비, board preview, 랭킹/관리 tabs, roster) appear here and only here.
  • Invariants: nothing renders on a card whose payoff page doesn't expand it; nothing member-scope renders on a guest surface, but its existence is advertised there. Acceptance: the tease→payoff table in the wireframes artifact (8068762b, composed-system revision) maps every fact.

3.4 Schedule = symmetrical agenda rows EVERYWHERE (owner v3 — supersedes the two-density split)

MeetAgendaRow is a two-cluster template (owner v4): WHEN cluster left — [mini-week strip 110px, this row's days lit][time-or-date 42px, tabular bold]; WHERE cluster right — [text, right-aligned, ellipsized][court glyph 18px, surface-colored, trailing]. Glyphs stack on one right edge across every row, mini-strips/times on one left edge; regular-meet and upcoming-session rows grid-align perfectly (a session row folds capacity into its right text, muted — no trailing chip). Merged by identical time+venue, uncapped. The merged-day-strip card projection is RETIRED (owner v3): cards render the same MeetAgendaRows as payoff pages — the venue hook is native to the rows, and the week pattern is carried by the union of the per-row mini strips. ClubScheduleBlock's day-cell vocabulary survives only as the row-internal mini strip. No pill chips anywhere in schedule rendering.

3.5 v2 round (owner notes 2026-07-18, later): sectioned card · merged 일정 · the canonical four panes

  • Card sectioning: DiscoverClubCard gets explicit zones using the SAME 10px-uppercase section-label vocabulary the payoff pages use (sectioning as a continuity device): who-we-are (name/region/tier ÷ hairline ÷ tagline + character chips) → labeled 정기 모임 (equal-width day cells + the venue line — surface-colored court-glyph + venue names, the owner's "least amount of hook") → labeled 다음 일정 compact line (now carrying its venue too).
  • Polish/symmetry standard (all club components): fixed-width leading columns (mini-week 110px · time 42px · rank 18px); uniform row min-heights per list; one 22px chip height; tabular numerals on every count; equal-flex day cells; common right-alignment edge; identical zone padding. Goes into the canon rewrite as a checklist.
  • B2 duplication fixed: ONE 다가오는 일정 section — the ClubNextSessionWell IS the first upcoming item; subsequent sessions render as agenda-style rows (weekday-lit mini strip, date in the time column, capacity chip). 정기 모임 stays a separate section (cadence ≠ instances).
  • Club detail IA — canonical verdict (research-grounded, club-detail-screen.tsx:47,103-108; SegmentedTabs=PillNav scroll-degrades ABOVE 4 items, pill-nav.tsx:76,255): the 4-pane shape is canonized as 홈 · 게시판 · 랭킹 · 관리(conditional) — with renames: 기록랭킹 (collides with the app-level 기록 top tab per terminology canon) and 소식게시판 (one concept one name; retire 소식/news from tab label, code identifiers NewsTab/news, and dedupe the near-identical empty-state i18n pair). Roster reachability is fixed via a tappable people strip on 홈 (faces + 멤버 N/M ›) → club-members-screen — NOT a 5th tab (capacity ceiling). Written into docs/canon/clubs.md in Phase 1.
  • 게시판 pane = the real board: merge ClubBoardScreen's composition (pinned 공지 tinted row, type chips 투표/일정, comment/공감 counts, unread dot, 글쓰기 CTA — already isMember-gated) into the tab, closing the member-can't-post hole (today the full board is reachable ONLY via the admin-gated 게시판 관리 row; the planned 전체 보기 link never shipped). Post entity already carries every field (isPinned, type, commentCount, reactionCount — all unrendered in the current tab).
  • 랭킹 pane = shared primitives: retire the bespoke rows; adopt LeaderboardPodium/LeaderboardRow + EloTierBadge (5B) + avatar chips + current-user highlight (thread userId — currently not passed at club-detail-screen.tsx:138) + tap→profile, mirroring the records-tab adapter pattern.
  • 관리 pane polish: symmetric 3×2 cell grid — attention row (송금 확인 · 가입 신청 · 회비 미납, warn-tinted) + status row (예정 일정 [promoted from the 3 fetched-but-unrendered snapshot counters] · 출석 주의 · 신고); 신고 stays visible-but-non-pressable until a review UI exists. Delete the vestigial AdminNavRow.badge prop pass-through and the dead adminTab.comingSoon key.

3.6 v3 round (owner, 2026-07-18 latest): tags-top card · graphical tiers · chip semantics

  • A1 card v3 composition (Journey A confirmed): banner → name row (모집 중 badge + quiet 월 3만원 price chip — price is a joining condition) → location meta (서울 · 강남구 · 2.4km — distance is a location fact) → titled 클럽 성격 section FIRST (character chips 친목형/복식 위주/혼성/저녁·주말 → tagline → tier bar) → titled 정기 모임 (MeetAgendaRows) → titled 다음 일정 (compact line + venue). Every card section is titled with the shared 10px section-label vocabulary.
  • Tier range = the tier-range BADGE, one form everywhere (owner v4 — the segmented bar is retired as visual clutter): a TierRangeBadge — ●실버 ~ ●골드 dot-pair in real tier colors, 22px chip-height, bordered — used in sections, strips, and meta lines alike. The tier-name VOCABULARY decision (5B) stands; formatSkillBand/formatTierRange text renderings retire.
  • Chip taxonomy rule (canonize): chips are for club character only; money renders as the name-row price chip; location/distance renders in the meta line; level renders as the TierRangeBadge. No mixed-semantics chip strips.
  • Shared components, different projections (owner v4): Journeys A and B render from the SAME component set — banner header, section labels, character chips, TierRangeBadge, MeetAgendaRow, ClubNextSessionWell, people strip. A surface never introduces a sibling variant of a shared component; CLUB_SURFACE_PROJECTION decides which components appear and in what order, never how a component looks. (Extends the single-source-of-truth rule to the club component family — enforce in review + the canon rewrite.)

3.7 v5 round (owner, 2026-07-18 latest): identity placement · chip organization · alert elevation

  • TierRangeBadge = club identity → beside the club name on EVERY surface (exactly how sessions treat the tier badge). It leaves 클럽 성격, meta lines, and strips' right edges.
  • Chip organization rule — each chip lives in the moment its reader needs it: 모집 중 (live status) → the banner overlay slot (where session cards put status); 가입 승인제 + 월 회비(·게스트비) + 남은 자리 (joining conditions) → a 가입 안내 strip at the card's decision footer (just before 다음 일정) and directly above the public profile's join CTA; region · distance stay in the meta line; character chips + tagline own 클럽 성격.
  • Member 홈 alert elevation: 내 회비 (납부 대기) and 게시판 (새 글) render as a warn-tinted 알림 zone at the top — the member's pending actions lead; the zone self-hides when clean (dues paid + board read) and 다가오는 일정 leads instead. The tappable 멤버 strip settles at the bottom as a quiet section.
  • Per-visitor information-flow rule (the canon rationale behind CLUB_SURFACE_PROJECTION's orders): the reader's pending decision/action comes first; identity anchors the top; conditions sit at the decision point; liveness closes. Stacks: guest = identity→character→logistics→conditions→liveness; prospect = same, expanded, →CTA; member = 알림→다가오는 일정→정기 모임→멤버; staff = member stack + admin strip/cells.

3.9m v20 (owner review 2026-07-20 — SHIPPED): the lean bait card re-split

The DiscoverClubCard leg of Phase 2 item 7 landed (cd51677a, bad94d6d, 2c23b40e, 5a11f517) and grew into a mini-profile: full per-meet schedule rows, a next-session block with a capacity meter, a titled 가입 안내 zone — payoff content on a browse glance. Owner review 2026-07-20 called this out directly against the card's own bait→payoff contract (§3.3): the card compresses, the profile — which already renders 정기 일정 + 다음 일정 + the join CTAs (public-club-profile-screen.tsx:24,426,468,513,640) — stays the one full read. Shipped (661cb825, OTA 74989e41; JSDoc at discover-club-card.tsx:1-18):

Top → bottom, no section labels, no divider rules — spacing alone carries the structure (retires, for the card leg specifically, §3.5's card-zone section labels and §3.6's A1 titled-sections; both remain accurate only as the profile's still-unbuilt ambition):

  1. BannerClubRecruitBadge (모집 중 · N자리) top-right overlay · club logo bottom-left · BannerCapacityCluster (faces + N/M) bottom-right.
  2. Identity — name + TierRangeBadge, then three GlyphFactRow fact lines: 📍 region · 👥 composition · 📅 meet frequency (model.frequencyLabel, NEW — club-card-model.ts:103,359-360, built from weeklyFrequency(meetDayCount) + daytime labels; new i18n key weeklyFrequency).
  3. Tagline — the club's own words, ink not a chip, up to 2 lines — the hook.
  4. Chips — ALL style chips, model.styleChips (NEW — club-card-model.ts:109,364): focus + format only. Composition and tier are NOT repeated here — they already live on the identity band's glyph line and the name-row TierRangeBadge, so §3.9d's fact-ownership rule now holds on the card the way it always held on the profile.
  5. Terms — one quiet line, join-policy label · dues (dues weighted 700) — no titled 가입 안내 zone, no strip (retires, for the card leg, §3.7's "가입 안내 strip at the card's decision footer").

Removed from the card, now profile-exclusive: the MeetAgendaRow schedule rows — the component was card-only per its own extraction docstring (§3.9l's "Confirmed new work" below) and is now deleted outright (meet-agenda-row.tsx, removed in 661cb825); §3.4's "MeetAgendaRow renders identically on cards and payoff pages" no longer applies to the card at all — the profile's regular-meet detail instead renders via its own DayDotsRow + per-meet metadata rows (public-club-profile-screen.tsx:468), not MeetAgendaRow. Also removed: the SessionCompactRow next-session block (§3.9l's "dateStyle 'dot' on the DiscoverClubCard line" binding below no longer has a card call site — it is still binding for 클럽 홈's 다가오는 일정 list) and the price/distance chip additions §3.2 had speculated (neither shipped — see §3.2, corrected).

§3.1's discoverCard row is updated in place to match this section; treat that table as the current source of truth and this section as the rationale + evidence trail.

3.9l v17 — verified canonical bindings (source-audited; supersedes assumptions above where they conflict)

Every primitive in the mapping was audited against its real implementation. Corrections and final bindings:

Rebinds to existing canonical components:

  • 모집 중 on the banner = ClubRecruitBadge (the club-level canonical wrapper, status-badges.tsx:270) moved from the identity row to the banner overlay slot — badges are proven on imagery (ClubJoinabilityBadge sits there today). NOTE the joinability badge's REAL label is 승인 후 가입 (ko/clubs.ts:668), not 가입 승인제 — wireframes corrected. ClubRoleBadge renders 운영 (never 총무 — that string is a member-list role tag).
  • TierRangeBadge is not an invention — it EVOLVES the already-canonical formatTierRange() + neutral-Badge pattern (docs/canon/data-and-hooks.md:65; 3 live sites incl. SessionCompactRow's tierRange prop). Build it as the graphical successor (owner's v4 dot-pair) and MIGRATE all 3 sites — extend, never fork. Prerequisite: TIER_DEFINITIONS' hex tier colors are not publicly exported and only elo-keyed — add a tier-name-keyed lookup + barrel export (EloTierBadge itself is flat-bg + emoji, carries no per-tier color).
  • Chips = Badge variant="neutral" only. The feature-literal allowlist is {neutral, warning, live, surface} (bounded-variant-enums.js:45 — FOUR, not CLAUDE.md's stale "3"); no primary-wash variant exists; accent is reachable only via canonical wrappers (computed, like ClubRoleBadge). Emphasis-by-color is deferred — if on-device QA wants it, it ships as a canonical wrapper, not a feature literal.
  • Compact upcoming = SessionCompactRow EXACTLY AS SHIPPED (accept, don't reshape): title weight 600; capacity = CapacityChip (ring pill) on its own row (not a right-aligned badge); venue = tappable VenueLink when venueId exists; built-in tierRange neutral-badge slot; optionally pressable to session detail. dateStyle per surface honoring DateTile's own doc ("event lists only, NOT club browse cards"): 'dot' on the DiscoverClubCard line, 'tile' on 클럽 홈's 다가오는 일정 list. (Superseded for DiscoverClubCard 2026-07-20 — §3.9m removed the card's SessionCompactRow render entirely; the 'tile' binding for 클럽 홈 is unaffected and still stands.)
  • Banner people cluster = ParticipationCluster (which by design never includes the ring) + CapacityRing (size prop accepts 14-16; it always renders its own N/M — no ring-only mode, which is exactly what the cluster wants) inside the shared overlay-chip container.
  • SectionEyebrow (renamed from SectionLabel) — promotes the existing GroupLabel (format-chooser-sheet.tsx:323, the exact uppercase+hairline pattern) into packages/ui as a sibling of SectionHeader (NOT a SectionBlock variant — different genre: intra-card eyebrow vs screen-level titled group). The name SectionLabel is taken by 2 unrelated local form-field labels (post-create-screen.tsx:48, edit-profile-screen.tsx:75) that are themselves violations of components.md:84 — fix both in the same pass.
  • Nav/list rows: no NavRow primitive exists (AdminNavRow/SessionNavRow are two unshared feature locals at ~70px). Rows bind to the Card row idiom; the ruler's 44 is a touch-target floor, not an exact height. Consolidating the two locals into a shared row primitive is Phase-2b-optional, not required.

Confirmed new work (no hidden siblings):

  • MeetAgendaRow — genuinely new; requires extracting ClubScheduleBlock's day-cell (currently monolithic, lines 88-128) into a reusable cell for the mini-week strip. On record: this supersedes the 2026-07-06 retirement of per-meet rows on cards (ClubScheduleBlock's own header documents that retirement) — the reversal is deliberate, with new rationale: rows carry the venue hook natively and the chip taxonomy changed (v3-v16).
  • ClubNextSessionWell v2 body — a genuine REBUILD (current body is bespoke VenueHero+ring+text; the discover-club-card docstring claiming it renders a SessionCompactRow is STALE — fix that comment). v2 composes VenueHero (kept) + SessionCompactRow.
  • MyDuesCard — extract into a named component: today it is an anonymous inline block in home-tab.tsx:304-327 (self-gated unpaid/partial), referenced by name only in a comment and an i18n key.

i18n decisions: 다가오는 일정 reuses clubDetail.sectionUpcomingSessions (exists verbatim). 클럽 소개 = new clubDetail-scoped key (exists only as publicClubProfile.title; clubDetail.sectionClubInfo says '클럽 정보'). 정기 모임 = new key AND a vocabulary unification — three wordings exist for one concept (정기 일정 / 정기 활동 / now 정기 모임): unify all to 정기 모임 per one-concept-one-name. 가입 안내 = new key.

Codebase hygiene found by the audit (fix during the build): stale discover-club-card docstring (SessionCompactRow claim); box-sizes.ts DOT_SIZE.SM comment vs PulseDot's literal default 7; text.tsx "10 total" header vs 11 roles; CLAUDE.md's "3 badge variants" vs the real 4-variant allowlist; the bounded-variant ESLint allowlist (4) vs Badge's 10 real variants (pre-existing gap, documented). Review-checklist additions: "render a gate's fields only within its section" and "no hand-rolled duplicates of an existing pattern" (how two divergent SectionLabels arose) — neither is mechanically enforceable today.

3.9k v16 amendment (owner): the spacing/alignment ruler + ClubNextSessionWell v2

The definitive spacing & alignment ruler (every number a token or component constant — nothing else legal in the build):

  • Row heights ∈ {44, 36}: bordered/touchable rows (strips, SessionCompactRow, nav/alert rows, Well body) = minHeight 44 (the a11y touch minimum); inline hairline rows (MeetAgendaRow) = 36.
  • Paddings: card frame $3 · bordered-row internal $2 · overlay chips 2/6.
  • Gaps: within-row $2 · icon-to-text and chip-to-chip $1 · list-row spacing $2 · section rhythm 12 above / 8 below the label.
  • Edges: ONE left content edge (frame padding; MeetAgendaRow runs flush so mini-week strips align with section labels) and ONE right trailing edge (chips/glyphs/counts terminate on it).
  • Overhangs: logo and faces share one depth (−12) with $2 visual clear below the banner. All prior off-grid values (3/5/6/7/9px gaps, 38/42px heights, −10/−14 overhangs) eliminated.

ClubNextSessionWell v2 — one anatomy at both densities: the hero density is literally the compact density plus imagery. Composition: a 48px venue hero band (the existing photo→map→surface-glyph cascade) with the SAME scrim token as the club banner, venue name overlaid bottom-left and surface·court-count bottom-right, stacked on an exact SessionCompactRow body (DateTile · title cardBody 700 + time meta · the same CapacityRing cluster as the banner people cluster · status Badge; minHeight 44). The old text-overlay body is retired — the Well no longer visually competes with the club banner, and the compact↔hero relationship is pure addition of the venue view.

3.9j v15 amendment (owner): rhythm restored to the card · SessionCompactRow as the compact density

  • Wireframe defect fixed: a scripted edit had removed 정기 모임 from the discover card while leaving a stray copy on 클럽 홈 — both now match the arcs (guest surfaces carry rhythm; member surfaces don't).
  • The bespoke UpcomingSessionLine is retired before ever being built. The nextSession gate's compact density is the EXISTING SessionCompactRow — DateTile-led (the canonical status-colored session-date visual), title + time·venue meta, capacity Badge right. Rendered for the discover card's single upcoming item and the member home's follow-on sessions. Zero new primitives, more graphical, and cross-domain consistency for free: a compactly-rendered session looks identical on club surfaces and everywhere else in the app. nextSession gate densities: ClubNextSessionWell (hero) | SessionCompactRow (compact).

3.9i v14 amendment (owner): one section title, one upcoming-line component

  • Section title unified: 다가오는 일정 everywhere the nextSession gate renders (card, profile, 클럽 홈). 다음 일정 is retired — the unified title stays true at both cardinalities (guest surfaces render 1 upcoming item, the member home renders N).
  • UpcomingSessionLine — the compact one-liner becomes a proper shared component: [day+date 52px, tabular 800] · [time 58px, tabular 700] · [title, flex ellipsis cardMeta] · [capacity Badge sm, right], $card bg / $borderSubtle border / radius $3 / minHeight 38. It renders the discover card's single upcoming item AND the member home's follow-on sessions (everything after the hero ClubNextSessionWell) — one component, both surfaces.
  • Components now align 1:1 with gates: rhythmMeetAgendaRow exclusively (the strained agenda-row session variant is retired); nextSessionClubNextSessionWell (hero density) | UpcomingSessionLine (compact density). Fact-ownership and the mapping table gain the corresponding rows.

3.9h v13 amendment (owner): the canonical style mapping — no raw values

Prompted by a real bug: the v12 ring+count cluster used a raw rgba(255,255,255,.92) instead of $background — a white pill in dark theme (the 모집 중 overlay had the same latent bug). Generalized into the rule Phase 2 builds against:

The wireframes' pixel values are approximations of named tokens/primitives. The build renders ONLY canonical components with $ tokens — no raw values, no one-off styles. The full element→primitive→token mapping lives in the artifact's §3.5 table; the load-bearing entries:

  • Banner overlay chips (모집 중 · ring+count): ONE shared style — bg $background (theme-aware), radius $2, height 20, paddingH $2.
  • People cluster: ParticipationCluster + CapacityRing (fill $primary, track $borderSubtle), avatar borders $background.
  • TierRangeBadge: 22px, border $borderSubtle, dots from the EXISTING EloTierBadge tier-color tokens.
  • Chips: Badge size sm from the bounded variant set ONLY (COMP-2) — neutral ($card/$borderSubtle) and the primary-wash variant; no custom chip styles.
  • SectionLabel: a NEW shared primitive (10/600 uppercase $textTertiary + $borderSubtle hairline) used by cards AND pages — the section vocabulary is one component.
  • 알림 rows: the Badge-warning tokens ($badgeWarningBg/$badgeWarningText) — never custom warn colors.
  • Facts column: Icon (lucide, xs) $textTertiary in a 16px column + Text cardMeta.
  • Text = the 11 roles only (tabular-nums on all counts); spacing = $N grid only; the banner scrim becomes a named token, not inline rgba.

3.9g v12 amendment (owner): unified capacity ring · Korean address canon · merged 소개 section

  • Capacity renderer UNIFIED: the banner's bottom-right people cluster is AvatarStack + CapacityRing (the canonical ring primitive) + N/M on EVERY banner-bearing surface (discover card, public profile, MyClubCard) — the v11 pill and the profile's separate donut line are both retired; composition/age joins the facts column on the profile, making the identity band structurally identical on card and profile. (클럽 홈 has no banner by design — its identity band's roster strip carries faces + count there.)
  • Korean address canon (label-adapter rule): regions render space-joined — 서울 강남구, never 서울 · 강남구 (the middle dot is not a canonical Korean address representation). The dot is reserved for separating DIFFERENT facts: 서울 강남구 · 2.4km. Applies to regionText everywhere it renders.
  • 클럽 성격 + 클럽 소개 merged into ONE 클럽 소개 section (same title on both guest surfaces): character chips row first, then the free text ($2 gap; card = chips + tagline, profile = chips + full description prose). The character gate keeps its internal name; only the section presentation merges.
  • Owner affirmation on record: the compactness of the member cards/pages is right.

3.9f v11 amendment (owner): identity-band polish

The identity band's visual specification (all within the locked rules — density renderers of one gate):

  • Banner overhangs: logo bubble bottom-left (2px $background ring); people cluster bottom-right = AvatarStack + fused count pill (12/16, tabular, bordered $background pill attached to the stack) — the CARD's capacity renderer; the PROFILE renders capacity as the donut line instead (single-occurrence per surface holds). A subtle bottom scrim (transparent → rgba(0,0,0,.16), bottom 22px) guarantees overhang contrast on any banner art.
  • Name row: club name + TierRangeBadge (22px), vertically centered to the name's cap height, $1 gap.
  • Facts column (replaces the stacked grey meta lines): fixed 16px glyph column — lucide MapPin / Users, 12px $textTertiary — with aligned rows: 📍 region · district · distance / 👥 composition · age band. Row gap $1; the glyph column alignment IS the symmetry.
  • Rhythm: banner → $2 → name row → $1 → facts column → $3 → first section label. All 4px-grid tokens.

3.9e v10 amendment (owner): people merges INTO identity

v9's strict ownership cost the discover card its demographics (composition is join-decision information). The owner's fix completes the v7 insight: people facts ARE identity, so people stops being a separate gate — IdentityInfo absorbs memberCount/maxMembers/capacityRatio/composition/memberAvatarUrls, and the identity BAND renders them at per-surface density (card: compact demographics line 혼성 · 20~40대 · 멤버 N/M명 under the location line; profile: the donut line; 클럽 홈: the tappable roster strip). Demographics return to the card with every rule intact: fact-ownership holds trivially (one owner, one section), the shared-component rule governs the density variants, and the roster tease stays proof-owned (it is about access, not identity). Gate vocabulary: identity · character · rhythm · conditions · proof · nextSession (+ member-scope obligations).

3.9d v9 amendment (owner): fact-ownership — the stability rule dedup was missing

Dedup created a new inconsistency: the same fact appeared in DIFFERENT sections on different surfaces (composition in the profile's people line vs the card's 성격 chip; 자리 in the card's 가입 안내 vs the profile's donut) because v8 kept whichever occurrence was locally convenient. The fix:

Every fact is owned by exactly ONE gate. A surface renders a fact only inside its owning gate's section. Omitting a gate makes its facts ABSENT — never relocated to another section. Enforcement is structural: each ClubInfoModel field lives in exactly one gate interface, so model.people.composition cannot render outside the people section — the type system carries the rule; the canon rewrite states it as "render a gate's fields only within that gate's section."

Consequences: 혼성 leaves the card's 성격 chips (people-owned; the card's people gate renders as banner faces only); 자리 남음 leaves the card's 가입 안내 (people-capacity-owned; the donut is its sole renderer). 성격 (친목형 · 복식 위주 + tagline) and 가입 안내 (승인제 · 월 회비) are now CONTENT-IDENTICAL on card and profile. The conditional-venue rule survives (presence within one gate, not relocation). The action-point CTA recap remains the single sanctioned second-render.

3.9c v8 amendment (owner): the dedup rule + status-invariant components

Single-occurrence rule: each fact renders ONCE per surface. Two named exceptions: the action-point recap (the CTA sublabel — a commitment disclosure, the Toss checkout pattern) and cross-surface repetition (bait→payoff IS the system). Removals: A2's second faces row (the banner overhang keeps the motif; the people line = donut + composition only) · the 혼성 chip from 성격 (composition lives in the people line) · the 저녁·주말 chip on both guest surfaces (derived — the agenda rows show it; 성격 tightens to focus + format) · 자리 남음 wherever the capacity donut renders (arithmetic on it; the CARD keeps it — no donut there) · 정기 모임 dropped from member surfaces entirely (B1 + 클럽 홈). Conditional rule: the 다음 일정 invitation line shows its venue only when it differs from that day's regular-meet venue.

Status-invariant components (owner check, affirmed): dedup removes sections from projections, never component variants — the v4 shared-component rule is the guarantee. Key case: 다가오는 일정 instance rows and 정기 모임 cadence rows are ONE MeetAgendaRow template, so a member who no longer sees the cadence section still reads the identical visual language on instances. Membership status changes WHICH components appear, never how any component looks.

3.9b v7 amendment (owner): people = identity, not proof

The old 멤버 section conflated three separable things: people-scale facts (count/capacity donut, 혼성·20~40대 composition, privacy-filtered faces) are identity-fit signals — theory's beat-② self-categorization, and the position (immediately after the name) every studied product uses — so they move UP into the identity band on all four surfaces (profile: under the name line; 클럽 홈: the tappable 멤버 strip rejoins the identity strip — for members, clubmates' faces ARE belonging, and the strip stays the roster entry). Proof vitals (클럽 활동 chips: 주 N회 경기 · 게시판 활발 · Since) and the join-gated roster tease stay deep at the proof beat; the full roster grid remains the deepest block. Arc orders in §2.1 updated; the subset rule is unaffected.

3.9 v6 round (owner, 2026-07-18 latest): the semantic recomposition

"Each page follows the flow of information in the model, which should be composed in a semantically coherent manner. If people were getting introduced to and acquainted with an organization, what is the order of information that will get them informed and interested? What would members want?"

Answered with a 4-leg research pass (acquaintance/trust theory · Meetup/Strava/당근/Heylo/Band real page orders · the session-model precedent · Korean member-jobs conventions) — the two-arc model in §2.1 is the result. Corrections it forced on v5 (all folded into artifact 8068762b v6):

  • Public profile's 가입 안내 moves UP beside the realistic preview (after 정기 모임, before 멤버/proof) — card and profile now agree on order; the pinned CTA keeps a conditions recap sublabel (월 3만원 · 가입 승인제), matching the products' "requirements disclosed early, payment executed last" split.
  • The price chip leaves the name row entirely — identity stays pure (name + TierRangeBadge); conditions own the money fact.
  • MyClubCard gains the obligations slot directly after identity (staff: 심사/미납 warn strip; plain member: their 회비-due badge in the same slot; self-hides when clean) — B1 becomes a literal prefix of 클럽 홈's member order, closing the last subset-rule violation.
  • Member-jobs research validated the 알림-first 홈 (dues-first is a deliberate TwoMore upgrade over Band/Kakao convention, where dues aren't even a native object) and flagged two follow-ups already in the pane rework: wire the built-but-unconsumed useUnreadPostCount into the 게시판 tab badge, and note the deliberate no-club-chat divergence from the 카톡 단톡방 convention as a product decision on record.

3.8 Earlier locked picks (v1 round — still standing except where superseded above)

  • W2-A: 홈 tab identity collapses to a one-line strip (logo · name · region · N/M · tier chip); banner hero lives on the card only.
  • W3: public profile adopts the card primitives (ClubNextSessionWell, day-dots ClubScheduleBlock), amended per §3.1 order.
  • Level vocabulary 5B: ELO tier names (실버~골드) everywhere — the vocabulary the rating system (EloTierBadge/leaderboards/profile) already speaks. NTRP band remains a secondary line on detail/settings. Closes drift #5.
  • Detail surfaces fetch ClubDiscoveryStats (useClubDiscoveryStatsQuery, use-clubs.ts:117-141) so the model is never fed degraded. Closes drift #4.
  • Drop the RPC's 3-row regular-meets cap (day-dots are already uncapped — the cap is internally inconsistent) → migration touching get_club_discovery_summaries. Closes drift #3.

5. Session-surface family unification + CTA canon (owner round, 2026-07-18 — source-audited)

5.1 The finding: the model exists, the surfaces ignore it

SESSION_GATE_ORDER/SESSION_SURFACE_PROJECTION are declared, but at runtime: zero card/row/well components call buildSessionInfoModel (SessionCard hand-re-derives every gate inline, incl. TWO cost signals — an always-on legacy costTotal badge plus the canonical participationFee label behind a dev flag — while the declared card projection omits cost and the canon doc claims it includes it: doc, constant, and runtime all disagree). The detail screen calls the model once and reads 2 of 9 gate namespaces; the other 7 are re-derived ad-hoc by its section components. SessionCompactRow has ONE real call site (my-club-card fallback) using dateStyle="tile" — the opposite of the canon's club-card prescription. Three production surfaces have no declared projection at all: ClubNextSessionWell (the PRIMARY next-session render, with a COMP-2-violating ad-hoc full/모집중 Badge, structurally unable to project status/kind/cost because ClubDiscoveryStats.nextSession doesn't carry them), home's PickupRow (bespoke: raw spotsLeft text, plain-text district, no badges), and buildSessionShareCard (named as surface #4 in the model's own docstring; absent from the SessionSurface type). "This club's next session" can render three visually unrelated ways (Well / compact-row-in-a-hand-rolled-wrapper / full SessionCard) — and DiscoverClubCard lacks MyClubCard's fallback branch entirely.

5.2 The unification: one family, model-driven, hierarchy decides display

The canonical family — every session render is one of these four, each a DECLARED projection:

SurfaceComponentProjection (gates, in SESSION_GATE_ORDER)
cardSessionCardeligibility · when · where · conditions · kind · who · cost (ADD — already rendered; source = participationFee, retire the legacy costTotal badge from the default layout) · urgency · action
compactSessionCompactRoweligibility · when · where · who · action — dateStyle per canon: 'dot' in club/browse contexts, 'tile' in session lists
well (NEW declared)ClubNextSessionWell v2 (§3.9k/l: VenueHero + SessionCompactRow body)eligibility · when · where · who · action(parent tap) — prereq: extend ClubDiscoveryStats.nextSession with status so eligibility renders via RecruitmentStatusBadge (killing the COMP-2 ad-hoc Badge)
share (NEW declared)buildSessionShareCardwhen · where · kind(partial) · who · cost · action(deep link)

Absorptions (the strays): home's PickupRowSessionCompactRow ('dot'; time added; district via VenueLink; spots via the canonical capacity treatment) — a teaser projection is NOT added; the compact projection covers it. MyClubCard's hand-rolled compact-row wrapper → the Well v2 body (stats and raw-Session paths render the SAME body; DiscoverClubCard gains the same fallback). Public-pickup preview's inline basics card → SessionCard beats.

Enforcement direction (the structural fix): buildSessionInfoModel gets WIRED into SessionCard + SessionCompactRow + share-cards (surfaces read model.<gate>.*, never raw session fields for gate values) — the same fact-ownership discipline as the club model (§3.9d). Model fields nothing reads after the wiring get deleted rather than left as dead declarations. Detail-section components migrate to the model's gates opportunistically (each section touched → reads the model).

Location-first graduation — DONE 2026-07-18 (owner decision). The venue-centric location-first SessionCard (the 2026-06-24→30 experiment: WHERE-led region title → VenueHero map hero carrying venue/court overlay + weather glance → soft WHEN band → two-tier gate-ordered chip strip) is now the PRODUCTION DEFAULT for full-size cards; the venueCardMode/sessionSummaryPolishMode dev flags are deleted (store persist v4 migration strips them), the classic date-tile layout survives only on size="compact", and the legacy costTotal-split chip is retired with it — the card's cost gate now reads participationFee only, closing the two-cost-signals drift in §5.1. A no-hero fallback (classic VenueLink + weather row) keeps WHERE/conditions rendered for sessions without a resolvable court venue. Canon updated (sessions.md). Owner on-device QA owed: the graduated card across home/discovery/club lists.

Stale-doc hygiene (fix in the same wave): the retired "LiveSessionCard" still referenced in 4 doc comments; the status-dot/3px-strip spec in components.md with zero consumers (implement-or-delete decision → DELETE the spec text, the chip+DateTile-tone treatment won); CapacityChip's false file comment + its missing entry in components.md; clubs.md zone-5 text (SessionCompactRow-only) updated to the Well-primary reality.

5.3 The CTA canon (source-audited)

CTAs live in exactly two places:

  1. The pinned BottomCtaBand — one contextual primary action per screen state (the named binary-fork exception: join-review's 승인/거절). Destructive actions never pin — header menu + useConfirm. BottomCtaBand gains a sublabel slot (the conditions-recap pattern, §3.9i's Toss-checkout binding — built BEFORE its first consumer, the public profile's join CTA, so the shape is canonical from day one).
  2. Sibling-of-card compact action rows — the pattern club-members/club-attendance/dues-row(self-attest) already prove. A hero/page CTA never sits inside a Card.

The bounded in-card exception, formalized (this was the owner's own 2026-07-13 walk-back — now given exact bounds): a per-item LIST-ROW Card may carry ≤2 compact mark-done ActionButtons (payments 납부확인/면제, attendance 출석/결석, applications 승인/거절, dues 확인, live-tab match controls, tournament winner-pick). Everything outside those bounds migrates to the sibling pattern. Retired regardless: live-tab's Card-swapped-for-identical-YStack lint dodge (:264-297) — it becomes an honest Card under the named exception. Boundary carve-out: form-field Cards inside ModalPanel editors (venue-correction) are form chrome, not browse cards — exempt, named.

Fix list: button.tsx:21's pre-walk-back absolute rule text (the most authoritative-looking stale line in the codebase) → current canon text; participants-screen.tsx 제외 destructive ActionButton without a visible useConfirm gate → add the gate; match-board + tournament-board adopt the pinned band (DetailShell scroll={false} + sibling BottomCtaBand) for their lifecycle CTAs (경기 시작/종료, 다음 라운드) — currently inline mid-scroll, the exact anti-pattern the IA rule prescribes against, with no comment justifying the skip.

5.4 Phase additions

  • Phase 2c — session family: wire the model into card/compact/share; add well+share to SessionSurface with declared arrays; Well v2 body (already Phase 2) doubles as the COMP-2 fix; absorb PickupRow + the MyClubCard wrapper + public-pickup basics; extend the discovery-stats RPC with nextSession.status (migration); retire the legacy costTotal badge path; stale-doc hygiene.
  • Phase 2d — CTA: BottomCtaBand sublabel slot; button.tsx doc fix; the bounded-exception canon text in components.md; live-tab dodge retirement; participants-screen confirm gate; match/tournament board pinned-band adoption.

4. Build phases

Phase 1 — model spine (no visual change)

  1. club-info-model.ts + tests + barrel exports (§2.1).
  2. club-card-model.ts → adapter (§2.2); both cards verified byte-identical output (snapshot the ClubDiscoverySummary for a fixture before/after).
  3. Drift closures that are pure reads: #1 (regionText), #6 (capacityRatio), #7 (barrel imports), #4 (detail fetches stats + renders model people fields), #2 (detail hero reads stats.nextSession).
  4. Migration 004XX: drop rn <= 3 in get_club_discovery_summaries (#3) — pgTAP: a 4-meet fixture club returns 4 rows. Prod push per standing authorization.
  5. Canon rewrite + CLAUDE.md pointer (§2.3).

Phase 2 — approved surfaces (OTA-visible; composed wireframes = artifact 8068762b composed-system revision)

  1. MeetAgendaRow component (§3.4) added beside ClubScheduleBlock (same family, shared day-cell vocabulary + surface-color mapping).
  2. DiscoverClubCard recomposition per §3.1/§3.2 (tags block, Well → compact trailing line, price+distance chips) — SHIPPED, then further reduced: landed as speced (cd51677a, bad94d6d, 2c23b40e, 5a11f517), then owner review 2026-07-20 re-split it to a lean bait card (661cb825, OTA 74989e41) — see §3.9m for the shipped composition, which supersedes the tags-block/Well-line/price-distance-chip plan this item describes. MyClubCard: 5B tier chip only (Well hero + admin row unchanged).
  3. 홈 tab reorder (identity strip → Well → 내 회비 → MeetAgendaRows → upcoming 일정 rows → board preview), consuming card primitives.
  4. Public profile per journey-A2 (tags → 클럽 소개 prose → uncapped MeetAgendaRows → people donut/composition/vitals + join-gated tease row → Well → CTA), fixing the region/capacity drifts in place.
  5. 5B vocabulary applied at all three former formatTierRange/formatSkillBand render sites.

Phase 2b — the four panes (v2 round, OTA-visible)

  1. 게시판 pane: fold ClubBoardScreen's row composition + 글쓰기 CTA into the tab (§3.5); rename tab/i18n/identifiers 소식→게시판; dedupe the empty-state string pair.
  2. 랭킹 pane: shared LeaderboardPodium/Row + EloTierBadge + userId threading + tap→profile; tab rename 기록→랭킹.
  3. 관리 pane: 3×2 symmetric cell grid (+예정 일정), delete vestigial badge prop + dead comingSoon key.
  4. 홈 tappable people strip → roster; B2 merged 다가오는 일정 section.

Phase 3 — mechanical batch (rides along)

  1. Dead join CTA deletion · dues-amount edit in club settings · "Strike" i18n · self-demote guard finish-or-delete · pull-to-refresh (members, dues) · join-policy review row (step 7).
  2. Net-new catches from the 2026-07-18 all-domain source audit (see below): pickup-preview fee line reads legacy costTotal → bind to the model's cost gate (participationFee) · head-to-head OpponentRow's Crosshair icon has no onPress (wire the drill-down or drop the icon) · edit-profile save = bare inline button → wrap in BottomCtaBand · login screen adopts StyledInput/LabeledField (raw RN TextInputs today).
  3. CTA-canon rider (§5.3 scope): tournament-board winner-pick ActionButtons render in-card with no documenting comment, and Swiss's 무승부 makes 3 actions — exceeds the ≤2 mark-done bound. Restyle to the compact per-item mark-done row + document, alongside the boards' pinned-band adoption.
  4. Venue hero SSoT: directory-venue-detail hand-rolls a map hero and club venue-detail has none — neither reuses the shared VenueHero photo-first cascade. One VenueHero everywhere.

Items 16-18, plus the full duplicate/bypass-audit remediation the same 2026-07-18 source read surfaced (38 findings — user-visible bugs, the bounded-variant-enums enforcement loophole, and the shared-primitive extraction wave), executed 2026-07-19 — see docs/rebuild-log.md's 2026-07-19 entry for the full shipped list.

Fast-follow (after chips bake)

  1. Discovery filters/sort for price + distance (ClubDiscoveryControls).

All-domain audit pass — DONE 2026-07-18

The "remaining domain audit passes" from the coverage plan ran as a 5-auditor parallel source read (~42 screens: home, discovery, matches, records, profile, venues, messaging, notifications, onboarding/auth, settings/admin-ops). Every domain page of artifact 0c120711 is now filled with wireframes rendered from the extracted beats — current-state compositions with the already-specified §5 fixes applied in place and each net-new catch flagged amber on its frame. The Primitives page now carries a labeled sample gallery (24 tiles) so every binding-table row has a rendered example. Net-new catches are folded into Phase 3 items 16–18 above; everything else audited on-canon (notification center matches the 3-band alert contract exactly; all three wizards share one WizardShell; DM composer's raw TextInput and live-tab's in-card 경기 종료 are the two documented exceptions).

Verification ladder per phase: targeted jest → yarn check → lint:strict → (Phase 1 only) pgTAP with migration applied → commit → push → OTA preview → baseline advance. Wireframe artifact 8068762b is the visual acceptance reference; owner on-device QA closes each phase.

Acceptance criteria

  • Every club fact renders from a ClubInfoModel gate on all four surfaces — zero inline re-derivations (grep-verifiable: no members.length-as-count, no inline region joins, no formatTierRange outside the one 5B site).
  • The same fixture club shows identical region/schedule/member-count/tier/next-session facts on card, detail, and profile.
  • docs/canon/clubs.md describes what ships.

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