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.elevationdefeats Android view-flattening, so every mounted instance becomes its own native layer: addingelevation: 1toCard's default tone made EVERY tab switch in the app exceed the 250ms settle threshold within one OTA (2026-08-21p — zerointeraction:tabtelemetry 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
Card→ only two tones:default(neutral card surface) andflat(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), andsize="none"(0 padding — the caller owns all internal padding, for aCardthat only exists to draw the bordered frame around content that manages its own row-level padding, e.g.edit-session-screen.tsx'sEditFieldRowlist wrapper).Card'stoneis the deliberate STRUCTURAL exception to whattonemeans elsewhere — here it switches border style (solid vs dashed), not color; everywhere else in the system (Callout,AvatarBubble,SectionHeader)toneselects 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 previousSessionCardsignaled status via a small DOT at the START of the title, inside the now-deletedSessionHeader's title row (in_progress→ redPulseDot; confirmed → solid green dot), colored bysessionStatusStripColor()— that function no longer exists, andsession-status-strip.ts/match-status-surface.tsthemselves 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'sSessionStateInline(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 asSessionStripChipBadges and a terminal state gets a full-widthBanner. Historical text follows — the previous mechanism was the topSessionStripViewband (session-strip-ui.tsx, BARE-RIBBON FULL-SOLID model, owner 2026-07-29 — "no pale strips"), conditional (renders nothing whenderiveSessionStripreturns 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 SAMESessionStripViewrenderer (size="card"vssize="detail") — never a second hand-rolled status treatment. The session-DETAIL screen additionally keeps a redPulseDot(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
BottomCtaBandon the detail screen); no FABs. But per the nested-affordance model directly below, a card MAY carry a small number of compact, visually-balanced nested actions: content on the left or top invites a quick action on the right or bottom — a list-row 확인/제외/출결 button, a match-card 경기 종료 footer band — and for a glance→act row keeping the action where the eye already is reads BETTER than detaching it. The line is page-CTA vs balanced nested action (not button-vs-no-button): anActionButton(the canonical, intentionally-styled action) in a balanced nested slot is sanctioned; a loud page CTA plastered on a pure content/navigate card is the smell. (History: a blunt "never a CTA in a card" was walked back 2026-07-13 — it contradicted this same model and over-flagged legitimate balanced action-rows.)Multiple touch targets in a card — the canonical model (Material 3 "one primary action + supplemental actions"; codified 2026-06-23 after a full clickable audit). A card has ONE primary touch target = the whole card → its detail destination; a SMALL number (≤~3) of NESTED affordances may do something different (a sub-nav like 상세 보기/전체 경기 보기, a toggle like the participation row, an action like save). Rules:
- The canonical clickable is
Pressablefrom@twomore/ui(variants card/row/pill/icon/button/fab) — it is the SINGLE SOURCE of interaction cues: UI-thread scale+opacity press feedback, webcursor:pointer+ hover dim +$primaryfocus ring, TS-requiredaccessibilityLabel, standardized disabled.Button/ActionButton(page CTAs) +PillNav/SegmentedTabs/SelectionChip(tabs/chips) are the only other sanctioned clickables. NEVER usereact-nativePressable/Touchable*, rawonPresson a TamaguiStack/XStack/YStack, or inlinepressStyle={{}}— they lose all of the above. (onPressInon a wrapper stack IS allowed — Protocol H prefetch.) - A tappable card WRAPS in
<Pressable variant="card">— never<Card onPress>.Cardis a pure displaystyled(Stack)with no press/hover feedback; passing itonPressgives a dead tap. Wrap:<Pressable variant="card" accessibilityLabel={…} onPress={…}><Card …>…</Card></Pressable>(the mutingopacityfor cancelled/archived stays on the innerCard). Canonical examples:SessionCardV2,ClubCard,VenueRow. The muting opacity itself is the named constantTERMINAL_MUTE_OPACITY(0.55,packages/ui/src/box-sizes.ts) — the one terminal-muting value (ClubCardarchivedAt,DirectoryVenueDetailScreenisClosed,AchievementsGalleryScreen!isUnlocked,DuesRowpaid); use the constant, never a bareopacity={0.55}literal. - Every interactive NESTED target inside a tappable card MUST call
e?.stopPropagation?.()as the first line of itsonPress. Native RN grants the responder to the deepest view, but WEB events bubble → without it, tapping the inner target ALSO fires the card'sonPress(double-nav). Canonical:session-save-button, the participation expand band, the전체 경기 보기footer, the notification dismiss. - Display-only elements stay non-interactive — chips, text, the
VenueLinkpill are NOT touch targets; the card's tap covers them. Don't make a value clickable unless it navigates somewhere different or performs an action. - The card's primary tap = the most-common intent; rare/secondary actions are the nested ones (or move off the card to the detail / a header overflow menu). Past ~3 nested targets the card becomes a mini-page — pull back.
- Enforced by
@twomore/no-raw-rn-pressable-in-features,@twomore/no-onpress-on-card-in-features,@twomore/no-onpress-on-stack-in-features(+ the pre-existingno-press-style-in-features). ThestopPropagationcontract is reviewer-checked (not mechanically linted).
- The canonical clickable is
CARD-NO-OVERFLOW — a card's contents, combined, must NEVER overflow its width (an essential attribute of every card component, owner directive 2026-06-20). Clipped or pushed-off content — the classic being a long venue name shoving the weather glance off the right edge — is always a bug, never an acceptable state. The mechanism, applied to EVERY row that mixes content kinds: in a row combining FLEXIBLE content (text that can run long — venue, title, player names) with FIXED content (chips, icons, the weather glance, a count), the flexible element is the ONLY one that may flex AND it MUST be able to shrink — wrap it in
flex={1} minWidth={0}and give its textnumberOfLines={1}(ellipsize). RN's defaultflexShrinkis 0 (unlike web's 1), so a flex child will NOT shrink below its content width unlessminWidth={0}is set — omitting it is the #1 cause of card overflow. Fixed siblings getflexShrink={0}(or are intrinsically fixed) so they stay fully visible. Canonical patterns:SessionCardV2's venue title row (session-card-v2.tsx— venue title inflex={1} minWidth={0}+ the weather label pinned right, fixed) andVenueLink(maxWidth="100%"+ namenumberOfLines={1} flexShrink={1}+trailingflexShrink={0}). Multi-chip strips that can exceed one row useflexWrap="wrap"(Material 3) or shed chips per the density rule — never clip. When adding or arranging anything on a card, verify the LONGEST realistic content (longest venue/name, max chips) still fits within the card width.Summary-card density — a card answers ONE decision, shows ~3–6 decision-critical signals, and DEFERS the rest to the detail screen. Research-grounded (NN/g "cards are entry points, not encyclopedias"; the field publishes NO hard chip/attribute cap — the discipline is qualitative; real dense cards show 3–6 signals and hide the long tail: Strava 3 stats / Google Flights 5 / Airbnb ~5). This is the SUMMARY-card mirror of the detail-screen single-protagonist rule. Five tests:
- One decision question → one dominant anchor. Frame the card around the single question it answers (SessionCard: "is there a spot / when / is it mine?") and make that the protagonist (participation); everything else recedes. Pre-attentive scanning lands on ONE figure (~200–500ms) — two heavy co-clusters competing (e.g. participation + a full weather strip) is the failure ("if everything is emphasized, nothing stands out").
- The defer-to-detail test. Before adding an attribute, ask "does this change the BROWSE decision?" If no (or rarely), it belongs on the DETAIL screen, not the card. The detail holds the rich version (hourly weather, wind, AQI breakdown, full roster); the card carries only the glanceable summary (condition+temp, N/M, faces).
- Chunk into ≤~4 recognized clusters (Miller 7±2 — for chunking, NOT a 7-item cap; Gestalt proximity; Polaris) — WHEN / WHERE / WHO / classification — grouped by the
$Nproximity hierarchy so each reads as one chunk, not N loose elements. Spacing does the grouping, not labels. - Chips: closed whitelist, one row, ≤20-char labels (Material 3). The strip is the sanctioned set only (no ad-hoc
<Badge>, COMP-2) and must NOT wrap past one row (M3: 2-row chip strips are hard to scan). Conditional chips (payment/approval) are rare, so the common case is ~3. - Color is triage, not decoration; keep the palette small (neutral-first; Astro UXDS: more status colors reduce learnability). Every color source on a card must earn its triage value; when several compete (status-toned date tile + status chip + payment chip + accent chip + brand chip + AQI dot + temp-toned fill), trim. The failure mode is the "baby webpage" card (Dave Rupert) / our "parallel mini-dashboards" anti-pattern — a card so dense it stops being a glance and becomes a compressed detail page. Cautionary example (2026-06, the SessionCard audit): the weather strip accreted AQI grade+label + wind + a min/max range bar — granular signals that don't change an RSVP decision; by test #2 the card weather leans to condition+temp (+ a bad-weather warning) and the rich strip + D-day chip move to the session detail.
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'sFactGlyph: 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 ownaccessibilityLabel, 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-offPressable+primary-Textreinvented 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 viavenueId(or a customonPress); plusVenueCard(@twomore/app, the RICH large tile: static-map thumbnail → Naver app + name + 영업 중/종료 status pill viaisVenueOpenNow+ 길찾기 + surface·courts·district + facility icons + weekday-rate hint; the single source replacing the old duplicated location tiles). Use VenueLink 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 canonicalXLink/XCardcomponent once and reuse it. The inner pressable wins the tap inside a tappable card; the card keeps its own destination. Free-text values with NO backing id stay plain muted text — never style a fake link. When a read path lacks the id needed for the link (e.g. an RPC summary), extend the read path (the 00261/00262 pattern) rather than shipping dead text.
Notices & banners
- Three shapes, never blurred (research decision, 2026-08-15): a persistent inline notice is one of
Banner,Callout, or a tintedPressablerow — 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),tone∈primary | warning | error | neutral | muted(fills mirror theCallout/badge palette; onlyprimarycarries a border, matching its reference call siteWizardDraftBanner;mutedis the terminal/archived-state look —$badgeMutedBgfill,$textSecondarytext, no actions — used by the club-detail terminal-state banner, folded intoBannerdirectly rather than a bespokeClubStatusBanner). Render order inactionsIS 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 toCallout— reach forBannerinstead the moment a notice needs a button.- Tap-anywhere navigation (e.g. the pending-applicants row on session-detail) →
Pressable+ a tint background, neverBanner/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 neitherBanner's bounded card frame norCallout's inset panel applies. Don't cite it as aBanner/Calloutdrift instance.
Participation & capacity indicators
- "Who's in" →
ParticipationCluster(@twomore/ui, the canonical FACES indicator) — anAvatarStackof participant faces with silhouette-fill + exact "+N" overflow. It is faces-only (owner canon 2026-07: the session summary card's usage IS the pattern — the ring is always composed separately, never inside this component). Passavatars: {name, avatarUrl}[](friends-first, caller-ordered) +total(true count for exact "+N");overPhotooutlines the faces to read against a banner photo;emptyLabelrenders a hint when there are 0 participants. Unknown/private participants still render a face —AvatarBubbleshows aUsersilhouette when a name is''or'?'(never a blank circle), so a populated session/club never shows a bare cluster. The ring is a SEPARATE primitive —CapacityRing— that the caller composes alongside the faces. RETIRED claim (do not resurrect): this section used to cite the deletedSessionCardas canonical for aCapacityRing direction="column"left-rail date cell +ParticipationClusterfaces composition. Neither piece survives on the current list card:SessionCardV2shows NO participant faces at all (moved to session-detail only, per the density-first redesign) and usesRecruitmentMeter— notCapacityRing— for its capacity signal (see § Participation & capacity indicators below).ParticipationCluster's live usage today is indirect — viaBannerCapacityCluster(packages/ui/src/banner-capacity-cluster.tsx, which composes it internally for the ring+faces cluster), consumed byclub-card.tsx's banner member cluster (overPhoto). It has no other direct call site inpackages/app/packages/features. Never hand-roll anAvatarStackwith silhouette/overflow logic per call site — that logic lives inParticipationCluster. - A participant avatar set must show ALL confirmed participants, not just the viewer's friends. Source faces from the confirmed-RSVP id set (friends-first ordering), NEVER gate the avatar fetch/render on
friendsCount > 0(the bug fixed 2026-06-12: session cards showed zero faces unless the viewer happened to have a friend in the session). The friends-going COUNT line ("친구 N명 외 M명") is separate social proof that may still gate on friends. - Capacity / fill at a glance →
CapacityRing(@twomore/ui, the canonical INLINE tracker) — a compactSegmentedDonutChartring +N/Mcount (propsvalue/max/size/countRole/direction).direction="row"(default) — ring and count side by side.direction="column"— ring stacked above the count. The deletedSessionCard's left-rail date-cell usage (48px bordered cell underDateTile) no longer exists —SessionCardV2usesRecruitmentMeterinstead (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 inSessionCompactRow's file and exported for reuse —SessionCompactRowitself renders it column-native (its own dedicated row), andSessionCardV2's StatusJoinLine renders the SAME component via itsflexprop (fills the remaining width of a row it shares with host/RSVP/saved badges), never a second bar implementation. What's actually retired is aProgressBarsharing a line with OTHER unrelated content (the old greedyflex:1bar 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 fullProgressBar: there the fill IS the focal protagonist (one hero number + one progress bar per the detail-screen IA rule), and a ring would shrink the main signal. So: ring for a compact inline unit, full-line bar when the meter gets its own line (compact row, v2 card, or the detail hero).ProgressBarremains 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).BottomCtaBandandWizardShell'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;MainTabShellleaves it to the band. Stacking16 + insetis 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 aButton— useStepper(@twomore/ui, see its own entry in Tabs & chips below — PD-02, primitive-drift-audit-2026-09-17: the realStepperhand-rolls a 32pxPressable variant="icon"circle, not aButton size="xs" shape="circle"). Variants: primary / secondary / outline / ghost / danger / destructive × xs/sm/md/lg × default/pill/circle. A page-level CTA pins toBottomCtaBand, never a card; a compact nestedActionButtonaction IS allowed (the ≤3 nested-affordance model in the Cards rule above — page-CTA vs balanced nested action).danger= quiet destructive TRIGGER (red outline + red text on transparent) for inline buttons that BEGIN a destructive flow (forfeit / delete / remove / leave).destructive= loud solid-red, reserved for the CONFIRM step (ConfirmSheetstyle:'destructive') and the rare phrase-gated final-commit button (delete-account). Trigger → danger, confirm → destructive; never swap them. Every intent-bearing CTA uses<ActionButton action="…">(from@twomore/ui), never a rawvariant— it maps INTENT (primary|secondary|neutral|cancel|tertiary|destructive) → variant viaACTION_VARIANT(the single source), so intent can't drift to the wrong color; SIZE still follows placement, not intent.cancel= a DISMISS/skip/undo (→ ghost);tertiary= a low-emphasis FORWARD action that is NOT a dismiss (add-note / approve-all / mark-read / mode-toggle — same ghost look, distinct intent). AdestructiveActionButton MUST be paired with auseConfirmgate at the call site (ACTION_REQUIRES_CONFIRMdocuments this). Parallels the status-badge intent→variant pattern. Sanctioned raw-Buttonexceptions (NOT routed through ActionButton — they carry no product "intent"): (1) dev-panel /__DEV__-gated tooling; (2) icon-only triggers (no text label); (3) selection-state toggles where the variant must FLIP with "is this selected"; (4) disabled, no-onPressstatus placeholders occupying a CTA slot; (5) the loud rawvariant="destructive"confirm/phrase-gated/direct-fire buttons (distinct from ActionButton'sdestructive→danger). Everything else = ActionButton. (Numeric steppers are NOT a raw-Buttoncase at all —Steppernever rendersButton; removed from this list 2026-09-17, PD-02, the same stale claim as the Button line above.) - Using
Text→ pick a role, never hardcodefontSize/lineHeight/fontWeight. Core 5:pageTitle(24/32 700 — the LARGE left-aligned title on tab-ROOT screens viaMainTabShell, iOS large-title style),cardTitle(16/24 600),cardBody(14/22 400),cardMeta(14/20 500 muted),badge(12/18 600). Nav chrome:navTitle(17/22 600 — the compact CENTERED title on pushed DETAIL screens viaAppHeader/DetailShell, iOS HIG). The tab-root (large, left, no border) vs detail (compact, centered, hairline border) header split is intentional (platform convention), not a drift — don't unify them. Hero/stat numbers + emoji glyphs:displaySm(20/28 700),displayMd(28/36 700),displayLg(36/44 700). Arole=+ inlinefontSizeoverride is the anti-pattern this rule exists to prevent — if no role fits, the role belongs intext.tsx, not as a per-screen override.fontWeightis 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/lineHeightstay hard-banned (the@twomore/no-raw-fontsize-in-featureseslint-disable idiom is the only escape hatch, reserved for slot-matched metrics a role can't express). - Using
Badge→ 8 theme-invariant semantic-intent variants + 1 brand variant + 1 on-tinted-surface variant:neutral(default gray, for non-status metadata),accent(teal),success(green),warning(amber),info(purple),live(red + PulseDot),muted(faded),premium(reserved) — all 8 from the theme-invariant status palette; PLUSprimarySolid(solid$primarybg +$primaryTexttext — the ONLY brand-colored, theme-VARIANT badge), used solely for RSVPconfirmed(참가 확정) so the chip pairs with the brand-green title dot on session cards.primarySolidreuses the primary Button's bg/text pair, so contrast is correct in light + dark; it's the deliberate exception to "status badges are theme-invariant" (confirmed-participation reads as a personal/brand cue, not a neutral status). PLUSsurface($surfacebg +$textSecondarytext) — for badges rendered ON TINTED SURFACES (warning strips, unread-tinted rows) where any same-hue badge bg disappears against the surrounding tint; a plain surface pill restores contrast.feature/presentationcode is bounded toneutralwarninglivesurfaceby@twomore/bounded-variant-enums(AGENTS.md COMP-2) — the other variants are used only by the canonicalXxxStatusBadgewrappers themselves. See the Status-indication intent palette indocs/canon/status-and-recruitment.md. For DOMAIN STATUS, never compose<Badge>ad-hoc — use theXxxStatusBadgewrappers.BadgeContainerbakesalignSelf:'flex-start'(so a standalone badge hugs its content instead of stretching in a column) — inside analignItems:centerrow next to text/avatars (e.g. aMatchScoreboardplayer row) that pins the pill to the TOP of the row; pass the opt-inalignSelf="center"prop to vertically center it.Badge's$2/$3radius (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
Iconat the canonical sizes (20pxlgfor row-level actions, 24pxxlfor header slots), tinted$textSecondary/$iconMutedresting. 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
Pressableas button → Use<Button>with correct variant
Empty & loading states
- Using
EmptyState→ page-level = full (64px icon bubble), section-level = compact (44px icon bubble) - Screens with async data → render
SkeletonCard/SkeletonHero/SkeletonRowduringisLoading. Never letEmptyStateflash before queries resolve.
Home feed
- Home tab = 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
FeedListheader (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 viaLiveSessionStackinside the 일간 view's 진행 중 section, gated to the real present day — there is no separate live banner or live card component (HomeLiveAlertBannerwas 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 →
MainTabShellfrom@twomore/ui(handles SafeAreaView top-edge,pageTitleheader,headerRight,bottomCta). Passtabs/tabValue/onTabChange/renderTabto get a built-inSegmentedTabsswitcher. Never roll your own SafeAreaView + pageTitle combo for a tab page. - Segmented tab switching inside a page →
SegmentedTabsfrom@twomore/ui(PillNav-driven,preload="all"mounts all panes post-paint for instant switching). Multi-tab content where each pane has its ownuseQuery/useStatehooks → ALWAYSSegmentedTabs preload="all". Conditional{tab === 'x' && ...}on hook-bearing panes is forbidden — it remounts hooks on every chip tap and blocks the JS thread. Never use conditional{activeTab === 'x' && ...}for tab content that contains hooks. PillNav.onChangeis a direct urgent state update — chip flip must commit on press frame. Deferral of heavy content rendering lives at the SegmentedTabs consumer viauseDeferredValue.SegmentedTabsusesuseDeferredValueinternally — content pane mount is low-priority while the chip stays urgent. Don't addstartTransitionon top.SegmentedTabspreload mode →"visited"by default.preload="all"only when ALL panes have lightweight first-paint cost;useDeferredValuekeeps chips responsive either way.SegmentedTabsconsumers → wrap each pane component inReact.memoAND wraprenderTabinuseCallbackwith stable-reference deps (e.g. hoistuserId ?? ''tosafeUserIdfirst). Without both, every chip tap re-renders all mounted panes (sincepreload="all"keeps them mounted) and the JS thread saturates. Either alone is insufficient:React.memowithout stablerenderTab→ memo bails on referential prop check; stablerenderTabwithoutReact.memo→ component re-renders fully anyway. Seepackages/features/clubs/src/club-list-screen.tsx/packages/features/activity/src/activity-screen.tsx/packages/features/records/src/records-leaderboard-screen.tsxfor canonical examples (commite0ce50b, 2026-05-02).- Chip-style tab controls → use
PillNavfrom@twomore/ui. Don't add per-chipbackgroundColorvariants — the active background slides between positions on the UI thread via a Reanimated worklet. - Selectable DATA chips (scope/filter selection — match-history scope, discovery filters, gallery kind/visibility toggles) →
SelectionChipfrom@twomore/ui(packages/ui/src/selection-chip.tsx):label+selected+ optionalleading/trailing,sizedefault|compact; selected state =$primaryborder +$primarySubtlebg +$primary700-weight label. Distinct fromPillNav/SegmentedTabs(navigation chrome) —SelectionChipis for filtering DATA, not switching views. Dropdown-style pickers composeDropdownChip/RangeChip(SelectionChip trigger +ModalPanelpicker;DropdownChipsupports controlledopen/onOpenChangefor cascades like region→district auto-open). Never hand-roll a selectable pill with ad-hoc Pressable styles in feature code. - Selectable TILES / choice cards (wizard preset swatches, format tiles, play-style cards — larger-than-chip "pick one of 2–4" content) →
SelectionCardfrom@twomore/ui(packages/ui/src/selection-card.tsx, promoted 2026-08-05 from the onboarding play-style fork):selected+ freechildren,Pressable variant="card"chrome, selected state = the SelectionChip family recipe ($primaryborder /$primarySubtlebg). 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 →
TogglePillfrom@twomore/ui(packages/ui/src/toggle-pill.tsx, promoted 2026-08-05 from clubs/settings; display-only 44×24 switch pill — the surroundingPressable variant="row"owns the press). ONE toggle visual app-wide; the byte-duplicateFeeTogglePill(sessions) and the club wizard's text-pill toggle are retired. - Bounded-integer −/+ control →
Stepperfrom@twomore/ui(clamps internally;formatrenders arbitrary display text — tier names, "N명"). Never hand-roll aButton 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'sprimaryHintprop (pinned with the CTA band) — never improvised in step bodies where it scrolls away; in-flight primary CTA state ridesButton's ownloading/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
RevealStackin@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 setselfConfirming. 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: therevealpreset (~250ms decelerate timing, both platform configs), no spring settle tail.confirmedstate 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
VenueFinder2026-09-01): the canonical finder isVenueFinder(@twomore/app,intent: 'explore' | 'pick') — ONE surface for the 경기-tab directory (explore: rows navigate to venue detail) and wizard picking (pick: rows emitVenuePickResult, incl. the Naver adopt-on-pick double-emit contract;pickedVenueIds/selectedVenueIdselected state,finderHint,initialRegions/initialDistrictsmount-once filter seeding, controlledquery). Grammar (owner-ratified via /wireframes/venue-finder):VenueFilterBar(SearchBar with the filter toggle INSIDE it viaExpandableFilterBar— 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 isCourtUsagePanel(which courts + tap-to-rename). Both wizard step-2s consume them — extend the components, never re-assemble the pieces. The pieces, for reference —SearchBaroverusePublicCourts(curated-first + Naver) result rows,RegionDistrictFilterfor explicit browse (search vs browse is a user CHOICE, never chips under a search bar),VenueRowrows that ALWAYS carrylatitude/longitude(the photo → static-map → glyph cascade needs coords — a coord-less row falls to the name glyph and reads detached), andVenueSaveButtonbookmarks with the 즐겨찾는 코트 list fed byuseSavedVenueIds+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 andVenueFinder(pick intent) mounts insideVenueSearchTakeover(@twomore/app), the full-screen search overlay (see wizards.md "Search takeover law"). New search-over-open-data surfaces reuseVenueSearchTakeover's shell shape rather than embedding a live search field in a long scroll. The Naver-source marker on a result row isNaverBadge(@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, aDropdownChippair over the canonicalREGIONSsource with shared cascade-reset behavior — deselecting a region prunes its now-orphaned districts). The single source for the 번개 pickup filter bar and the venue-directory filter bar (unified 2026-07-12, ~90 duplicated lines removed); lives inpackages/app(notpackages/ui, which stays primitive-pure, and not a featureshared/dir, since two sibling feature packages both mount it — ARCH-2). Encoded-value separator is parameterized (region-district-filter.ts) — venue matches it against a hyphen-composite DB slug, pickup uses a pure client-side comparison key.
Clickables
The base is the only touchable (owner law, 2026-08-08 — COMP-8): every touchable extends
@twomore/ui'sPressable(or a component built on it:Button,ActionButton,SelectionChip, …). RN offers NO global configuration for raw touchables — press physics, web cursor/hover/focus, the tonalstateLayer, and theTOUCH_TARGET-derived icon hit-slop floor exist only in the base, so a rawPressable/Touchable*ships with none of them and forces per-site hardcoding. Enforced by@twomore/no-raw-rn-pressable-in-featuresacross features, presentation, ANDpackages/ui/src(sole exemption:pressable.tsxitself).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 anonEditStateChangecallback (seeStep3Props.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$textSecondarycentered 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 passesicon={UserMinus}. The warning step is built in: passconfirm={{title, message?, confirmLabel, cancelLabel}}and the press opens the canonicalConfirmSheetwith 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 (
ListRowauto-chevrons whenever it hasonPressand notrailing; atrailingslot always supplies the cue instead); a section link isSectionHeader link={{ label, onPress }}(text + chevron, never a hand-rolledPressable > Textinright); an in-place action carries button chrome (Button/ActionButton) or, when text-only,$primary600 ink withaffordance="link"; a tappable Well/banner usesWell 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, whichPressablenow turns on by default forrow/cardvariants — no chevrons on feed cards; media that opens a viewer usesaffordance="layer".Pressable affordanceis the semantic marker (chevron | button | link | layer | none);noneis 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
onPressInto the matchingprefetchXhelper from@/presentation/cache. Press intent populates cache before nav resolves.Press feedback runs on the UI thread via the Moti animation driver — Tamagui
pressStyle+animation="quick"is safe for all shared primitives;Pressablefrom@twomore/uiis the only clickable (MotionPressablewas deleted 2026-05-09).Clickable surfaces → ALWAYS one of these primitives, never raw
onPresson a styledStackor inlinepressStyle={{ scale, opacity }}in feature code:- Page / form CTA →
Buttonfrom@twomore/ui(variants × sizes × shapes) - Tab strip / chip group →
PillNav/SegmentedTabsfrom@twomore/ui - Anything else clickable (cards, rows, chips, icons, FABs) →
Pressablefrom@twomore/uiwithvariant∈card | button | pill | row | icon | fab Pressableis the canonical clickable primitive. It encodes: requiredaccessibilityLabel(TS-enforced), per-variant scale + opacity press feedback on the UI thread (Reanimated worklet), reduced-motion fallback (opacity-only), web focus ring on$primary, standardized disabled state (0.5 opacity + non-interactive + busy a11y), andloadingprop that renders disabled state.- Inline
pressStyle={{ ... }}on rawXStack/YStackis forbidden inpackages/features/**. UsePressable variant="…"so the press feedback is consistent across the app. - Disabled state is rendered identically everywhere — opacity 0.5,
pointerEvents="none",accessibilityState={{ disabled }}. Don't roll your own. - Tap targets ≥44pt — enforce via outer container size when the Pressable wraps a small icon. iOS HIG / Material 48dp baseline. 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}(orROW_HEIGHT_TOUCH); compact visuals (32px chips, 20px glyphs, text actions) keep their size and close the gap with symmetrichitSlop={(TOUCH_TARGET.MIN - visualPx) / 2}— the same mathHeaderIconButtonandSelectionChipencode. Filter chips are NEVER hand-rolled:SelectionChip size="sm"already carries the floor.
- Page / form CTA →
Adding an admin tab to a detail screen → follow the
AdminTabnav-hub pattern (named component, PillNav-driven, ListRow per destination). Gate tab visibility oncanManageMembers || canManageDues. Never put mutation controls inside the hub — keep it navigation-only.HeaderIconButton(@twomore/ui) is MANDATORY for EVERY header icon — the back arrow AND every right-slot affordance (share, message, …) onAppHeader/DetailShell. A tight 24px (xl) icon whose touch target expands to the 44pt HIG minimum viahitSlop(no layout padding), so adjacent header icons space purely by containergapinstead of a padding/negative-margin dance. Never hand-roll a header icon withPressable variant="icon"+ a raw chevron/icon component directly, and never useButtonfor a header icon (its text-button padding/frame reads chunky next to the tight back arrow) — 8 bypass sites were migrated onto it 2026-07-19; the lastButton-ghost trigger (HeaderActionsMenu) and the raw-Pressableholdouts (bell, DM⋯) were migrated 2026-07-31. Resting tone is$textSecondary+strokeWidth 1.8(the component defaults — M3 renders trailing app-bar icons inonSurfaceVariant, one tone below the title; full-black stroke-2 lucide glyphs read as "solid filled") — passcoloronly 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
HeaderActionsMenuoverflow. The overflow trigger is⋯(MoreHorizontal, the component default); passtriggerIcon={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 (HeaderIconButtonbell/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$textInverseon a photo-scrim bar, mirroringHeaderActionsMenu'striggerColor), built onPressable variant="button" affordance="link"— the accent tint on the label IS the one resting affordance cue (no icon, no chevron). UnlikeHeaderIconButton(invisiblehitSlop, so tight icons space by containergapalone), 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 inBottomCtaBand— 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 theHeaderActionsMenu⋯ trigger inside oneXStack gap="$1",club-detail-screen.tsx).HeaderActionsMenuopens a centeredModalPanelaction list — NEVER a TamaguiPopover. The JS-portalPopoverz-fights headers, flickers on mount, and drops touches on RN (observed in production 2026-07-31);ModalPanelrides RN's nativeModal(always above content, always interactive). Menu anatomy: rows arePressable variant="row"≥44px with a 20px (lg)$textSecondaryicon +cardBodylabel; destructive rows last in$errorwith exactly ONEDividerbefore the destructive group (no per-row dividers); secondary actions only — the primary CTA stays inBottomCtaBand, stateful toggles stay in the bar; ~5 rows max. A content-bearing overlay (DM group's participants roster) is a bespokeModalPanel, not this menu.
Lists
Scrollable feeds (flat or grouped-by-section) →
FeedList/GroupedFeedListfrom@twomore/ui(wraps FlashList (FeedList) and SectionList (GroupedFeedList) with virtualization tuning, scroll-hint badge, loading-more footer, and empty/loading slots). Never use bareFlatList/ScrollViewfor a list that may grow.List render-callbacks (
renderItem/keyExtractor/ItemSeparatorComponent/renderSectionHeader) on any list component (FeedList/GroupedFeedList/FlashList/FlatList/SectionList) MUST be hoisted into auseCallbackwith stable-reference deps — never inline. An inline callback is a fresh reference on every parent render, so the list re-renders every visible row on each parent state tick (a sheet open/close, a chip flip, a countdown). Declare theuseCallbacks ABOVE any early return so they obey rules-of-hooks; if a callback closes over another handler, hoist that handler into auseCallbacktoo (otherwise the dep array churns and defeats the memo). Pair withReact.memoon the row component for full effect —useCallbackalone keeps the prop reference stable,React.memois what skips the row's re-render. Enforced by@twomore/require-memoized-list-render-callbacks(Wave H).Mixed-type
FeedList/FlashListMUST passgetItemType. When one list renders structurally different item kinds (e.g. a{kind:'header'}~30px row vs a{kind:'match'}~150px row), passgetItemType={(item) => item.kind}. FlashList v2 keeps ONE recycler key-pool + ONE height-average bucket PER item type; with nogetItemTypeeverything shares the'default'pool, so on scroll a recycled header view gets reassigned to a match row → React sees the same key with a different tree → full unmount+remount + a Fabric layout reflow on every such recycle (this was the 544msscroll:scorecard-livejank, 2026-06-03; canonical fix:packages/features/sessions/src/scorecard/live-tab.tsx).getItemTypeis NOT inFeedListProps' Omit list, so it passes straight through to FlashList — noFeedListchange needed. React Compiler does NOT mitigate this — a key reassignment remounts regardless of memoization. Not lint-enforced — reviewer discipline when a list'sdatais a discriminated union.Titled content groups on a detail screen →
SectionBlockfrom@twomore/ui(title, optionalrightslot,tone="danger"for destructive sections — theiconprop was REMOVED 2026-06; section titles are text-first). Section titles always live outside cards (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).SectionBlockdelegates its header row to theSectionHeaderprimitive (packages/ui/src/section-header.tsx—variant="block"displaySm$text/variant="list"cardTitle for virtualized list section headers,accessibilityRole="header"). UseSectionHeaderdirectly ONLY when a title row cannot wrap its body (aGroupedFeedListrenderSectionHeader, aFeedListheader slot) — never hand-roll section-label typography in feature code, and no decorative leading icons (therightslot is for real counts/actions/config only).SectionBlockalso takesvariant(passes through toSectionHeader) andinset('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 bySectionBlock— as the stack gap forvariant="block", asSectionHeader's ownpaddingBottomforvariant="list";SectionBlockzeroes its gap in list mode so the two can never stack); list item→item =$3(FeedList/GroupedFeedList'sDefaultSeparator); page chrome→content =$4(MainTabShelltitle block,SegmentedTabschip→pane); controls→list-title =$3. Drift signature to reject in review: a wrapperpadding*around aSectionHeader/SectionBlock(the doubled seam —$3wrapper +$2header = an uncanonical 20px), or apaddingTopthat duplicates a sibling gap. Corollary: a conditional section rendersnull, never an empty container — an emptyYStackstill 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
$surfaceSecondarytiles, ≤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/uiseams.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/FactListcloneedgeonto 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 inFlatColumn lead, never as a section (a section divider above AND below would frame them). Call-site gating: aFlatColumnchild 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.
contentContainerStylecannot take$Ntokens, so@twomore/uiexports 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/railedListfor a list inside a shell;banded/railedBandedunder a pinned band, where the tail collapses to the band seam;sheetfor aModalPanel.ScrollView). Lint@twomore/no-raw-content-inset(error, no waivers) rejects a numeric padding/gap inside acontentContainerStyleliteral or ascrollContentstyle 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,DividerandFlatColumnread;ListRow subtakesstring | readonly string[](one fact per line) exactly like herometa;MiniBarMeteris the one bar-meter shape;BADGE_SIZE.NAVER,RADIUS_PILL,SLIDER_GEOMETRYand the recordsRANK_COLUMNconstants 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
$borderSubtlehairline withSEAM.section($6, 24px) above and below, drawn byFlatColumnbetween its children (one owner), under aSectionHeader variant="flat"title atnavTitle700. Where a list renders its own section boundary (aSectionListheader, a list-header block), it rendersDivider seam="section"(bleedinside 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 fromHAIRLINE.width/HAIRLINE.color, so a literal-valued border is always the hand-rolled recipe, never a legitimate exception:Divideris 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, andpackages/app/src/presentation/**alongside features) rejects bothheight={HAIRLINE.width}and a hand-rolledborder{Bottom,Top}Width/border{Bottom,Top}Colorpair in features. Rows of the same kind inside a section (ListRow,FactRow, link rows, roster rows) carry NO line between them — their$2vertical 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") (RowListstacks 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 aWell,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, insetSEAM.elementat each end, it sits centered in the 8px gap between cells (4 · line · 4), and every cell carriesSEAM.itempadding 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/uitext-grammar.ts): primary meta (hero identity lines)cardBody$textSecondary; secondary/sub (row sub, fact label, stat label, section meta)cardMeta$textTertiary; eyebrowcardMeta$textTertiary700; caption (fine print, empty-state subtitle)cardMeta$textSecondary.TypeHero,MediaHero,ListRow,FactRow,SectionHeader,StatTile,EmptyStateread it; call sites importMETA_TEXTrather 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(56pxAVATAR_SIZE.HERO, top-aligned) replaced the retiredrightslot;MediaHero avataralready leads; rows carry graphics inleading. 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/MediaHerometaaccepts 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 ownTextand the break between two facts isMetaBreak, the same 1pxHAIRLINEtheStatStripdraws between cells — a surface with its own text role composesMetaBreakdirectly (the network banner, theVenueLinkpill). An i18n leaf therefore never carries two facts: split it into one key per fact (arrays of facts arereadonly string[]) or rewrite the sentence as prose.@twomore/no-middle-dot-joinflags' · '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/TypeHerorender 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 —titleBadgeandMediaHero statusare 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 theSEAM.hero(16) seam under the chrome hairline or the media band (neverSEAM.chrome, locked inseam-contracts.test.ts), sits on the 16 text rail, and carries no bottom padding (the page'sFlatColumn topowns the next seam). Order: eyebrow → identity row → nothing else. The eyebrow spans the rail ABOVE the identity row, so aleadinggraphic (56pxAVATAR_SIZE.HERO, on the rail,SEAM.blockto 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-stateBanner).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/모집 중/마감 — anXStack alignItems="center" gap="$2"where the title shrinks/truncates and the badge holds its size), never a badge row. Directly under it, ONEGlyphFactRow(facts,SEAM.itembelow 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 stackedmetalines entirely. Secondary numeric stats (counts, capacity, price) stay OUT of the hero; they belong in the page'sStatStrip(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 generatedsessionTitle()string (format + style), which now renders as the 🎾 fact instead (session-hero-section.tsx).MediaHero/TypeHero'stitleBadge/factsprops (backed byGlyphFactRow,packages/ui/src/glyph-fact-row.tsx) implement this; theirmeta/badgesprops are@deprecatedand 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 ($2for icon items and chips,$3for 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), thatStatStrip(dividers on) andActionIconRow(dividers off — a circle is its own figure) compose; only the item differs. Neverspace-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", andBottomCtaBandpairs 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
$cardground; the tinted grey$backgroundretreats 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 indetail-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-rolledborderBottomWidth={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 aCard: 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 andDetailShell(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 leftDuesRow/MemberRowas bare hairline rows with zeroCardboxes), 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-migrationboard 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 aStatStripinFlatColumn lead, never the boxedStatGrid. The migration proceeds by risk (shell first, then chrome, flat-row screens first) and every migrated screen must satisfy the group-spread law (equalflex={1}cells, the gap constant, content centered, neverspace-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) —DetailShellhas nogroundprop; every detail screen is the canvas, andMainTabShellalone keeps atintedoption for the main tabs;scripts/__tests__/wireframe-kit-parity.test.mjslocks the wireframe kit's Fl* metrics toseams.tsso an approved board never describes spacing the app cannot produce. The slice order lives indocs/architecture/flat-layout-implementation-plan.md.
Shells & detail screens
- Detail screens (back header + optional scroll body) →
DetailShellfrom@twomore/ui(SafeAreaView top-edge,AppHeaderback/title/right, optionalscrollprop). Never duplicate SafeAreaView + AppHeader manually in a detail screen. - Owner ruling D1 (2026-09-08): a
MediaHerodetail page usesDetailShell header="collapsing"—CollapsingTopBaroverlays 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 shareduseCollapsingTopBarthreshold hook (@twomore/ui); aTypeHeropage (no media) keeps the staticAppHeader.header="collapsing"only self-wires whenDetailShellowns theScrollView(thescrolldefault) — a screen with a nested list (FeedList,scroll={false}) composesCollapsingTopBar+useCollapsingTopBardirectly 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
ModalPanelsheet (no SafeAreaView, no tab chrome) →AppHeaderwithchrome="bare"(drops the$cardbackground + bottom hairline; same title typography and back affordance as the default chrome) — the scorecard sheets'ScoreCallSheet/ScoreCorrectionSheet/ScoreEditSheetare 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 asubtitle) is exported (APP_HEADER_HEIGHT/resolveAppHeaderHeight,@twomore/ui) — a screen that must derive its own layout math from the header's footprint (e.g. aKeyboardAvoidingView'skeyboardVerticalOffset) 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 bySectionHeader variant="list"(aFeedList/GroupedFeedListheader slot orrenderSectionHeader) or bySectionBlock variant="block"(a composed feed such as the home views) — the two render the samecardTitletitle and both own the title→first card =SEAM.item($2) seam; card→card =SEAM.block($3) is owned by the list'sDefaultSeparator(or the section body'sgap), never by a card margin; section→section =SEAM.rail($4) is owned by the ONE gap-bearing container (the composed feed's columngap, or a native list header slot's padding when a list breaks the gap chain), never by a header's ownpaddingTopplus a previous card'spaddingBottom; the scroll tail is owned by the scroll container'sSCROLL_CONTENTpreset alone (a view never adds its own trailing padding under it);$5is 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.tslocks the header variants to$2and the list separator to$3. BottomCtaBandpublishes its own measured height into the bottom-chrome store (useReportBottomCtaBandHeightfrom@twomore/app, wired through the band'sonHeightChangeprop) soToastHostfloats above it automatically —packages/uicannot import@twomore/app(canon/architecture.md), so the band only reports its height via the prop; the HOST screen supplies the callback, the sameonFooterHeightChange-through-a-prop patternWizardShellalready uses for the identical layering reason. The store is additive-by-id, not last-writer: a tab screen'sBottomCtaBandmounts ON TOP OF the still-mounted tab bar, andToastHostmust clear their COMBINED footprint (2026-09-10 spacing audit finding #4) — every new bottom-chrome publisher gets its own id-keyed slot inbottom-chrome.store.ts, summed. Live consumers:club-list-screen.tsx,activity-screen.tsx; any otherBottomCtaBandmount 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. Theirroutes.*factory may accept aclubIdfor callers' convenience but ignores it in the path (e.g.clubSession(_clubId, sessionId) => /sessions/${sessionId}, likematchBoard/scorecard). Only screens that are genuinely a sub-view of ONE tab (club members/dues/board/settings under 클럽) stay nested. Lesson from 2026-05-25: session detail was nested under(clubs)and anchored every entry point to the 클럽 tab.
Wizards & confirms
- Multi-step creation flows (
CreateSessionScreen,CreateClubScreen, future onboarding) →WizardShellfrom@twomore/ui(handles dismiss + progress bar + bottom CTA row). Form fields inside steps →LabeledField(label + optionallabelRightslot for a char counter + optionalcaptionslot for a below-control info line + input + optionalerror) wrappingStyledInput(themed RNTextInput), both from@twomore/ui. A label row with a123 / 500counter isLabeledField+labelRight, NOT a hand-rolledXStack. 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-AttachedPanelordering 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 viaLabeledField'scaptionprop). The "you have a saved draft" recovery prompt that pins above the wizard scroll →WizardDraftBanner(text + resume label + reset label as props; copy stays in feature i18n namespaces). The final 확인 (review) step's per-field rows →WizardReviewRow(icon + label + value + editLabel + onEdit; passt().common.editfor the trailing button), wrapped in aCard(create-session-step6.tsx,create-club-step7.tsx) — the trailing edit affordance isActionButton action="tertiary". (Correction, 2026-07-19: the component's own docstring previously said "never a Card" — that predated the Cards section's ≤3-nested-affordance model above and was stale; a balanced content-left/edit-right review row is the sanctioned nested-action case, not a page-CTA-in-card violation.) Never re-implement these as private components inside a wizard screen — single source of truth lives inpackages/ui/src/labeled-field.tsx,packages/ui/src/wizard-draft-banner.tsx, andpackages/ui/src/wizard-review-row.tsx. - Confirmation alerts →
useConfirmfrom@twomore/ui(imperativeconfirm.show({title, message?, buttons})). NeverAlert.alertdirectly — native Alert ignores design tokens and renders as an OS-styled centered dialog.ConfirmSheet(the file name survives; since 2026-06 it is ModalPanel-based, not a Tamagui Sheet) is the canonical replacement:$-tokens, dark-mode aware, hardware-back dismiss, backdrop press deliberately does NOT dismiss (dismissOnOverlayPress={false}— matchesAlert.alertsemantics), single-flight (only one prompt visible at a time). Button-style map:cancel→outline,destructive→destructive (red),primary→primary CTA,default|omitted→primary — 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 (variantForinconfirm-sheet.tsxis the authoritative branch order; PD-08, primitive-drift-audit-2026-09-17).ConfirmProvidermust be mounted in_layout.tsxabove any screen that callsuseConfirm.useConfirmworks from insideModalPanelcontent — RN<Modal>keeps children in the React tree, so context is reachable. The old portal caveat (context unreachable inside portaled content — the 2026-05-23 dev-panel TelemetryPane crash) now applies ONLY to the dev panel's TamaguiSheet: there, calluseConfirmfrom the component that OWNS the Sheet, or use an inline confirm. - Screen-archetype selection — the same KIND of interaction MUST use the same archetype across the app. A club-ready app can't surprise users with "edit opens a sheet here but a full screen there." The four archetypes + their canonical interaction kinds:
- Create-entity / multi-step flow → full-screen wizard (
WizardShell). Create session, create club, onboarding. - Edit-entity — standalone, multi-field, reached from a menu or route → full-screen route (
DetailShell). Edit session, session-level match rules, edit profile, session payments(정산), attendance roster, participants. - Edit a single field in-place on a settings list →
ModalPanel(theEditFieldModalpattern,settings/edit-field-modal.tsx). Tap-to-edit, no navigation — only when the field already sits inline on the settings screen. - Edit embedded in a larger flow (a wizard step's sub-editor, a per-item override) →
ModalPanel. Match-rules during create-session step + per-match scorecard override (the sameMatchRulesEditorbody; full-screenMatchRulesScreenis the standalone session-level entry — the split is intentional and documented). - Manage a roster (add / remove / role-change people) → full-screen route. Participants, members.
- Confirm a destructive / irreversible action → ALWAYS
useConfirm. Never a bespoke confirm panel that re-implements title + destructive button + cancel (anti-pattern fixed 2026-05-27: club member removal used a customConfirmRemoveSheetwhile every sibling — delete session/club, remove friend/participant, forfeit, end-session, logout, revoke consent, delete account — useduseConfirm; consolidated).useConfirmis for confirming a DESTRUCTIVE intent — don't stretch it into a generic multi-option action menu for non-destructive choices. - Pick from a small set of contextual options, or a list picker →
ModalPanel. Dues action cascade (dues-action-modals.tsx), score call/edit, swap teams, referee-recipient picker, discovery/pickup filters (viaDropdownChip/RangeChip, which embed ModalPanel), add-round. - Read-only info / explanation →
ModalPanel. Tier info, playability TMI, match-profiles. - View "see more" of low-stakes list content → inline expand (no navigation). Roster preview, FAQ, round accordion.
- Archetype scales with complexity + context: a 2-option no-input row action is inline buttons (session-payments 납부/면제); a 4-option-with-sub-inputs action is a ModalPanel cascade (club dues paid/partial/waive/note); a single text field is a quick ModalPanel; a multi-field standalone edit is a full screen. When the same editor appears embedded-in-flow vs standalone, the embedded one MAY be a ModalPanel and the standalone a full screen — but that split must be deliberate + documented, not accidental. Before adding a new interaction, find the matching kind above and use its archetype; if you're about to diverge, document why. (Until the 2026-06 Codex batch these overlay archetypes were Tamagui bottom sheets; every one is now
ModalPanel— see the Tamagui/Styling rule.)
- Create-entity / multi-step flow → full-screen wizard (
- A
ModalPanelconsumer that renders its own back/close header MUST passshowCloseButton={false}toModalPanel.Frame. The Frame's built-in X close button is redundant (and visually stacks) once the consumer's own header already carries a dismiss control — a back chevron (the scorecard sheets'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 defaultaccessibilityLabel/closeAccessibilityLabelis the Korean literal'닫기'(packages/ui/src/modal-panel.tsx) — nott()(theuipackage has no@twomore/appdependency), matchingWizardShell's owncloseAccessibilityLabel = '닫기'default precedent. Fixed 2026-08-18: it had leaked the English'Close panel'as the announced label on the ~39 of 45ModalPanel.Frameconsumers that never pass an explicit override — a screen-reader-only i18n gap invisible to sighted QA. Pass an explicitcloseAccessibilityLabelonly when a more specific label reads better in context; the shared default otherwise covers every consumer.ModalPanel.Frametitle row (owner audit, 2026-08-18): a left-aligned title Text isrole="cardTitle" fontWeight="700" marginBottom="$4" paddingRight="$8"— thepaddingRightclears the Frame's built-in top-right close button (absolute-positioned attop="$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-headerModalPanel.Frametitle; exempt: a custom-header consumer (showCloseButton={false}, rule above) and a centered/symmetric confirmation dialog (challenge-accept-modal.tsx) where adding one-sidedpaddingRightwould visibly off-center the text — those keep short, static copy instead. Audit swept and fixed the drift (missingfontWeight/paddingRight, ormarginBottom="$3"instead of"$4") inedit-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, andtransfer-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).ModalPanelis 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: aModalPanelform must not auto-commit keystrokes into the live entity, and anAttachedPanelmust not gate its content behind its own 저장 button — closing it (folding the row) IS the save. AttachedPanelspacing 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 agap="$3"(12px) content container the component owns aroundchildren— pass children directly, never re-wrap them in a caller-localYStackwith its owngapto get row spacing (an ad-hocgap="$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. aRevealStackcomposing its own sequential sections) may still own its own internalgap— that governs a different concern (its own item reveal rhythm) and should match this same$3baseline rather than an arbitrary larger value.
Query boundaries
- Query-backed SCREENS →
QueryBoundaryfrom@twomore/app(the canonical skeleton → error+retry → content flow). Pass the screen's critical-path query results + a shapedloadingskeleton; it renders the localized error+retry surface (QueryErrorState) when ANY query errors, the skeleton until all settle (viauseScreenReady/Protocol G), else the content. Never leave a data screen with noisErrorbranch (a failed load must show retry, not a permanent skeleton). For LIST screens, passerror={q.isError}+errorState={<QueryErrorState onRetry={() => void q.refetch()} />}toFeedList/GroupedFeedListinstead (the error renders in the empty slot; stale data stays on background-refetch errors).QueryErrorStateis the single error surface — never hand-roll an error EmptyState per screen. Composer-backed live screens (useLiveSessionData) use the composer'sisError/retrydirectly. Screens that render a list via manual.map()or gate it behind alength > 0branch: add the error branch to that conditional with<QueryErrorState />when next touched. - Offline is a fourth branch, not a variant of empty (2026-09-11 page audit, ~70 files). TanStack v5 reports
isLoading=falseANDisError=falsewhile a query is offline-paused (fetchStatus === 'paused', the defaultnetworkMode: '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/GroupedFeedListisPausedOffline={q.isPaused}+offlineState={<OfflineEmptyState onRetry=… />}, an earlyif (q.isPaused && !q.isFetched) return <OfflineEmptyState />placed BEFORE the loading branch and before any permission gate, or a composer'sisPausedOffline. A data hook that returnsisLoadingalso returnsisPausedOffline. 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 →
DataSectionfrom@twomore/ui. Eliminates the staggered-render anti-pattern (each section rendering null until its own query resolves → user sees the page populate piecewise). Default mode renders aSkeletonRowduringisLoadingso the section occupies layout space immediately;hideWhenAbsentmode returns null when loading and when empty (use for sections that should only appear once the user has earned them, e.g.PersonalRecordsSection). Optionaltitleprop wraps content inSectionBlockfor consistent heading rhythm. Never gate a section render on an outerisLoadingflag — letDataSectionmanage the loading state internally.
Shared primitives (2026-07-19 extraction wave)
Byte-near-identical JSX duplicated across sibling screens/sheets consolidates into one named primitive — reach for these instead of re-deriving the pattern locally:
StatTile(@twomore/ui) — the canonical "value + label" stat cell, consolidating 4 independently-drifted sites.wrapcard(wraps inCard tone="default") |flat(bare, for a tile already inside a carded parent);ordervalue-first|label-first;valueRole/valueSlot(override the value line with an arbitrary node, e.g. a tier badge — passvaluetoo as the text fallback);subValue;progress. Use for any numeric-stat-+-caption cell (profile hero, dues summary columns, records cards) instead of a localYStack+Textpair.ExpandableFilterBar(@twomore/app) — the canonical SearchBar + filter-toggle-badge + expanding-panel chrome shared by pickup discovery, club discovery, and the venue directory (all three hand-rolled the identical shell before this). Panel CONTENT stays feature-owned (passed aschildren); only the toggle button + panel shell + header/reset row is shared. Use whenever a list screen needs a SearchBar with a collapsible filter panel behind a toggle.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 frompackages/features/home(2026-09-01) to@twomore/uioncepackages/features/clubs' duesMonthSelectorneeded 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'sDayCalendarPicker/MonthYearPicker/WeekMonthPickerand clubs' duesMonthSelector.FriendPickerRow(messaging,packages/features/messaging/src/shared/friend-picker-row.tsx) — the shared candidate row (avatar + name + circular selection checkbox) for the DM member-picker screens (dm-add-members-screen,dm-create-group-screen).MatchListControlsmodule (records,packages/features/records/src/shared/match-list-controls.tsx) — one filter/sort/group state machine + controls ModalPanel sheet + count-trigger, shared byRecordsHistoryScreen(unbounded, multi-month feed) andRecordDetailScreen(single bounded period). Chronological grouping grain stays TWO distinctGroupModevalues ('month'for the unbounded history feed,'date'for an already-bounded period) — a genuine divergence the consolidation preserves rather than collapses.MarkerPill(@twomore/app,packages/app/src/presentation/components/sessions/marker-pill.tsx) — the canonical small-pill chrome ($7-radius,toneprimary|neutral) for a marker/hint pill.DDayChip(the file wasdday-chip.tsx) has been retired —MarkerPillis exported directly, with no wrapper. Reach forMarkerPilldirectly 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 overStatTile(single source for the value/label anatomy) — adds a$surfaceSecondarycard fill, equal-flex/stretch sizing, and an optional signeddeltaline (▲/▼) 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 localXStackof ad-hocYStack+Textcells.Button'sloading/loadingLabel(@twomore/ui,packages/ui/src/button.tsx) — the canonical in-flight CTA treatment:loadingswaps the leading icon for anActivityIndicatorand forces the disabled state; optionalloadingLabelswaps the button's text too (e.g. "저장 중…"). An ad-hocActivityIndicatorcomposed into an icon slot per call site is retired — useloading/loadingLabelonButton/ActionButtoninstead.usePickImages(@twomore/app) is the one library-pick flow — permission dialog, pick, resize; features never importexpo-image-pickerdirectly (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.