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..fa9aff95onmain) — current: supersedes the 2026-08-03 Phase 5 ship note further below (sameClubCard,packages/features/clubs/src/shared/club-card.tsx— the design kept iterating after that initial ship, this entry documents where it landed). OneClubCard, rendered for BOTH audiences, sharing a photo+seam floor under the CORNERS LAW and diverging below it (buildClubCardModelinclub-card-model.tssupplies 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") whenminTier/maxTierare 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 ownlogoUrlviaClubLogoBubble, else a deterministic generativeFlagMedallion(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,$badgeWarningTextfill, 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 withincludeFontPadding={false}+textAlignVertical="center"(the GlyphSlot Android-centering fix),overflow="hidden". Renders only forisMember && isAdmin, fed by the admin snapshot's pending-item count — never shown to a plain member or a discover viewer. The count caps at99+(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-scaleAvatarStackbeside a capacity pill,$surfaceSecondaryfill (final fix batch 2026-08-05 —$surfacewas invisible against a white photo edge; not an outlined pill), height-synced to the seam avatars,role="badge"text. ThecapacityLabelitself guardsmaxMembers > 0(falls back to the member-count-only string, never rendersN/0). - Recruit badge (모집 중) is RETIRED — no
RecruitmentStatusBadgerenders 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.bannerUrl→defaultClubPhoto(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
$2rhythm rather than sitting inside a nested wrapper). Each row (ClubCardSessionRow): a strip-colored status dot (the session's ownderiveSessionStrippalette, 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 theN/Mcount, bar-then-count. The WHOLE row renders in the singlecardMetarole 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 (noPressable/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 —
nearingBoundis demoted to the neutralonRhythmtone here, never advertise decay to a stranger) plus a conditional tagline line (model.subtitle, 1 line,$textSecondary). Then emoji fact chips —Badge 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/dotWeekno 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 staticDot(neverPulseDot— many cards render at once) +cardMetalabel, 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) —$successdot + text.onRhythm(24h < age, below 70% of the bound) —$textTertiarydot + 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) —$warningdot + text, member-surface only; the discover branch demotes this tier to the neutralonRhythmtone.- Past the bound entirely → the label/tier resolve to
nulland 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:MMvalues 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
7299ab89slices A-C +e1bd5ed4slices 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 borderedN/Mpill bottom-right (no top corners, no alarm badge), a member branch with aSectionEyebrow+ clickable session rows using fixed-width date/time columns, and a discover branch with aTierRangeBadgein the name row, aDayDotsRow appearance="dotWeek"rhythm row, and a three-tone chip row (character/time-bucket/terms). Retired from that ship, now orphaned exports flagged for cleanup (seeclub-surfaces-unification.mdPhase 5 slice E note): the §3.12TrackerSessionZoneandGlimpseVisualZone, 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.- Top-left (identity, clipped inside the photo):
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 (ClubLogoBubble36px · name · region·member-count, always present, both roles) — no banner, no straddled logo/avatar-cluster overlay on either branch (that grammar is retired;BannerCapacityClusterhas zero remaining consumers). Member TRACKING ("what's next, identity is a given"): identity row (+ClubRoleBadgefor admins + a bulk-fetched "새 글 N" unread indicator,useUnreadPostCountsByClubIds) → a conditional attention strip (the SAMEderiveSessionStrip/SessionStripViewprojection every session surface uses) → a venue-led compact session row (48pxVenueTile— the session card's own tile component, reused via a newsizeprop, same photo→map cascade — beside venue name/date-time/format/plain-textN/Mparticipation; noCapacityRing) →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 whenmodel.isRealImageis 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+TierRangeBadgerow → graphics-forward visuals replacing the old text fact-line —AvatarStackof 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 plainN명count, beside (photo state) or beneath (bare state) a compactDayDotsRow(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 (neutralfill; 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
$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 — 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 byClubNextSessionWell(VenueHero photo/map strip +SessionCompactRow, member card only) with a plainWell+SessionCompactRowfallback when only a rawSessionis 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 · 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) 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 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. 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 —ClubAdminRowplacement current under Phase 5): owner/admin viewers historically got aClubRoleBadge("운영", one label — never split owner/admin; brand-tintedaccentBadge variant, an identity cue rather than a state signal) — banner-overlaid top-right, then §3.12's inline-in-identity-row placement.ClubRoleBadgeis NOT rendered on the Phase 5 card at all (zero import inclub-card.tsxpost-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 nestedPressable→ 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'sappearance="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
unreadPostsIndicatori18n 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 byclub-card.tsxsince that badge retired.GlyphFactRow(packages/ui) — zero<GlyphFactRowcall sites anywhere. Its innerGlyphSlotIS 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
- Conventions › Sessions —
SessionCompactRowrules that club cards embed (SessionCardis deleted; club cards never embedded it directly). - 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.