Skip to content

Club Surface Conventions

Status: Active Last reviewed: 2026-08-03

Canonical composition rules for club card and list surfaces in TwoMore. The PHASE 5 — SHIPPED FINAL COMPOSITIONS entry below (shipped 2026-08-03, commits 7299ab89/e1bd5ed4) is the current single source of truth for how club cards are structured. §3.12 ROLE-DEFINED CARDS and the v3 FOUR-ZONE model further down are HISTORICAL — kept for provenance, superseded in sequence: v3 → §3.10's banner+embedded-card composition → §3.12's role-diverged anatomy → Phase 5's shared-floor anatomy. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.

Club card composition

  • PHASE 5.1 — SHIPPED FINAL, CORNERS LAW (2026-08-04, iterations 20–23 + final fix batch, commits 184f46d6..fa9aff95 on main) — current: supersedes the 2026-08-03 Phase 5 ship note further below (same ClubCard, packages/features/clubs/src/shared/club-card.tsx — the design kept iterating after that initial ship, this entry documents where it landed). One ClubCard, rendered for BOTH audiences, sharing a photo+seam floor under the CORNERS LAW and diverging below it (buildClubCardModel in club-card-model.ts supplies every field — never re-derived at the card).

    SHARED seam — the CORNERS LAW (club-card.tsx:394-457, doc-labeled "CORNERS LAW, iteration 23-D" in source): the seam straddling the photo/body boundary is a fixed FOUR-CORNER grid — LEFT column = identity, RIGHT column = state/numbers. Every overhanging element's offset is exactly half its own height ("center-dissected by the photo border" — a literal, not a spacing token, so the border bisects each corner element symmetrically).

    • Top-left (identity, clipped inside the photo): TierRangeBadge (size="sm") when minTier/maxTier are set; a tier-less club renders a 실력 무관 surface-chip (t().sessionHeader.open — the SAME shared i18n key the session card's tier-less fallback uses; the earlier '오픈' label was rejected by the owner 2026-08-04 because 오픈부 names the elite amateur division in Korean tennis vernacular, the opposite of "no restriction") in the same slot instead of leaving the corner empty.
    • Bottom-left (identity, overhanging -24): the 48px flag/logo circle (FLAG_SIZE = 48 — bumped from an earlier 40px 2026-08-04, "the identity mark deserves a step more presence") — the club's own logoUrl via ClubLogoBubble, else a deterministic generative FlagMedallion (pure-JS, token-only, no image asset).
    • Top-right (state, member-only, overhanging past the photo edge): ClubAlarmBadge (packages/app/src/presentation/components/status/status-badges.tsx) — a 22px circle, $badgeWarningText fill, BORDERLESS (owner 2026-08-05: chips/badges never carry borders — a ring reads as chrome and shrinks the content box until the numeral can't center; the deep warning fill alone separates against any photo), role="badge" numeral with includeFontPadding={false} + textAlignVertical="center" (the GlyphSlot Android-centering fix), overflow="hidden". Renders only for isMember && isAdmin, fed by the admin snapshot's pending-item count — never shown to a plain member or a discover viewer. The count caps at 99+ (final fix batch 2026-08-05).
    • Seam people cluster — placement history: bottom-right per the corners law (2026-08-04) → moved beside the flag on the left (owner 2026-09-06) → back to the bottom-right END (owner 2026-09-08: "the avatar row + membership status shouldn't render right next to the logo — it's supposed to be at the end"). Two reversals: the corners law stands; the flag is alone on the left, the people cluster ends the seam on the right (CLUSTER_RIGHT_INSET = the flag's own 12px inset, club-photo-seam.tsx).
    • Bottom-right (state, overhanging -14): the capacity cluster — an optional seam-scale AvatarStack beside a capacity pill, $surfaceSecondary fill (final fix batch 2026-08-05 — $surface was invisible against a white photo edge; not an outlined pill), height-synced to the seam avatars, role="badge" text. The capacityLabel itself guards maxMembers > 0 (falls back to the member-count-only string, never renders N/0).
    • Recruit badge (모집 중) is RETIRED — no RecruitmentStatusBadge renders anywhere on the card; the capacity pill carries recruitment implicitly (an open/near-full club reads through its own N/M number, no separate status chip).
    • Photo, 3:1 window (PHOTO_WINDOW_RATIO = 3) — cascade: real featured club-media photo → any other approved club-media photo → club.bannerUrldefaultClubPhoto(clubId).cardUrl (deterministic per-club pick from a curated, license-recorded stock set). ALWAYS a real image; this card never falls back to a gradient placeholder.

    MEMBER body: headline = name + activity indicator ONLY — no region, no tier badge (tier now lives in the photo's top-left corner). Below, render-only session rows, up to 2, as direct body children with NO eyebrow (the "다가오는 일정" label was removed — "rows self-describe" — so the rows inherit the body's own $2 rhythm rather than sitting inside a nested wrapper). Each row (ClubCardSessionRow): a strip-colored status dot (the session's own deriveSessionStrip palette, never a fixed color) + date + start time only (no end time — the full range wrapped on device) in one when-cluster, a flex-ellipsized venue name, and a trailing micro-meter — a 24×3px fill bar (amber past 85% fill, else accent) followed by the N/M count, bar-then-count. The WHOLE row renders in the single cardMeta role with a gray-ladder hierarchy only (date/time $text → venue/count $textSecondary, tabular nums; no font-size or weight variation). Rows are NOT tap targets (no Pressable/onPress — a per-row-clickable design was tried and reverted because the touch targets ate the list's gap; the card itself opens the club, sessions stay one tap away from there). No upcoming sessions → a quiet "예정된 일정 없음" line.

    DISCOVER body: same headline grammar (name + activity indicator — nearingBound is demoted to the neutral onRhythm tone here, never advertise decay to a stranger) plus a conditional tagline line (model.subtitle, 1 line, $textSecondary). Then emoji fact chipsBadge variant="surface" size="sm" with a <GlyphSlot emoji="…" /> icon (the canonical 16×16 glyph slot from @twomore/ui — the earlier raw <Text role="micro"> glyph was replaced in the final fix batch 2026-08-05, fixing Android baseline drift) + alignSelf="center" per chip: 📍 region, 👥 composition + age band (space-joined; the chip is skipped only when BOTH halves are absent), 📅 매주 + comma-separated meet-weekday labels. DayDotsRow/dotWeek no longer renders on this card at all — the emoji day-chip fully replaced the dot-week row (see orphans below).

    Activity indicator — a fixed component, cycle-relative tiers (ClubCardActivityIndicator): a 6px static Dot (never PulseDot — many cards render at once) + cardMeta label, deliberately OFF the card's type ramp (its state colors are fixed tokens, not ramp grays). Resolved against a cycle-relative bound = 2× the club's own regular-meet interval, floored at 7 days, capped at 21 days. Three rendered tiers:

    • fresh (age ≤ 24h) — $success dot + text.
    • onRhythm (24h < age, below 70% of the bound) — $textTertiary dot + text (the one ramp gray this fixed component is permitted to use, since it's a state color, not a ramp position).
    • nearingBound (age ≥ 70% of the bound) — $warning dot + text, member-surface only; the discover branch demotes this tier to the neutral onRhythm tone.
    • Past the bound entirely → the label/tier resolve to null and the indicator renders nothing — there is no rendered 4th "bound" state; past-bound is absence, not a visual mark.

    Click-through rule (survives from the earlier ship): no exact times render anywhere on the discover branch — the 📅 day-chip communicates cadence only (which weekdays, not what time). Real HH:MM values appear ONLY on the member branch's session rows; the detail/preview page is where a potential member learns the actual schedule.

    PHASE 5 — earlier ship (2026-08-03, commits 7299ab89 slices A-C + e1bd5ed4 slices D-E) — HISTORICAL, superseded by the corners-law entry above: the initial recomposition shared a photo+seam floor with a 40px flag circle bottom-left + a bordered N/M pill bottom-right (no top corners, no alarm badge), a member branch with a SectionEyebrow + clickable session rows using fixed-width date/time columns, and a discover branch with a TierRangeBadge in the name row, a DayDotsRow appearance="dotWeek" rhythm row, and a three-tone chip row (character/time-bucket/terms). Retired from that ship, now orphaned exports flagged for cleanup (see club-surfaces-unification.md Phase 5 slice E note): the §3.12 TrackerSessionZone and GlimpseVisualZone, plus the identity row's unread "새 글 N" indicator machinery — see the orphans list below, which folds these in with the corners-law pass's own new orphans.

  • HISTORICAL — ROLE-DEFINED CARDS (2026-08-02, §3.12 of club-surfaces-unification.md — superseded 2026-08-03 by Phase 5 above): one Club, two anatomies by viewer role (packages/features/clubs/src/shared/club-card.tsx), diverging below a shared IDENTITY FLOOR (ClubLogoBubble 36px · name · region·member-count, always present, both roles) — no banner, no straddled logo/avatar-cluster overlay on either branch (that grammar is retired; BannerCapacityCluster has zero remaining consumers). Member TRACKING ("what's next, identity is a given"): identity row (+ ClubRoleBadge for admins + a bulk-fetched "새 글 N" unread indicator, useUnreadPostCountsByClubIds) → a conditional attention strip (the SAME deriveSessionStrip/SessionStripView projection every session surface uses) → a venue-led compact session row (48px VenueTile — the session card's own tile component, reused via a new size prop, same photo→map cascade — beside venue name/date-time/format/plain-text N/M participation; no CapacityRing) → ClubAdminRow (staff-only, self-hiding, unchanged). No next session → a quiet "예정된 일정 없음" line. No tier badge (the tracker doesn't need to persuade). Discover GLIMPSE ("spark the tap; the detail page persuades"): a 16:9 photo band ONLY when model.isRealImage is true (an actual photo/banner — a placeholder gradient never counts as imagery) with the recruit-badge overlay; no real photo ⇒ no band at all (the bare anatomy is the PRIMARY majority-state design, not a fallback — most launch clubs are photo-less). Body: logo+name+TierRangeBadge row → graphics-forward visuals replacing the old text fact-line — AvatarStack of real member faces (≤4 @ 32px when a photo exists, promoted to ≤5 @ 44px as the card's own visual anchor when bare — "people become the visual") + a plain N명 count, beside (photo state) or beneath (bare state) a compact DayDotsRow (variant="compact", new — fixed-size non-stretching cells so it can share a row instead of spanning full width) → one quiet terms line (region · dues · join policy, shown only when non-'open') → character-only chips (neutral fill; joinPolicy/dues no longer render as chips — they moved into the terms line). Taps: member → club detail; potential member → club preview.

  • HISTORICAL — superseded 2026-08-01 by §3.10, then 2026-08-02 by §3.12 above (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 — RETIRED, do not resurrect: the titled "정기 일정" week-strip block described here (ClubScheduleBlock, a numbered week strip with one row per meet below it) is dead code, deleted from the card. Cadence today is folded into ZONE 2's identity fact row as a single inline glyph fact (model.frequencyLabel — Calendar icon + text, potential-member card only); it is not a standalone card zone. The actual next session lives in ZONE 5 below, rendered by ClubNextSessionWell (VenueHero photo/map strip + SessionCompactRow, member card only) with a plain Well + SessionCompactRow fallback when only a raw Session is available. ZONE 4 chips use the CANONICAL SessionCardV2 chip-strip chrome 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) cadence fact (see ZONE 3 above — RETIRED as a standalone block; folded into the ZONE 2 identity fact row, model.frequencyLabel); (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. Current (usability audit 2026-09-16, U-25): the row's 조 (조 편성 group) tag renders only once the club has 2+ active groups to actually distinguish between (showsGroupTag, club-groups.rules.ts) — a single group is dropped silently rather than shown as inert noise — and a legend line (clubList.clubGroup.legend) explains the tag above the list whenever it's showing.

  • ClubRoleBadge + ClubAdminRow (B1, 2026-07 owner wireframe review; badge placement HISTORICAL, see below — ClubAdminRow placement current under Phase 5): owner/admin viewers historically got a ClubRoleBadge ("운영", one label — never split owner/admin; brand-tinted accent Badge variant, an identity cue rather than a state signal) — banner-overlaid top-right, then §3.12's inline-in-identity-row placement. ClubRoleBadge is NOT rendered on the Phase 5 card at all (zero import in club-card.tsx post-e1bd5ed4) — the shared floor's identity row has no admin-role affordance today; component still exists (status-badges.tsx:426) for other call sites. The card's 관리 로우 (ClubAdminRow) is unaffected and stays current — its own nested Pressable → the club's 관리 탭 (routes.club(clubId, { tab: 'admin' })), R3's "알림 N건" + chevron treatment, the member-branch card's LAST zone (after the session rows).

Spacing & corners laws (Phase 5.1, corners-law iteration)

CORNERS LAW — named and specced under the PHASE 5.1 entry above: the four-corner seam grid (identity left / state right), every overhang literal set to exactly half its own height so the photo border bisects each element symmetrically.

SPACING LAW (club-card.tsx, both branches — three gap classes, $-token only, no raw pixel gaps anywhere in the seam or body):

  • Body rows $2 — the vertical rhythm between headline / session-rows / chips / etc. within a branch body.
  • Glyph↔text $1 — dot-to-label and icon-to-count pairings within one visual cluster (the activity indicator, a session row's when-cluster, the micro-meter's bar+count).
  • Intra-row clusters $3 — the separation between peer clusters inside one row (a session row's when-cluster / venue / meter).

Orphans / cleanup candidates (zero remaining call sites as of the corners-law ship; exported or computed but unconsumed — flag for deletion next time each is touched, not deleted by this docs pass):

  • DayDotsRow's appearance="dotWeek" mode — no JSX call site anywhere passes it; the discover card's 📅 emoji chip replaced the dot-week row entirely.
  • useUnreadPostCountsByClubIds (packages/app/src/presentation/hooks/queries/use-posts.ts) — zero callers app-wide.
  • The unreadPostsIndicator i18n key (ko/clubs.ts, en/clubs.ts) — zero render sites; the identity row's unread "새 글 N" indicator it backed was retired with the earlier Phase 5 ship.
  • buildSessionCountByClub (packages/features/clubs/src/shared/club-card-summary.ts) — not even re-exported from the clubs package barrel, uncalled.
  • model.recruitLabel (club-card-model.ts) — still computed every render (feeding the retired recruit badge), never read by club-card.tsx since that badge retired.
  • GlyphFactRow (packages/ui) — zero <GlyphFactRow call sites anywhere. Its inner GlyphSlot IS now in use (the discover card's emoji fact chips adopted it in the final fix batch, 2026-08-05), but the composed row primitive itself remains uncalled.

Venue provenance (owner decision 2026-08-07)

Club-linked courts come from reputable sources ONLY — a court_venues row must originate from the curated venues spine or a Naver Local Search result (its venue_id / naver_place_id / coordinates may be null only because the source didn't resolve them, never because a user typed the venue by hand). No UI offers free-text venue creation: the create-club picker's 직접 입력 escape was removed (2026-08-07); a court missing from the spine is reachable through the picker's Naver supplement, which is the sanctioned escape hatch. clubs.court_info is display metadata (a mirror of the home pick's name, or a description like "실외 하드코트 2면") — it is never a venue identity and never materializes venue rows. The create-session wizard's free-text venueName predates this rule and is queued for reconciliation.

Pick-through ingestion (migration 00452) — the automated "ask for inclusion" path: a user finding a court via the picker's Naver supplement IS the inclusion request; there is no form and no moderation queue. On pick-persist, private.ensure_venue_for_court_venue (the court_venues BEFORE-INSERT link trigger from 00284) creates the spine row with real provenance: verification_status='imported', data_source='club_pick', plus a naver_place row in venue_source_observations so the 00362 source-family counter starts the naver family honestly. Promotion into the curated cohort (source_count >= 2 AND not-unverified) then happens automatically when an enrichment pass corroborates from an independent family (kakao/tmap) — no manual step. Guardrails: host_verified is NEVER stamped by picks (reserved for operator-accepted venue_corrections, one rung up the 00335 trust ladder); ingest-venues' resetImported corpus wipe excludes data_source='club_pick' rows (they anchor club home courts via an ON-DELETE-SET-NULL FK); id-less picks stay unverified with no observation — only what a reputable source resolved gets attested.

Activity regions derive from courts, never entered by hand (migration 00650)clubs.adm1_slug/adm2_slug (primary) and club_play_regions (additional, capped at 3 total) are a PURE FUNCTION of the club's court_venues districts, recomputed by a trigger (trg_sync_club_regions) on every court add/remove/edit — the same server-side path whether the courts came from the create-club wizard or club-venues-screen's admin add. The only way to change a club's regions is to add or remove a court; club-settings' 정보 section reflects this with a read-only row instead of a free-text field once any court has a district.

See also

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