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
Card→ only two tones:default(neutral card surface) andflat(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 aSessionCardsummary is a small DOT at the START of the title (inSessionHeader's title row, leading the title text):in_progress→ a redPulseDot($error, LIVE/temporal, participation-independent); viewerconfirmed→ a solid green dot ($primary8px circle, "this session is yours"); every other state incl. open-not-joined → none (the teal모집 중chip carries "available"). Color viasessionStatusStripColor(session.status, myRsvp?.status)frompackages/app/src/presentation/components/status/session-status-strip.ts. The confirmed dot's brand-green is intentionally the SAME$primaryas the 참가 확정 chip (RsvpStatusBadgeconfirmed →primarySolidsolid-green pill) so the dot + chip read as one coordinated "you're in" cue. A left color-STRIP (both a corner-wrappingborderLeftAND 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 iscardTitlemuted, 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 redPulseDot(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
BottomCtaBandon 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): anActionButton(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
Pressablefrom@twomore/ui(variants card/row/pill/icon/button/fab) — it is the SINGLE SOURCE of interaction cues: UI-thread scale+opacity press feedback, webcursor:pointer+ hover dim +$primaryfocus ring, TS-requiredaccessibilityLabel, standardized disabled.Button/ActionButton(page CTAs) +PillNav/SegmentedTabs/SelectionChip(tabs/chips) are the only other sanctioned clickables. NEVER usereact-nativePressable/Touchable*, rawonPresson a TamaguiStack/XStack/YStack, or inlinepressStyle={{}}— they lose all of the above. (onPressInon a wrapper stack IS allowed — Protocol H prefetch.) - A tappable card WRAPS in
<Pressable variant="card">— never<Card onPress>.Cardis a pure displaystyled(Stack)with no press/hover feedback; passing itonPressgives a dead tap. Wrap:<Pressable variant="card" accessibilityLabel={…} onPress={…}><Card …>…</Card></Pressable>(the mutingopacityfor cancelled/archived stays on the innerCard). Canonical examples:SessionCard,MyClubCard,VenueRow. The muting opacity itself is the named constantTERMINAL_MUTE_OPACITY(0.55,packages/ui/src/box-sizes.ts) — the one terminal-muting value, established independently atMyClubCard(archivedAt),SessionCard(cancelled),DirectoryVenueDetailScreen(isClosed), andAchievementsGalleryScreen(!isUnlocked) before being named; use the constant, never a bareopacity={0.55}literal. - Every interactive NESTED target inside a tappable card MUST call
e?.stopPropagation?.()as the first line of itsonPress. Native RN grants the responder to the deepest view, but WEB events bubble → without it, tapping the inner target ALSO fires the card'sonPress(double-nav). Canonical:session-save-button, the participation expand band, the전체 경기 보기footer, the notification dismiss. - Display-only elements stay non-interactive — chips, text, the
VenueLinkpill 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-existingno-press-style-in-features). ThestopPropagationcontract is reviewer-checked (not mechanically linted).
- The canonical clickable is
- 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 textnumberOfLines={1}(ellipsize). RN's defaultflexShrinkis 0 (unlike web's 1), so a flex child will NOT shrink below its content width unlessminWidth={0}is set — omitting it is the #1 cause of card overflow. Fixed siblings getflexShrink={0}(or are intrinsically fixed) so they stay fully visible. Canonical patterns: theSessionHeaderwhere-row (venue inflex={1} minWidth={0}+ the weather glance pinned right, fixed) andVenueLink(maxWidth="100%"+ namenumberOfLines={1} flexShrink={1}+trailingflexShrink={0}). Multi-chip strips that can exceed one row useflexWrap="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:
- 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").
- 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).
- 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
$Nproximity hierarchy so each reads as one chunk, not N loose elements. Spacing does the grouping, not labels. - 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. - 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-offPressable+primary-Textreinvented per call site. The canonical venue link family:VenueLink(@twomore/app) withvariant="compact"(inlineMapPin+$primaryname + a chevron — the chevron makes it read as a touch destination; for dense meta lines) andvariant="medium"(a bordered row — pin in a tinted square + name + optional secondary line + chevron); plusVenueCard(@twomore/app, the RICH large tile: static-map thumbnail → Naver app + name + 영업 중/종료 status pill viaisVenueOpenNow+ 길찾기 + 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 canonicalXLink/XCardcomponent 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) — anAvatarStackof 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). Passavatars: {name, avatarUrl}[](friends-first, caller-ordered) +total(true count for exact "+N");overPhotooutlines the faces to read against a banner photo;emptyLabelrenders a hint when there are 0 participants. Unknown/private participants still render a face —AvatarBubbleshows aUsersilhouette 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. TheSessionCardis canonical:CapacityRing direction="column"in the left-rail date cell (date + fill in one column) +ParticipationClusterfaces 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 anAvatarStackwith silhouette/overflow logic per call site — that logic lives inParticipationCluster. - 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 compactSegmentedDonutChartring +N/Mcount (propsvalue/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 greedyflex:1ProgressBaris retired from session summary/compact/live-row cards — it doesn't share a line). Exception — the session DETAIL participation hero keeps its fullProgressBar: 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.ProgressBarremains 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 toBottomCtaBand, never a card; a compact nestedActionButtonaction 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 (ConfirmSheetstyle:'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 rawvariant— it maps INTENT (primary|secondary|neutral|cancel|tertiary|destructive) → variant viaACTION_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). AdestructiveActionButton MUST be paired with auseConfirmgate at the call site (ACTION_REQUIRES_CONFIRMdocuments this). Parallels the status-badge intent→variant pattern. Sanctioned raw-Buttonexceptions (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-onPressstatus placeholders occupying a CTA slot; (6) the loud rawvariant="destructive"confirm/phrase-gated/direct-fire buttons (distinct from ActionButton'sdestructive→danger). Everything else = ActionButton. - Using
Text→ pick a role, never hardcodefontSize/lineHeight/fontWeight. Core 5:pageTitle(24/32 700 — the LARGE left-aligned title on tab-ROOT screens viaMainTabShell, 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 viaAppHeader/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). Arole=+ inlinefontSizeoverride is the anti-pattern this rule exists to prevent — if no role fits, the role belongs intext.tsx, not as a per-screen override. - Using
Badge→ 8 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; PLUSprimarySolid(solid$primarybg +$primaryTexttext — the ONLY brand-colored, theme-VARIANT badge), used solely for RSVPconfirmed(참가 확정) so the chip pairs with the brand-green title dot on session cards.primarySolidreuses 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). PLUSsurface($surfacebg +$textSecondarytext) — 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/presentationcode is bounded toneutralwarninglivesurfaceby@twomore/bounded-variant-enums(AGENTS.md COMP-2) — the other variants are used only by the canonicalXxxStatusBadgewrappers themselves. See the Status-indication intent palette indocs/canon/status-and-recruitment.md. For DOMAIN STATUS, never compose<Badge>ad-hoc — use theXxxStatusBadgewrappers.BadgeContainerbakesalignSelf:'flex-start'(so a standalone badge hugs its content instead of stretching in a column) — inside analignItems:centerrow next to text/avatars (e.g. aMatchScoreboardplayer row) that pins the pill to the TOP of the row; pass the opt-inalignSelf="center"prop to vertically center it. - Tempted to use raw Tamagui
Pressableas 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/SkeletonRowduringisLoading. Never letEmptyStateflash 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 inHomeFeedScreenat the right priority position HomeLiveAlertBanner→ must stay mounted above PillNav at the very top ofHomeFeedScreen. Renders only when a user session hasstatus=in_progress. Never move it below the nav or inside a feed card.
Tabs & chips
- Tab pages →
MainTabShellfrom@twomore/ui(handles SafeAreaView top-edge,pageTitleheader,headerRight,bottomCta). Passtabs/tabValue/onTabChange/renderTabto get a built-inSegmentedTabsswitcher. Never roll your own SafeAreaView + pageTitle combo for a tab page. - Segmented tab switching inside a page →
SegmentedTabsfrom@twomore/ui(PillNav-driven,preload="all"mounts all panes post-paint for instant switching). Multi-tab content where each pane has its ownuseQuery/useStatehooks → ALWAYSSegmentedTabs 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.onChangeis a direct urgent state update — chip flip must commit on press frame. Deferral of heavy content rendering lives at the SegmentedTabs consumer viauseDeferredValue.SegmentedTabsusesuseDeferredValueinternally — content pane mount is low-priority while the chip stays urgent. Don't addstartTransitionon top.SegmentedTabspreload mode →"visited"by default.preload="all"only when ALL panes have lightweight first-paint cost;useDeferredValuekeeps chips responsive either way.SegmentedTabsconsumers → wrap each pane component inReact.memoAND wraprenderTabinuseCallbackwith stable-reference deps (e.g. hoistuserId ?? ''tosafeUserIdfirst). Without both, every chip tap re-renders all mounted panes (sincepreload="all"keeps them mounted) and the JS thread saturates. Either alone is insufficient:React.memowithout stablerenderTab→ memo bails on referential prop check; stablerenderTabwithoutReact.memo→ component re-renders fully anyway. Seepackages/features/clubs/src/club-list-screen.tsx/packages/features/activity/src/activity-screen.tsx/packages/features/records/src/records-leaderboard-screen.tsxfor canonical examples (commite0ce50b, 2026-05-02).- Chip-style tab controls → use
PillNavfrom@twomore/ui. Don't add per-chipbackgroundColorvariants — 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) →
SelectionChipfrom@twomore/ui(packages/ui/src/selection-chip.tsx):label+selected+ optionalleading/trailing,sizedefault|compact; selected state =$primaryborder +$primarySubtlebg +$primary700-weight label. Distinct fromPillNav/SegmentedTabs(navigation chrome) —SelectionChipis for filtering DATA, not switching views. Dropdown-style pickers composeDropdownChip/RangeChip(SelectionChip trigger +ModalPanelpicker;DropdownChipsupports controlledopen/onOpenChangefor 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, aDropdownChippair over the canonicalREGIONSsource 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 inpackages/app(notpackages/ui, which stays primitive-pure, and not a featureshared/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
onPressInto the matchingprefetchXhelper 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.MotionPressablestill preferred for fine-grained Reanimated worklet control on bespoke surfaces. - Clickable surfaces → ALWAYS one of these primitives, never raw
onPresson a styledStackor inlinepressStyle={{ scale, opacity }}in feature code:- Page / form CTA →
Buttonfrom@twomore/ui(variants × sizes × shapes) - Tab strip / chip group →
PillNav/SegmentedTabsfrom@twomore/ui - Anything else clickable (cards, rows, chips, icons, FABs) →
Pressablefrom@twomore/uiwithvariant∈card | button | pill | row | icon | fab Pressableis the canonical clickable primitive. It encodes: requiredaccessibilityLabel(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), andloadingprop that renders disabled state.- Inline
pressStyle={{ ... }}on rawXStack/YStackis forbidden inpackages/features/**. UsePressable 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.
- Page / form CTA →
- Adding an admin tab to a detail screen → follow the
AdminTabnav-hub pattern (named component, PillNav-driven, ListRow per destination). Gate tab visibility oncanManageMembers || 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, …) onAppHeader/DetailShell. A tight 24px (xl) icon whose touch target expands to the 44pt HIG minimum viahitSlop(no layout padding), so adjacent header icons space purely by containergapinstead of a padding/negative-margin dance. Never hand-roll a header icon withPressable variant="icon"+ a raw chevron/icon component directly, and never useButtonfor 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 aPopover.Trigger).
Lists
- Scrollable feeds (flat or grouped-by-section) →
FeedList/GroupedFeedListfrom@twomore/ui(wraps FlashList (FeedList) and SectionList (GroupedFeedList) with virtualization tuning, scroll-hint badge, loading-more footer, and empty/loading slots). Never use bareFlatList/ScrollViewfor 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 auseCallbackwith 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 theuseCallbacks ABOVE any early return so they obey rules-of-hooks; if a callback closes over another handler, hoist that handler into auseCallbacktoo (otherwise the dep array churns and defeats the memo). Pair withReact.memoon the row component for full effect —useCallbackalone keeps the prop reference stable,React.memois what skips the row's re-render. Enforced by@twomore/require-memoized-list-render-callbacks(Wave H). - Mixed-type
FeedList/FlashListMUST passgetItemType. When one list renders structurally different item kinds (e.g. a{kind:'header'}~30px row vs a{kind:'match'}~150px row), passgetItemType={(item) => item.kind}. FlashList v2 keeps ONE recycler key-pool + ONE height-average bucket PER item type; with nogetItemTypeeverything 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 544msscroll:scorecard-livejank, 2026-06-03; canonical fix:packages/features/sessions/src/scorecard/live-tab.tsx).getItemTypeis NOT inFeedListProps' Omit list, so it passes straight through to FlashList — noFeedListchange needed. React Compiler does NOT mitigate this — a key reassignment remounts regardless of memoization. Not lint-enforced — reviewer discipline when a list'sdatais a discriminated union. - Titled content groups on a detail screen →
SectionBlockfrom@twomore/ui(title, optionalrightslot,tone="danger"for destructive sections — theiconprop was REMOVED 2026-06; section titles are text-first). Section titles always live outside cards.SectionBlockdelegates its header row to theSectionHeaderprimitive (packages/ui/src/section-header.tsx—variant="block"displaySm$text/variant="list"cardTitle for virtualized list section headers,accessibilityRole="header"). UseSectionHeaderdirectly ONLY when a title row cannot wrap its body (aGroupedFeedListrenderSectionHeader, aFeedListheader slot) — never hand-roll section-label typography in feature code, and no decorative leading icons (therightslot is for real counts/actions/config only).
Shells & detail screens
- Detail screens (back header + optional scroll body) →
DetailShellfrom@twomore/ui(SafeAreaView top-edge,AppHeaderback/title/right, optionalscrollprop). 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. Theirroutes.*factory may accept aclubIdfor callers' convenience but ignores it in the path (e.g.clubSession(_clubId, sessionId) => /sessions/${sessionId}, likematchBoard/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) →WizardShellfrom@twomore/ui(handles dismiss + progress bar + bottom CTA row). Form fields inside steps →LabeledField(label + optionallabelRightslot for a char counter + input + optional error) wrappingStyledInput(themed RNTextInput), both from@twomore/ui. A label row with a123 / 500counter isLabeledField+labelRight, NOT a hand-rolledXStack. 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; passt().common.editfor the trailing button), wrapped in aCard(create-session-step6.tsx,create-club-step7.tsx) — the trailing edit affordance isActionButton 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 inpackages/ui/src/labeled-field.tsx,packages/ui/src/wizard-draft-banner.tsx, andpackages/ui/src/wizard-review-row.tsx. - Confirmation alerts →
useConfirmfrom@twomore/ui(imperativeconfirm.show({title, message?, buttons})). NeverAlert.alertdirectly — 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}— matchesAlert.alertsemantics), single-flight (only one prompt visible at a time). Button-style map:cancel→outline,destructive→destructive (red),primary→primary CTA,default|omitted→primary.ConfirmProvidermust be mounted in_layout.tsxabove any screen that callsuseConfirm.useConfirmworks from insideModalPanelcontent — 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 TamaguiSheet: there, calluseConfirmfrom 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(theEditFieldModalpattern,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 sameMatchRulesEditorbody; full-screenMatchRulesScreenis 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 customConfirmRemoveSheetwhile every sibling — delete session/club, remove friend/participant, forfeit, end-session, logout, revoke consent, delete account — useduseConfirm; consolidated).useConfirmis 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 (viaDropdownChip/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.)
- Create-entity / multi-step flow → full-screen wizard (
- A
ModalPanelconsumer that renders its own back/close header MUST passshowCloseButton={false}toModalPanel.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' sharedScoreSheetHeader) 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 →
QueryBoundaryfrom@twomore/app(the canonical skeleton → error+retry → content flow). Pass the screen's critical-path query results + a shapedloadingskeleton; it renders the localized error+retry surface (QueryErrorState) when ANY query errors, the skeleton until all settle (viauseScreenReady/Protocol G), else the content. Never leave a data screen with noisErrorbranch (a failed load must show retry, not a permanent skeleton). For LIST screens, passerror={q.isError}+errorState={<QueryErrorState onRetry={() => void q.refetch()} />}toFeedList/GroupedFeedListinstead (the error renders in the empty slot; stale data stays on background-refetch errors).QueryErrorStateis the single error surface — never hand-roll an error EmptyState per screen. Composer-backed live screens (useLiveSessionData) use the composer'sisError/retrydirectly. Screens that render a list via manual.map()or gate it behind alength > 0branch: add the error branch to that conditional with<QueryErrorState />when next touched. - Query-backed content sections →
DataSectionfrom@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 aSkeletonRowduringisLoadingso the section occupies layout space immediately;hideWhenAbsentmode returns null when loading and when empty (use for sections that should only appear once the user has earned them, e.g.PersonalRecordsSection). Optionaltitleprop wraps content inSectionBlockfor consistent heading rhythm. Never gate a section render on an outerisLoadingflag — letDataSectionmanage 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.wrapcard(wraps inCard tone="default") |flat(bare, for a tile already inside a carded parent);ordervalue-first|label-first;valueRole/valueSlot(override the value line with an arbitrary node, e.g. a tier badge — passvaluetoo as the text fallback);subValue;progress. Use for any numeric-stat-+-caption cell (profile hero, dues summary columns, records cards) instead of a localYStack+Textpair.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 aschildren); 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 withModalPanel.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).MatchListControlsmodule (records,packages/features/records/src/shared/match-list-controls.tsx) — one filter/sort/group state machine + controls ModalPanel sheet + count-trigger, shared byRecordsHistoryScreen(unbounded, multi-month feed) andRecordDetailScreen(single bounded period). Chronological grouping grain stays TWO distinctGroupModevalues ('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.