Skip to content

Component Conventions ​

Status: Active Last reviewed: 2026-10-19

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

One shared foundation (owner, 2026-10-19: "I want our components across pages to have the same shared foundation. That eliminates the possibility of UI/UX divergence as well as hardcoding"). Every page composes the shared components. A page never keeps its own variant of a shared pattern or a hardcoded value (colour, size, spacing, copy); a need a shared component lacks is met by extending that component once, for every page. The flat v2 boards follow the same rule: the page boards compose only the kit (docs/twomore_design_system/wireframes/flat-v2-kit.jsx and its flat-v2-kit-*.jsx parts).

Cards ​

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

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

  • A card is PRIMARILY display + navigation — the whole card is the main tap → its detail. A page-level / hero CTA never sits on a card (those pin to BottomCtaBand on the detail screen); no FABs. But per the nested-affordance model directly below, a card MAY carry a small number of compact, visually-balanced nested actions: content on the left or top invites a quick action on the right or bottom — a list-row 확인/제외/출결 button, a match-card 경기 종료 footer band — and for a glance→act row keeping the action where the eye already is reads BETTER than detaching it. The line is page-CTA vs balanced nested action (not button-vs-no-button): an ActionButton (the canonical, intentionally-styled action) in a balanced nested slot is sanctioned; a loud page CTA plastered on a pure content/navigate card is the smell. (History: a blunt "never a CTA in a card" was walked back 2026-07-13 — it contradicted this same model and over-flagged legitimate balanced action-rows.)

  • Multiple touch targets in a card — the canonical model (Material 3 "one primary action + supplemental actions"; codified 2026-06-23 after a full clickable audit). A card has ONE primary touch target = the whole card → its detail destination; a SMALL number (≤~3) of NESTED affordances may do something different (a sub-nav like 상세 보기/전체 경기 보기, a toggle like the participation row, an action like save). Rules:

    • The canonical clickable is Pressable from @twomore/ui (variants card/row/pill/icon/button/fab) — it is the SINGLE SOURCE of interaction cues: UI-thread scale+opacity press feedback, web cursor:pointer + hover dim + $primary focus ring, TS-required accessibilityLabel, standardized disabled. Button/ActionButton (page CTAs) + PillNav/SegmentedTabs/SelectionChip (tabs/chips) are the only other sanctioned clickables. NEVER use react-native Pressable/Touchable*, raw onPress on a Tamagui Stack/XStack/YStack, or inline pressStyle={{}} — they lose all of the above. (onPressIn on a wrapper stack IS allowed — Protocol H prefetch.)
    • A tappable card WRAPS in <Pressable variant="card"> — never <Card onPress>. Card is a pure display styled(Stack) with no press/hover feedback; passing it onPress gives a dead tap. Wrap: <Pressable variant="card" accessibilityLabel={…} onPress={…}><Card …>…</Card></Pressable> (the muting opacity for cancelled/archived stays on the inner Card). Canonical examples: SessionCardV2, ClubCard, VenueRow. The muting opacity itself is the named constant TERMINAL_MUTE_OPACITY (0.55, packages/ui/src/box-sizes.ts) — the one terminal-muting value (ClubCard archivedAt, DirectoryVenueDetailScreen isClosed, AchievementsGalleryScreen !isUnlocked, DuesRow paid); use the constant, never a bare opacity={0.55} literal.
    • Every interactive NESTED target inside a tappable card MUST call e?.stopPropagation?.() as the first line of its onPress. Native RN grants the responder to the deepest view, but WEB events bubble → without it, tapping the inner target ALSO fires the card's onPress (double-nav). Canonical: session-save-button, the participation expand band, the 전체 경기 보기 footer, the notification dismiss.
    • Display-only elements stay non-interactive — chips, text, the VenueLink pill are NOT touch targets; the card's tap covers them. Don't make a value clickable unless it navigates somewhere different or performs an action.
    • The card's primary tap = the most-common intent; rare/secondary actions are the nested ones (or move off the card to the detail / a header overflow menu). Past ~3 nested targets the card becomes a mini-page — pull back.
    • Enforced by @twomore/no-raw-rn-pressable-in-features, @twomore/no-onpress-on-card-in-features, @twomore/no-onpress-on-stack-in-features (+ the pre-existing no-press-style-in-features). The stopPropagation contract is reviewer-checked (not mechanically linted).
  • CARD-NO-OVERFLOW — a card's contents, combined, must NEVER overflow its width (an essential attribute of every card component, owner directive 2026-06-20). Clipped or pushed-off content — the classic being a long venue name shoving the weather glance off the right edge — is always a bug, never an acceptable state. The mechanism, applied to EVERY row that mixes content kinds: in a row combining FLEXIBLE content (text that can run long — venue, title, player names) with FIXED content (chips, icons, the weather glance, a count), the flexible element is the ONLY one that may flex AND it MUST be able to shrink — wrap it in flex={1} minWidth={0} and give its text numberOfLines={1} (ellipsize). RN's default flexShrink is 0 (unlike web's 1), so a flex child will NOT shrink below its content width unless minWidth={0} is set — omitting it is the #1 cause of card overflow. Fixed siblings get flexShrink={0} (or are intrinsically fixed) so they stay fully visible. Canonical patterns: SessionCardV2's title line (session-card-v2.tsx — venue title in flex={1} minWidth={0} + the state chip and bookmark pinned right, fixed) and VenueLink (maxWidth="100%" + name numberOfLines={1} flexShrink={1} + trailing flexShrink={0}). Multi-chip strips that can exceed one row use flexWrap="wrap" (Material 3) or shed chips per the density rule — never clip. When adding or arranging anything on a card, verify the LONGEST realistic content (longest venue/name, max chips) still fits within the card width.

  • Summary-card density — a card answers ONE decision, shows ~3–6 decision-critical signals, and DEFERS the rest to the detail screen. Research-grounded (NN/g "cards are entry points, not encyclopedias"; the field publishes NO hard chip/attribute cap — the discipline is qualitative; real dense cards show 3–6 signals and hide the long tail: Strava 3 stats / Google Flights 5 / Airbnb ~5). This is the SUMMARY-card mirror of the detail-screen single-protagonist rule. Five tests:

    1. One decision question → one dominant anchor. Frame the card around the single question it answers (SessionCard: "is there a spot / when / is it mine?") and make that the protagonist (participation); everything else recedes. Pre-attentive scanning lands on ONE figure (~200–500ms) — two heavy co-clusters competing (e.g. participation + a full weather strip) is the failure ("if everything is emphasized, nothing stands out").
    2. The defer-to-detail test. Before adding an attribute, ask "does this change the BROWSE decision?" If no (or rarely), it belongs on the DETAIL screen, not the card. The detail holds the rich version (hourly weather, wind, AQI breakdown, full roster); the card carries only the glanceable summary (condition+temp, N/M, faces).
    3. Chunk into ≤~4 recognized clusters (Miller 7±2 — for chunking, NOT a 7-item cap; Gestalt proximity; Polaris) — WHEN / WHERE / WHO / classification — grouped by the $N proximity hierarchy so each reads as one chunk, not N loose elements. Spacing does the grouping, not labels.
    4. Chips: closed whitelist, one row, ≤20-char labels (Material 3). The strip is the sanctioned set only (no ad-hoc <Badge>, COMP-2) and must NOT wrap past one row (M3: 2-row chip strips are hard to scan). Conditional chips (payment/approval) are rare, so the common case is ~3.
    5. Color is triage, not decoration; keep the palette small (neutral-first; Astro UXDS: more status colors reduce learnability). Every color source on a card must earn its triage value; when several compete (status-toned date tile + status chip + payment chip + accent chip + brand chip + AQI dot + temp-toned fill), trim. The failure mode is the "baby webpage" card (Dave Rupert) / our "parallel mini-dashboards" anti-pattern — a card so dense it stops being a glance and becomes a compressed detail page. Cautionary example (2026-06, the SessionCard audit): the weather strip accreted AQI grade+label + wind + a min/max range bar — granular signals that don't change an RSVP decision; by test #2 the card weather leans to condition+temp (+ a bad-weather warning) and the rich strip + D-day chip move to the session detail.
  • Fact glyphs on browse cards — the narrow exception to the icon law (owner, 2026-09-04). canon/styling.md's icon rule bans decorative section/beat lead icons (a Calendar before a date, a MapPin before a venue, an icon before a count) — content leads with typography, not icons. A compact fact glyph on a browse card (DirectoryVenueCard's FactGlyph: indoor/lighting/parking) is allowed to break that rule ONLY when all three hold: the glyph encodes a boolean fact (present/absent, not a decorative label), it carries its own accessibilityLabel, and it would otherwise have to render as a full word badge (the icon is standing in for a word, not decorating one). It is never legal as a decorative lead icon in front of running text, and never legal in front of a bare count — a court-count fact renders as TEXT ALONE, no icon, even inside the same glyph row as the boolean icons next to it.

  • Backend-backed data never renders as dead text, and links go through an established CLICKABLE COMPONENT — never ad-hoc styled text (owner rule 2026-06-12). If a displayed value has a backing entity with its own screen (a venue with courtVenueId → venue detail; a player name → public profile; a club name → club preview; a session reference → session detail), it renders as a deep link via the entity's canonical clickable component — NOT a one-off Pressable+primary-Text reinvented per call site. The canonical venue link family (the venue display ladder — VenueLink inline → VenueRow row → VenueCard detail tile): VenueLink (@twomore/app) is a single non-interactive inline PILL (leading court-surface mark/MapPin + surface-tinted name; renders for any venue name, no backing id required) — for dense meta lines (compact session row, session meta); VenueRow (@twomore/app) is the bordered ROW rung (surface-graphic column + name + "distance · district · courts" meta line + trailing chevron) — the actual clickable component that taps through to venue detail via venueId (or a custom onPress); plus VenueCard (@twomore/app, the RICH large tile: static-map thumbnail → Naver app + name + 영업 중/종료 status pill via isVenueOpenNow + 길찾기 + surface·courts·district + facility icons + weekday-rate hint; the single source replacing the old duplicated location tiles). Use VenueLink for a dense inline mention with no tap target of its own (the parent card's tap covers it), VenueRow wherever a venue with an id needs to be its own tappable list item (venue lists, pickers, directory), VenueCard for the detail-screen location tile. When a NEW linkable entity recurs, build its canonical XLink/XCard component once and reuse it. The inner pressable wins the tap inside a tappable card; the card keeps its own destination. Free-text values with NO backing id stay plain muted text — never style a fake link. When a read path lacks the id needed for the link (e.g. an RPC summary), extend the read path (the 00261/00262 pattern) rather than shipping dead text.

Notices & banners ​

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

