Skip to content

Component Conventions

Status: Active Last reviewed: 2026-07-19

How TwoMore wires together its UI building blocks — Card tones, clickable primitives, participation and capacity indicators, text + badge roles, empty/loading states, the home feed architecture, tab and chip controls, list virtualization, shell components, wizard flows, confirmation alerts, and query boundaries. These are the canonical, single-source rules; feature code follows them and never bypasses these primitives with ad-hoc alternatives. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (COMP-1..COMP-7).

Cards

  • Using Cardonly two tones: default (neutral card surface) and flat (dashed-border empty state). Sizes: compact (12px padding), default (16px), hero (20px). Never tonal/live/warning card FILLS, and no screen-wide status tint (a pulsing screen wash was tried and reads ominous) — state lives inside the card's content. The sanctioned status accent on a SessionCard summary is a small DOT at the START of the title (in SessionHeader's title row, leading the title text): in_progress → a red PulseDot ($error, LIVE/temporal, participation-independent); viewer confirmed → a solid green dot ($primary 8px circle, "this session is yours"); every other state incl. open-not-joined → none (the teal 모집 중 chip carries "available"). Color via sessionStatusStripColor(session.status, myRsvp?.status) from packages/app/src/presentation/components/status/session-status-strip.ts. The confirmed dot's brand-green is intentionally the SAME $primary as the 참가 확정 chip (RsvpStatusBadge confirmed → primarySolid solid-green pill) so the dot + chip read as one coordinated "you're in" cue. A left color-STRIP (both a corner-wrapping borderLeft AND a straight inset side-bar) and a top-right corner dot were all tried and REJECTED — the strip wrapped/curved the rounded corners and the side-bar/corner-dot read as ugly; the title-leading dot is the chosen cue. SessionHeader's title group is dot → date → title, all styled as one unit (the date is cardTitle muted, sitting between the dot and the title), with the weather emoji+temp alone on the right; the bottom-left "where" line shows the venue/place (session.location), not the 시/도 region. The session-DETAIL screen signals state WITHOUT a card fill or screen tint: a status chip beside the title (SessionStatusBadge, canonical "라이브"/"모집 중" labels) + a red PulseDot (color $error — the true live-red; NOT $badgeErrorText, which is salmon-pink in dark mode).
  • A card is PRIMARILY display + navigation — the whole card is the main tap → its detail. A page-level / hero CTA never sits on a card (those pin to BottomCtaBand on the detail screen); no FABs. But per the nested-affordance model directly below, a card MAY carry a small number of compact, visually-balanced nested actions: content on the left or top invites a quick action on the right or bottom — a list-row 확인/제외/출결 button, a match-card 경기 종료 footer band — and for a glance→act row keeping the action where the eye already is reads BETTER than detaching it. The line is page-CTA vs balanced nested action (not button-vs-no-button): an ActionButton (the canonical, intentionally-styled action) in a balanced nested slot is sanctioned; a loud page CTA plastered on a pure content/navigate card is the smell. (History: a blunt "never a CTA in a card" was walked back 2026-07-13 — it contradicted this same model and over-flagged legitimate balanced action-rows.)
  • Multiple touch targets in a card — the canonical model (Material 3 "one primary action + supplemental actions"; codified 2026-06-23 after a full clickable audit). A card has ONE primary touch target = the whole card → its detail destination; a SMALL number (≤~3) of NESTED affordances may do something different (a sub-nav like 상세 보기/전체 경기 보기, a toggle like the participation row, an action like save). Rules:
    • The canonical clickable is Pressable from @twomore/ui (variants card/row/pill/icon/button/fab) — it is the SINGLE SOURCE of interaction cues: UI-thread scale+opacity press feedback, web cursor:pointer + hover dim + $primary focus ring, TS-required accessibilityLabel, standardized disabled. Button/ActionButton (page CTAs) + PillNav/SegmentedTabs/SelectionChip (tabs/chips) are the only other sanctioned clickables. NEVER use react-native Pressable/Touchable*, raw onPress on a Tamagui Stack/XStack/YStack, or inline pressStyle={{}} — they lose all of the above. (onPressIn on a wrapper stack IS allowed — Protocol H prefetch.)
    • A tappable card WRAPS in <Pressable variant="card"> — never <Card onPress>. Card is a pure display styled(Stack) with no press/hover feedback; passing it onPress gives a dead tap. Wrap: <Pressable variant="card" accessibilityLabel={…} onPress={…}><Card …>…</Card></Pressable> (the muting opacity for cancelled/archived stays on the inner Card). Canonical examples: SessionCard, MyClubCard, VenueRow. The muting opacity itself is the named constant TERMINAL_MUTE_OPACITY (0.55, packages/ui/src/box-sizes.ts) — the one terminal-muting value, established independently at MyClubCard (archivedAt), SessionCard (cancelled), DirectoryVenueDetailScreen (isClosed), and AchievementsGalleryScreen (!isUnlocked) before being named; use the constant, never a bare opacity={0.55} literal.
    • Every interactive NESTED target inside a tappable card MUST call e?.stopPropagation?.() as the first line of its onPress. Native RN grants the responder to the deepest view, but WEB events bubble → without it, tapping the inner target ALSO fires the card's onPress (double-nav). Canonical: session-save-button, the participation expand band, the 전체 경기 보기 footer, the notification dismiss.
    • Display-only elements stay non-interactive — chips, text, the VenueLink pill are NOT touch targets; the card's tap covers them. Don't make a value clickable unless it navigates somewhere different or performs an action.
    • The card's primary tap = the most-common intent; rare/secondary actions are the nested ones (or move off the card to the detail / a header overflow menu). Past ~3 nested targets the card becomes a mini-page — pull back.
    • Enforced by @twomore/no-raw-rn-pressable-in-features, @twomore/no-onpress-on-card-in-features, @twomore/no-onpress-on-stack-in-features (+ the pre-existing no-press-style-in-features). The stopPropagation contract is reviewer-checked (not mechanically linted).
  • CARD-NO-OVERFLOW — a card's contents, combined, must NEVER overflow its width (an essential attribute of every card component, owner directive 2026-06-20). Clipped or pushed-off content — the classic being a long venue name shoving the weather glance off the right edge — is always a bug, never an acceptable state. The mechanism, applied to EVERY row that mixes content kinds: in a row combining FLEXIBLE content (text that can run long — venue, title, player names) with FIXED content (chips, icons, the weather glance, a count), the flexible element is the ONLY one that may flex AND it MUST be able to shrink — wrap it in flex={1} minWidth={0} and give its text numberOfLines={1} (ellipsize). RN's default flexShrink is 0 (unlike web's 1), so a flex child will NOT shrink below its content width unless minWidth={0} is set — omitting it is the #1 cause of card overflow. Fixed siblings get flexShrink={0} (or are intrinsically fixed) so they stay fully visible. Canonical patterns: the SessionHeader where-row (venue in flex={1} minWidth={0} + the weather glance pinned right, fixed) and VenueLink (maxWidth="100%" + name numberOfLines={1} flexShrink={1} + trailing flexShrink={0}). Multi-chip strips that can exceed one row use flexWrap="wrap" (Material 3) or shed chips per the density rule — never clip. When adding or arranging anything on a card, verify the LONGEST realistic content (longest venue/name, max chips) still fits within the card width.
  • Summary-card density — a card answers ONE decision, shows ~3–6 decision-critical signals, and DEFERS the rest to the detail screen. Research-grounded (NN/g "cards are entry points, not encyclopedias"; the field publishes NO hard chip/attribute cap — the discipline is qualitative; real dense cards show 3–6 signals and hide the long tail: Strava 3 stats / Google Flights 5 / Airbnb ~5). This is the SUMMARY-card mirror of the detail-screen single-protagonist rule. Five tests:
    1. One decision question → one dominant anchor. Frame the card around the single question it answers (SessionCard: "is there a spot / when / is it mine?") and make that the protagonist (participation); everything else recedes. Pre-attentive scanning lands on ONE figure (~200–500ms) — two heavy co-clusters competing (e.g. participation + a full weather strip) is the failure ("if everything is emphasized, nothing stands out").
    2. The defer-to-detail test. Before adding an attribute, ask "does this change the BROWSE decision?" If no (or rarely), it belongs on the DETAIL screen, not the card. The detail holds the rich version (hourly weather, wind, AQI breakdown, full roster); the card carries only the glanceable summary (condition+temp, N/M, faces).
    3. Chunk into ≤~4 recognized clusters (Miller 7±2 — for chunking, NOT a 7-item cap; Gestalt proximity; Polaris) — WHEN / WHERE / WHO / classification — grouped by the $N proximity hierarchy so each reads as one chunk, not N loose elements. Spacing does the grouping, not labels.
    4. Chips: closed whitelist, one row, ≤20-char labels (Material 3). The strip is the sanctioned set only (no ad-hoc <Badge>, COMP-2) and must NOT wrap past one row (M3: 2-row chip strips are hard to scan). Conditional chips (payment/approval) are rare, so the common case is ~3.
    5. Color is triage, not decoration; keep the palette small (neutral-first; Astro UXDS: more status colors reduce learnability). Every color source on a card must earn its triage value; when several compete (status-toned date tile + status chip + payment chip + accent chip + brand chip + AQI dot + temp-toned fill), trim. The failure mode is the "baby webpage" card (Dave Rupert) / our "parallel mini-dashboards" anti-pattern — a card so dense it stops being a glance and becomes a compressed detail page. Cautionary example (2026-06, the SessionCard audit): the weather strip accreted AQI grade+label + wind + a min/max range bar — granular signals that don't change an RSVP decision; by test #2 the card weather leans to condition+temp (+ a bad-weather warning) and the rich strip + D-day chip move to the session detail.
  • Backend-backed data never renders as dead text, and links go through an established CLICKABLE COMPONENT — never ad-hoc styled text (owner rule 2026-06-12). If a displayed value has a backing entity with its own screen (a venue with courtVenueId → venue detail; a player name → public profile; a club name → club preview; a session reference → session detail), it renders as a deep link via the entity's canonical clickable component — NOT a one-off Pressable+primary-Text reinvented per call site. The canonical venue link family: VenueLink (@twomore/app) with variant="compact" (inline MapPin + $primary name + a chevron — the chevron makes it read as a touch destination; for dense meta lines) and variant="medium" (a bordered row — pin in a tinted square + name + optional secondary line + chevron); plus VenueCard (@twomore/app, the RICH large tile: static-map thumbnail → Naver app + name + 영업 중/종료 status pill via isVenueOpenNow + 길찾기 + surface·courts·district + facility icons + weekday-rate hint; the single source replacing the old duplicated location tiles). Use VenueLink wherever a venue with an id appears inline (compact session row, session meta), VenueCard for the detail-screen location tile. When a NEW linkable entity recurs, build its canonical XLink/XCard component once and reuse it. The inner pressable wins the tap inside a tappable card; the card keeps its own destination. Free-text values with NO backing id stay plain muted text — never style a fake link. When a read path lacks the id needed for the link (e.g. an RPC summary), extend the read path (the 00261/00262 pattern) rather than shipping dead text.

Participation & capacity indicators

  • "Who's in" → ParticipationCluster (@twomore/ui, the canonical FACES indicator) — an AvatarStack of participant faces with silhouette-fill + exact "+N" overflow. It is faces-only (owner canon 2026-07: the session summary card's usage IS the pattern — the ring is always composed separately, never inside this component). Pass avatars: {name, avatarUrl}[] (friends-first, caller-ordered) + total (true count for exact "+N"); overPhoto outlines the faces to read against a banner photo; emptyLabel renders a hint when there are 0 participants. Unknown/private participants still render a faceAvatarBubble shows a User silhouette when a name is '' or '?' (never a blank circle), so a populated session/club never shows a bare cluster. The ring is a SEPARATE primitive — CapacityRing — that the caller composes alongside the faces. The SessionCard is canonical: CapacityRing direction="column" in the left-rail date cell (date + fill in one column) + ParticipationCluster faces in the meta row + the status chip pinned right. Club-card member clusters use the same faces component on the banner overhang (overPhoto). Never hand-roll an AvatarStack with silhouette/overflow logic per call site — that logic lives in ParticipationCluster.
  • A participant avatar set must show ALL confirmed participants, not just the viewer's friends. Source faces from the confirmed-RSVP id set (friends-first ordering), NEVER gate the avatar fetch/render on friendsCount > 0 (the bug fixed 2026-06-12: session cards showed zero faces unless the viewer happened to have a friend in the session). The friends-going COUNT line ("친구 N명 외 M명") is separate social proof that may still gate on friends.
  • Capacity / fill at a glance → CapacityRing (@twomore/ui, the canonical single-line tracker) — a compact SegmentedDonutChart ring + N/M count (props value/max/size/countRole/direction). direction="row" (default) — ring and count side by side. direction="column" — ring stacked above the count; used by the session summary card's left rail tile (48px bordered cell under the DateTile) where a vertical layout maximises the ring's visual size in a narrow column. It is the ONE fill tracker for BROWSE/LIST/CARD surfaces: club-card member capacity AND session-card participant fill both use it (the greedy flex:1 ProgressBar is retired from session summary/compact/live-row cards — it doesn't share a line). Exception — the session DETAIL participation hero keeps its full ProgressBar: there the fill IS the focal protagonist (one hero number + one progress bar per the detail-screen IA rule), and a ring would shrink the main signal. So: ring for glancing in a list, bar for deciding on the detail. ProgressBar remains the primitive for every NON-participant progress use (dues, records, analytics, attendance).

Buttons, text & badges

  • Using Button → page CTA = lg + primary (pinned to BottomCtaBand), stepper = xs + circle + iconOnly. Variants: primary / secondary / outline / ghost / danger / destructive × xs/sm/md/lg × default/pill/circle. A page-level CTA pins to BottomCtaBand, never a card; a compact nested ActionButton action IS allowed (the ≤3 nested-affordance model in the Cards rule above — page-CTA vs balanced nested action). danger = quiet destructive TRIGGER (red outline + red text on transparent) for inline buttons that BEGIN a destructive flow (forfeit / delete / remove / leave). destructive = loud solid-red, reserved for the CONFIRM step (ConfirmSheet style:'destructive') and the rare phrase-gated final-commit button (delete-account). Trigger → danger, confirm → destructive; never swap them. Every intent-bearing CTA uses <ActionButton action="…"> (from @twomore/ui), never a raw variant — it maps INTENT (primary|secondary|neutral|cancel|tertiary|destructive) → variant via ACTION_VARIANT (the single source), so intent can't drift to the wrong color; SIZE still follows placement, not intent. cancel = a DISMISS/skip/undo (→ ghost); tertiary = a low-emphasis FORWARD action that is NOT a dismiss (add-note / approve-all / mark-read / mode-toggle — same ghost look, distinct intent). A destructive ActionButton MUST be paired with a useConfirm gate at the call site (ACTION_REQUIRES_CONFIRM documents this). Parallels the status-badge intent→variant pattern. Sanctioned raw-Button exceptions (NOT routed through ActionButton — they carry no product "intent"): (1) dev-panel / __DEV__-gated tooling; (2) icon-only triggers (no text label); (3) numeric steppers (xs+circle); (4) selection-state toggles where the variant must FLIP with "is this selected"; (5) disabled, no-onPress status placeholders occupying a CTA slot; (6) the loud raw variant="destructive" confirm/phrase-gated/direct-fire buttons (distinct from ActionButton's destructivedanger). Everything else = ActionButton.
  • Using Text → pick a role, never hardcode fontSize/lineHeight/fontWeight. Core 5: pageTitle (24/32 700 — the LARGE left-aligned title on tab-ROOT screens via MainTabShell, iOS large-title style), cardTitle (16/24 600), cardBody (14/22 400), cardMeta (13/18 500 muted), badge (12/18 600). Nav chrome: navTitle (17/22 600 — the compact CENTERED title on pushed DETAIL screens via AppHeader/DetailShell, iOS HIG). The tab-root (large, left, no border) vs detail (compact, centered, hairline border) header split is intentional (platform convention), not a drift — don't unify them. Hero/stat numbers + emoji glyphs: displaySm (20/28 700), displayMd (28/36 700), displayLg (36/44 700). A role= + inline fontSize override is the anti-pattern this rule exists to prevent — if no role fits, the role belongs in text.tsx, not as a per-screen override.
  • Using Badge8 theme-invariant semantic-intent variants + 1 brand variant + 1 on-tinted-surface variant: neutral (default gray, for non-status metadata), accent (teal), success (green), warning (amber), info (purple), live (red + PulseDot), muted (faded), premium (reserved) — all 8 from the theme-invariant status palette; PLUS primarySolid (solid $primary bg + $primaryText text — the ONLY brand-colored, theme-VARIANT badge), used solely for RSVP confirmed (참가 확정) so the chip pairs with the brand-green title dot on session cards. primarySolid reuses the primary Button's bg/text pair, so contrast is correct in light + dark; it's the deliberate exception to "status badges are theme-invariant" (confirmed-participation reads as a personal/brand cue, not a neutral status). PLUS surface ($surface bg + $textSecondary text) — for badges rendered ON TINTED SURFACES (warning strips, unread-tinted rows) where any same-hue badge bg disappears against the surrounding tint; a plain surface pill restores contrast. feature/presentation code is bounded to neutral warning live surface by @twomore/bounded-variant-enums (AGENTS.md COMP-2) — the other variants are used only by the canonical XxxStatusBadge wrappers themselves. See the Status-indication intent palette in docs/canon/status-and-recruitment.md. For DOMAIN STATUS, never compose <Badge> ad-hoc — use the XxxStatusBadge wrappers. BadgeContainer bakes alignSelf:'flex-start' (so a standalone badge hugs its content instead of stretching in a column) — inside an alignItems:center row next to text/avatars (e.g. a MatchScoreboard player row) that pins the pill to the TOP of the row; pass the opt-in alignSelf="center" prop to vertically center it.
  • Tempted to use raw Tamagui Pressable as button → Use <Button> with correct variant

Empty & loading states

  • Using EmptyState → page-level = full (64px icon bubble), section-level = compact (44px icon bubble)
  • Screens with async data → render SkeletonCard / SkeletonHero / SkeletonRow during isLoading. Never let EmptyState flash before queries resolve.

Home feed

  • Home tab = Toss-style feed of independent cards. Each card reads its own hooks and returns null when nothing to say. No state classifier
  • Adding a new home card → create in packages/features/home/src/cards/, register in HomeFeedScreen at the right priority position
  • HomeLiveAlertBanner → must stay mounted above PillNav at the very top of HomeFeedScreen. Renders only when a user session has status=in_progress. Never move it below the nav or inside a feed card.

Tabs & chips

  • Tab pages → MainTabShell from @twomore/ui (handles SafeAreaView top-edge, pageTitle header, headerRight, bottomCta). Pass tabs/tabValue/onTabChange/renderTab to get a built-in SegmentedTabs switcher. Never roll your own SafeAreaView + pageTitle combo for a tab page.
  • Segmented tab switching inside a page → SegmentedTabs from @twomore/ui (PillNav-driven, preload="all" mounts all panes post-paint for instant switching). Multi-tab content where each pane has its own useQuery/useState hooks → ALWAYS SegmentedTabs preload="all". Conditional {tab === 'x' && ...} on hook-bearing panes is forbidden — it remounts hooks on every chip tap and blocks the JS thread. Never use conditional {activeTab === 'x' && ...} for tab content that contains hooks.
  • PillNav.onChange is a direct urgent state update — chip flip must commit on press frame. Deferral of heavy content rendering lives at the SegmentedTabs consumer via useDeferredValue.
  • SegmentedTabs uses useDeferredValue internally — content pane mount is low-priority while the chip stays urgent. Don't add startTransition on top.
  • SegmentedTabs preload mode → "visited" by default. preload="all" only when ALL panes have lightweight first-paint cost; useDeferredValue keeps chips responsive either way.
  • SegmentedTabs consumers → wrap each pane component in React.memo AND wrap renderTab in useCallback with stable-reference deps (e.g. hoist userId ?? '' to safeUserId first). Without both, every chip tap re-renders all mounted panes (since preload="all" keeps them mounted) and the JS thread saturates. Either alone is insufficient: React.memo without stable renderTab → memo bails on referential prop check; stable renderTab without React.memo → component re-renders fully anyway. See packages/features/clubs/src/club-list-screen.tsx / packages/features/activity/src/activity-screen.tsx / packages/features/records/src/records-leaderboard-screen.tsx for canonical examples (commit e0ce50b, 2026-05-02).
  • Chip-style tab controls → use PillNav from @twomore/ui. Don't add per-chip backgroundColor variants — the active background slides between positions on the UI thread via a Reanimated worklet.
  • Selectable DATA chips (scope/filter selection — match-history scope, discovery filters, gallery kind/visibility toggles) → SelectionChip from @twomore/ui (packages/ui/src/selection-chip.tsx): label + selected + optional leading/trailing, size default|compact; selected state = $primary border + $primarySubtle bg + $primary 700-weight label. Distinct from PillNav/SegmentedTabs (navigation chrome) — SelectionChip is for filtering DATA, not switching views. Dropdown-style pickers compose DropdownChip/RangeChip (SelectionChip trigger + ModalPanel picker; DropdownChip supports controlled open/onOpenChange for cascades like region→district auto-open). Never hand-roll a selectable pill with ad-hoc Pressable styles in feature code.
  • Region → district cascading filter (시/도 → 시/군/구 multi-select) → RegionDistrictFilter (packages/app/src/presentation/components/region-district-filter.tsx, a DropdownChip pair over the canonical REGIONS source with shared cascade-reset behavior — deselecting a region prunes its now-orphaned districts). The single source for the 번개 pickup filter bar and the venue-directory filter bar (unified 2026-07-12, ~90 duplicated lines removed); lives in packages/app (not packages/ui, which stays primitive-pure, and not a feature shared/ dir, since two sibling feature packages both mount it — ARCH-2). Encoded-value separator is parameterized (region-district-filter.ts) — venue matches it against a hyphen-composite DB slug, pickup uses a pure client-side comparison key.

Clickables

  • Pressable cards/rows that navigate to a detail screen → wire onPressIn to the matching prefetchX helper from @/presentation/cache. Press intent populates cache before nav resolves.
  • Press feedback runs on the UI thread via the Moti animation driver — Tamagui pressStyle + animation="quick" is now safe for all shared primitives. MotionPressable still preferred for fine-grained Reanimated worklet control on bespoke surfaces.
  • Clickable surfaces → ALWAYS one of these primitives, never raw onPress on a styled Stack or inline pressStyle={{ scale, opacity }} in feature code:
    • Page / form CTA → Button from @twomore/ui (variants × sizes × shapes)
    • Tab strip / chip group → PillNav / SegmentedTabs from @twomore/ui
    • Anything else clickable (cards, rows, chips, icons, FABs) → Pressable from @twomore/ui with variantcard | button | pill | row | icon | fab
    • Pressable is the canonical clickable primitive. It encodes: required accessibilityLabel (TS-enforced), per-variant scale + opacity press feedback on the UI thread (Reanimated worklet), reduced-motion fallback (opacity-only), web focus ring on $primary, standardized disabled state (0.5 opacity + non-interactive + busy a11y), and loading prop that renders disabled state.
    • Inline pressStyle={{ ... }} on raw XStack/YStack is forbidden in packages/features/**. Use Pressable variant="…" so the press feedback is consistent across the app.
    • Disabled state is rendered identically everywhere — opacity 0.5, pointerEvents="none", accessibilityState={{ disabled }}. Don't roll your own.
    • Tap targets ≥44pt — enforce via outer container size when the Pressable wraps a small icon. iOS HIG / Material 48dp baseline.
  • Adding an admin tab to a detail screen → follow the AdminTab nav-hub pattern (named component, PillNav-driven, ListRow per destination). Gate tab visibility on canManageMembers || canManageDues. Never put mutation controls inside the hub — keep it navigation-only.
  • HeaderIconButton (@twomore/ui) is MANDATORY for EVERY header icon — the back arrow AND every right-slot affordance (share, message, …) on AppHeader/DetailShell. A tight 24px (xl) icon whose touch target expands to the 44pt HIG minimum via hitSlop (no layout padding), so adjacent header icons space purely by container gap instead of a padding/negative-margin dance. Never hand-roll a header icon with Pressable variant="icon" + a raw chevron/icon component directly, and never use Button for a header icon (its text-button padding/frame reads chunky next to the tight back arrow) — 8 bypass sites were migrated onto it 2026-07-19. The one sanctioned exception is the gear overflow menu (HeaderActionsMenu, needs a Popover.Trigger).

Lists

  • Scrollable feeds (flat or grouped-by-section) → FeedList / GroupedFeedList from @twomore/ui (wraps FlashList (FeedList) and SectionList (GroupedFeedList) with virtualization tuning, scroll-hint badge, loading-more footer, and empty/loading slots). Never use bare FlatList/ScrollView for a list that may grow.
  • List render-callbacks (renderItem / keyExtractor / ItemSeparatorComponent / renderSectionHeader) on any list component (FeedList / GroupedFeedList / FlashList / FlatList / SectionList) MUST be hoisted into a useCallback with stable-reference deps — never inline. An inline callback is a fresh reference on every parent render, so the list re-renders every visible row on each parent state tick (a sheet open/close, a chip flip, a countdown). Declare the useCallbacks ABOVE any early return so they obey rules-of-hooks; if a callback closes over another handler, hoist that handler into a useCallback too (otherwise the dep array churns and defeats the memo). Pair with React.memo on the row component for full effect — useCallback alone keeps the prop reference stable, React.memo is what skips the row's re-render. Enforced by @twomore/require-memoized-list-render-callbacks (Wave H).
  • Mixed-type FeedList/FlashList MUST pass getItemType. When one list renders structurally different item kinds (e.g. a {kind:'header'} ~30px row vs a {kind:'match'} ~150px row), pass getItemType={(item) => item.kind}. FlashList v2 keeps ONE recycler key-pool + ONE height-average bucket PER item type; with no getItemType everything shares the 'default' pool, so on scroll a recycled header view gets reassigned to a match row → React sees the same key with a different tree → full unmount+remount + a Fabric layout reflow on every such recycle (this was the 544ms scroll:scorecard-live jank, 2026-06-03; canonical fix: packages/features/sessions/src/scorecard/live-tab.tsx). getItemType is NOT in FeedListProps' Omit list, so it passes straight through to FlashList — no FeedList change needed. React Compiler does NOT mitigate this — a key reassignment remounts regardless of memoization. Not lint-enforced — reviewer discipline when a list's data is a discriminated union.
  • Titled content groups on a detail screen → SectionBlock from @twomore/ui (title, optional right slot, tone="danger" for destructive sections — the icon prop was REMOVED 2026-06; section titles are text-first). Section titles always live outside cards. SectionBlock delegates its header row to the SectionHeader primitive (packages/ui/src/section-header.tsxvariant="block" displaySm $text / variant="list" cardTitle for virtualized list section headers, accessibilityRole="header"). Use SectionHeader directly ONLY when a title row cannot wrap its body (a GroupedFeedList renderSectionHeader, a FeedList header slot) — never hand-roll section-label typography in feature code, and no decorative leading icons (the right slot is for real counts/actions/config only).

Shells & detail screens

  • Detail screens (back header + optional scroll body) → DetailShell from @twomore/ui (SafeAreaView top-edge, AppHeader back/title/right, optional scroll prop). Never duplicate SafeAreaView + AppHeader manually in a detail screen.
  • Detail-route placement — a screen reachable from MORE THAN ONE tab MUST be a top-level cross-cutting route, NOT nested under a tab group. The app uses Expo Router per-tab stacks (app/(tabs)/(home|clubs|sessions|records|profile)/…); a file physically under (clubs)/… belongs to the 클럽 tab's stack, so navigating to it from 홈 or 경기 hijacks the 클럽 tab and back-navigation lands on the wrong tab. Cross-cutting detail screens (session detail, scorecard, match-board, venue, public profile, create-session) live at the ROOT (app/sessions/[sessionId]/index.tsx, app/match-board.tsx, …) so they push OVER the current tab and back returns to the originating tab. Their routes.* factory may accept a clubId for callers' convenience but ignores it in the path (e.g. clubSession(_clubId, sessionId) => /sessions/${sessionId}, like matchBoard/scorecard). Only screens that are genuinely a sub-view of ONE tab (club members/dues/board/settings under 클럽) stay nested. Lesson from 2026-05-25: session detail was nested under (clubs) and anchored every entry point to the 클럽 tab.

Wizards & confirms

  • Multi-step creation flows (CreateSessionScreen, CreateClubScreen, future onboarding) → WizardShell from @twomore/ui (handles dismiss + progress bar + bottom CTA row). Form fields inside steps → LabeledField (label + optional labelRight slot for a char counter + input + optional error) wrapping StyledInput (themed RN TextInput), both from @twomore/ui. A label row with a 123 / 500 counter is LabeledField + labelRight, NOT a hand-rolled XStack. The "you have a saved draft" recovery prompt that pins above the wizard scroll → WizardDraftBanner (text + resume label + reset label as props; copy stays in feature i18n namespaces). The final 확인 (review) step's per-field rows → WizardReviewRow (icon + label + value + editLabel + onEdit; pass t().common.edit for the trailing button), wrapped in a Card (create-session-step6.tsx, create-club-step7.tsx) — the trailing edit affordance is ActionButton action="tertiary". (Correction, 2026-07-19: the component's own docstring previously said "never a Card" — that predated the Cards section's ≤3-nested-affordance model above and was stale; a balanced content-left/edit-right review row is the sanctioned nested-action case, not a page-CTA-in-card violation.) Never re-implement these as private components inside a wizard screen — single source of truth lives in packages/ui/src/labeled-field.tsx, packages/ui/src/wizard-draft-banner.tsx, and packages/ui/src/wizard-review-row.tsx.
  • Confirmation alerts → useConfirm from @twomore/ui (imperative confirm.show({title, message?, buttons})). Never Alert.alert directly — native Alert ignores design tokens and renders as an OS-styled centered dialog. ConfirmSheet (the file name survives; since 2026-06 it is ModalPanel-based, not a Tamagui Sheet) is the canonical replacement: $-tokens, dark-mode aware, hardware-back dismiss, backdrop press deliberately does NOT dismiss (dismissOnOverlayPress={false} — matches Alert.alert semantics), single-flight (only one prompt visible at a time). Button-style map: cancel→outline, destructive→destructive (red), primary→primary CTA, default|omitted→primary. ConfirmProvider must be mounted in _layout.tsx above any screen that calls useConfirm. useConfirm works from inside ModalPanel content — RN <Modal> keeps children in the React tree, so context is reachable. The old portal caveat (context unreachable inside portaled content — the 2026-05-23 dev-panel TelemetryPane crash) now applies ONLY to the dev panel's Tamagui Sheet: there, call useConfirm from the component that OWNS the Sheet, or use an inline confirm.
  • Screen-archetype selection — the same KIND of interaction MUST use the same archetype across the app. A club-ready app can't surprise users with "edit opens a sheet here but a full screen there." The four archetypes + their canonical interaction kinds:
    • Create-entity / multi-step flow → full-screen wizard (WizardShell). Create session, create club, onboarding.
    • Edit-entity — standalone, multi-field, reached from a menu or route → full-screen route (DetailShell). Edit session, session-level match rules, edit profile, session payments(정산), attendance roster, participants.
    • Edit a single field in-place on a settings list → ModalPanel (the EditFieldModal pattern, settings/edit-field-modal.tsx). Tap-to-edit, no navigation — only when the field already sits inline on the settings screen.
    • Edit embedded in a larger flow (a wizard step's sub-editor, a per-item override) ModalPanel. Match-rules during create-session step + per-match scorecard override (the same MatchRulesEditor body; full-screen MatchRulesScreen is the standalone session-level entry — the split is intentional and documented).
    • Manage a roster (add / remove / role-change people) → full-screen route. Participants, members.
    • Confirm a destructive / irreversible action → ALWAYS useConfirm. Never a bespoke confirm panel that re-implements title + destructive button + cancel (anti-pattern fixed 2026-05-27: club member removal used a custom ConfirmRemoveSheet while every sibling — delete session/club, remove friend/participant, forfeit, end-session, logout, revoke consent, delete account — used useConfirm; consolidated). useConfirm is for confirming a DESTRUCTIVE intent — don't stretch it into a generic multi-option action menu for non-destructive choices.
    • Pick from a small set of contextual options, or a list picker → ModalPanel. Dues action cascade (dues-action-modals.tsx), score call/edit, swap teams, referee-recipient picker, discovery/pickup filters (via DropdownChip/RangeChip, which embed ModalPanel), add-round.
    • Read-only info / explanation → ModalPanel. Tier info, playability TMI, match-profiles.
    • View "see more" of low-stakes list content → inline expand (no navigation). Roster preview, FAQ, round accordion.
    • Archetype scales with complexity + context: a 2-option no-input row action is inline buttons (session-payments 납부/면제); a 4-option-with-sub-inputs action is a ModalPanel cascade (club dues paid/partial/waive/note); a single text field is a quick ModalPanel; a multi-field standalone edit is a full screen. When the same editor appears embedded-in-flow vs standalone, the embedded one MAY be a ModalPanel and the standalone a full screen — but that split must be deliberate + documented, not accidental. Before adding a new interaction, find the matching kind above and use its archetype; if you're about to diverge, document why. (Until the 2026-06 Codex batch these overlay archetypes were Tamagui bottom sheets; every one is now ModalPanel — see the Tamagui/Styling rule.)
  • A ModalPanel consumer that renders its own back/close header MUST pass showCloseButton={false} to ModalPanel.Frame. The Frame's built-in X close button is redundant (and visually stacks) once the consumer's own header already carries a dismiss control — a back chevron (the scorecard sheets' shared ScoreSheetHeader) or an equivalent affordance (match-profiles, match-rules-editor). All five current sheets that render their own header (ScoreCallSheet, ScoreCorrectionSheet, ScoreEditSheet, match-profiles-sheet.tsx, match-rules-editor-sheet.tsx) pass it; a new custom-header ModalPanel consumer must too.

Query boundaries

  • Query-backed SCREENS → QueryBoundary from @twomore/app (the canonical skeleton → error+retry → content flow). Pass the screen's critical-path query results + a shaped loading skeleton; it renders the localized error+retry surface (QueryErrorState) when ANY query errors, the skeleton until all settle (via useScreenReady/Protocol G), else the content. Never leave a data screen with no isError branch (a failed load must show retry, not a permanent skeleton). For LIST screens, pass error={q.isError} + errorState={<QueryErrorState onRetry={() => void q.refetch()} />} to FeedList/GroupedFeedList instead (the error renders in the empty slot; stale data stays on background-refetch errors). QueryErrorState is the single error surface — never hand-roll an error EmptyState per screen. Composer-backed live screens (useLiveSessionData) use the composer's isError/retry directly. Screens that render a list via manual .map() or gate it behind a length > 0 branch: add the error branch to that conditional with <QueryErrorState /> when next touched.
  • Query-backed content sections → DataSection from @twomore/ui. Eliminates the staggered-render anti-pattern (each section rendering null until its own query resolves → user sees the page populate piecewise). Default mode renders a SkeletonRow during isLoading so the section occupies layout space immediately; hideWhenAbsent mode returns null when loading and when empty (use for sections that should only appear once the user has earned them, e.g. PersonalRecordsSection). Optional title prop wraps content in SectionBlock for consistent heading rhythm. Never gate a section render on an outer isLoading flag — let DataSection manage the loading state internally.

Shared primitives (2026-07-19 extraction wave)

Byte-near-identical JSX duplicated across sibling screens/sheets consolidates into one named primitive — reach for these instead of re-deriving the pattern locally:

  • StatTile (@twomore/ui) — the canonical "value + label" stat cell, consolidating 4 independently-drifted sites. wrap card (wraps in Card tone="default") | flat (bare, for a tile already inside a carded parent); order value-first | label-first; valueRole/valueSlot (override the value line with an arbitrary node, e.g. a tier badge — pass value too as the text fallback); subValue; progress. Use for any numeric-stat-+-caption cell (profile hero, dues summary columns, records cards) instead of a local YStack+Text pair.
  • ExpandableFilterBar (@twomore/app) — the canonical SearchBar + filter-toggle-badge + expanding-panel chrome shared by pickup discovery, club discovery, and the venue directory (all three hand-rolled the identical shell before this). Panel CONTENT stays feature-owned (passed as children); only the toggle button + panel shell + header/reset row is shared. Use whenever a list screen needs a SearchBar with a collapsible filter panel behind a toggle.
  • ScoreSheetHeader (sessions scorecard, packages/features/sessions/src/scorecard/score-sheet-header.tsx) — the shared back-chevron + centered-title header for the scorecard ModalPanel sheets (ScoreCallSheet, ScoreCorrectionSheet, ScoreEditSheet). Pair with ModalPanel.Frame showCloseButton={false} per the Wizards & confirms rule above — the back chevron is the sheet's one dismiss control.
  • NotificationRowShell (home, packages/features/home/src/shared/notification-row-shell.tsx) — the shared row shell for the notification center's three row kinds (SignalRow, GroupRow, AdminAttentionRow): icon bubble (with border ring, closing a prior unexplained divergence) + title/body column + footer badges + optional trailing dismiss. The shell owns layout/chrome only — badge variant stays caller-supplied so the alert-model band rules (T1/T2/T3) keep living at the call site.
  • PeriodScrubberShell (home, packages/features/home/src/components/period-scrubber-shell.tsx) — the ‹ label › stepper header shared by the home period pickers (DayCalendarPicker's month stepper, MonthYearPicker's year stepper), wrapping whatever grid content the caller passes as children.
  • FriendPickerRow (messaging, packages/features/messaging/src/shared/friend-picker-row.tsx) — the shared candidate row (avatar + name + circular selection checkbox) for the DM member-picker screens (dm-add-members-screen, dm-create-group-screen).
  • MatchListControls module (records, packages/features/records/src/shared/match-list-controls.tsx) — one filter/sort/group state machine + controls ModalPanel sheet + count-trigger, shared by RecordsHistoryScreen (unbounded, multi-month feed) and RecordDetailScreen (single bounded period). Chronological grouping grain stays TWO distinct GroupMode values ('month' for the unbounded history feed, 'date' for an already-bounded period) — a genuine divergence the consolidation preserves rather than collapses.

See also

  • AGENTS.md — COMP-1..COMP-7 (machine-enforced component constraints).
  • Screen Blueprint — where these component rules apply on each screen.
  • CLAUDE.md — orchestration core + the pointer index back to this doc.

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