Skip to content

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 $background pill (20px capacity ring + N/M count) on the LEFT, AvatarStack outlined aligned 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 via AVATAR_STACK_HALF_HEIGHT paddingTop); 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 ($primarySubtle bg / $primary text — 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 · location meta; fed by the full Session entity or the stats.nextSession fallback) 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-separated SessionCompactRow (from @twomore/appDateTile + generated session title + HH:MM~HH:MM · venue meta, surface-less, no press handler) at the card BOTTOM — the canonical compact representation whenever a session is referenced inside another surface. DateTile itself 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 in packages/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 (via buildClubCardModel().mediaUrl; never gallery[0] ad-hoc) + ClubJoinabilityBadge OVERLAID 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 (SegmentedDonutChart size 32 strokeWidth 4, filled=$primary/track=$borderSubtle via theme .val) + N/M cardMeta on the left, AvatarStack (≤5 member avatars from ClubDiscoveryStats.memberAvatarUrls — migration 00259 — with +N overflow sized by activeMemberCount) on the right; (5) ClubScheduleBlock (see ZONE 3 above); (6) labeled 다음 일정 section — flat next-session line ONLY when sessionSummary.tone === 'next'; (7) chip strip = four canonical orthogonal dimensions (single source in buildClubCardModel): format · tier? · focus (omit the 'balanced' default — not a signal) · memberComposition? (omit 'any' — not a signal) — ≤4 chips, one row by construction. The free-form tags field 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 a tags axis to the chip strip — identity is now the four canonical dimensions. model.peopleText survives for PROFILE text contexts only, not cards. Absent values OMIT their chip — never render a generic placeholder ('오픈'). Avatar privacy (00260): the discovery stack respects profile_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): the GroupedFeedList section 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 umbrella SectionBlock title 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 (custom renderSectionHeader, 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.

  • MyClubCard role badge (B1, 2026-07 owner wireframe review): owner/admin viewers get a ClubRoleBadge ("운영", one label — never split owner/admin) overlaid top-right on the banner, the same slot ClubJoinabilityBadge occupies on DiscoverClubCard (empty for a plain member); it uses the brand-tinted accent Badge variant, not the status-intent palette, since it's an identity cue rather than a state signal. The card's 관리 로우 (ClubAdminRow) is its own nested Pressable → the club's 관리 탭 (routes.club(clubId, { tab: 'admin' })), and renders its breakdown as canonical Badge chips (variant="live", the $badgeErrorBg/$badgeErrorText pair also used by AdminSnapshotStrip) instead of a joined text string.

See also

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