Club Surface Conventions
Status: Active Last reviewed: 2026-06-30
Canonical composition rules for club card and list surfaces in TwoMore. The v3 FOUR-ZONE model below is the single source of truth for how club cards are structured — how ZONE ordering, the members cluster, schedule block, chip strip, and next-session row compose. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.
Club card composition
Club card composition (2026-06-12 v3 — FOUR ZONES; owner: composition over component-piles, "if a section needs a text label, the graphic isn't doing its job"): ZONE 1 HERO — banner 3:1 + joinability overlay (discover) + the consolidated MEMBERS CLUSTER spanning the FULL banner width on its bottom edge, summary→detail order: the
$backgroundpill (20px capacity ring +N/Mcount) on the LEFT,AvatarStack outlinedaligned to the RIGHT end (left/right="$4"+justifyContent="space-between"; empty spacer keeps avatars at the end when no pill; people facts never split across zones; content below clears viaAVATAR_STACK_HALF_HEIGHTpaddingTop); ZONE 2 IDENTITY — logo · name · region inline + tagline (the card's ONLY prose; discover only); ZONE 3 SCHEDULE =ClubScheduleBlock— a rich, titled "정기 일정" section that is a KEY browse signal shown on the card (not deferred to the detail screen). It renders: a numbered week strip where active cells carry the meet NUMBER in ONE brand tone ($primarySubtlebg /$primarytext — never the semantic status palette info/accent variants); one row per meet below the strip (recurrence description + time + optional venue). Clubs without any regular meets render no schedule block. ZONE 4 chips use the CANONICAL SessionCard strip exactly (Badge size="sm",gap="$2") — no card-local chip variants. ZONE 5 NEXT SESSION —<Divider />+SessionCompactRow(the upcoming session as EVENT-genre content:DateTile+sessionTitle()+HH:MM~HH:MM · locationmeta; fed by the fullSessionentity or thestats.nextSessionfallback) at the card bottom; renders nothing when no upcoming session. GENRE RULE (research-grounded — Meetup group cards carry NO event dates; date tiles are the Luma EVENT-list pattern), refined 2026-06-12: the group's OWN visuals stay rhythm-only — the week-strip cells encode ONLY regular meets (no solid game-day markers; clubs without regular meets render no cells) with the when·where meta line (timeSlot · courtInfo ?? location) beneath. An upcoming session is EVENT-genre content and appears ONLY as an embedded, divider-separatedSessionCompactRow(from@twomore/app—DateTile+ generated session title +HH:MM~HH:MM · venuemeta, surface-less, no press handler) at the card BOTTOM — the canonical compact representation whenever a session is referenced inside another surface.DateTileitself stays reserved for event-genre contexts (this row, 경기/session lists, club-detail 다음 일정). Location text stays off cards — the detail page carries it; ZONE 4 UTILITY, one quiet row — 24px ring +N/M+ the chip strip. Shared composition lives inpackages/features/clubs/src/shared/club-vitals.ts(weekday labels, day-index map, avatar-stack/date-tile composition, overlap constant) — cards + profile import ONE source. The superseded v2 details (media precedence, avatar privacy, vocabulary rules) still apply: (v2 — VISUAL VITALS, owner direction: graphics over text on browsing surfaces; NN-G fixed-placement-for-comparison): (1) banner 3:1 with ONE media precedence on every club surface — featured media → any approved media →bannerUrl→ preset color (viabuildClubCardModel().mediaUrl; nevergallery[0]ad-hoc) +ClubJoinabilityBadgeOVERLAID top-right on the discover banner; (2) identity row (logo · name · region/district); (3) tagline (discover only — the card's only prose); (4) VITALS ROW — people facts as GRAPHICS, not text: 32px capacity ring (SegmentedDonutChartsize 32 strokeWidth 4, filled=$primary/track=$borderSubtlevia theme.val) +N/McardMeta on the left,AvatarStack(≤5 member avatars fromClubDiscoveryStats.memberAvatarUrls— migration 00259 — with+Noverflow sized byactiveMemberCount) on the right; (5)ClubScheduleBlock(see ZONE 3 above); (6) labeled 다음 일정 section — flat next-session line ONLY whensessionSummary.tone === 'next'; (7) chip strip = four canonical orthogonal dimensions (single source inbuildClubCardModel):format·tier?·focus(omit the 'balanced' default — not a signal) ·memberComposition?(omit 'any' — not a signal) — ≤4 chips, one row by construction. The free-formtagsfield was RETIRED in migration 00278 because it duplicated focus/composition/schedule (that redundancy caused the strip to overflow).'social'and'training'canonical tag values were also dropped in migration 00265 for the same reason. Do NOT re-add atagsaxis to the chip strip — identity is now the four canonical dimensions.model.peopleTextsurvives for PROFILE text contexts only, not cards. Absent values OMIT their chip — never render a generic placeholder ('오픈'). Avatar privacy (00260): the discovery stack respectsprofile_visibility.avatar(public default / club_members / never for friends+private) — controllable from 설정 → 프라이버시 (프로필 사진 row); profile-page/leaderboard avatars stay always-visible identity. Role-grouped LIST screens follow the same de-icon + single-source-of-truth discipline (packages/features/clubs/src/club-members-screen.tsx): theGroupedFeedListsection header owns the grouping dimension (the role), so each row drops its redundant per-row role icon AND role badge (role was being shown 3× — header + icon + badge) and reads typography-first (avatar + name + earned trust badge + kebab); never wrap the list in an extra umbrellaSectionBlocktitle when the per-section headers already name each group (that's a double-header). The largest group collapses by default — its section header doubles as a count + chevron toggle (customrenderSectionHeader, identified by title), mirroring the session-detail roster "collapsed → expand on tap" so the meaningful structure (leadership) reads at a glance and the long tail is one tap away.MyClubCardrole badge (B1, 2026-07 owner wireframe review): owner/admin viewers get aClubRoleBadge("운영", one label — never split owner/admin) overlaid top-right on the banner, the same slotClubJoinabilityBadgeoccupies onDiscoverClubCard(empty for a plain member); it uses the brand-tintedaccentBadge variant, not the status-intent palette, since it's an identity cue rather than a state signal. The card's관리 로우(ClubAdminRow) is its own nestedPressable→ the club's 관리 탭 (routes.club(clubId, { tab: 'admin' })), and renders its breakdown as canonicalBadgechips (variant="live", the$badgeErrorBg/$badgeErrorTextpair also used byAdminSnapshotStrip) instead of a joined text string.
See also
- Conventions › Sessions —
SessionCompactRowandSessionCardrules that club cards embed. - Conventions › Components — card tones, clickable primitives, and shared UI discipline.
- Architecture › Screen Blueprint — screen-level IA for club surfaces.
- CLAUDE.md — orchestration core + the pointer index back to this doc.