Skip to content

Component Conventions

Status: Active Last reviewed: 2026-09-17

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-8).

Cards

  • No shadow/elevation on Card (or any global primitive) — ever. elevation defeats Android view-flattening, so every mounted instance becomes its own native layer: adding elevation: 1 to Card's default tone made EVERY tab switch in the app exceed the 250ms settle threshold within one OTA (2026-08-21p — zero interaction:tab telemetry reports in the prior 72h, then all panes 290–420ms). Card depth is the hairline border, in both canvases; a surface that genuinely needs a shadow gets it locally, on a bounded number of instances, with the perf trade-off argued at the call site.

  • Using Cardonly two tones: default (neutral card surface) and flat (dashed-border empty state). Sizes: sm (12px padding, dense list-row cards), md (16px padding, standalone content cards — the default), lg (20px padding, hero cards), and size="none" (0 padding — the caller owns all internal padding, for a Card that only exists to draw the bordered frame around content that manages its own row-level padding, e.g. edit-session-screen.tsx's EditFieldRow list wrapper). Card's tone is the deliberate STRUCTURAL exception to what tone means elsewhere — here it switches border style (solid vs dashed), not color; everywhere else in the system (Callout, AvatarBubble, SectionHeader) tone selects semantic COLOR. 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. RETIRED mechanism (do not resurrect): the previous SessionCard signaled status via a small DOT at the START of the title, inside the now-deleted SessionHeader's title row (in_progress → red PulseDot; confirmed → solid green dot), colored by sessionStatusStripColor() — that function no longer exists, and session-status-strip.ts/match-status-surface.ts themselves were deleted (Wave-2B conformance sweep, 2026-09) once a follow-up audit found the pair had zero live consumers left. Current mechanism (2026-09-07, flat-layout F1 — the band is retired): the sanctioned status/relationship accent on the canonical list card (SessionCardV2) is the closing row's SessionStateInline (lead chip as a state word in its palette color + the viewer's standing as a quiet badge, then the meter) and the bookmark glyph on the venue line; the detail hero carries the same lead/standing as SessionStripChipBadges and a terminal state gets a full-width Banner. Historical text follows — the previous mechanism was the top SessionStripView band (session-strip-ui.tsx, BARE-RIBBON FULL-SOLID model, owner 2026-07-29 — "no pale strips"), conditional (renders nothing when deriveSessionStrip returns null). When present it is a fully saturated fill (never a pale tint) carrying ONE ranked attention lead on the left (icon + label — LIVE pulse, 마감 countdown, 송금 대기, …) plus quiet identity marks on the right (host · RSVP · saved); a bookmark glyph (filled when saved, outline otherwise) always sits at the strip's end as a read-only status marker. Color alone carries meaning: red = live, amber = money/deadline/weather/host-action, green = confirmed, gray = terminal/standings. Card and detail share the SAME SessionStripView renderer (size="card" vs size="detail") — never a second hand-rolled status treatment. The session-DETAIL screen additionally keeps a red PulseDot (color $error — the true live-red; NOT $badgeErrorText, which is salmon-pink in dark mode) in the sticky screen header for the "happening now" cue while scrolling.

  • 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: SessionCardV2, ClubCard, 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 (ClubCard archivedAt, DirectoryVenueDetailScreen isClosed, AchievementsGalleryScreen !isUnlocked, DuesRow paid); 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: SessionCardV2's venue title row (session-card-v2.tsx — venue title in flex={1} minWidth={0} + the weather label 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.
  • Fact glyphs on browse cards — the narrow exception to the icon law (owner, 2026-09-04). canon/styling.md's icon rule bans decorative section/beat lead icons (a Calendar before a date, a MapPin before a venue, an icon before a count) — content leads with typography, not icons. A compact fact glyph on a browse card (DirectoryVenueCard's FactGlyph: indoor/lighting/parking) is allowed to break that rule ONLY when all three hold: the glyph encodes a boolean fact (present/absent, not a decorative label), it carries its own accessibilityLabel, and it would otherwise have to render as a full word badge (the icon is standing in for a word, not decorating one). It is never legal as a decorative lead icon in front of running text, and never legal in front of a bare count — a court-count fact renders as TEXT ALONE, no icon, even inside the same glyph row as the boolean icons next to it.

  • 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 (the venue display ladder — VenueLink inline → VenueRow row → VenueCard detail tile): VenueLink (@twomore/app) is a single non-interactive inline PILL (leading court-surface mark/MapPin + surface-tinted name; renders for any venue name, no backing id required) — for dense meta lines (compact session row, session meta); VenueRow (@twomore/app) is the bordered ROW rung (surface-graphic column + name + "distance · district · courts" meta line + trailing chevron) — the actual clickable component that taps through to venue detail via venueId (or a custom onPress); 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 for a dense inline mention with no tap target of its own (the parent card's tap covers it), VenueRow wherever a venue with an id needs to be its own tappable list item (venue lists, pickers, directory), 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.

Notices & banners

  • Three shapes, never blurred (research decision, 2026-08-15): a persistent inline notice is one of Banner, Callout, or a tinted Pressable row — pick by what the viewer does next.
    • Banner (@twomore/ui) — an EXPLICIT, bounded choice: ≤2 buttons, stacked message-row-then-action-row anatomy (Material M2 banner shape), toneprimary | warning | error | neutral | muted (fills mirror the Callout/badge palette; only primary carries a border, matching its reference call site WizardDraftBanner; muted is the terminal/archived-state look — $badgeMutedBg fill, $textSecondary text, no actions — used by the club-detail terminal-state banner, folded into Banner directly rather than a bespoke ClubStatusBanner). Render order in actions IS visual order — list the action that most advances the flow LAST so it lands rightmost. Canonical call sites: WizardDraftBanner (draft resume/reset), UnsettledPoolNotice (match-board settle prompt), the session-detail payment-reserved notice (pay / confirm-transfer).
    • Callout (packages/ui/src/callout.tsx) — the ACTIONLESS inset: a tinted panel for prose/warnings with no button at all (a host's note, a rain-out warning, "awaiting host confirmation"). Never add an action to Callout — reach for Banner instead the moment a notice needs a button.
    • Tap-anywhere navigation (e.g. the pending-applicants row on session-detail) → Pressable + a tint background, never Banner/Callout — the whole row IS the control, so it carries no separate button.
    • Documented exception — root-mounted system chrome: NetworkBanner (packages/app/src/presentation/components/network-banner.tsx) is a legitimate 4th shape outside this set — an edge-to-edge, root-mounted connectivity strip below the status bar (system chrome, not a screen's content-level notice), so neither Banner's bounded card frame nor Callout's inset panel applies. Don't cite it as a Banner/Callout drift instance.

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. RETIRED claim (do not resurrect): this section used to cite the deleted SessionCard as canonical for a CapacityRing direction="column" left-rail date cell + ParticipationCluster faces composition. Neither piece survives on the current list card: SessionCardV2 shows NO participant faces at all (moved to session-detail only, per the density-first redesign) and uses RecruitmentMeter — not CapacityRing — for its capacity signal (see § Participation & capacity indicators below). ParticipationCluster's live usage today is indirect — via BannerCapacityCluster (packages/ui/src/banner-capacity-cluster.tsx, which composes it internally for the ring+faces cluster), consumed by club-card.tsx's banner member cluster (overPhoto). It has no other direct call site in packages/app/packages/features. 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 INLINE 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. The deleted SessionCard's left-rail date-cell usage (48px bordered cell under DateTile) no longer exists — SessionCardV2 uses RecruitmentMeter instead (see below). Current real call site: add-round-sheet.tsx's feasibility tile 1 (games-range stat, direction="column"). It is the tracker for a ring+count unit that sits INLINE alongside other content on the same line — a ring never shares a line as the sole fill signal, it's a compact unit dropped into a row that's doing other things too. The line meters (ProgressBar) are canonical wherever a fill bar owns a FULL line by itself — that's the real retirement criterion, not "linear bars are retired." One implementation, two callers (owner direction 2026-07-20; shared 2026-07-27, SessionCardV2 slice ③): RecruitmentMeter (status word · ProgressBar · N/M count, all on one dedicated row, packages/app/src/presentation/components/sessions/session-compact-row.tsx) is defined in SessionCompactRow's file and exported for reuse — SessionCompactRow itself renders it column-native (its own dedicated row), and SessionCardV2's StatusJoinLine renders the SAME component via its flex prop (fills the remaining width of a row it shares with host/RSVP/saved badges), never a second bar implementation. What's actually retired is a ProgressBar sharing a line with OTHER unrelated content (the old greedy flex:1 bar squeezed into a session summary/compact/live-row alongside other elements) — a bar that owns its own line, or flex-fills the remainder of a role-badge line, is fine. 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 a compact inline unit, full-line bar when the meter gets its own line (compact row, v2 card, or the detail hero). ProgressBar remains the primitive for every NON-participant progress use (dues, records, analytics, attendance).

Buttons, text & badges

  • Bottom clearance under a pinned CTA is max(16, safe-area inset) — the inset is the clearance, never stacked on top of a base (2026-09-11, resolveBandPaddingBottom). BottomCtaBand and WizardShell's footer both read it; a shell that reserves the bottom edge (DetailShell/PlaceShell/WizardShell) pads the inset and the band adds only what 16 still needs; MainTabShell leaves it to the band. Stacking 16 + inset is what made the band read as a thick sheet under a 56px button (40–64px on an edge-to-edge Android).
  • Using Button → page CTA = lg + primary (pinned to BottomCtaBand); a bounded ±1 numeric control is never a Button — use Stepper (@twomore/ui, see its own entry in Tabs & chips below — PD-02, primitive-drift-audit-2026-09-17: the real Stepper hand-rolls a 32px Pressable variant="icon" circle, not a Button size="xs" shape="circle"). 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) selection-state toggles where the variant must FLIP with "is this selected"; (4) disabled, no-onPress status placeholders occupying a CTA slot; (5) the loud raw variant="destructive" confirm/phrase-gated/direct-fire buttons (distinct from ActionButton's destructivedanger). Everything else = ActionButton. (Numeric steppers are NOT a raw-Button case at all — Stepper never renders Button; removed from this list 2026-09-17, PD-02, the same stale claim as the Button line above.)
  • 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 (14/20 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. fontWeight is the one sanctioned exception: role="…" fontWeight="600" / "700" on top of a role is a legitimate EMPHASIS override (bolding a name, a stat, an active state) — every role already fixes its own base weight, so this never substitutes for picking the right role, it only nudges weight within one; fontSize/lineHeight stay hard-banned (the @twomore/no-raw-fontsize-in-features eslint-disable idiom is the only escape hatch, reserved for slot-matched metrics a role can't express).
  • 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. Badge's $2/$3 radius (a rounded tag, sm/md) is deliberate chrome, distinct from the true-pill $7-radius family (SelectionChip, DropdownChip/RangeChip, Button variant="pill", marker pills) — a badge is a passive label, never a tappable control, so it doesn't borrow the fully-rounded pill shape reserved for interactive chips/buttons.
  • Glyph law (owner, 2026-08-23): emoji = INFORMATION accent, icons = ACTIONS. An emoji may lead a fact (identity chips 📍🎾📅, stat cells, celebratory copy) but NEVER labels a tappable action — action affordances render lucide icons via Icon at the canonical sizes (20px lg for row-level actions, 24px xl for header slots), tinted $textSecondary/$iconMuted resting. The C feed's ♡/💬/↗ emoji footer and the 🖼 album prompt were the drift this canonized against — replaced with Heart/MessageCircle/Share2/Images.
  • 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 = a temporal briefing, not a card feed (the 2026-04-17 card-feed model is retired). MainTabShell's own pill row picks a granularity (일 · 주 · 월, useHomePeriodStore); each granularity renders ONE self-contained view (views/home-today-view.tsx, home-week-view.tsx, home-month-view.tsx) with a fixed section order. A section renders only when its own condition holds (progressive disclosure inside the view) — never an empty-looking section holding space.
  • Cross-pane slots are the FeedList header (HomeDisputedAlertCard ▸ the viewer has a disputed score; HomeAdminAttentionStrip ▸ open admin obligations) and footer (HomeRecommendationsSection). Everything else lives inside the active view.
  • Live sessions render as plain SessionCardV2s via LiveSessionStack inside the 일간 view's 진행 중 section, gated to the real present day — there is no separate live banner or live card component (HomeLiveAlertBanner was retired with the card feed). Adding a home surface → add a section to the right view, never a new cross-pane card.
  • Current anatomy: screen-blueprints/home.md.

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.
  • Selectable TILES / choice cards (wizard preset swatches, format tiles, play-style cards — larger-than-chip "pick one of 2–4" content) → SelectionCard from @twomore/ui (packages/ui/src/selection-card.tsx, promoted 2026-08-05 from the onboarding play-style fork): selected + free children, Pressable variant="card" chrome, selected state = the SelectionChip family recipe ($primary border / $primarySubtle bg). Never hand-roll a per-screen tile — the create-club banner presets, create-session format/vibe tiles, and onboarding play-style cards were three divergent forks of exactly this, consolidated onto it.
  • Boolean toggle → TogglePill from @twomore/ui (packages/ui/src/toggle-pill.tsx, promoted 2026-08-05 from clubs/settings; display-only 44×24 switch pill — the surrounding Pressable variant="row" owns the press). ONE toggle visual app-wide; the byte-duplicate FeeTogglePill (sessions) and the club wizard's text-pill toggle are retired.
  • Bounded-integer −/+ control → Stepper from @twomore/ui (clamps internally; format renders arbitrary display text — tier names, "N명"). Never hand-roll a Button shape="circle" −/+ pair; the create-club and create-session wizard forks were consolidated onto it 2026-08-05.
  • Wizard chrome: disabled-CTA hint text → WizardShell's primaryHint prop (pinned with the CTA band) — never improvised in step bodies where it scrolls away; in-flight primary CTA state rides Button's own loading/loadingLabel.
  • Progressive reveal (owner law, 2026-08-06): tooling that depends on a gate choice stays HIDDEN until that choice is made — default = not rendered, never disabled-but-visible (a wall of pending controls overwhelms; the create-club logo generator renders only after the 문양 사용 tile is selected). Applies generally: reveal dependent controls on selection, collapse them when the gate deselects.
  • Sequential wizard reveal — THE CONTRACT (owner law, 2026-08-08, via RevealStack in @twomore/ui; grounded in the Toss signup engineering writeup toss.tech/article/toss-signup-process): wizard steps present ONE decision at a time, and the next section reveals ONLY on explicit confirm — a confirm-button tap or keyboard done, never mid-typing validity (Toss: "다음 버튼을 누르면 … 필드를 자동으로 추가"). The stack renders the active section's confirm row itself (uniform across pages); optional fields are first-class sections with a visible 건너뛰기, never silently bypassed by a gate condition; selection forks whose tap IS the commit set selfConfirming. Choreography: answered sections stack UPWARD as a visible, editable trail; the active section stays keyboard-anchored (bottom of stack, host-scrolled above the keyboard) — NOT viewport-centered, per Toss's reversed-stack finding that centering puts the active input under the keyboard. Motion: the reveal preset (~250ms decelerate timing, both platform configs), no spring settle tail. confirmed state is controlled by the step (resume with valid data starts confirmed). Applications: create-club steps 1–3 (이름 → 꾸미기; courts-only single input; the 일정 live-fill loop), create-session steps 1 (날짜 → 시작 → 종료) and 4 (참가비 → 참석 마감 → 반복 → 한 줄 소개) — the full wizard-facing contract lives in wizards.md.
  • Venue-pick canon set (owner, 2026-08-24; COMPONENTIZED 2026-08-25; UNIFIED into VenueFinder 2026-09-01): the canonical finder is VenueFinder (@twomore/app, intent: 'explore' | 'pick') — ONE surface for the 경기-tab directory (explore: rows navigate to venue detail) and wizard picking (pick: rows emit VenuePickResult, incl. the Naver adopt-on-pick double-emit contract; pickedVenueIds/selectedVenueId selected state, finderHint, initialRegions/initialDistricts mount-once filter seeding, controlled query). Grammar (owner-ratified via /wireframes/venue-finder): VenueFilterBar (SearchBar with the filter toggle INSIDE it via ExpandableFilterBar — never a separate filter row) + 전체/코트/연습장/즐겨찾기 nav chips + province-grouped browse + the shared dual-source search leg. VenuePickPanel (검색/둘러보기 mode chips + region-drill browse) is DELETED — never rebuild it; consumers own only "what a pick means". Per-venue court config is CourtUsagePanel (which courts + tap-to-rename). Both wizard step-2s consume them — extend the components, never re-assemble the pieces. The pieces, for reference — SearchBar over usePublicCourts (curated-first + Naver) result rows, RegionDistrictFilter for explicit browse (search vs browse is a user CHOICE, never chips under a search bar), VenueRow rows that ALWAYS carry latitude/longitude (the photo → static-map → glyph cascade needs coords — a coord-less row falls to the name glyph and reads detached), and VenueSaveButton bookmarks with the 즐겨찾는 코트 list fed by useSavedVenueIds+useVenuesByIds (optimistic ids + keepPreviousData + lite-entry merge so a new bookmark renders on the press frame). First assembled in create-club step2 + create-session step2 — extend those, never fork. Presentation (owner-directed 2026-09-01): inside a wizard step the finder is NEVER rendered inline in the step's scroll — the step shows a search-field-shaped trigger row and VenueFinder (pick intent) mounts inside VenueSearchTakeover (@twomore/app), the full-screen search overlay (see wizards.md "Search takeover law"). New search-over-open-data surfaces reuse VenueSearchTakeover's shell shape rather than embedding a live search field in a long scroll. The Naver-source marker on a result row is NaverBadge (@twomore/ui, promoted 2026-08-24 from two screen-local copies) — a 22px $naver-tinted "N" square; use it, don't refork it. The step's ANSWER is a picked row or free text explicitly committed via the edit scope's 저장 (venueFreeTextCommitted) — typed search text alone never enables 다음, and a committed answer always collapses into the selected-card state so it is visible (owner 2026-08-25).
  • 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

  • The base is the only touchable (owner law, 2026-08-08 — COMP-8): every touchable extends @twomore/ui's Pressable (or a component built on it: Button, ActionButton, SelectionChip, …). RN offers NO global configuration for raw touchables — press physics, web cursor/hover/focus, the tonal stateLayer, and the TOUCH_TARGET-derived icon hit-slop floor exist only in the base, so a raw Pressable/Touchable* ships with none of them and forces per-site hardcoding. Enforced by @twomore/no-raw-rn-pressable-in-features across features, presentation, AND packages/ui/src (sole exemption: pressable.tsx itself).

  • Contextual footer CTA (owner law, 2026-08-08): a wizard's footer primary reflects the INNERMOST open editing scope — while an attached edit panel (or any inline editor) is open, the slot becomes 저장 (closing that scope only) and reverts to 다음 when it closes. Never dual-fire save-and-advance in one tap: the wizard is a live-draft model, so 다음 already means "commit this step and continue" — the only ambiguity is a nested editing scope, and the slot follows it. Bridge pattern: the step surfaces {complete, close} via an onEditStateChange callback (see Step3Props.ScheduleEditState); the screen swaps label/disabled/onPress, never the shell.

  • Item removes (owner law, 2026-08-08): the row/card item-remove family renders ONLY via RemoveIconButton (@twomore/ui) — an 18px md Trash2 in $textSecondary centered in a 36px circular state-layer surface (hover/press paint a visible tinted circle; the bare-glyph scale/opacity dim was invisible). Icon vocabulary: trash = delete an item; ✕ = dismiss a surface, NEVER a remove; person-removal passes icon={UserMinus}. The warning step is built in: pass confirm={{title, message?, confirmLabel, cancelLabel}} and the press opens the canonical ConfirmSheet with a destructive action — REQUIRED for removes that destroy multi-input work (a composed schedule, a fee tier) or persistent data (kick member); one-tap-to-recreate selections (a picked court) may omit it. stopPropagation is built in (nested-affordance contract). Family boundaries: in-chip removes (RemovableChip) stay xs ✕-proportional; surface closes (ModalPanel, wizard header) are dismiss semantics; input clears (SearchBar) are their own idiom.

  • Affordance law (owner-directed 2026-09-06) — every tappable shows exactly ONE resting cue. Press feedback is transient; on the flat canvas a tappable with no resting cue "renders and behaves like a normal component". The cue is chosen by intent, never omitted: a navigation row/tile/avatar shows a trailing chevron (ListRow auto-chevrons whenever it has onPress and no trailing; a trailing slot always supplies the cue instead); a section link is SectionHeader link={{ label, onPress }} (text + chevron, never a hand-rolled Pressable > Text in right); an in-place action carries button chrome (Button/ActionButton) or, when text-only, $primary 600 ink with affordance="link"; a tappable Well/banner uses Well onPress accessibilityLabel (state layer + chevron); a whole-surface card in a browsing list (feed cards, SessionCardV2, ClubCard, post rows) relies on the pressed state layer, which Pressable now turns on by default for row/card variants — no chevrons on feed cards; media that opens a viewer uses affordance="layer". Pressable affordance is the semantic marker (chevron | button | link | layer | none); none is legal only when an adjacent element supplies the cue, with a same-line comment saying which. Enforced by @twomore/pressable-needs-affordance (error + ratchet); the 2026-09-06 sweep applied it to all 101 legacy sites.

  • 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 safe for all shared primitives; Pressable from @twomore/ui is the only clickable (MotionPressable was deleted 2026-05-09).

  • 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. The recipe (2026-08-23 audit — the C-surface build shipped ~20-24px hand-rolled chips/footer actions and re-learned this): rows/tabs get minHeight={TOUCH_TARGET.MIN} (or ROW_HEIGHT_TOUCH); compact visuals (32px chips, 20px glyphs, text actions) keep their size and close the gap with symmetric hitSlop={(TOUCH_TARGET.MIN - visualPx) / 2} — the same math HeaderIconButton and SelectionChip encode. Filter chips are NEVER hand-rolled: SelectionChip size="sm" already carries the floor.
  • 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 last Button-ghost trigger (HeaderActionsMenu) and the raw-Pressable holdouts (bell, DM ) were migrated 2026-07-31. Resting tone is $textSecondary + strokeWidth 1.8 (the component defaults — M3 renders trailing app-bar icons in onSurfaceVariant, one tone below the title; full-black stroke-2 lucide glyphs read as "solid filled") — pass color only for stateful semantics (saved bookmark $primary, badge tints).

  • Header-right composition — at most TWO glyphs (owner 2026-07-31): slot 1 = the screen's single signature action (stateful/badged icons get priority — a badge never hides in a menu), slot 2 = the HeaderActionsMenu overflow. The overflow trigger is (MoreHorizontal, the component default); pass triggerIcon={Settings} ONLY when the menu genuinely is settings (club-detail: 설정/나가기). A labeled text button (알림 "모두 읽음"-style) may occupy a slot, but prefer relocating list-scoped actions onto the band/section header they act on (notification center's 모두 읽음 lives on the 새 소식 band header, not the bar). Never three glyphs — the third action moves into the overflow.

  • The header right slot holds passive icons OR at most one labelled HeaderTextAction — never the page's primary action (usability audit U-05/U-09, 2026-09-17). AppHeader/CollapsingTopBar's right slot is passive-icon territory (HeaderIconButton bell/gear/⋯), but a secondary NAVIGATION task with no obvious glyph (초대 코드, 관리) reads as a short accent-coloured label instead of a guess-the-glyph icon. HeaderTextAction (@twomore/ui) is the ONE labelled-text primitive for this: label/onPress/color? (default $brand600; pass $textInverse on a photo-scrim bar, mirroring HeaderActionsMenu's triggerColor), built on Pressable variant="button" affordance="link" — the accent tint on the label IS the one resting affordance cue (no icon, no chevron). Unlike HeaderIconButton (invisible hitSlop, so tight icons space by container gap alone), it earns its 44pt floor from REAL layout (minHeight + paddingHorizontal="$2") because the visible label itself needs the tap room. It NEVER carries the page's primary action — that stays in BottomCtaBand — and is reserved for a secondary nav task the right slot has no icon for. Live consumers: ClubJoinHeaderAction (초대 코드 → routes.joinClub, club-join-header-action.tsx) and club-detail's admin shortcut (관리 → routes.clubAdmin, sitting left of the HeaderActionsMenu ⋯ trigger inside one XStack gap="$1", club-detail-screen.tsx).

  • HeaderActionsMenu opens a centered ModalPanel action list — NEVER a Tamagui Popover. The JS-portal Popover z-fights headers, flickers on mount, and drops touches on RN (observed in production 2026-07-31); ModalPanel rides RN's native Modal (always above content, always interactive). Menu anatomy: rows are Pressable variant="row" ≥44px with a 20px (lg) $textSecondary icon + cardBody label; destructive rows last in $error with exactly ONE Divider before the destructive group (no per-row dividers); secondary actions only — the primary CTA stays in BottomCtaBand, stateful toggles stay in the bar; ~5 rows max. A content-bearing overlay (DM group's participants roster) is a bespoke ModalPanel, not this menu.

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 (in-card section titles were tried on the club home 2026-08-22 and rejected on device — "a big mistake"; the seam-void problem they targeted is a canvas/figure-ground issue, not a title-placement issue). 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). SectionBlock also takes variant (passes through to SectionHeader) and inset ('none' default — the parent padded column owns the $4 inset; 'all' — the block insets title AND content, for unpadded full-bleed pages like the club home; 'title' — title inset only, content full-bleed, for horizontal rails that own their own edge bleed).

  • Section-rhythm law (2026-08-21o) — every vertical seam has exactly ONE owner; never sum two spacing sources across a boundary. The canonical seams: section→section = $4 (owned by the page's ONE gap-bearing column — or by a single list-slot padding where a native list slot (FlashList header/footer) breaks the gap chain); title→content = $2 (owned by SectionBlock — as the stack gap for variant="block", as SectionHeader's own paddingBottom for variant="list"; SectionBlock zeroes its gap in list mode so the two can never stack); list item→item = $3 (FeedList/GroupedFeedList's DefaultSeparator); page chrome→content = $4 (MainTabShell title block, SegmentedTabs chip→pane); controls→list-title = $3. Drift signature to reject in review: a wrapper padding* around a SectionHeader/SectionBlock (the doubled seam — $3 wrapper + $2 header = an uncanonical 20px), or a paddingTop that duplicates a sibling gap. Corollary: a conditional section renders null, never an empty container — an empty YStack still consumes the parent's gap slot (the 32px rail→소식 seam bug, 2026-08-21o).

  • Place-surface law (2026-08-22h) — gesture follows role. Pages split into two roles: a browsing surface (a list traversed to find/manage things — 내 클럽, 탐색, 전체 일정, 소식 feed, 기록 table) scrolls VERTICALLY, always; a place surface (a page that IS a thing, previewing its facets — club home is the app's precedent; a future session "home" may adopt it) composes bounded horizontal preview shelves (rail inside the section's one Card, recessed $surfaceSecondary tiles, ≤5 items) with a 더보기 escape into the vertical browsing page. A shelf may NEVER be the sole path to content — every rail is backed by a complete vertical list one tap away (the original "no horizontal scroll for my stuff" rule is this law's browsing-surface half). Rationale: shelf grammar keeps the user IN the room (belonging — Spotify/Netflix home model); a vertical feed on a place surface hands the page over to the items and duplicates the browsing page.

  • Seam ladder + two rails (owner-directed 2026-09-06) — a seam's value comes from the relationship, never from the screen; one owner per seam. SEAM (@twomore/ui seams.ts) is the single source: element $1=4 (title→sub, icon→label) · item $2=8 (element→element, row padding, section title→content, chip/icon-item gap) · block $3=12 (block peers, leading→text, Well/Callout inner padding, button pairs, chrome→content) · rail/hero/band $4=16 (the text rail, hero identity→first section, content→CTA band, band padding) · section $6=24 (each side of the full-bleed section divider) · tail $9=48 (scroll tail with no band). Alignment has exactly two rails: the text rail at 16 for every text and control (section titles carry no inset of their own) and full-bleed at 0 for media, section dividers, the CTA hairline, the nav hairline. Edge rule: a section's first child carries no top padding and its last no bottom padding (RowList/FactList clone edge onto rows), section headers carry no min-height slack, no block adds an outer margin — so every visible gap equals its rung whatever child sits at the edge. Hero zone: stat strips and hero facts go in FlatColumn lead, never as a section (a section divider above AND below would frame them). Call-site gating: a FlatColumn child is gated where it is placed (cond ? <Section/> : null); a component that returns null internally still earns a divider. A contract test (seam-contracts.test.ts) locks every primitive seam to its rung; post detail is on this system too — there is no second grammar.

  • Content insets are configuration (2026-09-06 rhythm audit, docs/design/rhythm-alignment-audit-2026-09-06.md) — a scroll container never types a number. contentContainerStyle cannot take $N tokens, so @twomore/ui exports the ladder's pixel values once (CONTENT_INSET) and the only presets a screen may compose (SCROLL_CONTENT.page = the shell default, so a screen restating it passes nothing; list/railedList for a list inside a shell; banded/railedBanded under a pinned band, where the tail collapses to the band seam; sheet for a ModalPanel.ScrollView). Lint @twomore/no-raw-content-inset (error, no waivers) rejects a numeric padding/gap inside a contentContainerStyle literal or a scrollContent style object in features. Before this rule ~50 screens each pasted their own tail and it had forked across 16/24/32/48/56/96/100/104 px. Same family: HAIRLINE ({width:1, color:'$borderSubtle'}) is the one recipe every chrome line, Divider and FlatColumn read; ListRow sub takes string | readonly string[] (one fact per line) exactly like hero meta; MiniBarMeter is the one bar-meter shape; BADGE_SIZE.NAVER, RADIUS_PILL, SLIDER_GEOMETRY and the records RANK_COLUMN constants replace the last unnamed sizes. A raw number that must stay carries a // reason: comment (box-sizes.ts convention).

  • Divider law (owner-directed 2026-09-06, final) — dividers separate sections; peers are separated by spacing. A section boundary on a flat page is a FULL-BLEED 1px $borderSubtle hairline with SEAM.section ($6, 24px) above and below, drawn by FlatColumn between its children (one owner), under a SectionHeader variant="flat" title at navTitle 700. Where a list renders its own section boundary (a SectionList header, a list-header block), it renders Divider seam="section" (bleed inside a railed container), never a stack drawn as a line, and never a literal-valued border (borderBottomWidth={1} borderBottomColor="$borderSubtle") used as the separator either — a real chrome edge reads those two literals from HAIRLINE.width/HAIRLINE.color, so a literal-valued border is always the hand-rolled recipe, never a legitimate exception: Divider is the ONE hairline component and lint @twomore/no-hand-hairline (error, F4 2026-09-07; widened 2026-09-10 color/type audit MEDIUM-2 to also catch the literal-border shape; widened again 2026-09-17 primitive-drift audit PD-05 to also catch that same width/color pair behind a ternary — borderBottomWidth={hairline ? 1 : 0}, the actual shape nearly every row used — any $border* token beyond $borderSubtle, and packages/app/src/presentation/** alongside features) rejects both height={HAIRLINE.width} and a hand-rolled border{Bottom,Top}Width/border{Bottom,Top}Color pair in features. Rows of the same kind inside a section (ListRow, FactRow, link rows, roster rows) carry NO line between them — their $2 vertical padding is the rhythm (list rows 40px floor with hitSlop to 44, fact rows 36; owner 2026-09-06 "make the inter-peer gap tighter") (RowList stacks them with no separators). The one exception: when each peer is itself a multi-component block (a match row with a scoreboard, a review block, a correction row with chips), a divider between blocks is allowed (RowList dividers). Club detail's home tab is the precedent: its blocks are divided by a line; the rows inside are not. Nothing frames a Well, StatStrip, or hero — the block's own edge does that. Cell dividers are not section dividers: a horizontal strip (StatStrip) has no row rhythm, so a 1px vertical hairline between its cells is the device that groups a value with its label (App Store / Naver Place model); it spans only the cell content, inset SEAM.element at each end, it sits centered in the 8px gap between cells (4 · line · 4), and every cell carries SEAM.item padding inside so content never touches its own edge (owner 2026-09-06). History: the 2026-09-05 build drew hairlines between rows and only whitespace between sections, which made a section edge read like one more row; whitespace-only seams read crammed on device; the tinted band stays a documented escape hatch only.

  • Meta text grammar (owner-directed 2026-09-06) — one table, read by every primitive. META_TEXT (@twomore/ui text-grammar.ts): primary meta (hero identity lines) cardBody $textSecondary; secondary/sub (row sub, fact label, stat label, section meta) cardMeta $textTertiary; eyebrow cardMeta $textTertiary 700; caption (fine print, empty-state subtitle) cardMeta $textSecondary. TypeHero, MediaHero, ListRow, FactRow, SectionHeader, StatTile, EmptyState read it; call sites import META_TEXT rather than restating a role and color; a contract test locks the table.

  • Graphics lead (owner-directed 2026-09-06) — avatars, logos, flags, thumbnails, and any inline graphical asset render to the LEFT and TOP of their block, never the right. TypeHero leading (56px AVATAR_SIZE.HERO, top-aligned) replaced the retired right slot; MediaHero avatar already leads; rows carry graphics in leading. The trailing edge is for status, counts, chevrons, and actions only.

  • No middle-dot joins (owner-directed 2026-09-06; restated 2026-09-14: "proper visual breaks for each semantically meaningful piece of information") — facts are not glued together with ·, and there are no exceptions. A meta line holds ONE fact; several facts stack as lines (TypeHero/MediaHero meta accepts an array — one line per entry) or become chips/fact rows. When short facts share one line (date + time, court + round, paid + pending), MetaSegments (@twomore/ui) is the one primitive that puts them there: each fact is its own Text and the break between two facts is MetaBreak, the same 1px HAIRLINE the StatStrip draws between cells — a surface with its own text role composes MetaBreak directly (the network banner, the VenueLink pill). An i18n leaf therefore never carries two facts: split it into one key per fact (arrays of facts are readonly string[]) or rewrite the sentence as prose. @twomore/no-middle-dot-join flags ' · ' in rendered strings and i18n tables at error with zero disables (the 2026-09-06 "single-string composite" waivers were all swept on 2026-09-14).

  • Badge placement law (HERO CANON, owner-directed 2026-09-06, revised) — a hero has ONE badge home: the badge row under the title. MediaHero/TypeHero render every badge in a single row directly under the title, before the meta lines, in the order the caller passes (convention: entity state → verification → category); the title line and the media overlay carry none — titleBadge and MediaHero status are both retired. Category/kind (실내 · 테니스장 · 샵) is a badge in that row, or eyebrow text, never both. Row-level status (RSVP · paid · waitlist) is the row's trailing slot, vertically centered, and replaces the chevron. Counts and facts (12명 · 4면) are meta text or stat cells, never badges. Two badges max in a hero; overflow moves to the meta line or a fact row. Hero composition (same ruling — "noncanonical top padding, contents skewed to the left"): every hero opens with the SEAM.hero (16) seam under the chrome hairline or the media band (never SEAM.chrome, locked in seam-contracts.test.ts), sits on the 16 text rail, and carries no bottom padding (the page's FlatColumn top owns the next seam). Order: eyebrow → identity row → nothing else. The eyebrow spans the rail ABOVE the identity row, so a leading graphic (56px AVATAR_SIZE.HERO, on the rail, SEAM.block to the text) top-aligns with the TITLE line, never with the eyebrow; title, badge row and meta lines all start on the same x. A screen never wraps a hero in padding or margin of its own; the hero is the first child under the header (or under a terminal-state Banner).

  • Hero identity pattern (owner ruling, 2026-09-08) — supersedes the Badge placement law above's "one badge row under the title": no rows of flat metadata under a hero title. A hero's title line carries the title plus ONE inline key badge (titleBadge — the entity state, e.g. live/모집 중/마감 — an XStack alignItems="center" gap="$2" where the title shrinks/truncates and the badge holds its size), never a badge row. Directly under it, ONE GlyphFactRow (facts, SEAM.item below the title) — up to 4 facts, each a glyph + one value with no separate text label (the glyph IS the category: 📅 date, ⏰ time, 🌤 weather, 🎾 format, …) — replaces the old stacked meta lines entirely. Secondary numeric stats (counts, capacity, price) stay OUT of the hero; they belong in the page's StatStrip (FlatColumn lead), never a hero fact or badge. A hero must never restate a generated/derived title as its own title when a more concrete identity (a place, a person, a real name) is available — e.g. the session hero's title is the venue/place name, not the generated sessionTitle() string (format + style), which now renders as the 🎾 fact instead (session-hero-section.tsx). MediaHero/TypeHero's titleBadge/facts props (backed by GlyphFactRow, packages/ui/src/glyph-fact-row.tsx) implement this; their meta/badges props are @deprecated and removed once every caller has migrated.

  • Group-spread law (owner-directed 2026-09-05) — a horizontal group of peers fills the rail. Tabs, segmented controls, action-icon rows, CTA-band button pairs, stat strips, and single-line chip sets span the page rail edge to edge with the GAP as the constant and the ITEM WIDTH as the variable (owner 2026-09-06 final: "items in a row should have canonical spacing; the size of each item should be adjusted instead"): equal flex={1} columns separated by the canonical gap ($2 for icon items and chips, $3 for button pairs, 0 for hairline cells), the first column starting on the rail and each next one where the previous gap ends, with the CONTENT CENTERED inside its column (owner: "items should centrally align the contents") — ONE layout primitive, CellRow (equal columns, centered content, an 8px GAP between cells, 8px PADDING inside each cell — gap and padding are different things: the gap keeps neighbouring containers apart, the padding keeps content off its own edge — and an optional 1px cell divider centered in the gap, 4 · line · 4), that StatStrip (dividers on) and ActionIconRow (dividers off — a circle is its own figure) compose; only the item differs. Never space-between (which makes the gap the variable) and never a centered fixed-width cluster; a group centered with fixed-width items (crammed in the middle) is the drift signature to reject. Overflowing chip sets wrap left-aligned; horizontal rails start at the rail and bleed right. ActionIconRow, StatStrip, PillNav variant="underline", and BottomCtaBand pairs implement it.

  • Canvas law (owner-directed 2026-09-02) — ONE flat non-grey ground, and components go flat WITH it. Every product surface rides the flat $card ground; the tinted grey $background retreats to placeholders and recessed wells. This supersedes the browsing-surface half of the place-surface law above (which kept browsing surfaces on tinted $background) and the figure-ground law recorded in detail-shell.tsx (2026-08-22c). Read the history before touching this — a flat canvas was tried once and reverted: the 2026-08-22 pilot flattened the GROUND while leaving card-boxed components on it, so the cards lost the surface they sat on and "read as floating objects", and tinted was restored the next day. The mitigation that makes this attempt different is not a lighter grey — it is that the components flatten too: rows become bare hairline-separated blocks on the canvas, not boxes. Flattening the ground WITHOUT flattening the components reproduces the 2026-08-22 failure exactly; the two halves ship together or not at all. The grammar already exists on the club home (its C-redesign is the precedent, 당근 모임 model): rows sit directly on the canvas (club-schedule-rows.tsx — "utility rows on the bare canvas, NOT inside a Card"), separated by bottom-hairline-only blocks (a trailing <Divider /> — the top hairline was removed 2026-08-24 as lopsided framing; the block itself was a hand-rolled borderBottomWidth={1} borderBottomColor="$borderSubtle" until the 2026-09-10 color/type audit repointed it at the primitive), and canonical cards expose a boxless variant for embedding (SessionCardV2 variant="embedded", the "E1 flat embed" — full card anatomy, no outer box). What STAYS a Card: only a genuine figure that must lift off the canvas — the detail hero, and a surface whose whole job is to be a distinct object. A list row, a settings row, a stat strip, a section body: never boxed. Section titles stay OUTSIDE any card (existing rule, unchanged). Migration status: PlaceShell (flat $card) is the target shell and DetailShell (tinted) is the legacy one — ~60 screens still ride the tinted ground. The remaining work is smaller than a raw <Card> count suggests, and mis-scoping it is the trap: the LIST-ROW level already complies (the 2026-08-25 row-grammar-parity wave left DuesRow/MemberRow as bare hairline rows with zero Card boxes), so what is actually still boxed is (a) the ground itself and (b) the surrounding utility chrome — summary cards, month selectors, section heroes, entry/nav rows. Of the ~189 <Card> instances in features, many are legitimately staying (form sections, modals, and genuine lifted figures); they are spread thin (~4–7 per screen), not wrapping list rows. Migrate in staged batches — shell first, then chrome — behind the /wireframes/canvas-migration board rather than as one sweep, and audit per screen rather than trusting a global grep. Until a screen is migrated it keeps its current ground — a half-flattened screen (flat ground, boxed chrome) is the one state this law forbids. Ruling 2026-09-07 (owner): one grammar, keyed on content shape, not page type. Flat sections for every detail page and every homogeneous list — the club 운영 screens (members, attendance, dues, sessions, venues, challenges, the 관리 entry), the guest review flow and the venue finder's chrome included; object cards (SessionCardV2, ClubCard, DirectoryVenueCard) remain only inside browsing collections. Headline numbers on a flat page are a StatStrip in FlatColumn lead, never the boxed StatGrid. The migration proceeds by risk (shell first, then chrome, flat-row screens first) and every migrated screen must satisfy the group-spread law (equal flex={1} cells, the gap constant, content centered, never space-between) and the seam ladder — the owner's condition for the go-ahead was "component positions, spacing, alignment, equal distribution on the same row". Enforcement (2026-09-07, flat-layout plan F0): the tinted-shell ratchet ran the migration to zero and was deleted with F8 (2026-09-07) — DetailShell has no ground prop; every detail screen is the canvas, and MainTabShell alone keeps a tinted option for the main tabs; scripts/__tests__/wireframe-kit-parity.test.mjs locks the wireframe kit's Fl* metrics to seams.ts so an approved board never describes spacing the app cannot produce. The slice order lives in docs/architecture/flat-layout-implementation-plan.md.

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.
  • Owner ruling D1 (2026-09-08): a MediaHero detail page uses DetailShell header="collapsing"CollapsingTopBar overlays the hero (expanded/transparent over the media, no static title repeating the hero's own) and flips to solid chrome once the hero scrolls under it, driven by the shared useCollapsingTopBar threshold hook (@twomore/ui); a TypeHero page (no media) keeps the static AppHeader. header="collapsing" only self-wires when DetailShell owns the ScrollView (the scroll default) — a screen with a nested list (FeedList, scroll={false}) composes CollapsingTopBar + useCollapsingTopBar directly instead. Live consumers: session-detail-screen.tsx (conditional on hero mode), directory-venue-detail-screen.tsx, venue-detail-screen.tsx, public-club-profile-screen.tsx; club detail's own two-state bar (use-collapsing-banner.ts) shares the same threshold resolver.
  • A header embedded inside a ModalPanel sheet (no SafeAreaView, no tab chrome) → AppHeader with chrome="bare" (drops the $card background + bottom hairline; same title typography and back affordance as the default chrome) — the scorecard sheets' ScoreCallSheet/ScoreCorrectionSheet/ScoreEditSheet are the reference consumers. Never hand-roll a back-chevron-plus-centered-title header locally; AppHeader (bare or default) is the one implementation. AppHeader's own height (56, or 64 with a subtitle) is exported (APP_HEADER_HEIGHT/resolveAppHeaderHeight, @twomore/ui) — a screen that must derive its own layout math from the header's footprint (e.g. a KeyboardAvoidingView's keyboardVerticalOffset) reads it from there, never re-types the two literals (dm-thread-screen.tsx's group-thread offset was silently 8px short before this — 2026-09-10 spacing audit finding #3).
  • Hub-vs-detail law (owner-directed 2026-09-14) — a bottom-tab hub is a card-based browsing collection; a detail page is flat. Grounded in how the platforms and the benchmark apps split it: a hub that lists many heterogeneous, scannable things (Material 3 cards "for a collection of related content", the App Store Today feed, Naver/Toss home feeds) chunks each thing into one tappable card, while a page that IS one thing (Apple HIG grouped detail views, Toss detail pages, the canvas law above) lays its facets out flat. The rule keys on CONTENT SHAPE, which the 2026-09-07 ruling already stated: 홈 / 클럽 / 경기 / 기록 are browsing collections → object cards (SessionCardV2, ClubCard, DirectoryVenueCard, the home cards, the 기록 dashboard widgets); 프로필 is a single entity and stays flat; every pushed detail route is flat. Hub section grammar (one owner per seam, shared primitives only): a hub section is titled OUTSIDE its cards by SectionHeader variant="list" (a FeedList/GroupedFeedList header slot or renderSectionHeader) or by SectionBlock variant="block" (a composed feed such as the home views) — the two render the same cardTitle title and both own the title→first card = SEAM.item ($2) seam; card→card = SEAM.block ($3) is owned by the list's DefaultSeparator (or the section body's gap), never by a card margin; section→section = SEAM.rail ($4) is owned by the ONE gap-bearing container (the composed feed's column gap, or a native list header slot's padding when a list breaks the gap chain), never by a header's own paddingTop plus a previous card's paddingBottom; the scroll tail is owned by the scroll container's SCROLL_CONTENT preset alone (a view never adds its own trailing padding under it); $5 is not a rung and never appears. Every hub collection has a title (ActivityListControls-style filter bars are controls, not titles). A dashboard widget on a hub keeps its card but its title moves out of the card (SectionBlock). seam-contracts.test.ts locks the header variants to $2 and the list separator to $3.
  • BottomCtaBand publishes its own measured height into the bottom-chrome store (useReportBottomCtaBandHeight from @twomore/app, wired through the band's onHeightChange prop) so ToastHost floats above it automaticallypackages/ui cannot import @twomore/app (canon/architecture.md), so the band only reports its height via the prop; the HOST screen supplies the callback, the same onFooterHeightChange-through-a-prop pattern WizardShell already uses for the identical layering reason. The store is additive-by-id, not last-writer: a tab screen's BottomCtaBand mounts ON TOP OF the still-mounted tab bar, and ToastHost must clear their COMBINED footprint (2026-09-10 spacing audit finding #4) — every new bottom-chrome publisher gets its own id-keyed slot in bottom-chrome.store.ts, summed. Live consumers: club-list-screen.tsx, activity-screen.tsx; any other BottomCtaBand mount may wire the same hook.
  • 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 + optional caption slot for a below-control info line + 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. Below-control caption canon (research-grounded 2026-08-18 against Material Design's supporting-text spec and NN/g's placeholder-text findings — see citations in the linked section) — wizards.md field grammar, including the caption-vs-AttachedPanel ordering rule: when a field has both, order is control → AttachedPanel → caption (never caption between them — pass the caption as a manual trailing line after the panel instead of via LabeledField's caption prop). 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 — UNLESS it is the ONLY button in the prompt (a lone non-cancel/non-primary button), which falls back to outline, matching a native Alert's single-button treatment (variantFor in confirm-sheet.tsx is the authoritative branch order; PD-08, primitive-drift-audit-2026-09-17). 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' AppHeader chrome="bare") 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.
  • ModalPanel's built-in close button's default accessibilityLabel/closeAccessibilityLabel is the Korean literal '닫기' (packages/ui/src/modal-panel.tsx) — not t() (the ui package has no @twomore/app dependency), matching WizardShell's own closeAccessibilityLabel = '닫기' default precedent. Fixed 2026-08-18: it had leaked the English 'Close panel' as the announced label on the ~39 of 45 ModalPanel.Frame consumers that never pass an explicit override — a screen-reader-only i18n gap invisible to sighted QA. Pass an explicit closeAccessibilityLabel only when a more specific label reads better in context; the shared default otherwise covers every consumer.
  • ModalPanel.Frame title row (owner audit, 2026-08-18): a left-aligned title Text is role="cardTitle" fontWeight="700" marginBottom="$4" paddingRight="$8" — the paddingRight clears the Frame's built-in top-right close button (absolute-positioned at top="$3" right="$3" sized $8), which a long title otherwise runs under. Reference implementations: recurrence-editor.tsx, group-editor.tsx, regroup-preview.tsx. Applies to every default-header ModalPanel.Frame title; exempt: a custom-header consumer (showCloseButton={false}, rule above) and a centered/symmetric confirmation dialog (challenge-accept-modal.tsx) where adding one-sided paddingRight would visibly off-center the text — those keep short, static copy instead. Audit swept and fixed the drift (missing fontWeight/paddingRight, or marginBottom="$3" instead of "$4") in edit-field-modal.tsx, dues-action-modals.tsx, dues-attest-modal.tsx, club-guests-section.tsx's two modals, gallery-section.tsx, regular-meets-section.tsx, club-groups-section.tsx, and transfer-ownership-modal.tsx.
  • Edit-box grammar — which surface for a committed value: AttachedPanel (packages/ui/src/attached-panel.tsx, canon in wizards.md) is LIVE-COMMIT — it edits the draft/entity directly as the user interacts, no separate save step; reserve it for a control that is a DEPENDENT of the field it's attached to (a day-of-month stepper under a nonzero fee, a court-count picker under a picked venue). ModalPanel is STAGED — nothing commits until an explicit 저장/제출 press (or the cascade's own terminal action); reserve it for an independent entity edit (a regular meet, a group, a single settings field). Never blend the two: a ModalPanel form must not auto-commit keystrokes into the live entity, and an AttachedPanel must not gate its content behind its own 저장 button — closing it (folding the row) IS the save.
  • AttachedPanel spacing spec (owner-ratified, 2026-08-18 — closes a "differs box to box" drift audit): all three numbers are baked into the component, not caller-parameterized. Outer: marginTop="$2" (8px) between the owning control and the panel — the component's own root margin, never a per-call-site override (the notch's existing offset still bridges this gap and reads as pointing at the control). Internal: padding="$3" (12px) on the panel box, plus a gap="$3" (12px) content container the component owns around children — pass children directly, never re-wrap them in a caller-local YStack with its own gap to get row spacing (an ad-hoc gap="$1" wrapper around a suggestion-dropdown's rows was the drift instance that prompted this audit; collapsed in the same pass). A component nested one level inside the panel (e.g. a RevealStack composing its own sequential sections) may still own its own internal gap — that governs a different concern (its own item reveal rhythm) and should match this same $3 baseline rather than an arbitrary larger value.

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.
  • Offline is a fourth branch, not a variant of empty (2026-09-11 page audit, ~70 files). TanStack v5 reports isLoading=false AND isError=false while a query is offline-paused (fetchStatus === 'paused', the default networkMode: 'online'), so a surface whose only branches are loading → error → empty renders a false claim on a cold start offline ("no venues", "venue doesn't exist", "no permission", "club not found"). Every query-gated surface therefore reads one offline primitive: QueryBoundary (owns all five phases), FeedList/GroupedFeedList isPausedOffline={q.isPaused} + offlineState={<OfflineEmptyState onRetry=… />}, an early if (q.isPaused && !q.isFetched) return <OfflineEmptyState /> placed BEFORE the loading branch and before any permission gate, or a composer's isPausedOffline. A data hook that returns isLoading also returns isPausedOffline. Enforced by @twomore/require-offline-branch-with-loading-gate (error; // offline-safe: <reason> waives a read that is genuinely not a network query).
  • 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.
  • 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 (@twomore/ui, packages/ui/src/period-scrubber-shell.tsx) — the ‹ label › stepper header shared by any period picker/stepper, wrapping whatever grid/list content the caller passes as children. Promoted from packages/features/home (2026-09-01) to @twomore/ui once packages/features/clubs' dues MonthSelector needed the identical idiom — features can't import each other, so a primitive two feature packages both need lives in @twomore/ui. Pure presentation (label/handlers/a11y strings are caller-supplied props); used by home's DayCalendarPicker/MonthYearPicker/WeekMonthPicker and clubs' dues MonthSelector.
  • 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.
  • MarkerPill (@twomore/app, packages/app/src/presentation/components/sessions/marker-pill.tsx) — the canonical small-pill chrome ($7-radius, tone primary|neutral) for a marker/hint pill. DDayChip (the file was dday-chip.tsx) has been retired — MarkerPill is exported directly, with no wrapper. Reach for MarkerPill directly for any new marker/hint pill instead of re-deriving the bg/radius/padding recipe.
  • StatGrid (@twomore/ui, packages/ui/src/stat-grid.tsx) — a row of equal-width, equal-height stat cells for a headline-numbers cluster (e.g. member-stats-modal.tsx's top-3: club ELO+delta · rank · win rate). Thin wrapper over StatTile (single source for the value/label anatomy) — adds a $surfaceSecondary card fill, equal-flex/stretch sizing, and an optional signed delta line (▲/▼) that always renders (opacity 0 when a cell carries no delta) so every cell in the row shares one height. Use for any "headline numbers row" instead of a local XStack of ad-hoc YStack+Text cells.
  • Button's loading/loadingLabel (@twomore/ui, packages/ui/src/button.tsx) — the canonical in-flight CTA treatment: loading swaps the leading icon for an ActivityIndicator and forces the disabled state; optional loadingLabel swaps the button's text too (e.g. "저장 중…"). An ad-hoc ActivityIndicator composed into an icon slot per call site is retired — use loading/loadingLabel on Button/ActionButton instead.
  • usePickImages (@twomore/app) is the one library-pick flow — permission dialog, pick, resize; features never import expo-image-picker directly (lint @twomore/no-raw-image-picker).

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.