Participation & capacity indicators ​

  • "Who's in" → ParticipationCluster (@twomore/ui, the canonical FACES indicator) — an AvatarStack of participant faces with silhouette-fill + exact "+N" overflow. It is faces-only (owner canon 2026-07: the session summary card's usage IS the pattern — the ring is always composed separately, never inside this component). Pass avatars: {name, avatarUrl}[] (friends-first, caller-ordered) + total (true count for exact "+N"); overPhoto outlines the faces to read against a banner photo; emptyLabel renders a hint when there are 0 participants. Unknown/private participants still render a face — AvatarBubble shows a User silhouette when a name is '' or '?' (never a blank circle), so a populated session/club never shows a bare cluster. The ring is a SEPARATE primitive — CapacityRing — that the caller composes alongside the faces. RETIRED claim (do not resurrect): this section used to cite the deleted SessionCard as canonical for a CapacityRing direction="column" left-rail date cell + ParticipationCluster faces composition. Neither piece survives on the current list card: SessionCardV2 shows NO participant faces at all (moved to session-detail only, per the density-first redesign) and uses RecruitmentMeter — not CapacityRing — for its capacity signal (see § Participation & capacity indicators below). ParticipationCluster's live usage today is indirect — via BannerCapacityCluster (packages/ui/src/banner-capacity-cluster.tsx, which composes it internally for the ring+faces cluster), consumed by club-card.tsx's banner member cluster (overPhoto). It has no other direct call site in packages/app/packages/features. Never hand-roll an AvatarStack with silhouette/overflow logic per call site — that logic lives in ParticipationCluster.
  • A participant avatar set must show ALL confirmed participants, not just the viewer's friends. Source faces from the confirmed-RSVP id set (friends-first ordering), NEVER gate the avatar fetch/render on friendsCount > 0 (the bug fixed 2026-06-12: session cards showed zero faces unless the viewer happened to have a friend in the session). The friends-going COUNT line ("친구 N명 외 M명") is separate social proof that may still gate on friends.
  • Capacity / fill at a glance → CapacityRing (@twomore/ui, the canonical INLINE tracker) — a compact SegmentedDonutChart ring + N/M count (props value/max/size/countRole/direction). direction="row" (default) — ring and count side by side. direction="column" — ring stacked above the count. The deleted SessionCard's left-rail date-cell usage (48px bordered cell under DateTile) no longer exists — SessionCardV2 uses RecruitmentMeter instead (see below). Current real call site: add-round-sheet.tsx's feasibility tile 1 (games-range stat, direction="column"). It is the tracker for a ring+count unit that sits INLINE alongside other content on the same line — a ring never shares a line as the sole fill signal, it's a compact unit dropped into a row that's doing other things too. The line meters (ProgressBar) are canonical wherever a fill bar owns a FULL line by itself — that's the real retirement criterion, not "linear bars are retired." One implementation, two callers (owner direction 2026-07-20; shared 2026-07-27, SessionCardV2 slice ③): RecruitmentMeter (status word · ProgressBar · N/M count, all on one dedicated row, packages/app/src/presentation/components/sessions/session-compact-row.tsx) is defined in SessionCompactRow's file and exported for reuse — SessionCompactRow itself renders it column-native (its own dedicated row), and SessionCardV2's StatusJoinLine renders the SAME component via its flex prop (fills the remaining width of a row it shares with host/RSVP/saved badges), never a second bar implementation. What's actually retired is a ProgressBar sharing a line with OTHER unrelated content (the old greedy flex:1 bar squeezed into a session summary/compact/live-row alongside other elements) — a bar that owns its own line, or flex-fills the remainder of a role-badge line, is fine. Exception — the session DETAIL participation hero keeps its full ProgressBar: there the fill IS the focal protagonist (one hero number + one progress bar per the detail-screen IA rule), and a ring would shrink the main signal. So: ring for a compact inline unit, full-line bar when the meter gets its own line (compact row, v2 card, or the detail hero). ProgressBar remains the primitive for every NON-participant progress use (dues, records, analytics, attendance).

Buttons, text & badges ​

  • The web frame is one definite viewport box (F01, 2026-09-29). apps/web renders every route inside [data-web-frame] (100dvh, overflow: hidden, layout.tsx) and a page slot [data-web-page] keyed by path (providers.tsx), so bottom chrome (the tab bar, a DetailShell footer, the DM composer) pins to the viewport and each shell's own ScrollView owns scrolling, as on native. A shell-less page (legal, login, share) overflows the slot, and the slot scrolls. The tab bar is in flow (never position: absolute), and anything between a bounded parent and a list passes the size through (flex: 1; Freeze's web fallback included). scripts/__tests__/web-frame-contract.test.mjs holds each clause.
  • RETIRED 2026-11-13 (UI/UX pass, wave A: deleted, no page imported it; the text stays as history). BottomCtaBand (packages/ui/src/bottom-cta-band.tsx) is the ONE pinned-CTA implementation — WizardShell's footer and the venue search takeover's confirm bar are the same component now, not lookalikes. Bottom clearance is base (SEAM.band, 16px) plus the safe-area inset EXACTLY ONCE, painted by whoever actually reaches the physical screen edge (2026-09-17, resolveBandPaddingBottom(base, inset, reserved) = base + (reserved ? 0 : inset), replacing the 2026-09-11 max(16, inset) rule). A shell that reserves the bottom edge itself (DetailShell/PlaceShell/WizardShell's own SafeAreaView) — or, for MainTabShell, whose sibling tab bar pads insets.bottom + 8 itself — states reserved via BottomInsetReservationProvider; the band then adds only its own base. Two owner-reported 2026-09-17 symptoms were one bug: under MainTabShell (not yet reserved) the band stacked a full max(16, inset) a second time on top of the tab bar's already-reserved inset (a "thick band"); under the reserved shells the old formula collapsed to max(16 - inset, 0) = 0, leaving the CTA flush against the nav-bar edge with no clearance of its own. @twomore/no-hand-cta-band (ESLint) forbids a second hand-rolled copy of the border+padding+background shape.
  • Using Button → page CTA = lg + primary (pinned to BottomCtaBand); a bounded ±1 numeric control is never a Button — use Stepper (@twomore/ui, see its own entry in Tabs & chips below — PD-02, primitive-drift-audit-2026-09-17: the real Stepper hand-rolls a 32px Pressable variant="icon" circle, not a Button size="xs" shape="circle"). Variants: primary / secondary / outline / ghost / danger / destructive × xs/sm/md/lg × default/pill/circle. A page-level CTA pins to BottomCtaBand, never a card; a compact nested ActionButton action IS allowed (the ≤3 nested-affordance model in the Cards rule above — page-CTA vs balanced nested action). danger = quiet destructive TRIGGER (red outline + red text on transparent) for inline buttons that BEGIN a destructive flow (forfeit / delete / remove / leave). destructive = loud solid-red, reserved for the CONFIRM step (ConfirmSheet style:'destructive') and the rare phrase-gated final-commit button (delete-account). Trigger → danger, confirm → destructive; never swap them. Every intent-bearing CTA uses <ActionButton action="…"> (from @twomore/ui), never a raw variant — it maps INTENT (primary|secondary|neutral|cancel|tertiary|destructive) → variant via ACTION_VARIANT (the single source), so intent can't drift to the wrong color; SIZE still follows placement, not intent. cancel = a DISMISS/skip/undo (→ ghost); tertiary = a low-emphasis FORWARD action that is NOT a dismiss (add-note / approve-all / mark-read / mode-toggle — same ghost look, distinct intent). A destructive ActionButton MUST be paired with a useConfirm gate at the call site (ACTION_REQUIRES_CONFIRM documents this). Parallels the status-badge intent→variant pattern. Sanctioned raw-Button exceptions (NOT routed through ActionButton — they carry no product "intent"): (1) dev-panel / __DEV__-gated tooling; (2) icon-only triggers (no text label); (3) selection-state toggles where the variant must FLIP with "is this selected"; (4) disabled, no-onPress status placeholders occupying a CTA slot; (5) the loud raw variant="destructive" confirm/phrase-gated/direct-fire buttons (distinct from ActionButton's destructive→danger). Everything else = ActionButton. (Numeric steppers are NOT a raw-Button case at all — Stepper never renders Button; removed from this list 2026-09-17, PD-02, the same stale claim as the Button line above.)
  • A page's parts are proved with the real kit (batch 59). A screen test that mocks @twomore/ui cannot catch a page drawn wrongly (a control that is not a button, a name cut to one line, a list that never renders its rows). Prove the parts with renderRealUi from @twomore/test-kit/src/real-ui-harness (imported by that path, never from the index): it renders the REAL @twomore/ui under a TamaguiProvider with the app's tokens and themes, a ConfirmProvider, a QueryClient and a FlashList stand-in, and mocks only @twomore/app through createAppMock. Find a control with buttonNamed (role button, exactly one), a heading with headingNamed, and check a name with isNameClipped(node, NAME_LINES); pin the date with pinRealUiClock. Ten such tests exist (login, 홈, notifications, clubs list and detail, session detail, scorecard, create-session step 1, 기록, 프로필); a swipe commit cannot be proved under jest (the reanimated stub never finishes a slide), so it stays a device item (List 53).
  • Using Text → pick a textRole (the typography variant; role is ARIA), never hardcode fontSize/lineHeight/fontWeight. The eleven roles are listed in styling (display hero title headline reading body foot footWrap caption tab count); the default is body. Nav chrome: the Header (K4b) draws every bar's title left-aligned in the chrome class — the title role (20/28·700) at a tab root, headline (17/24·600) on a pushed page — with no divider at rest and a sticky edge once content scrolls under it. Hero/stat numbers: display (40/48 · 700, tabular) or hero (24/32 · 700). A role= + inline fontSize override is the anti-pattern this rule exists to prevent — if no role fits, the role belongs in text.tsx, not as a per-screen override. fontWeight is the one sanctioned exception: role="…" fontWeight="600" / "700" on top of a role is a legitimate EMPHASIS override (bolding a name, a stat, an active state) — every role already fixes its own base weight, so this never substitutes for picking the right role, it only nudges weight within one; fontSize/lineHeight stay hard-banned (the @twomore/no-raw-fontsize-in-features eslint-disable idiom is the only escape hatch, reserved for slot-matched metrics a role can't express).
  • Accessibility vocabulary (Tamagui 2, 2026-09-30). Props are aria-* and role (COMP-12); Text's typography variant is textRole. Headings carry aria-level: 1 = screen title (the Header and CollapsingTopBar), 2 = hero title and SectionHeader (on its title text, never the row holding its action), 3 = a heading inside a section. RN-only semantics go through helpers: accessibilityHintProps(hint) (aria-description on web) and adjustableActionProps(onStep) (RN actions on native, arrow keys on web — every role="slider" uses it). Lint: no-rn-accessibility-props, heading-has-level, tamagui-image-src (Image takes src).
  • Korean breaks between words (C04, 2026-09-29). Text sets lineBreakStrategyIOS: 'hangul-word' on iOS, and web sets word-break: keep-all + overflow-wrap: break-word on .is_Text; Android has no RN equivalent yet. Never hand-insert line breaks to fix a mid-word wrap.
  • Using a Tag (W2a, 2026-10-19: the v2 Tag replaced the Badge, whose imports are ratcheted out by @twomore/no-legacy-badge-and-chip) → a passive label of state, category or exception (library "Tag"): tone neutral (default) | brand | success | warning | danger | info, tier weak (default; solid only over photos and heroes), size sm (24, the default) | md (28, hero and identity Blocks only), dot true | 'pulse' (the Live tag: danger with a 6 dot pulsing 5 s per mount). One line, never truncated, never shrinks; it hugs its label (alignSelf: flex-start) and, beside a label, sits in a LineSlot, which centres it on the label's first line — never an alignSelf at the call site. Feature and presentation code writes only neutral, warning and danger (@twomore/bounded-variant-enums, COMP-2); every other tone is a domain state, chosen by its XxxStatusBadge wrapper (the tone table is in docs/canon/status-and-recruitment.md). The 6 radius (sm) and 8 (md) are a tag's, distinct from the fully round Choice: a Tag is passive, never a control.
  • Glyph law (owner, 2026-08-23): emoji = INFORMATION accent, icons = ACTIONS. An emoji may lead a fact (identity chips 📍🎾📅, stat cells, celebratory copy) but NEVER labels a tappable action — action affordances render lucide icons via Icon at the canonical sizes (20px lg for row-level actions, 24px xl for header slots), tinted $textSecondary/$iconMuted resting. The C feed's ♡/💬/↗ emoji footer and the 🖼 album prompt were the drift this canonized against — replaced with Heart/MessageCircle/Share2/Images.
  • Tempted to use raw Tamagui Pressable as button → Use <Button> with correct variant

Empty & loading states ​

  • Using EmptyState → page-level = full (64px icon bubble), section-level = compact (44px icon bubble)
  • Screens with async data → render SkeletonCard / SkeletonHero / SkeletonRow during isLoading. Never let EmptyState flash before queries resolve.
  • EMPTY-ONCE (owner ruling, improvement plan 2026-09-23, W1) — a section with nothing to show renders nothing. A section's own emptiness predicate gates it at the CALL SITE (cond ? <Section/> : null — the same call-site-gating discipline the Lists section's section-rhythm law already requires), never an internal fallback well, a ghost "— — —" row, or a placeholder advertising an unbuilt feature. A page that is mostly empty for a day-one viewer (a brand-new profile, a quiet club) shows ONE calm line in place of its usual layout, not N empty sections each apologizing separately. Applied 2026-09-23: the profile StatStrip/MannerTagsSection/AchievementsPreviewSection (screen-blueprints/profile.md), records history's empty period, club admin dashboards, roster groups. Evidence: decision log (IMPROVEMENT-PLAN, ROUND-4 DECISIONS).

Drawing rules (2026-11-14) ​

  • A drawing rule (a Strip holds one to four cells, a team has one or two named players, a tile has its figure) is built from breakRule (packages/ui/src/design-rule.ts), never a bare throw. In development and under jest it throws (a developer's mistake is caught at once; every test that expects the throw stays). In a release build (__DEV__ false: the one switch, native and web) it reports once per message through the reporter the app wires with setErrorReporter (error-reporter.ts) and answers false, and the component draws nothing for itself: withDrawingRule(View, rule) wraps it (a wrapper, never an early return after hooks), or a component without hooks returns null when the rule answers false (MatchRow). A breach before the app has wired its reporter (the web wires it after the first render; the server render has none) is kept in a queue of at most 20 messages and handed over when the reporter is set; the once-per-message memory is bounded too.
  • A component whose absence would strand a person keeps its throw in every build: withStrictRule (the TabBar, the only navigation; a Notice, which carries a failed write's words and its retry), and the DetailShell bar. The error boundary's retry is safer there than a page with no way on.
  • An unknown value from a newer server is not a design error: an unknown outcome kind reads as void (outcomeDefOf), an unknown reaction id is skipped and reported once (chipsWithOptions, reportOnce). A held fact missing its count or name, and a finished count that is no whole number, leave that one fact out of a row or card that still draws.
  • What is held, exactly: the throws left under packages/ui/src are on the list in __tests__/design-rule-throws.test.ts, each with its reason (programmer-only precondition, provider missing, exhaustive switch, strands a person, behind a component rule that holds the input first); a new throw new Error( that is neither the helper's nor on that list fails the test. Nothing else is claimed: a throw on that list can still take a page, and a page is lost to a component's input only through one of them.

Home feed ​

  • Home tab = a temporal briefing, not a card feed (the 2026-04-17 card-feed model is retired). MainTabShell's own pill row picks a granularity (일 · 주 · 월, useHomePeriodStore); each granularity renders ONE self-contained view (views/home-today-view.tsx, home-week-view.tsx, home-month-view.tsx) with a fixed section order. A section renders only when its own condition holds (progressive disclosure inside the view) — never an empty-looking section holding space.
  • Cross-pane slots are the FeedList header (HomeDisputedAlertCard ▸ the viewer has a disputed score; HomeAdminAttentionStrip ▸ open admin obligations) and footer (HomeRecommendationsSection). Everything else lives inside the active view.
  • Live sessions render as plain SessionCardV2s via LiveSessionStack inside the 일간 view's 진행 중 section, gated to the real present day — there is no separate live banner or live card component (HomeLiveAlertBanner was retired with the card feed). Adding a home surface → add a section to the right view, never a new cross-pane card.
  • Current anatomy: screen-blueprints/home.md.

Tabs & chips ​

  • Tab pages → MainTabShell from @twomore/ui (handles SafeAreaView top-edge, title header, headerRight, bottomCta). Pass tabs/tabValue/onTabChange/renderTab to get a built-in SegmentedTabs switcher. Never roll your own SafeAreaView + pageTitle combo for a tab page. A flat v2 tab root opts into chrome="tabs" (W4 batch 6: 경기): the kit Tabs pin and draw the one edge hairline once the pane scrolls (the Header then none), and search puts the Header in its search mode (the panes freeze, children is the results). The kit TabBar is the app's tab bar (apps/mobile/app/(tabs)/_layout.tsx), drawn over the panes: a full-bleed pane's FeedList clears it by itself, and a list FAB is Fab placed="tab" with useFabListTail('tab').
  • Segmented tab switching inside a page → SegmentedTabs from @twomore/ui (PillNav-driven, preload="all" mounts all panes post-paint for instant switching). Multi-tab content where each pane has its own useQuery/useState hooks → ALWAYS SegmentedTabs preload="all". Conditional {tab === 'x' && ...} on hook-bearing panes is forbidden — it remounts hooks on every chip tap and blocks the JS thread. Never use conditional {activeTab === 'x' && ...} for tab content that contains hooks.
  • PillNav.onChange is a direct urgent state update — chip flip must commit on press frame. Deferral of heavy content rendering lives at the SegmentedTabs consumer via useDeferredValue.
  • SegmentedTabs uses useDeferredValue internally — content pane mount is low-priority while the chip stays urgent. Don't add startTransition on top.
  • SegmentedTabs preload mode → "visited" by default. preload="all" only when ALL panes have lightweight first-paint cost; useDeferredValue keeps chips responsive either way.
  • SegmentedTabs consumers → wrap each pane component in React.memo AND wrap renderTab in useCallback with stable-reference deps (e.g. hoist userId ?? '' to safeUserId first). Without both, every chip tap re-renders all mounted panes (since preload="all" keeps them mounted) and the JS thread saturates. Either alone is insufficient: React.memo without stable renderTab → memo bails on referential prop check; stable renderTab without React.memo → component re-renders fully anyway. See packages/features/clubs/src/club-list-screen.tsx / packages/features/activity/src/activity-screen.tsx / packages/features/records/src/records-leaderboard-screen.tsx for canonical examples (commit e0ce50b, 2026-05-02).
  • Chip-style tab controls → use PillNav from @twomore/ui. Don't add per-chip backgroundColor variants — the active background slides between positions on the UI thread via a Reanimated worklet.
  • Choosing from a small set (scope/filter selection, a form's options) → a Choice inside a ChoiceSet from @twomore/ui (W2a; library "Choice", "Choice set"): the set is a named radiogroup (one choice; one tab stop, arrow keys on web) or a group of toggle buttons (multiple), 44 tall in a form and 36 in a filter row or a sheet (tall={false}), wrapped left-aligned 8 apart and never leaving one Choice alone on its last row (choiceRows); fill shares one line among two to four that fit it; at most five options (seven for a fixed series). Selected = ink fill, $textInverse label at 700, a leading check. A Choice ▾ (chevron) shows its value and opens a Sheet (DropdownChip/RangeChip are built on it); a removable Choice (onRemove) removes only itself. A loose Choice in a .map or beside a sibling fails @twomore/choice-in-choice-set. Distinct from PillNav/SegmentedTabs (navigation chrome). The old SelectionChip family is ratcheted out; never hand-roll a selectable pill.
  • RETIRED 2026-11-13 (UI/UX pass, wave A: deleted, no page imported it; the text stays as history). Selectable TILES / choice cards (wizard preset swatches, format tiles, play-style cards — larger-than-chip "pick one of 2–4" content) → SelectionCard from @twomore/ui (packages/ui/src/selection-card.tsx, promoted 2026-08-05 from the onboarding play-style fork): selected + free children, Pressable variant="card" chrome, selected state = the SelectionChip family recipe ($primary border / $primarySubtle bg). Never hand-roll a per-screen tile — the create-club banner presets, create-session format/vibe tiles, and onboarding play-style cards were three divergent forks of exactly this, consolidated onto it.
  • Boolean toggle → Switch from @twomore/ui (packages/ui/src/switch.tsx; K2a, library "Switch") in a toggle Cell for a setting that applies at once; a yes or no inside a Save form is a CheckRow kind="check" (library §1.22 rule 1). ONE toggle visual app-wide; the display-only TogglePill (promoted 2026-08-05, retired by no-legacy-rows in K2b), the byte-duplicate FeeTogglePill (sessions) and the club wizard's text-pill toggle are retired.
  • Bounded-integer −/+ control → Stepper from @twomore/ui (clamps internally; format renders arbitrary display text — tier names, "N명"). Never hand-roll a Button shape="circle" −/+ pair; the create-club and create-session wizard forks were consolidated onto it 2026-08-05.
  • Wizard chrome: disabled-CTA hint text → WizardShell's primaryHint prop (pinned with the CTA band) — never improvised in step bodies where it scrolls away; in-flight primary CTA state rides Button's own loading/loadingLabel.
  • Progressive reveal (owner law, 2026-08-06): tooling that depends on a gate choice stays HIDDEN until that choice is made — default = not rendered, never disabled-but-visible (a wall of pending controls overwhelms; the create-club logo generator renders only after the 문양 사용 tile is selected). Applies generally: reveal dependent controls on selection, collapse them when the gate deselects.
  • Sequential wizard reveal — THE CONTRACT (owner law, 2026-08-08, via RevealStack in @twomore/ui; grounded in the Toss signup engineering writeup toss.tech/article/toss-signup-process): wizard steps present ONE decision at a time, and the next section reveals ONLY on explicit confirm — a confirm-button tap or keyboard done, never mid-typing validity (Toss: "다음 버튼을 누르면 … 필드를 자동으로 추가"). The stack renders the active section's confirm row itself (uniform across pages); optional fields are first-class sections with a visible 건너뛰기, never silently bypassed by a gate condition; selection forks whose tap IS the commit set selfConfirming. Choreography: answered sections stack UPWARD as a visible, editable trail; the active section stays keyboard-anchored (bottom of stack, host-scrolled above the keyboard) — NOT viewport-centered, per Toss's reversed-stack finding that centering puts the active input under the keyboard. Motion: the reveal preset (~250ms decelerate timing, both platform configs), no spring settle tail. confirmed state is controlled by the step (resume with valid data starts confirmed). Applications: create-club steps 1–3 (이름 → 꾸미기; courts-only single input; the 일정 live-fill loop), create-session steps 1 (날짜 → 시작 → 종료) and 4 (참가비 → 참석 마감 → 반복 → 한 줄 소개) — the full wizard-facing contract lives in wizards.md.
  • Venue-pick canon set (owner, 2026-08-24; COMPONENTIZED 2026-08-25; UNIFIED into VenueFinder 2026-09-01; SPLIT 2026-11-08): since W4 batch 6 VenueFinder is pick-only and the 경기 tab's 테니스장 pane is VenueExplorePane (@twomore/app, venue-explore/*: Feed items from venueFeedItemModel, the filter grammar above, its own Search takeover); what follows describes the pick finder. The canonical finder was VenueFinder (@twomore/app, intent: 'explore' | 'pick') — ONE surface for the 경기-tab directory (explore: rows navigate to venue detail) and wizard picking (pick: rows emit VenuePickResult, incl. the Naver adopt-on-pick double-emit contract; pickedVenueIds/selectedVenueId selected state, finderHint, initialRegions/initialDistricts mount-once filter seeding, controlled query). Grammar (owner-ratified via /wireframes/venue-finder): VenueFilterBar (SearchBar with the filter toggle INSIDE it via ExpandableFilterBar — never a separate filter row) + 전체/코트/연습장/즐겨찾기 nav chips + province-grouped browse + the shared dual-source search leg. VenuePickPanel (검색/둘러보기 mode chips + region-drill browse) is DELETED — never rebuild it; consumers own only "what a pick means". Per-venue court config is CourtUsagePanel (which courts + tap-to-rename). Both wizard step-2s consume them — extend the components, never re-assemble the pieces. The pieces, for reference — SearchBar over usePublicCourts (curated-first + Naver) result rows, RegionDistrictFilter for explicit browse (search vs browse is a user CHOICE, never chips under a search bar), VenueRow rows that ALWAYS carry latitude/longitude (the photo → static-map → glyph cascade needs coords — a coord-less row falls to the name glyph and reads detached), and VenueSaveButton bookmarks with the 즐겨찾는 코트 list fed by useSavedVenueIds+useVenuesByIds (optimistic ids + keepPreviousData + lite-entry merge so a new bookmark renders on the press frame). First assembled in create-club step2 + create-session step2 — extend those, never fork. Presentation (owner-directed 2026-09-01): inside a wizard step the finder is NEVER rendered inline in the step's scroll — the step shows a search-field-shaped trigger row and VenueFinder (pick intent) mounts inside VenueSearchTakeover (@twomore/app), the full-screen search overlay (see wizards.md "Search takeover law"). New search-over-open-data surfaces reuse VenueSearchTakeover's shell shape rather than embedding a live search field in a long scroll. The Naver-source marker on a result row is NaverBadge (@twomore/ui, promoted 2026-08-24 from two screen-local copies) — a 22px $naver-tinted "N" square; use it, don't refork it. The step's ANSWER is a picked row or free text explicitly committed via the edit scope's 저장 (venueFreeTextCommitted) — typed search text alone never enables 다음, and a committed answer always collapses into the selected-card state so it is visible (owner 2026-08-25).
  • Region → district cascading filter (시/도 → 시/군/구 multi-select) → RegionDistrictFilter (packages/app/src/presentation/components/region-district-filter.tsx, a DropdownChip pair over the canonical REGIONS source with shared cascade-reset behavior — deselecting a region prunes its now-orphaned districts). The single source for the 번개 pickup filter bar and the venue-directory filter bar (unified 2026-07-12, ~90 duplicated lines removed); lives in packages/app (not packages/ui, which stays primitive-pure, and not a feature shared/ dir, since two sibling feature packages both mount it — ARCH-2). Encoded-value separator is parameterized (region-district-filter.ts) — venue matches it against a hyphen-composite DB slug, pickup uses a pure client-side comparison key.

Clickables ​

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

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

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

  • Affordance law (owner-directed 2026-09-06) — every tappable shows exactly ONE resting cue. Press feedback is transient; on the flat canvas a tappable with no resting cue "renders and behaves like a normal component". The cue is chosen by intent, never omitted: a navigation row/tile/avatar shows a trailing chevron (ListRow auto-chevrons whenever it has onPress and no trailing; a trailing slot alone supplies the cue instead — an action button — and a nav row that also carries a content MARKER (a status badge, a value) passes trailing={marker} chevron: the marker sits beside the one chevron cue, 2026-09-21 — the primitive composes it, callers never hand-roll ChevronRight into trailing as AdminNavRow/SettingsRow once did); a tappable media band (MediaHero onMediaPress) — a MAP band is its own cue (people expect to tap a map; owner 2026-09-28 retired the session map's 장소 보기 pill, and the venue pages never had one), a PHOTO band that navigates shows mediaHint — a MarkerPill bottom-left on the band (the same chrome as VenueCard's 지도 보기 tile hint; MarkerPill lives in @twomore/ui since 2026-09-21 for that reason) — a media band with a press and no hint is relying on an adjacent cue and must say so; a section link is SectionHeader link={{ label, onPress }} (text + chevron, never a hand-rolled Pressable > Text in right); an in-place action carries button chrome (Button/ActionButton) or, when text-only, $primary 600 ink with affordance="link"; a user-authored body (공지, DM bubble, post body, club description) renders through AutoLinkText (@twomore/app, LinkifiedText + openExternalUrl) so pasted URLs are tappable links — $primary 600 underlined, the text-only in-place action ink — never a bare Text, 2026-09-21; a tappable Well/banner uses Well onPress accessibilityLabel (its chevron); a whole-surface card in a browsing list (feed cards, SessionCardV2, ClubCard, post rows) is its own cue — a card people expect to open — declared affordance="surface", no chevrons on feed cards; media that opens a viewer uses affordance="surface". Pressable affordance is the semantic marker (chevron | button | link | surface | none; surface was layer until the tint band retired, 2026-09-28); none is legal only when an adjacent element supplies the cue, with a same-line comment saying which. Enforced by @twomore/pressable-needs-affordance (error + ratchet); the 2026-09-06 sweep applied it to all 101 legacy sites.

  • Press feedback stays inside the thing pressed — no tint band (owner 2026-09-28: "Is there a way to indicate that something is being pondered for action without rendering the grey band over the component? … We are always going for something clean and uncluttered"). The state layer is retired, along with the stateLayer prop and the $surfaceTertiary wash that row/card pressables drew edge to edge on press and on web hover. The content itself answers, within 0.1s (NN/g response limits):

    • it settles (scale on the press spring) and dims (opacity), with one row of values per variant in VARIANTS (pressable-visual.ts): row 0.98/0.80, card 0.98/0.90, pill 0.96/0.85, button 0.97/0.92, icon 0.92/0.78, fab 0.96/0.90;
    • hover (web, real pointers only; Tamagui gates hoverStyle under @media (hover: hover)) is one gentle dim, HOVER_OPACITY 0.85, shared by Pressable and Button, plus the pointer cursor;
    • reduced motion keeps the dim and drops the settle, Button included;
    • the keyboard focus ring (FOCUS_RING) is a separate signal and stays (WCAG 2.4.7).

    Evidence: Apple keeps pressed feedback inside the control (UIButton dims itself; the full-cell grey is only UIKit's list default); Material 3 draws its tint inside the component's own shape; Toss's engineering team added motion so a tap "feels pressed" (toss.tech). Never re-add a background tint on press or hover; a surface that needs more signal settles further.

  • A list's section title owns the title→first-item seam, 8pt, everywhere (owner 2026-09-28: "there is a wider gap between the section and items on the club pages than in other pages"). (K3b 2026-11-02: the head is now SectionHead.) That was SectionHeader variant="list"'s own paddingBottom (SEAM.item); a host never adds padding under it (the 추천 클럽 header stacked paddingBottom $3 on it: 20pt). GroupedFeedList draws its section/section seam (SEAM.rail, 16pt) as a section FOOTER after every section but the last (resolveSectionSeam, feed-list/section-seam.ts), never through RN's SectionSeparatorComponent. VirtualizedSectionList keeps a separator's props in state set once per cell, so after a re-sort (내 클럽 sorts by next session) the leading separator kept a stale leadingItem and drew 16pt between 가입한 클럽 and its first card (24pt total). Measured after the fix: 8pt on 내 클럽, 탐색 and 홈.

  • Pressable cards/rows that navigate to a detail screen → wire onPressIn to the matching prefetchX helper from @/presentation/cache. Press intent populates cache before nav resolves.

  • Press feedback runs on the UI thread via the Moti animation driver — Tamagui pressStyle + animation="quick" is safe for all shared primitives; Pressable from @twomore/ui is the only clickable (MotionPressable was deleted 2026-05-09).

  • Clickable surfaces → ALWAYS one of these primitives, never raw onPress on a styled Stack or inline pressStyle={{ scale, opacity }} in feature code:

    • Page / form CTA → Button from @twomore/ui (variants × sizes × shapes)
    • Tab strip / chip group → PillNav / SegmentedTabs from @twomore/ui
    • Anything else clickable (cards, rows, chips, icons, FABs) → Pressable from @twomore/ui with variant ∈ card | button | pill | row | icon | fab
    • Pressable is the canonical clickable primitive. It encodes: required accessibilityLabel (TS-enforced), per-variant scale + opacity press feedback on the UI thread (Reanimated worklet), reduced-motion fallback (opacity-only), web focus ring on $primary, standardized disabled state (0.5 opacity + non-interactive + busy a11y), and loading prop that renders disabled state.
    • Inline pressStyle={{ ... }} on raw XStack/YStack is forbidden in packages/features/**. Use Pressable variant="…" so the press feedback is consistent across the app.
    • Disabled state is rendered identically everywhere — opacity 0.5, pointerEvents="none", accessibilityState={{ disabled }}. Don't roll your own.
    • Tap targets ≥44pt — enforce via outer container size when the Pressable wraps a small icon. iOS HIG / Material 48dp baseline. The recipe (2026-08-23 audit — the C-surface build shipped ~20-24px hand-rolled chips/footer actions and re-learned this): rows/tabs get minHeight={TOUCH_TARGET.MIN} (or ROW_HEIGHT_TOUCH); compact visuals (32px chips, 20px glyphs, text actions) keep their size and close the gap with symmetric hitSlop={(TOUCH_TARGET.MIN - visualPx) / 2} — the same math HeaderIconButton and SelectionChip encode. Filter chips are NEVER hand-rolled: SelectionChip size="sm" already carries the floor.
  • Adding an admin tab to a detail screen → follow the AdminTab nav-hub pattern (named component, PillNav-driven, a Cell per destination). Gate tab visibility on canManageMembers || canManageDues. Never put mutation controls inside the hub — keep it navigation-only.

  • A control on a tappable card is its sibling, never its child (F03, 2026-09-29). A button inside the card's own Pressable is a button inside a button: axe nested-interactive on web, and unreachable to VoiceOver on iOS (the card is one accessibility element). The card keeps an empty slot of the control's exact footprint in its layout, and the control renders after the card's Pressable in a relative wrapper, placed at the slot's measured origin (the sum of onLayout offsets on the slot's path — never a guessed px, never a token mirrored as a number). useLayoutPathOrigin (@twomore/ui) is the one measurement; SessionCardV2's save toggle is the reference; a toggle's state is pressed (aria-pressed), never selected. hitSlop on web is realised by Pressable itself (an invisible out-of-flow child), so the 44pt recipe above holds on both platforms. ProgressBar requires an accessibilityLabel: the fact already beside the bar.

  • A header icon is a registry glyph the shell's Header draws (K4b), never a hand-built control. A page passes actions to DetailShell, MainTabShell or CollapsingTopBar: at most TWO of the eleven glyphs (back and close lead the title and are the shell's), each a press (icon, onPress, optional badge dot or count, selected for a toggle such as the bookmark, coveredByWebNav for an entry the web nav carries) or the ⋯ / + with a menu. Slot 1 is the screen's single signature action (stateful or badged glyphs get priority — a badge never hides in a menu), slot 2 the ⋯ overflow; never three — the third moves into the menu or the body. There is no free node: a control with no registry glyph (a people icon, a pencil) is a menu row or a body Button. HeaderIconButton, HeaderTextAction, HeaderActionsMenu and AppHeader are deleted (@twomore/no-legacy-chrome).

  • A mark on an icon (Badged) keeps one place for one digit and one rule for more. A dot or a count of one digit hangs by its right edge (3.5 beyond the 24 box); a count of two digits or more keeps the one-digit mark's LEFT edge and grows to the right (badgePlacement), cap 99+, so the number never covers the glyph.

  • The viewer's mark "(나)" is made by personLabel alone. It returns the printed name and the face name together (spread into PersonRow and the shelf and leaderboard rows), because a face derives its initials from the name and "dev (나)" would draw the Hangul 나 instead of a capital D; person-label.scan.test.ts refuses any other file that reads matchResult.me (text-only files are listed there).

  • Text appears in the bar only to leave or commit a mode, and the primary action never lives in the bar. An edit or select mode replaces the whole bar with mode (left a tonal Button — 취소 in the back button's place — and right a primary one: 완료, 저장; see the edit-state archetype below); the page's primary action stays in BottomCtaBand. A secondary navigation task with no glyph (초대 코드) is the bar's one tonal mode.right Button on a tab root, or a menu row; prefer relocating list-scoped actions onto the section head they act on (알림's 모두 읽음 is its first head's command).

  • The ⋯ opens through the Header itself (HeaderMenu), and never through a Tamagui Popover. The JS-portal Popover z-fights headers, flickers on mount and drops touches on RN (observed in production 2026-07-31; mechanically banned by @twomore/no-popover-in-product). Under 600 of content width the menu is the bottom MenuSheet; from 600 it is the anchored Menu (240 wide, r12, a 1 edge, no shadow) rendered inside RN's native Modal and positioned from the trigger's measureInWindow(), with the web's roving focus (arrows, Home/End, Enter, Escape, Tab) and focus returned to the trigger. Rows are command Cells; destructive rows last, in the danger group, each behind its own confirm; ~5 rows is the practical ceiling. A content-bearing overlay (the DM participants roster) is a bespoke ModalPanel, not this menu.

Lists ​

  • Scrollable feeds (flat or grouped-by-section) → FeedList / GroupedFeedList from @twomore/ui (wraps FlashList (FeedList) and SectionList (GroupedFeedList) with virtualization tuning, loading-more footer, and empty/loading slots). Never use bare FlatList/ScrollView for a list that may grow. The next page loads as skeleton rows at the list's pitch (loadingMoreNode), never a spinner; the end of a list is FeedEnd doors as the footer once nothing is left to load (W4 batch 6).

  • A filtered list speaks one filter grammar (W4 batch 6; the owner's 2026-10-28 grammar): FilterHead (@twomore/ui: the first SectionHead carries the 필터 / 필터 N Choice, and one removable Choice per active value sits under it) opens FilterSheet (@twomore/ui: groups in the caller's order — 보기 first, then where and when as Picker Cells, then a Choice set per facet, then the level and fee bands; 초기화 soft while nothing is set; 결과 N개 보기 applies the draft). The region is one filter (RegionPickerSheet, @twomore/app: 시/도, then its 시/군/구; formatRegionPick writes 서울 강남구 외 1곳) and the date one (DateFilterSheet). Never an always-on filter bar: ExpandableFilterBar, DropdownChip filter rows and RegionDistrictFilter retire as each discovery list converts. The filtered-empty state's one action is filters.reset (필터 초기화).

  • Row metadata is one line; explanation the reader decides on is shown whole (D6, 2026-09-29). PersonRow / TextRow line 2 (l2Lines) default to one line; helper copy that says what a setting does, or a report reason a moderator acts on, passes EXPLANATION_LINES (4). Longer content belongs on the confirm or detail surface behind the row (a report's full reason is in its resolve confirmation). SectionHeader/SectionBlock take titleLines for a heading that must wrap (a legal article).

  • SCROLL-PILL-RETIRED (owner ruling, improvement plan 2026-09-23, D1) — no floating "더보기"/scroll-hint pill on any list, ever. The floating "아래로 스크롤해 더보기" pill that FeedList/GroupedFeedList used to render is removed app-wide — its own measurement bug had put it under single-row lists, and no mainstream list surface pairs a static list with a floating scroll-hint (the cut-off row plus the platform's native scroll indicator already carry that signal). The prop, its i18n key, and the ui-strings bridge are DELETED, not merely unused — don't reintroduce a scroll-hint affordance. Removing it exposed four hand-copied list-slot implementations across native/web, consolidated into ONE GroupedFeedList for both platforms (packages/ui/src/feed-list/grouped-feed-list.tsx) with one slot rule (resolveListSlots) and one scroll-handler composition (useScrollJankHandlers). Evidence: decision log (W0 + GEMINI FOLLOW-THROUGH).

  • List render-callbacks (renderItem / keyExtractor / ItemSeparatorComponent / renderSectionHeader) on any list component (FeedList / GroupedFeedList / FlashList / FlatList / SectionList) MUST be hoisted into a useCallback with stable-reference deps — never inline. An inline callback is a fresh reference on every parent render, so the list re-renders every visible row on each parent state tick (a sheet open/close, a chip flip, a countdown). Declare the useCallbacks ABOVE any early return so they obey rules-of-hooks; if a callback closes over another handler, hoist that handler into a useCallback too (otherwise the dep array churns and defeats the memo). Pair with React.memo on the row component for full effect — useCallback alone keeps the prop reference stable, React.memo is what skips the row's re-render. Enforced by @twomore/require-memoized-list-render-callbacks (Wave H).

  • Mixed-type FeedList/FlashList MUST pass getItemType. When one list renders structurally different item kinds (e.g. a {kind:'header'} ~30px row vs a {kind:'match'} ~150px row), pass getItemType={(item) => item.kind}. FlashList v2 keeps ONE recycler key-pool + ONE height-average bucket PER item type; with no getItemType everything shares the 'default' pool, so on scroll a recycled header view gets reassigned to a match row → React sees the same key with a different tree → full unmount+remount + a Fabric layout reflow on every such recycle (this was the 544ms scroll:scorecard-live jank, 2026-06-03; canonical fix: packages/features/sessions/src/scorecard/live-tab.tsx). getItemType is NOT in FeedListProps' Omit list, so it passes straight through to FlashList — no FeedList change needed. React Compiler does NOT mitigate this — a key reassignment remounts regardless of memoization. Not lint-enforced — reviewer discipline when a list's data is a discriminated union.

  • K3b (2026-11-02): SectionBlock and SectionHeader are deleted. A titled group is a SectionHead over its rows in a YStack (title, count, one action door, choice, bulk, expand, info, note; no tone="danger" and no leading: the danger shows in a destructive Button or Cell). The head pads its own rail, so in a railed column or list it sits in RailBleed (lint no-double-rail); GroupedFeedList's default section header and DataSection's title do. RailBleed only cancels a rail the CALLER adds (a FlatColumn, a railedList preset): a rail-owning part in an unrailed list (SessionDayHeader in the tail-only 경기 and 클럽 일정 lists) is never wrapped in one, or it is pushed one rail out on each side (batch 61; session-day-header.real-ui.test.tsx holds it; no lint, because a component's caller is another file and the AST cannot see the rail). The rest of this entry is the history of the retired pair.

  • Titled content groups on a detail screen → SectionBlock from @twomore/ui (title, optional right slot, tone="danger" for destructive sections — the icon prop was REMOVED 2026-06; section titles are text-first). Section titles always live outside cards (in-card section titles were tried on the club home 2026-08-22 and rejected on device — "a big mistake"; the seam-void problem they targeted is a canvas/figure-ground issue, not a title-placement issue). SectionBlock delegates its header row to the SectionHeader primitive (packages/ui/src/section-header.tsx — variant="block" displaySm $text / variant="list" cardTitle for virtualized list section headers, accessibilityRole="header"). Use SectionHeader directly ONLY when a title row cannot wrap its body (a GroupedFeedList renderSectionHeader, a FeedList header slot) — never hand-roll section-label typography in feature code, and no decorative leading icons (the right slot is for real counts/actions/config only). SectionBlock also takes variant (passes through to SectionHeader) and inset ('none' default — the parent padded column owns the $4 inset; 'all' — the block insets title AND content, for unpadded full-bleed pages like the club home; 'title' — title inset only, content full-bleed, for horizontal rails that own their own edge bleed).

  • Section-rhythm law (2026-08-21o; K3b 2026-11-02: SectionBlock and SectionHeader below are the retired pair, the head is SectionHead) — every vertical seam has exactly ONE owner; never sum two spacing sources across a boundary. The canonical seams: section→section = $4 (owned by the page's ONE gap-bearing column — or by a single list-slot padding where a native list slot (FlashList header/footer) breaks the gap chain); title→content = $2 (owned by SectionBlock — as the stack gap for variant="block", as SectionHeader's own paddingBottom for variant="list"; SectionBlock zeroes its gap in list mode so the two can never stack); list item→item = $3 (FeedList/GroupedFeedList's DefaultSeparator); page chrome→content = $4 (MainTabShell title block, SegmentedTabs chip→pane); controls→list-title = $3. Drift signature to reject in review: a wrapper padding* around a SectionHeader/SectionBlock (the doubled seam — $3 wrapper + $2 header = an uncanonical 20px), or a paddingTop that duplicates a sibling gap. Corollary: a conditional section renders null, never an empty container — an empty YStack still consumes the parent's gap slot (the 32px rail→소식 seam bug, 2026-08-21o). List hosts contribute nothing after a header (audit 2026-09-19, owner: "consistent spacing between titles and items across pages"): a section/day header row INSIDE a FeedList/GroupedFeedList owns its bottom seam (SectionHeader list/flat/dense paddingBottom $2), so the host's separator after it is a doubled seam — GroupedFeedList's default sectionSeparator is now SectionSeamSeparator (nothing after a header — RN's leading section separator arrives with leadingItem undefined — and SEAM.rail 16px before the next header), and a flat FeedList whose rows include headers passes itemSeparator={seamOwnerSeparator(isHeaderRow)}; never null a separator per site to "fix" a seam (the 클럽 탭 fix of 2026-09 removed a wrapper padding and the SectionList default re-created the same 20px through the other path). The three numbers on every list: header→first item 8, row→row 12, boundary 16 (SessionDayHeader, day-sectioned session lists, member role groups, 기록 month groups, 알림 bands, 코트 찾기 result groups). Block-variant headers have no min-height beyond their displaySm line (the $7 = 32px floor hid 2px of dead air under every hub title); dense owns paddingBottom $2 like the other variants. SectionHeader leading (2026-09-19) is the one slot for a graphic before a title (the 조 header's tier dot) — a header row is never hand-rolled to get one. FactRow icon (2026-09-20) is the one slot for a glyph before a fact label — the anchor a run of short label/value rows needs at the row start; a fact's glyph is the SAME wherever the fact renders (SESSION_FACT_GLYPH), and the glyph belongs to the FACT, not to a block (2026-09-21: the session detail's titled 정보 rows carry theirs — 🏷️ 경기 유형 · 🏅 경기 방식 · 📈 ELO · 🔄 로테이션 · 📋 취소 규정 — like the title-less block above them; a fact whose value has its own graphic, 날씨, uses that graphic as the row glyph and keeps the value text-only — never two emoji on one row). The seam is 8 to the content's first VISIBLE edge (owner 2026-09-20, club pages): a card starts at its border, so the header owns the 8. A RowList/FactList trims its first row's top padding (the edge rule below), so the header keeps its 8 over it — tappable ListRows included: they keep their padding so the press layer is never clipped and BLEED by it at a section edge instead (resolveListRowBox, one box for both branches: negative margin = the padding a static row drops, so the content lands exactly where a static row's does and only the press layer spills into the seam; screen audit 2026-09-23 E1 — the 2026-09-22 symmetric-padding change had put every title over a tappable first row 16dp away). The edge reaches a row THROUGH any wrapper component (AdminNavRow, SettingsRow…) by context (RowEdgeContext, row-edge-context.ts; an explicit edge prop wins, ModalPanel resets it) — the clone alone stopped at the wrapper. A padded row a host renders WITHOUT RowList (a FeedList item, MemberRow/DuesRow/applicant groups) has no trimmed edge: its own SEAM.item top padding is the seam, and over those the header is flush (SectionHeader flush / SectionBlock flush: no paddingBottom, no block gap). Measured before the rule: 멤버/관리 titles sat 23–24dp above their first row's content vs 12dp above a card border. A Divider between title and rows is a visible edge — not flush. Leading elements follow M3 list-item alignment: one- and two-line rows CENTRE the avatar/icon, a three-line row TOP-aligns it (DuesRow: name / amount / note) so it sits at the row's first visible edge, not 12dp below the seam.

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

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

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

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

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

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

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

  • Feed row header — two lines (owner 2026-09-27: "we don't need to render the name, date and category into three rows"). A feed row's header is avatar + line 1 (the name or the event sentence, badge and ⋯ on the right) + ONE meta line holding its short facts as segments: the post's time and board, or a system row's time and 자동. It is the Facebook / Band / Threads post header. ListRow/PersonRow sub takes it as a segment line (an entry that is itself an array renders as MetaSegments). Member posts (PostFeedRow) and system rows (feed-auto-rows.tsx) use this one ListRow header, at one avatar size, FEED_AVATAR_SIZE = AVATAR_SIZE.MD (40pt; Discord and Facebook use 40, Threads 36). A session-link chip keeps its date and format on one segment line too. The feed body renders through AutoLinkText, like every user-authored body. Feed photos are big enough to see (owner 2026-09-27): FeedPhotoStrip: one photo spans the row at 4:3 (the camera's own ratio); two are side-by-side squares; three or more are three squares with "+N" on the third (the Facebook / Band collage, kept to one line). It is one layout for member posts and photo-upload rows.

  • Collapse / expand is ONE pattern: ExpandToggle (@twomore/ui, owner 2026-09-28: "I would stay away from text-based control buttons at all … implement the collapse/expansion properly"). The control is a full-width 44pt row whose resting cue is a chevron — never a text link (DisclosureLink is deleted; never rebuild it). With children it is an accordion HEADER (the row's own content + a trailing chevron, the whole row the target — a receipt's headline; LabeledField's collapsible label row is the same grammar and shares ExpandChevron); without, a lone centred chevron continuing a shortened list (the session facts, tier credentials, the crash screen's details) — that list's LAST ROW: its box is the glyph plus an inset on the seam ladder (SEAM.block each side standalone; a FactList that owns it through disclosure passes its own row rhythm, so glyph-to-row equals row-to-row and the section seam follows), and the 44pt floor is hitSlop, never a padded box the glyph floats in (it put 15px above and 38px below the glyph on the session page — owner 2026-09-28: "proper spacing up and below"). ExpandChevron points down closed and turns up open on the quick spring; what opens enters through ExpandReveal (the reveal animation, 8pt slide + fade), or FactList revealedItems for fact rows (same list, same rhythm); both rest under reduced motion. The accessible name says what the press does now ("자세히 보기" / "접기" / "왜 이 숫자죠?", never shown) with expanded state. Where another control must stay the target (a chip, a notification row shell, a sticky round header), it keeps its container and draws ExpandChevron with expanded state on its pressable. NN/g (2020 accordion-icon study): the caret beat arrows and plus signs, which did no better than no icon; label and icon must do the same thing, so the whole row is the target; one level deep, one toggle per block (GOV.UK). Enforced by ESLint @twomore/no-hand-rolled-disclosure: a chevron swapped or turned by state, or a fold word (…collapse, …Hide) written by hand instead of sitting in an accessibility name or the label of an ExpandToggle, is an error outside expand-toggle.tsx (fifteen hand-rolled sites migrated 2026-09-28). Inverted 2026-11-01 (K3b, review 52): the flat v2 kit draws the fold word, so a lone toggle is the label count form (a tonal Button, "4개 더 보기", 접기 once open) and the chevron-only form above is retired by @twomore/no-legacy-heads as the K3b sweep reaches each caller. A shortened list of rows already loaded opens and folds with the same lone chevron (the venue pick takeover's "N곳 더보기" is its accessible name); only network paging (FeedList onEndReached) loads without a control.

  • A detail page's hero zone runs without dividers (owner 2026-09-28: the map, title, alerts, notice and the fact block "together form the hero"). Blocks that belong to the hero — alerts, a notice, the lead facts — go in FlatColumn's lead slot as one stack (SEAM.hero between them): no line above them, exactly one below, so a page's first divider opens its first real section (the session page: above 참석).

  • A detail page's bottom holds ONE control — its CTA band (owner 2026-09-28: "get rid of extra control at the bottom of detail pages … one main CTA at the bottom, and the rest should be inside the menu or top right"). Everything else a detail page offers — management tools, share/chat, and the destructive ends (일정 취소 · 일정 삭제 · 경기 종료 · 대회 취소) — lives in the header ⋯ HeaderActionsMenu, management first and the destructive items last in tone="destructive", each still behind its confirm; no tool list or 위험 구역 section in a detail page's body (the session page's 호스트 도구/위험 구역 retired into buildSessionHostMenuActions; the tournament board's 대회 취소 into its bar). A SETTINGS page (club settings, profile menu) keeps its bottom danger zone — there the list IS the page (iOS Settings, Material settings).

  • A badge's fill separates from the page in dark mode (owner 2026-09-28: the 호스트 badge "can't really be seen as a badge" — neutral sat at 1.04:1 on $card). Dark badge fills are a tonal step above the page: ≥1.5:1 against $card and $background in every theme (Material 3's surfaceContainerHighest-over-surface step, IBM Carbon's dark tag background step), text on them ≥4.5:1 — gated by check:color-contrast's fill pairs (light fills read by hue and are reported only). The quiet surface Badge fills with $badgeSurfaceBg (the tinted tertiary surface in light, the gated muted badge fill in dark), never a raw surface token a theme can set too close to the page.

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

  • Hero identity pattern (owner ruling, 2026-09-08) — supersedes the Badge placement law above's "one badge row under the title": no rows of flat metadata under a hero title. A hero's title line carries the title plus ONE inline key badge (titleBadge — the entity state, e.g. live/모집 중/마감 — an XStack alignItems="center" gap="$2" where the title shrinks/truncates and the badge holds its size), never a badge row. Directly under it, ONE GlyphFactRow (facts, SEAM.item below the title) — up to 4 facts, each a glyph + one value with no separate text label (the glyph IS the category: 📅 date, ⏰ time, 🌤 weather, 🎾 format, …) — replaces the old stacked meta lines entirely. Secondary numeric stats (counts, capacity, price) stay OUT of the hero; they belong in the page's StatStrip (FlatColumn lead), never a hero fact or badge. A hero must never restate a generated/derived title as its own title when a more concrete identity (a place, a person, a real name) is available — e.g. the session hero's title is the venue/place name, not the generated sessionTitle() string (format + style), which now renders as the 🎾 fact instead (session-hero-section.tsx). MediaHero/TypeHero's titleBadge/facts props (backed by GlyphFactRow, packages/ui/src/glyph-fact-row.tsx) implement this; their meta/badges props are @deprecated and removed once every caller has migrated. Glyph rows are never clipped (owner device QA 2026-09-18): Android paints a color emoji taller than GlyphSlot's 16px box, so a clipping ancestor one text line tall (overflow="hidden" on a 20px cardMeta row) cuts the glyph's bottom edge flat (the 🎯 on the session card). No overflow="hidden" on an inline GlyphFactRow or any one-line row hosting a GlyphSlot — horizontal containment is minWidth={0} + flexShrink + numberOfLines={1} on the segments (CARD-NO-OVERFLOW), never a clip. And the emoji's own line box (owner device QA 2026-09-19, the clip survived the parent fix on a Samsung phone): Android draws a Text only inside its own line box and vendor emoji fonts render up to ~1.4× the fontSize, so every emoji Text sizes its lineHeight through emojiLineHeight(fontSize) (= fontSize + 8; GlyphSlot's emoji slot is 12 in a 20px box, TierBadgeChrome the same rule) — never fontSize + 2, never a text role's own line. An emoji beside text goes through GlyphSlot, not a bare Text textRole="caption" (the old badge role).

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

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

Shells & detail screens ​

  • Navigation chrome (2026-10-09, IA rulings 15–16). Every non-tab screen is a push onto the root Stack and hides the tab bar — there is no "detail keeps the tab bar" mode. The root layout exports unstable_settings = { anchor: '(tabs)' } (Expo Router's documented anchor; initialRouteName is its deprecated alias), so a cold-start deep link or push opens as [(tabs), screen] and Back lands on a tab. appRouter.back() is the ONE back: when there is no history it replaces to the tab root for the current path (back-fallback.ts, table-tested against the real route tree); screens never hand-roll canGoBack() branches. Push targets are keyed by signal TYPE (signal-routes.ts, a satisfies Record<PaymentSignalType, …> table plus a migration sweep), so a payer is never sent to an officials-only screen.
  • Detail screens (back header + optional scroll body) → DetailShell from @twomore/ui (SafeAreaView top-edge, the Header back/title/actions, optional scroll prop). Never duplicate SafeAreaView + a header manually in a detail screen.
  • Owner ruling D1 (2026-09-08): a MediaHero detail page uses DetailShell header="collapsing" — CollapsingTopBar overlays the hero (expanded/transparent over the media, no static title repeating the hero's own) and flips to solid chrome once the hero scrolls under it, driven by the shared useCollapsingTopBar threshold hook (@twomore/ui); a TypeHero page (no media) keeps the static AppHeader. header="collapsing" only self-wires when DetailShell owns the ScrollView (the scroll default) — a screen with a nested list (FeedList, scroll={false}) composes CollapsingTopBar + useCollapsingTopBar directly instead. Live consumers: session-detail-screen.tsx (conditional on hero mode), directory-venue-detail-screen.tsx, venue-detail-screen.tsx, public-club-profile-screen.tsx; club detail's own two-state bar (use-collapsing-banner.ts) shares the same threshold resolver.
  • A fact row answers ONE question (owner 2026-09-27: "consolidate and compact the information"). A FactRow states one question's answer (when? what game? what level? what cost?). Short facts of the same answer share its main line as FactSegments (value style) or its second line (secondaryValue as an array, muted), with hairline breaks and never a middle dot. A fact that answers a different question keeps its own row, and an empty fact gets no row. This supersedes the 2026-09-24 rule that the second line could only carry a consequence of the value. First consumer: the session fact list (sessions.md). A long fact block leads with the decision (2026-09-28): the facts a reader needs to act lead; the rest joins the same FactList (revealedItems) behind one ExpandToggle (closed by default) — never per-row accordions, never more than one level, and a warning-tone fact always leads (session precedent, sessions.md). Rows read as separate by proximity, never lines (owner 2026-09-27: "make each information row visually more separate … without adding clutter"): a row's own lines sit flush, and a FactList whose rows carry a second line or a note spaces EVERY row SEAM.block per side (24 between rows, FactListRhythm 'roomy'); a list of one-line rows keeps SEAM.item (the 09-06 tighter peers). One rhythm per list, never mixed gaps; no hairlines between fact rows (Material's list spec: rows' lines on adjacent leading, 12dp padding for three-line items; NN/g: lines only when spacing fails).
  • An editable field is a FactRow in its edit state (2026-09-27, owner: "merge the edit pages"). A row that shows a field and changes it is ONE grammar everywhere: label left, value right, the whole row the tap target, trailing the EditCue pencil (owner 2026-09-28: an edit icon, not the word "수정"; same size and quiet tone as the chevron and the lock, never beside a chevron) — editCue carries the word for the row's accessible name, which is "장소, 수정" (field, then the cue) (GOV.UK summary list's visually-hidden change text; its task list found users tap anywhere on the row). A field that cannot change there trails a lock (locked) and states why on its own line (note) — the reason is always visible, never a dimmed, disabled-looking value; a field edited on its own screen (which saves by itself) keeps the chevron. note is also the point-of-decision line on an editable row ("만든 뒤에는 바꿀 수 없어요" on a wizard review). valueAccessory puts a swatch, logo or 기본값 badge before the value. A wizard's review step and an entity's edit state render the SAME rows for the same entity — GOV.UK uses one summary list for a form's check-answers page and a saved record, Shopify Polaris one resource-details layout to create, view and edit — and differ only in where a tap goes (back to the step that asks the field, or to the field's editor). Precedent: the session record (packages/features/sessions/src/session-record/, sessions.md) and the club wizard's review. WizardReviewRow (label over value, pencil) is retired — never rebuild it; EditFieldRow stays for settings lists of long free text.
  • Controls over a photo sit on a scrim, never bare (owner 2026-09-27: "the photos can overpower them"). Bare white glyphs over a photo vanished on bright shots, so PhotoScrim (@twomore/ui, numbers in photo-scrim-visual.ts) darkens the photo edge behind any control drawn over it:
    • Top: CollapsingTopBar in white-glyph mode lays a black gradient behind the bar, 50% at the very top and 45% through the bar's bottom edge, fading to clear 40pt below. It fades out when the bar goes solid. A light static map (expandedInk="text") gets none.
    • Bottom: album pagers lay a 45%→0 gradient over their bottom 56pt, under the page dots and the count pill.
    • Why 45%: that is the floor where white on a white photo still clears WCAG 1.4.11's 3:1, which photo-scrim-visual.test.ts checks.
    • Rejected: per-glyph circles, retired 2026-08-23 as noise.
    • Ink: anything drawn on a photo takes ON_PHOTO_INK (the $white token), never $textInverse, which flips dark in the dark theme and left dark glyphs on the dark scrim.
  • One hero band for every place page: 2:1 of the width (owner 2026-09-27: the 132px band "might be too small"; then "make the map photo on top of the session detail page match the size of that on the club detail page"). resolveHeroBandHeight (@twomore/ui): 195pt on a 390pt phone, capped at 280pt on wide windows. It sizes the club home's album banner and every MediaHero — photo, album AND map (session, venue, directory venue) — and heroBandCollapseThreshold flips their bars (DetailShell's default collapseAt). 2:1 is the group-page norm: 당근 모임 2:1, Strava clubs about 2.04:1, Facebook Groups 1.91:1, Meetup 16:9. It stays short of 16:9 so the name and tabs still show above the fold (NN/g's false-floor finding). A hero map is requested in the band's own shape (venueHeroMapOptions: 640×320 at scale 2 = 1280×640 px), never a wider tile cropped and upscaled. DEFAULT_MEDIA_HERO_BAND_HEIGHT (132) is only the first-layout fallback before the width is known.
  • A header inside a sheet is the Sheet's own title row (back, more, ×); never hand-roll a back-chevron-plus-centred-title bar locally. The Header's height (56, or 64 with a sub line) is headerHeight() (@twomore/ui) — a screen that must derive layout math from the bar's footprint (e.g. a KeyboardAvoidingView's keyboardVerticalOffset) reads it from there, never re-types the two literals.
  • Hub-vs-detail law (owner-directed 2026-09-14) — a bottom-tab hub is a card-based browsing collection; a detail page is flat. Grounded in how the platforms and the benchmark apps split it: a hub that lists many heterogeneous, scannable things (Material 3 cards "for a collection of related content", the App Store Today feed, Naver/Toss home feeds) chunks each thing into one tappable card, while a page that IS one thing (Apple HIG grouped detail views, Toss detail pages, the canvas law above) lays its facets out flat. The rule keys on CONTENT SHAPE, which the 2026-09-07 ruling already stated: 홈 / 클럽 / 경기 / 기록 are browsing collections → object cards (SessionCardV2, ClubCard, DirectoryVenueCard, the home cards, the 기록 dashboard widgets); 프로필 is a single entity and stays flat; every pushed detail route is flat. Hub section grammar (one owner per seam, shared primitives only): a hub section is titled OUTSIDE its cards by SectionHeader variant="list" (a FeedList/GroupedFeedList header slot or renderSectionHeader) or by SectionBlock variant="block" (a composed feed such as the home views) — the two render the same cardTitle title and both own the title→first card = SEAM.item ($2) seam; card→card = SEAM.block ($3) is owned by the list's DefaultSeparator (or the section body's gap), never by a card margin; section→section = SEAM.rail ($4) is owned by the ONE gap-bearing container (the composed feed's column gap, or a native list header slot's padding when a list breaks the gap chain), never by a header's own paddingTop plus a previous card's paddingBottom; the scroll tail is owned by the scroll container's SCROLL_CONTENT preset alone (a view never adds its own trailing padding under it); $5 is not a rung and never appears. Every hub collection has a title (ActivityListControls-style filter bars are controls, not titles). A dashboard widget on a hub keeps its card but its title moves out of the card (SectionBlock). seam-contracts.test.ts locks the header variants to $2 and the list separator to $3.
  • Every bottom-pinned primitive reports itself to the Toast; a feature never wires it (batch 65, 2026-11-15). CtaBand (its measured height, plus the inset a shell paints under it), a placed Fab (what it adds above the tab bar's report, or above the bare inset) and PinnedBottomBar (the box a composer sits in) call useBottomChromeReport() (packages/ui/src/bottom-chrome-report.ts); BottomChromeReportRoot in @twomore/app, mounted once in each app root, provides the one reporter (useBottomChromeReporter, bottom-chrome.store.ts). A layer counts only while its screen is focused (cleared on blur and unmount, republished on focus), and the two tab layouts report the bar through the same reporter, so a tab bar that stays mounted under a pushed page no longer lifts the Toast over it. The store is additive-by-id: a band on a tab page sits ON TOP of the bar and the Toast clears both. A Toast with no action dismisses on tap, so one that sits on a page's bottom button eats the press (J17: 참석 취소 pressed at once did nothing). A new bar that pins itself to the foot (a composer, an absolute bottom) goes through one of these primitives; bottom-chrome-report-scan.test.ts lists every bottom-pinned file and fails on one that is neither listed nor reporting.
  • Detail-route placement — a screen reachable from MORE THAN ONE tab MUST be a top-level cross-cutting route, NOT nested under a tab group. The app uses Expo Router per-tab stacks (app/(tabs)/(home|clubs|sessions|records|profile)/…); a file physically under (clubs)/… belongs to the 클럽 tab's stack, so navigating to it from 홈 or 경기 hijacks the 클럽 tab and back-navigation lands on the wrong tab. Cross-cutting detail screens (session detail, scorecard, match-board, venue, public profile, create-session) live at the ROOT (app/sessions/[sessionId]/index.tsx, app/match-board.tsx, …) so they push OVER the current tab and back returns to the originating tab. Their routes.* factory may accept a clubId for callers' convenience but ignores it in the path (e.g. clubSession(_clubId, sessionId) => /sessions/${sessionId}, like matchBoard/scorecard). Only screens that are genuinely a sub-view of ONE tab (club members/dues/board/settings under 클럽) stay nested. Lesson from 2026-05-25: session detail was nested under (clubs) and anchored every entry point to the 클럽 tab.

Window size classes (desktop web, 2026-09-30) ​

Owner: "turn the web into a proper web app, not a mobile app being rendered" — board https://twomore-cockpit.pages.dev/wireframes/web-app (W1–W5).

  • One breakpoint source: packages/ui/src/window-size.ts. Material 3 window size classes — compact < 600, medium 600–839, expanded 840–1199, large ≥ 1200. WINDOW_MEDIA is the media of BOTH Tamagui configs (native through @tamagui/react-native-media-driver's createMedia), so style props read mobile-first: $medium from 600, $expanded from 840, $large from 1200. On web they compile to CSS media queries (SSR-safe, no re-render); useMedia() knows the window only after hydration — use it to decide what MOUNTS or what a hook REPORTS, never for styling. apps/web/app/globals.css repeats the numbers; web-frame-contract.test.mjs fails on drift.
  • Widths come from LAYOUT_WIDTH, never a literal: reading 680 (detail pages, forms, 프로필), pane 340, list 360, wizard 640, control 480 (a segmented strip, a period pager, the search bar), shell 1200 (a tab root), rail 80, sidebar 240. A cap is just maxWidth — a no-op on a phone — so it needs no breakpoint; reach for a media prop only when the STRUCTURE changes (a pane appears, the nav changes form).
  • Every shell caps its own column and paints its ground edge to edge. MainTabShell (always reading, 680: a tab root's tabs and rows sit in the reading column), DetailShell (width reading default, wide for boards and directories; the ScrollView spans the page so a wheel scrolls anywhere, its content caps and centres), PlaceShell (reading), WizardShell (640, header and CTA included). AppHeader and CollapsingTopBar keep their fill full width and line their glyphs up with the column (contentMaxWidth); the collapsing bar's photo scrim covers only the column. BottomCtaBand spans its shell but caps its content (contentMaxWidth, the reading column by default).
  • Size to the column, never the window. A shell wraps its column in ContentColumn (the measurement lives there, so a new width re-renders only its readers); a component that sizes itself reads useContentWidth() (the window before the first measurement, so a phone's first frame is unchanged). @twomore/no-window-width-in-layout flags useWindowDimensions/Dimensions.get in packages/ui and packages/features outside an allowlist keyed on why the window is right there (shell measurement, window-anchored menus, full-screen overlays).
  • Feeds are one column. A tab root sits in the reading column and its day-sectioned session rows are one column at every width (batch 63, N2); FeedList/GroupedFeedList have no grid mode (the opt-in grid prop and feedGridColumns were deleted when no feature used them).
  • One composer field, and a fill that must be seen takes $gray100 (batch 63). The chat's message box and the post's comment box draw the one ComposerField from @twomore/ui (a 40-tall rounded field on $gray100, the board kit's FvkComposer); a page never builds a second one. The role $surfaceSecondary was the page's own ground under another name, so a fill on it could not be seen: it is retired (deleted from the themes). A received bubble, a Callout or Banner, the send ball, a photo tile, a secondary button and a chip take $gray100; an element meant to sit flush takes $background. The contrast gate's visible pair holds $gray100 against the page.
  • A not-found wall's header is the back arrow alone. NotFoundState declares the wall (useDeclareWall) and DetailShell draws no title, subtitle, action or mode for it (barOf, detail-shell-wall.ts), so no page can name a page that is not there; the wall's door is its one way out (홈으로 가기 when the page has no parent).
  • On the web, Pressable is a tab stop and presses on Enter or Space. The primitive sets tabIndex and the key press itself (webKeyboardPressProps, by role as the platform has it: Enter and Space for a button, tab and switch, Space for a checkbox and a radio, Enter for a link; a handled Space never scrolls the page; a key bubbling from a child control ignored, the caller's own props win; focusable={false} opts out), so a page never adds its own. A bordered pressable's hitSlop is measured from the border edge (webHitSlopOverlayInset), which keeps a link row at 44. Native focus is Tamagui's and is unchanged.
  • A detail page's supporting pane: DetailShell aside docks the page's summary and its footer action in a 340 pane from 840 (M3 supporting pane); below 840 the summary is not shown and the footer stays the pinned band. The footer mounts ONCE (inside the pane's box), and BottomCtaBand sheds its band chrome there (useInSupportingPane). The summary is a projection of the page's own model (session detail: SessionActionPane — fee via formatWonOrFree, the card's RecruitmentMeter).
  • List-detail (M3): 메시지 and 설정 keep their list in a 360 pane beside the open page from 840 (apps/web/components/list-detail-layout.tsx, a Next nested layout); the pane mounts only once a detail page is open and only at expanded; the page slot keys by the section (apps/web/lib/page-slot.ts) so the list survives switching pages. The open row is ListRow selected (a $primarySubtle fill + aria-current; a state, not press feedback).
  • Navigation (web only, apps/web/components/app-nav.tsx): under 600 the (tabs) bottom bar; from 600 a navigation rail, from 1200 a labeled sidebar — the five destinations as real links (next/link through Tamagui's render, aria-current="page"), the create action (번개 만들기) as a button, 알림/메시지 with the shared counts (useNotificationBellState, CornerCountBadge), the account row → 설정. It lives beside the page slot, never inside it, and hides on pages about the app (login, legal, share — lib/public-paths.ts, which the route guard shares). A skip link precedes it (WCAG 2.4.1).
  • One entry, one place: an in-page entry the web nav already carries (the home bell, 경기's 매치 만들기 band, 프로필's 메시지/설정 icons) wraps in WebNavCovered — hidden on the web from 600, kept on phones AND native tablets (no web nav there). A page the nav opens directly passes DetailShell navRoot so its back button hides the same way.

Wizards & confirms ​

  • Multi-step creation flows (CreateSessionScreen, CreateClubScreen, future onboarding) → WizardShell from @twomore/ui (handles dismiss + progress bar + bottom CTA row). Form fields inside steps → LabeledField (label + optional labelRight slot for a char counter + optional caption slot for a below-control info line + input + optional error) wrapping StyledInput (themed RN TextInput), both from @twomore/ui. A label row with a 123 / 500 counter is LabeledField + labelRight, NOT a hand-rolled XStack. Below-control caption canon (research-grounded 2026-08-18 against Material Design's supporting-text spec and NN/g's placeholder-text findings — see citations in the linked section) — wizards.md field grammar, including the caption-vs-AttachedPanel ordering rule: when a field has both, order is control → AttachedPanel → caption (never caption between them — pass the caption as a manual trailing line after the panel instead of via LabeledField's caption prop). Draft resume is a SILENT auto-restore (owner-adopted 2026-08-15) — a fresh persisted draft resumes automatically on mount with no banner/prompt, firing one action toast (t().common.draftResumeToast, action label t().common.startOver) that lets the user undo back to a blank draft; a stale (TTL-expired) draft discards silently with no toast at all. The prior explicit-choice WizardDraftBanner is retired — no component of that name exists any more. Mechanism: useDraftResumeLifecycle + buildResumedDraft (packages/features/sessions/src/create-session/use-draft-lifecycle.ts, draft-resume.ts); create-club mirrors it via resumeClubDraft (packages/features/clubs/src/create-club/resume-draft.ts). The final 확인 (review) step's per-field rows → a flat FactList of editable FactRows (editCue + onPress back to the step that asks the field; no Card, no summary Callout — a consequence sits on its own field's row as note), the same rows the entity's edit state renders where one exists (the session record, create-session-step6.tsx; the club review, create-club-step8.tsx). Never re-implement these as private components inside a wizard screen — single source of truth lives in packages/ui/src/labeled-field.tsx and packages/ui/src/fact-row.tsx (the draft-resume mechanism above has no packages/ui component any more — it lives in the feature-level lifecycle hooks cited there).
  • Confirmation alerts → useConfirm from @twomore/ui (imperative confirm.show({title, message?, buttons})). Never Alert.alert directly — native Alert ignores design tokens and renders as an OS-styled centered dialog. ConfirmSheet (the file name survives; since 2026-06 it is ModalPanel-based, not a Tamagui Sheet) is the canonical replacement: $-tokens, dark-mode aware, hardware-back dismiss, backdrop press deliberately does NOT dismiss (dismissOnOverlayPress={false} — matches Alert.alert semantics), single-flight (only one prompt visible at a time). Button-style map: cancel→outline, destructive→destructive (red), primary→primary CTA, default|omitted→primary — UNLESS it is the ONLY button in the prompt (a lone non-cancel/non-primary button), which falls back to outline, matching a native Alert's single-button treatment (variantFor in confirm-sheet.tsx is the authoritative branch order; PD-08, primitive-drift-audit-2026-09-17). ConfirmProvider must be mounted in _layout.tsx above any screen that calls useConfirm. useConfirm works from inside ModalPanel content — RN <Modal> keeps children in the React tree, so context is reachable. The old portal caveat (context unreachable inside portaled content — the 2026-05-23 dev-panel TelemetryPane crash) now applies ONLY to the dev panel's Tamagui Sheet: there, call useConfirm from the component that OWNS the Sheet, or use an inline confirm.
  • Screen-archetype selection — the same KIND of interaction MUST use the same archetype across the app. A club-ready app can't surprise users with "edit opens a sheet here but a full screen there." The four archetypes + their canonical interaction kinds:
    • Create-entity / multi-step flow → full-screen wizard (WizardShell). Create session, create club, onboarding.
    • Edit-entity — standalone, multi-field, reached from a menu or route → full-screen route (DetailShell). Session-level match rules, edit profile, session payments(정산), attendance roster, participants.
    • Edit an entity whose page already shows its fields → the page's own Edit/Done state (2026-09-27, the session page: Apple Contacts/Calendar Edit → Done; HIG nav-bar Edit/Done). The header's 수정 flips to 완료, the facts turn into editable FactRows (above), each field opens its single-field ModalPanel that 적용s into a whole-record draft (commit="apply"), and 완료 commits the changed fields at once — asking first only when others will be told. No separate edit page duplicating the page's fields. Navigation while editing (owner 2026-09-27: "take proper care of navigation"): the bar offers the MODE's two ways out, never the page's — 취소 (HeaderTextAction prominent={false}) replaces the back chevron in the leading slot (DetailShell headerLeft; AppHeader/CollapsingTopBar left), 완료 is the trailing slot's only control (Contacts/Calendar Cancel/Done; M3's full-screen editor has no back arrow either). 취소 and Android back (useWizardBackHandler, focus-gated) leave the mode, asking "수정을 그만둘까요?" only if something changed. The page becomes the edit page (owner 2026-09-28: "an edit page probably doesn't need a thumbnail photo and the meta text"): a plain bar titled for the task (일정 수정) with 취소 · 완료, and only the editable rows — no hero, no hint line, and none of the sections that link or act elsewhere (roster, matches, alerts, the CTA band) until 취소/완료 (NN/g: a mode needs clear edges; Contacts hides its action rows in edit mode; the title and the Cancel/Done pair are the mode's two visible signs). Leaving the PAGE with changes pending goes through the leave guard below.
  • Unsaved input is never lost to a stray back or swipe (2026-09-27). A full-screen editor with local unsaved input (경기 규칙, 프로필 수정, 글쓰기) calls useUnsavedChangesGuard(dirty && !saving) (@twomore/app); an in-page edit mode calls useLeaveGuard with its own discard step. While active, the iOS swipe-back is locked (native-stack gestureEnabled: false), any other removal of the screen is stopped and asks the app's ONE leave question (useLeaveConfirm: 나갈까요? · 계속 작성 / 삭제하고 나가기 — the wizards' exit copy), and the web target asks the browser on reload/close. A screen that navigates away itself after a successful save calls release() first. Never usePreventRemove on this stack — its cancelled native swipe desyncs JS and native (react-navigation#13072, open on native 7.2.2 / RN 0.83 / Expo 55). Wizards keep their own draft-resume contract (the draft persists; their exit confirm is useWizardBackHandler's).
    • Edit a single field in-place → the bottom EDIT SHEET (EditControlModal/EditFieldModal, @twomore/app: ModalPanel placement="bottom", owner 2026-09-28 "the edit doesn't open a modal but render a bottom edit sheet"): full width, flush to the bottom edge, the page visible above it, a full-width 취소 (secondary) · 저장/적용 (primary) pair at its foot (Material 3's modal bottom sheet; Toss's sheet with its fixed bottom CTA); the scrim or 취소 closes it — no drag, no grabber, no X. Confirms and pickers stay centered dialogs. Tap-to-edit, no navigation — only when the field already sits inline on the settings screen.
    • Edit embedded in a larger flow (a wizard step's sub-editor, a per-item override) → ModalPanel. Match-rules during create-session step + per-match scorecard override (the same MatchRulesEditor body; full-screen MatchRulesScreen is the standalone session-level entry — the split is intentional and documented).
    • Manage a roster (add / remove / role-change people) → full-screen route. Participants, members.
    • Confirm a destructive / irreversible action → ALWAYS useConfirm. Never a bespoke confirm panel that re-implements title + destructive button + cancel (anti-pattern fixed 2026-05-27: club member removal used a custom ConfirmRemoveSheet while every sibling — delete session/club, remove friend/participant, forfeit, end-session, logout, revoke consent, delete account — used useConfirm; consolidated). useConfirm is for confirming a DESTRUCTIVE intent — don't stretch it into a generic multi-option action menu for non-destructive choices.
    • Pick from a small set of contextual options, or a list picker → ModalPanel. Dues action cascade (dues-action-modals.tsx), score call/edit, swap teams, referee-recipient picker, discovery/pickup filters (via DropdownChip/RangeChip, which embed ModalPanel), add-round.
    • Read-only info / explanation → ModalPanel. Tier info, playability TMI, match-profiles.
    • View "see more" of low-stakes list content → inline expand (no navigation). Roster preview, FAQ, round accordion.
    • Archetype scales with complexity + context: a 2-option no-input row action is inline buttons (session-payments 납부/면제); a 4-option-with-sub-inputs action is a ModalPanel cascade (club dues paid/partial/waive/note); a single text field is a quick ModalPanel; a multi-field standalone edit is a full screen. When the same editor appears embedded-in-flow vs standalone, the embedded one MAY be a ModalPanel and the standalone a full screen — but that split must be deliberate + documented, not accidental. Before adding a new interaction, find the matching kind above and use its archetype; if you're about to diverge, document why. (Until the 2026-06 Codex batch these overlay archetypes were Tamagui bottom sheets; every one is now ModalPanel — see the Tamagui/Styling rule.)
  • A ModalPanel consumer that renders its own back/close header MUST pass showCloseButton={false} to ModalPanel.Frame. The Frame's built-in X close button is redundant (and visually stacks) once the consumer's own header already carries a dismiss control — a back chevron (the scorecard sheets' AppHeader chrome="bare") or an equivalent affordance (match-profiles, match-rules-editor). All five current sheets that render their own header (ScoreCallSheet, ScoreCorrectionSheet, ScoreEditSheet, match-profiles-sheet.tsx, match-rules-editor-sheet.tsx) pass it; a new custom-header ModalPanel consumer must too.
  • ModalPanel's built-in close button's default accessibilityLabel/closeAccessibilityLabel is the Korean literal '닫기' (packages/ui/src/modal-panel.tsx) — not t() (the ui package has no @twomore/app dependency), matching WizardShell's own closeAccessibilityLabel = '닫기' default precedent. Fixed 2026-08-18: it had leaked the English 'Close panel' as the announced label on the ~39 of 45 ModalPanel.Frame consumers that never pass an explicit override — a screen-reader-only i18n gap invisible to sighted QA. Pass an explicit closeAccessibilityLabel only when a more specific label reads better in context; the shared default otherwise covers every consumer.
  • ModalPanel.Frame title row (owner audit, 2026-08-18): a left-aligned title Text is textRole="headline" marginBottom="$4" paddingRight="$8" — the paddingRight clears the Frame's built-in top-right close button (absolute-positioned at top="$3" right="$3" sized $8), which a long title otherwise runs under. Reference implementations: recurrence-editor.tsx, group-editor.tsx, regroup-preview.tsx. Applies to every default-header ModalPanel.Frame title; exempt: a custom-header consumer (showCloseButton={false}, rule above) and a centered/symmetric confirmation dialog (challenge-accept-modal.tsx) where adding one-sided paddingRight would visibly off-center the text — those keep short, static copy instead. Audit swept and fixed the drift (missing fontWeight/paddingRight, or marginBottom="$3" instead of "$4") in edit-field-modal.tsx, dues-action-modals.tsx, dues-attest-modal.tsx, club-guests-section.tsx's two modals, gallery-section.tsx, regular-meets-section.tsx, club-groups-section.tsx, and transfer-ownership-modal.tsx.
  • Edit-box grammar — which surface for a committed value: AttachedPanel (packages/ui/src/attached-panel.tsx, canon in wizards.md) is LIVE-COMMIT — it edits the draft/entity directly as the user interacts, no separate save step; reserve it for a control that is a DEPENDENT of the field it's attached to (a day-of-month stepper under a nonzero fee, a court-count picker under a picked venue). ModalPanel is STAGED — nothing commits until an explicit 저장/제출 press (or the cascade's own terminal action); reserve it for an independent entity edit (a regular meet, a group, a single settings field). Never blend the two: a ModalPanel form must not auto-commit keystrokes into the live entity, and an AttachedPanel must not gate its content behind its own 저장 button — closing it (folding the row) IS the save.
  • AttachedPanel spacing spec (owner-ratified, 2026-08-18 — closes a "differs box to box" drift audit): all three numbers are baked into the component, not caller-parameterized. Outer: marginTop="$2" (8px) between the owning control and the panel — the component's own root margin, never a per-call-site override (the notch's existing offset still bridges this gap and reads as pointing at the control). Internal: padding="$3" (12px) on the panel box, plus a gap="$3" (12px) content container the component owns around children — pass children directly, never re-wrap them in a caller-local YStack with its own gap to get row spacing (an ad-hoc gap="$1" wrapper around a suggestion-dropdown's rows was the drift instance that prompted this audit; collapsed in the same pass). A component nested one level inside the panel (e.g. a RevealStack composing its own sequential sections) may still own its own internal gap — that governs a different concern (its own item reveal rhythm) and should match this same $3 baseline rather than an arbitrary larger value.

Person & match rows ​

  • A person's name is never cut (owner 2026-09-28: "we shouldn't cut people's names. Other things should be cropped if we are crammed for space"). Every person name wraps to NAME_LINES (2), whatever its text role or primitive: PersonRow/ListRow via titleLines, and the player chip, identity card, podium, match-history, timeline, court-card, payment and partner rows directly. When a row is crammed, its other content yields in this order:

    1. blank spacers and reserved columns (the leaderboard's 24pt trailing spacer is gone, and 나 sits beside the name);
    2. columns a header already states (the 조 column in the 조별 view, where the section header names the group);
    3. secondary figures, which fold under the name or move behind a tap;
    4. the headline figure;
    5. last, the avatar.

    A place's or a thing's name (venue, club) may crop. Enforced by ESLint @twomore/no-single-line-title: a single-line <Text> rendering a person-shaped name is an error. Evidence: Apple's larger-text criteria say to wrap to two or more lines rather than truncate; W3C "Personal names around the world" says to leave space for the name; Hoober (UXmatters 2020, mobile tables) says to cut columns, never the identifying one.

  • PERSON-ROW (owner ruling, improvement plan 2026-09-23, W2 F1; K5b 2026-11-02: the row is now the ui PersonRow — packages/ui/src/person-row.tsx, the kit's FvkPersonRow, on Avatar, Thumb and Meta — and the app PersonRow below is a thin wrapper over it; @twomore/no-legacy-people-rows ratchets the old ListRow-based forms out, see the plan's "K5b sweep: the people rows") — every list of people renders through the ONE PersonRow on ListRow (packages/app/src/presentation/components/person-row.tsx). Anatomy: photo (else initials), name, ONE sub-line, an exception badge (see BADGE-EXCEPTION below), and an optional action; the default tap opens the profile with prefetch. Live consumers: the session roster preview, the three ParticipantRow variants, pickup applicants, friends/add-friend/blocked-users, DM participants, club members, dues rows, head-to-head, and the partner suggester (which now shows the candidate's real face, not a decorative sparkle). Bylines and match-scoreboard player slots use the sibling primitive PlayerIdentityChip (packages/app/src/presentation/components/player-identity-chip.tsx) — never a hand-built avatar+name pair on either primitive's turf. Enforced by @twomore/no-hand-built-person-row (error) with its own canary test and NO exemptions (eslint.ratchet.config.mjs is [] — the round-4 cleanup migrated every listed file). The rule flags ROWS (an XStack or flexDirection="row" container holding an AvatarBubble and a name Text); since 2026-09-24 it sees the avatar through {cond ? … : …} / && wrappers and the name through a title column two stacks deep — the first version only saw bare JSX children, and a ternary around the avatar hid the DM thread row and the pickup host card, which now ride ListRow and PlayerIdentityCard. A vertical identity block (a centered chat intro, a page hero) is a hero, not a row: it composes through TypeHero's leading slot (hero composition above). Evidence: decision log (ROUND 4 — PERSON-ROW).

  • BADGE-EXCEPTION (owner ruling, improvement plan 2026-09-23) — a per-row badge appears only when the row differs from its list's own default, never to restate what the list already says about every row. A session roster's default is 참석 (attending), so only 호스트 · 친구 · 예비 · 나 carry a badge; a leaderboard/scorecard's default is the surrounding tier band, so only a row whose tier differs from its neighbors carries one. Never badge every row of a list with the value that is already true of all of them — the list's own title/heading states the default once, and a badge exists to mark the exception to it. At most one badge per row: 2026-09-24 (friends-going decision) added 친구 to the session roster's exceptions with an explicit precedence, 나 > 호스트 > 친구 > 예비 (participation-section.tsx) — 나 never competes for the slot (it's the name-suffix mechanism, not a badge, and structurally exclusive from 친구), 호스트 outranks a friend who happens to be the host, and 친구 outranks 예비 so a waitlisted friend still reads 친구.

  • ResultRow (@twomore/ui, fed by matchHistoryResultRowProps) is the ONE match row (F3/GM-16, 2026-09-23; MatchHistoryRow retired with /records/history, 2026-11-10). Records' matches and the public profile's recent-matches list render the SAME component — tappable through to the session, scores formatted by one shared matchup formatter. Never re-derive a second match-row layout per screen; every row shown is a session the viewer is actually allowed to open (match history silently skips a session the viewer can't read, rather than rendering a dead row).

Query boundaries ​

  • Query-backed SCREENS → QueryBoundary from @twomore/app (the canonical skeleton → error+retry → content flow). Pass the screen's critical-path query results + a shaped loading skeleton; it renders the localized error+retry surface (QueryErrorState) when ANY query errors, the skeleton until all settle (via useScreenReady/Protocol G), else the content. Never leave a data screen with no isError branch (a failed load must show retry, not a permanent skeleton). For LIST screens, pass error={q.isError} + errorState={<QueryErrorState onRetry={() => void q.refetch()} />} to FeedList/GroupedFeedList instead (the error renders in the empty slot; stale data stays on background-refetch errors). QueryErrorState is the single error surface — never hand-roll an error EmptyState per screen. Composer-backed live screens (useLiveSessionData) use the composer's isError/retry directly. Screens that render a list via manual .map() or gate it behind a length > 0 branch: add the error branch to that conditional with <QueryErrorState /> when next touched.
  • Offline is a fourth branch, not a variant of empty (2026-09-11 page audit, ~70 files). TanStack v5 reports isLoading=false AND isError=false while a query is offline-paused (fetchStatus === 'paused', the default networkMode: 'online'), so a surface whose only branches are loading → error → empty renders a false claim on a cold start offline ("no venues", "venue doesn't exist", "no permission", "club not found"). Every query-gated surface therefore reads one offline primitive: QueryBoundary (owns all five phases), FeedList/GroupedFeedList isPausedOffline={q.isPaused} + offlineState={<OfflineEmptyState onRetry=… />}, an early if (q.isPaused && !q.isFetched) return <OfflineEmptyState /> placed BEFORE the loading branch and before any permission gate, or a composer's isPausedOffline. A data hook that returns isLoading also returns isPausedOffline. Enforced by @twomore/require-offline-branch-with-loading-gate (error; // offline-safe: <reason> waives a read that is genuinely not a network query). Owner decision 16 (2026-11-10) replaced the 2026-06-01 rule that blanked the screen: a WARM cache shows, only a COLD read blocks. QueryBoundary renders children for offline-stale and OfflineEmptyState only for offline-cold; its offline?: 'cached' | 'hidden' prop (default cached) is the private-read switch: hidden renders OfflineEmptyState for any offline phase. Private (hidden-class) reads are the staff queues and snapshots (신고 관리, 가입 심사, 입금 확인, the clubAdmin counts that open the gear and the doors) and ownership claims, certifications and the contribution inbox: never from disk, never offline, even memory-warm; the page withholds the section (offline="hidden", or an online gate where the section sits inside a page that otherwise shows). What a player owes is own data: readable offline, with no buttons. The write gate is the kit's, not the page's. @twomore/ui reads one context (WriteGateProvider, supplied at the app root by OfflineWritesProvider from the source the strip reads): Fab, the CtaBand primary, Cell toggle/destructive, Button write, a MenuSheet row or an IconButton/row control/answer marked write render soft-disabled while offline unless the call site passes offlineOk (a write that queues durably, or a press that only navigates). A page never keeps its own stale/readOnly flag to disable a control. Navigation, retry and share are never gated.
  • Query-backed content sections → the state helper (UX-7, 2026-11-13): one decision, pageState (offline-cold, offline-hidden, error, loading, not-found, no-permission, ready — in that order), and one component, PageStateView (both from @twomore/app); QueryBoundary is the same two under the old props. A not-found state cannot be built without its door (a type rule), the skeleton is the page's own, and a page never draws OfflineEmptyState, QueryErrorState or NotFoundState for its own phase beside it. DataSection and useScreenGate are deleted (no importer); never gate a section render on an outer isLoading flag, and never add a second gate.

Batch 58 additions: the clubs gate, the one guard, scroll-to-top, copy ​

  • Every clubs page waits, fails and says not-found one way (UX-7). useClubPageState / clubPageState (features/clubs/src/shared/club-page-state.ts) feed the app's pageState, and ClubGateView (shared/club-gate-view.tsx) draws it with the doors of club-doors.ts: a not-found club page offers 클럽으로. A wall decides not-found before no-permission: the permission reads of a club that is not there are all false, so a page passes hasRecord and the gate's types refuse a no-permission slot without it (batch 61: 멤버 관리 and 글 쓰기 walled a missing club as "운영진만"); the club home's not-found sits in the same back-only shell as the session and event walls, and the default wall carries its second line (matchRules.notFoundBody). The offline class hidden (other members' money, staff queues and analytics, the officials' ledger and dues rows) never draws a warm copy offline; a member's own data and the club home feed are cached. A page never computes offline from isPaused, isPausedOffline or useIsOnline: useReadOffline only (a FeedList slot reads its list's own cold flag).
  • One guard for a Sheet that holds typed input (UX-9): useGuardedExit({ dirty, busy }) from @twomore/ui (guard-dialogs.ts, the rule in guarded-exit.ts). It returns exit(leave): while a submit runs every way out is held, with something typed the edit guard asks and leave runs only on 그만두기 (so leave must clear the draft), with nothing typed leave runs at once. A Sheet's onOpenChange is (open) => !open && exit(onClose). Used by the apply, group, attest, album-photo, report, excuse-request, receipt-review and DM-members Sheets; never copy the three branches into a Sheet. guarded-exit.test.ts pins the rule; each Sheet has its own guard test.
  • An inline composer keeps a silent per-place draft; a Sheet asks (NAV-5). The comment box under a post and a chat's message box are not forms to submit: the unsent text is kept in memory per place (useInlineDraft(inlineDraftKey.post(id) | .dm(id)), packages/app/src/presentation/stores/inline-draft.store.ts), read when the box mounts, written on every change and cleared when the send lands, by the send hook's own success path (useCreateComment, useSendDmMessage call clearSentInlineDraft(key, sentText)): a per-call mutate callback never runs once the page has unmounted, so the page never clears it, and text typed meanwhile survives. No prompt, no badge, nothing on disk (other people's words and private text are not persisted), and the store is emptied with the account (session-cleanup.ts). A Sheet or page with typed input keeps its guard (useGuardedExit, useUnsavedChangesGuard) and asks before it discards.
  • Re-tapping the active tab scrolls to the top (UX-8). FeedList, GroupedFeedList and ShellScrollView register their scroll handle through useScrollToTopRef; the app that owns the tab bar mounts ScrollToTopProvider with the navigation library's useScrollToTop (the phone: first route of the focused tab only, so a deeper page never scrolls) or the web nav calls scrollRegisteredToTop() only when the pathname is the destination's root. Every tab root's scroll container must be one of the three (the 기록 tab uses ShellScrollView).
  • The copy action is copyText(text) from @twomore/app (expo-clipboard on the phone, the browser clipboard on the web; resolves false when declined). CopyableValue shows 복사 on every platform; a declined copy shows the hint line, never a Toast. The test kit's Cell mock draws the copy control.
  • A failed read is never a Toast. The query cache only reports it (the page shows its state). The one error Toast outside mutation hooks is link-open-failure-toast.ts (a link that will not open: a transient device refusal raised by a global handler with no control to carry it); check:error-toast-growth holds its baseline at 1.
  • A ⋯ menu opens three or more rows. Fewer is a direct control (the comment's own 삭제 or 신고). The post header's ⋯ stays for now: the post-detail board (flat-v2-detail, the menu frames) draws it, and the app opens one row there today (삭제 or 신고); changing it is a board decision first.

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:

  • RETIRED 2026-11-13 (UI/UX pass, wave A: deleted, no page imported it; the text stays as history). StatTile (@twomore/ui) — the canonical "value + label" stat cell, consolidating 4 independently-drifted sites. wrap card (wraps in Card tone="default") | flat (bare, for a tile already inside a carded parent); order value-first | label-first; valueRole/valueSlot (override the value line with an arbitrary node, e.g. a tier badge — pass value too as the text fallback); subValue; progress. Use for any numeric-stat-+-caption cell (profile hero, dues summary columns, records cards) instead of a local YStack+Text pair.
  • ExpandableFilterBar (@twomore/app) — the canonical SearchBar + filter-toggle-badge + expanding-panel chrome shared by pickup discovery, club discovery, and the venue directory (all three hand-rolled the identical shell before this). Panel CONTENT stays feature-owned (passed as children); only the toggle button + panel shell + header/reset row is shared. Use whenever a list screen needs a SearchBar with a collapsible filter panel behind a toggle.
  • ListRow size="person" (deleted 2026-11-02, K5b, in favour of the ui PersonRow; this entry is the history of its pitch) (@twomore/ui, list-row-person.tsx; 2026-10-21) — the flat v2 PersonRow pitch (library § 5): 12 padding, a 40 lead centred on the text column, line 1 15/22·600 and line 2 13/18·500 (Meta foot for facts), 64 tall with one line and 68 with two; marker + markerSlot (the unread dot's 8 + 8 slot, reserved so the text edge holds), meta (a trailing time on line 1), check (Select mode's checkbox in the marker's slot) and control (a trailing IconButton, the row's second stop outside its press area). 알림's rows are the first; the card-era NotificationRowShell is gone. The same pass added SectionHeader variant="feed" (the flat v2 Section head: 32 above, 12 below, first 12 under the Header, command as a tonal sm Button and choice on the title's line), ValueSheet (the Sheet a Choice ▾ opens; DropdownChip is retired), FeedEnd and SkeletonRow person.
  • RETIRED 2026-11-13 (UI/UX pass, wave A: deleted, no page imported it; the text stays as history). PeriodScrubberShell (@twomore/ui, packages/ui/src/period-scrubber-shell.tsx) — the ‹ label › stepper header shared by any period picker/stepper, wrapping whatever grid/list content the caller passes as children. Promoted from packages/features/home (2026-09-01) to @twomore/ui once packages/features/clubs' dues MonthSelector needed the identical idiom — features can't import each other, so a primitive two feature packages both need lives in @twomore/ui. Pure presentation (label/handlers/a11y strings are caller-supplied props); used by home's DayCalendarPicker/MonthYearPicker/WeekMonthPicker and clubs' dues MonthSelector.
  • PeoplePickerBody / PeoplePickerSheet (@twomore/ui, people-picker-body.tsx, people-picker-sheet.tsx; numbers in people-picker-sheet-visual.ts) — the one people picker: the picked faces (at most 5) as a Shelf, 이름으로 검색, the people under a head per group, each row trailing a 24 check box. It is the body of 단톡방 만들기, 멤버 초대 (dm-create-group-screen, dm-add-members-screen) and the draw's 참석 Sheet. Never fork a per-feature picker.
  • MatchListControls module (records, packages/features/records/src/shared/match-list-controls.tsx) — one filter/sort/group state machine + controls ModalPanel sheet + count-trigger, shared by RecordsHistoryScreen (unbounded, multi-month feed) and RecordDetailScreen (single bounded period). Chronological grouping grain stays TWO distinct GroupMode values ('month' for the unbounded history feed, 'date' for an already-bounded period) — a genuine divergence the consolidation preserves rather than collapses.
  • MarkerPill (@twomore/app, packages/app/src/presentation/components/sessions/marker-pill.tsx) — the canonical small-pill chrome ($7-radius, tone primary|neutral) for a marker/hint pill. DDayChip (the file was dday-chip.tsx) has been retired — MarkerPill is exported directly, with no wrapper. Reach for MarkerPill directly for any new marker/hint pill instead of re-deriving the bg/radius/padding recipe.
  • RETIRED 2026-11-13 (UI/UX pass, wave A: deleted, no page imported it; the text stays as history). StatGrid (@twomore/ui, packages/ui/src/stat-grid.tsx) — a row of equal-width, equal-height stat cells for a headline-numbers cluster (e.g. member-stats-modal.tsx's top-3: club ELO+delta · rank · win rate). Thin wrapper over StatTile (single source for the value/label anatomy) — adds a $surfaceSecondary card fill, equal-flex/stretch sizing, and an optional signed delta line (▲/▼) that always renders (opacity 0 when a cell carries no delta) so every cell in the row shares one height. Use for any "headline numbers row" instead of a local XStack of ad-hoc YStack+Text cells.
  • A chart with per-point meaning is scrubbable (external UI/UX audit item 04, 2026-09-17): a plain line/sparkline whose points each mean something (a match, a day) is unreadable at a single point unless it's touchable. SparkLine (@twomore/ui, packages/ui/src/charts/SparkLine.tsx) takes an optional selectedIndex/onScrub/onScrubEnd — tap or drag snaps to the nearest point (native: the slider cores' Gesture.Pan grammar; web: onMouseMove/onClick), drawing a small filled marker + hairline vertical guide at the selected index and turning the chart into one accessibilityRole="adjustable" element. SparkLine never invents a default selection — the caller resolves one (e.g. EloTrendCard, packages/features/records/src/cards/elo-trend-card.tsx, defaults to the newest point so the chart shows a marker and a non-empty live label even at rest, reverting there on release).
  • Button's loading/loadingLabel (@twomore/ui, packages/ui/src/button.tsx) — the canonical in-flight CTA treatment: loading swaps the leading icon for an ActivityIndicator and forces the disabled state; optional loadingLabel swaps the button's text too (e.g. "저장 중…"). An ad-hoc ActivityIndicator composed into an icon slot per call site is retired — use loading/loadingLabel on Button/ActionButton instead.
  • usePickImages (@twomore/app) is the one library-pick flow — permission dialog, pick, resize; features never import expo-image-picker directly (lint @twomore/no-raw-image-picker).
  • The offline strip is ONE place: the app's root. NetworkBanner (@twomore/app, built on the kit's Notice, grounded on the board's FvkOfflineStrip) is mounted once above the auth gate (mobile _layout.tsx) and once in the web root (providers.tsx); it says "오프라인이에요" with the queued count ("3건 대기 중") and 다시 시도, and the failed-write line. A page, a form or a list never draws a strip of its own (OfflineStrip and FormOfflineStrip are retired); a cold page draws EmptyState kind="offline" instead. Enforced by @twomore/no-legacy-feedback (a NetworkBanner import outside the two app frames fails).
  • Coachmark / CoachmarkOverlay / Scrim (@twomore/ui, W4 batch 16) are the one-time pointer: the bubble on the Toast's inverse surface with its 8 arrow, and the first-run form that dims the page with a Scrim and lifts the page's own target (the FAB) above it. Words are props; the page decides once-only through useCoachmarkSeen. Replaces CoachmarkTooltip and the tour library for new marks.
  • FeedItem onLongPress + longPressLabel (@twomore/ui, W4 batch 16) carry a command the board draws no button for (홈's 숨기기): Pressable takes both and adds the custom accessibility action, so a screen reader reaches what the gesture does. The light haptic is the page's (haptic.light() in its handler; @twomore/ui holds no haptics dependency).
  • Shelf maxItems (@twomore/ui, W4 batch 16) lifts the row's cap of five for a lane that holds more (홈's 할 일, up to nine); 1.5x text still reflows to three.
  • matchStateBlockProps (@twomore/app, presentation/utils/match-state-block.ts) is the one binding of Block kind="match" to matchWords() and the live Tag: the scorecard's MatchStateBlock and 홈's Lead both draw from it.

See also ​

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

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