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.elevationdefeats Android view-flattening, so every mounted instance becomes its own native layer: addingelevation: 1toCard's default tone made EVERY tab switch in the app exceed the 250ms settle threshold within one OTA (2026-08-21p — zerointeraction:tabtelemetry reports in the prior 72h, then all panes 290–420ms). Card depth is the hairline border, in both canvases; a surface that genuinely needs a shadow gets it locally, on a bounded number of instances, with the perf trade-off argued at the call site.Using
Card→ only two tones:default(neutral card surface) andflat(dashed-border empty state). Sizes:sm(12px padding, dense list-row cards),md(16px padding, standalone content cards — the default),lg(20px padding, hero cards), andsize="none"(0 padding — the caller owns all internal padding, for aCardthat only exists to draw the bordered frame around content that manages its own row-level padding, e.g. a bordered frame around a list whose rows own their padding).Card'stoneis the deliberate STRUCTURAL exception to whattonemeans elsewhere — here it switches border style (solid vs dashed), not color; everywhere else in the system (Callout,AvatarBubble,SectionHeader)toneselects semantic COLOR. Never tonal/live/warning card FILLS, and no screen-wide status tint (a pulsing screen wash was tried and reads ominous) — state lives inside the card's content. RETIRED mechanism (do not resurrect): the previousSessionCardsignaled status via a small DOT at the START of the title, inside the now-deletedSessionHeader's title row (in_progress→ redPulseDot; confirmed → solid green dot), colored bysessionStatusStripColor()— that function no longer exists, andsession-status-strip.ts/match-status-surface.tsthemselves were deleted (Wave-2B conformance sweep, 2026-09) once a follow-up audit found the pair had zero live consumers left. Current mechanism (2026-09-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 aSessionStripChipBadge; 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 itstitleBadgeand a terminal state gets a full-widthBanner. Historical text follows — the previous mechanism was the topSessionStripViewband (session-strip-ui.tsx, BARE-RIBBON FULL-SOLID model, owner 2026-07-29 — "no pale strips"), conditional (renders nothing whenderiveSessionStripreturns null). When present it is a fully saturated fill (never a pale tint) carrying ONE ranked attention lead on the left (icon + label — LIVE pulse, 마감 countdown, 송금 대기, …) plus quiet identity marks on the right (host · RSVP · saved); a bookmark glyph (filled when saved, outline otherwise) always sits at the strip's end as a read-only status marker. Color alone carries meaning: red = live, amber = money/deadline/weather/host-action, green = confirmed, gray = terminal/standings. Card and detail share the SAMESessionStripViewrenderer (size="card"vssize="detail") — never a second hand-rolled status treatment. The session-DETAIL screen additionally keeps a redPulseDot(color$error— the true live-red; NOT$badgeErrorText, which is salmon-pink in dark mode) in the sticky screen header for the "happening now" cue while scrolling.A card is PRIMARILY display + navigation — the whole card is the main tap → its detail. A page-level / hero CTA never sits on a card (those pin to
BottomCtaBandon the detail screen); no FABs. But per the nested-affordance model directly below, a card MAY carry a small number of compact, visually-balanced nested actions: content on the left or top invites a quick action on the right or bottom — a list-row 확인/제외/출결 button, a match-card 경기 종료 footer band — and for a glance→act row keeping the action where the eye already is reads BETTER than detaching it. The line is page-CTA vs balanced nested action (not button-vs-no-button): anActionButton(the canonical, intentionally-styled action) in a balanced nested slot is sanctioned; a loud page CTA plastered on a pure content/navigate card is the smell. (History: a blunt "never a CTA in a card" was walked back 2026-07-13 — it contradicted this same model and over-flagged legitimate balanced action-rows.)Multiple touch targets in a card — the canonical model (Material 3 "one primary action + supplemental actions"; codified 2026-06-23 after a full clickable audit). A card has ONE primary touch target = the whole card → its detail destination; a SMALL number (≤~3) of NESTED affordances may do something different (a sub-nav like 상세 보기/전체 경기 보기, a toggle like the participation row, an action like save). Rules:
- The canonical clickable is
Pressablefrom@twomore/ui(variants card/row/pill/icon/button/fab) — it is the SINGLE SOURCE of interaction cues: UI-thread scale+opacity press feedback, webcursor:pointer+ hover dim +$primaryfocus ring, TS-requiredaccessibilityLabel, standardized disabled.Button/ActionButton(page CTAs) +PillNav/SegmentedTabs/SelectionChip(tabs/chips) are the only other sanctioned clickables. NEVER usereact-nativePressable/Touchable*, rawonPresson a TamaguiStack/XStack/YStack, or inlinepressStyle={{}}— they lose all of the above. (onPressInon a wrapper stack IS allowed — Protocol H prefetch.) - A tappable card WRAPS in
<Pressable variant="card">— never<Card onPress>.Cardis a pure displaystyled(Stack)with no press/hover feedback; passing itonPressgives a dead tap. Wrap:<Pressable variant="card" accessibilityLabel={…} onPress={…}><Card …>…</Card></Pressable>(the mutingopacityfor cancelled/archived stays on the innerCard). Canonical examples:SessionCardV2,ClubCard,VenueRow. The muting opacity itself is the named constantTERMINAL_MUTE_OPACITY(0.55,packages/ui/src/box-sizes.ts) — the one terminal-muting value (ClubCardarchivedAt,DirectoryVenueDetailScreenisClosed,AchievementsGalleryScreen!isUnlocked,DuesRowpaid); use the constant, never a bareopacity={0.55}literal. - Every interactive NESTED target inside a tappable card MUST call
e?.stopPropagation?.()as the first line of itsonPress. Native RN grants the responder to the deepest view, but WEB events bubble → without it, tapping the inner target ALSO fires the card'sonPress(double-nav). Canonical:session-save-button, the participation expand band, the전체 경기 보기footer, the notification dismiss. - Display-only elements stay non-interactive — chips, text, the
VenueLinkpill are NOT touch targets; the card's tap covers them. Don't make a value clickable unless it navigates somewhere different or performs an action. - The card's primary tap = the most-common intent; rare/secondary actions are the nested ones (or move off the card to the detail / a header overflow menu). Past ~3 nested targets the card becomes a mini-page — pull back.
- Enforced by
@twomore/no-raw-rn-pressable-in-features,@twomore/no-onpress-on-card-in-features,@twomore/no-onpress-on-stack-in-features(+ the pre-existingno-press-style-in-features). ThestopPropagationcontract is reviewer-checked (not mechanically linted).
- The canonical clickable is
CARD-NO-OVERFLOW — a card's contents, combined, must NEVER overflow its width (an essential attribute of every card component, owner directive 2026-06-20). Clipped or pushed-off content — the classic being a long venue name shoving the weather glance off the right edge — is always a bug, never an acceptable state. The mechanism, applied to EVERY row that mixes content kinds: in a row combining FLEXIBLE content (text that can run long — venue, title, player names) with FIXED content (chips, icons, the weather glance, a count), the flexible element is the ONLY one that may flex AND it MUST be able to shrink — wrap it in
flex={1} minWidth={0}and give its textnumberOfLines={1}(ellipsize). RN's defaultflexShrinkis 0 (unlike web's 1), so a flex child will NOT shrink below its content width unlessminWidth={0}is set — omitting it is the #1 cause of card overflow. Fixed siblings getflexShrink={0}(or are intrinsically fixed) so they stay fully visible. Canonical patterns:SessionCardV2's title line (session-card-v2.tsx— venue title inflex={1} minWidth={0}+ the state chip and bookmark pinned right, fixed) andVenueLink(maxWidth="100%"+ namenumberOfLines={1} flexShrink={1}+trailingflexShrink={0}). Multi-chip strips that can exceed one row useflexWrap="wrap"(Material 3) or shed chips per the density rule — never clip. When adding or arranging anything on a card, verify the LONGEST realistic content (longest venue/name, max chips) still fits within the card width.Summary-card density — a card answers ONE decision, shows ~3–6 decision-critical signals, and DEFERS the rest to the detail screen. Research-grounded (NN/g "cards are entry points, not encyclopedias"; the field publishes NO hard chip/attribute cap — the discipline is qualitative; real dense cards show 3–6 signals and hide the long tail: Strava 3 stats / Google Flights 5 / Airbnb ~5). This is the SUMMARY-card mirror of the detail-screen single-protagonist rule. Five tests:
- One decision question → one dominant anchor. Frame the card around the single question it answers (SessionCard: "is there a spot / when / is it mine?") and make that the protagonist (participation); everything else recedes. Pre-attentive scanning lands on ONE figure (~200–500ms) — two heavy co-clusters competing (e.g. participation + a full weather strip) is the failure ("if everything is emphasized, nothing stands out").
- The defer-to-detail test. Before adding an attribute, ask "does this change the BROWSE decision?" If no (or rarely), it belongs on the DETAIL screen, not the card. The detail holds the rich version (hourly weather, wind, AQI breakdown, full roster); the card carries only the glanceable summary (condition+temp, N/M, faces).
- Chunk into ≤~4 recognized clusters (Miller 7±2 — for chunking, NOT a 7-item cap; Gestalt proximity; Polaris) — WHEN / WHERE / WHO / classification — grouped by the
$Nproximity hierarchy so each reads as one chunk, not N loose elements. Spacing does the grouping, not labels. - Chips: closed whitelist, one row, ≤20-char labels (Material 3). The strip is the sanctioned set only (no ad-hoc
<Badge>, COMP-2) and must NOT wrap past one row (M3: 2-row chip strips are hard to scan). Conditional chips (payment/approval) are rare, so the common case is ~3. - Color is triage, not decoration; keep the palette small (neutral-first; Astro UXDS: more status colors reduce learnability). Every color source on a card must earn its triage value; when several compete (status-toned date tile + status chip + payment chip + accent chip + brand chip + AQI dot + temp-toned fill), trim. The failure mode is the "baby webpage" card (Dave Rupert) / our "parallel mini-dashboards" anti-pattern — a card so dense it stops being a glance and becomes a compressed detail page. Cautionary example (2026-06, the SessionCard audit): the weather strip accreted AQI grade+label + wind + a min/max range bar — granular signals that don't change an RSVP decision; by test #2 the card weather leans to condition+temp (+ a bad-weather warning) and the rich strip + D-day chip move to the session detail.
Fact glyphs on browse cards — the narrow exception to the icon law (owner, 2026-09-04).
canon/styling.md's icon rule bans decorative section/beat lead icons (a Calendar before a date, a MapPin before a venue, an icon before a count) — content leads with typography, not icons. A compact fact glyph on a browse card (DirectoryVenueCard'sFactGlyph: indoor/lighting/parking) is allowed to break that rule ONLY when all three hold: the glyph encodes a boolean fact (present/absent, not a decorative label), it carries its ownaccessibilityLabel, and it would otherwise have to render as a full word badge (the icon is standing in for a word, not decorating one). It is never legal as a decorative lead icon in front of running text, and never legal in front of a bare count — a court-count fact renders as TEXT ALONE, no icon, even inside the same glyph row as the boolean icons next to it.Backend-backed data never renders as dead text, and links go through an established CLICKABLE COMPONENT — never ad-hoc styled text (owner rule 2026-06-12). If a displayed value has a backing entity with its own screen (a venue with
courtVenueId→ venue detail; a player name → public profile; a club name → club preview; a session reference → session detail), it renders as a deep link via the entity's canonical clickable component — NOT a one-offPressable+primary-Textreinvented per call site. The canonical venue link family (the venue display ladder — VenueLink inline → VenueRow row → VenueCard detail tile):VenueLink(@twomore/app) is a single non-interactive inline PILL (leading court-surface mark/MapPin + surface-tinted name; renders for any venue name, no backing id required) — for dense meta lines (compact session row, session meta);VenueRow(@twomore/app) is the bordered ROW rung (surface-graphic column + name + "distance · district · courts" meta line + trailing chevron) — the actual clickable component that taps through to venue detail viavenueId(or a customonPress); plusVenueCard(@twomore/app, the RICH large tile: static-map thumbnail → Naver app + name + 영업 중/종료 status pill viaisVenueOpenNow+ 길찾기 + surface·courts·district + facility icons + weekday-rate hint; the single source replacing the old duplicated location tiles). Use VenueLink for a dense inline mention with no tap target of its own (the parent card's tap covers it), VenueRow wherever a venue with an id needs to be its own tappable list item (venue lists, pickers, directory), VenueCard for the detail-screen location tile. When a NEW linkable entity recurs, build its canonicalXLink/XCardcomponent once and reuse it. The inner pressable wins the tap inside a tappable card; the card keeps its own destination. Free-text values with NO backing id stay plain muted text — never style a fake link. When a read path lacks the id needed for the link (e.g. an RPC summary), extend the read path (the 00261/00262 pattern) rather than shipping dead text.
Notices & banners
- Three shapes, never blurred (research decision, 2026-08-15): a persistent inline notice is one of
Banner,Callout, or a tintedPressablerow — pick by what the viewer does next.Banner(@twomore/ui) — an EXPLICIT, bounded choice: ≤2 buttons, stacked message-row-then-action-row anatomy (Material M2 banner shape),tone∈primary | warning | error | neutral | muted(fills mirror theCallout/badge palette; onlyprimarycarries a border, matching its reference call site — the session-detail pending-applicants and payment-reserved notices;mutedis the terminal/archived-state look —$badgeMutedBgfill,$textSecondarytext, no actions — used by the club-detail terminal-state banner, folded intoBannerdirectly rather than a bespokeClubStatusBanner). Render order inactionsIS visual order — list the action that most advances the flow LAST so it lands rightmost. Canonical call sites: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 aBanner(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 toCallout— reach forBannerinstead the moment a notice needs a button.- Tap-anywhere navigation (e.g. the pending-applicants row on session-detail) →
Pressable+ a tint background, neverBanner/Callout— the whole row IS the control, so it carries no separate button. - Documented exception — root-mounted system chrome:
NetworkBanner(packages/app/src/presentation/components/network-banner.tsx) is a legitimate 4th shape outside this set — an edge-to-edge, root-mounted connectivity strip below the status bar (system chrome, not a screen's content-level notice), so neitherBanner's bounded card frame norCallout's inset panel applies. Don't cite it as aBanner/Calloutdrift instance.
Participation & capacity indicators
- "Who's in" →
ParticipationCluster(@twomore/ui, the canonical FACES indicator) — anAvatarStackof participant faces with silhouette-fill + exact "+N" overflow. It is faces-only (owner canon 2026-07: the session summary card's usage IS the pattern — the ring is always composed separately, never inside this component). Passavatars: {name, avatarUrl}[](friends-first, caller-ordered) +total(true count for exact "+N");overPhotooutlines the faces to read against a banner photo;emptyLabelrenders a hint when there are 0 participants. Unknown/private participants still render a face —AvatarBubbleshows aUsersilhouette when a name is''or'?'(never a blank circle), so a populated session/club never shows a bare cluster. The ring is a SEPARATE primitive —CapacityRing— that the caller composes alongside the faces. RETIRED claim (do not resurrect): this section used to cite the deletedSessionCardas canonical for aCapacityRing direction="column"left-rail date cell +ParticipationClusterfaces composition. Neither piece survives on the current list card:SessionCardV2shows NO participant faces at all (moved to session-detail only, per the density-first redesign) and usesRecruitmentMeter— notCapacityRing— for its capacity signal (see § Participation & capacity indicators below).ParticipationCluster's live usage today is indirect — viaBannerCapacityCluster(packages/ui/src/banner-capacity-cluster.tsx, which composes it internally for the ring+faces cluster), consumed byclub-card.tsx's banner member cluster (overPhoto). It has no other direct call site inpackages/app/packages/features. Never hand-roll anAvatarStackwith silhouette/overflow logic per call site — that logic lives inParticipationCluster. - A participant avatar set must show ALL confirmed participants, not just the viewer's friends. Source faces from the confirmed-RSVP id set (friends-first ordering), NEVER gate the avatar fetch/render on
friendsCount > 0(the bug fixed 2026-06-12: session cards showed zero faces unless the viewer happened to have a friend in the session). The friends-going COUNT line ("친구 N명 외 M명") is separate social proof that may still gate on friends. - Capacity / fill at a glance →
CapacityRing(@twomore/ui, the canonical INLINE tracker) — a compactSegmentedDonutChartring +N/Mcount (propsvalue/max/size/countRole/direction).direction="row"(default) — ring and count side by side.direction="column"— ring stacked above the count. The deletedSessionCard's left-rail date-cell usage (48px bordered cell underDateTile) no longer exists —SessionCardV2usesRecruitmentMeterinstead (see below). Current real call site:add-round-sheet.tsx's feasibility tile 1 (games-range stat,direction="column"). It is the tracker for a ring+count unit that sits INLINE alongside other content on the same line — a ring never shares a line as the sole fill signal, it's a compact unit dropped into a row that's doing other things too. The line meters (ProgressBar) are canonical wherever a fill bar owns a FULL line by itself — that's the real retirement criterion, not "linear bars are retired." One implementation, two callers (owner direction 2026-07-20; shared 2026-07-27, SessionCardV2 slice ③):RecruitmentMeter(status word ·ProgressBar· N/M count, all on one dedicated row,packages/app/src/presentation/components/sessions/session-compact-row.tsx) is defined inSessionCompactRow's file and exported for reuse —SessionCompactRowitself renders it column-native (its own dedicated row), andSessionCardV2's StatusJoinLine renders the SAME component via itsflexprop (fills the remaining width of a row it shares with host/RSVP/saved badges), never a second bar implementation. What's actually retired is aProgressBarsharing a line with OTHER unrelated content (the old greedyflex:1bar squeezed into a session summary/compact/live-row alongside other elements) — a bar that owns its own line, or flex-fills the remainder of a role-badge line, is fine. Exception — the session DETAIL participation hero keeps its fullProgressBar: there the fill IS the focal protagonist (one hero number + one progress bar per the detail-screen IA rule), and a ring would shrink the main signal. So: ring for a compact inline unit, full-line bar when the meter gets its own line (compact row, v2 card, or the detail hero).ProgressBarremains the primitive for every NON-participant progress use (dues, records, analytics, attendance).
Buttons, text & badges
- The web frame is one definite viewport box (F01, 2026-09-29).
apps/webrenders 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, aDetailShellfooter, 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 (neverposition: 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.mjsholds 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 isbase(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-11max(16, inset)rule). A shell that reserves the bottom edge itself (DetailShell/PlaceShell/WizardShell's ownSafeAreaView) — or, forMainTabShell, whose sibling tab bar padsinsets.bottom + 8itself — statesreservedviaBottomInsetReservationProvider; the band then adds only its ownbase. Two owner-reported 2026-09-17 symptoms were one bug: underMainTabShell(not yetreserved) the band stacked a fullmax(16, inset)a second time on top of the tab bar's already-reserved inset (a "thick band"); under thereservedshells the old formula collapsed tomax(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 aButton— useStepper(@twomore/ui, see its own entry in Tabs & chips below — PD-02, primitive-drift-audit-2026-09-17: the realStepperhand-rolls a 32pxPressable variant="icon"circle, not aButton size="xs" shape="circle"). Variants: primary / secondary / outline / ghost / danger / destructive × xs/sm/md/lg × default/pill/circle. A page-level CTA pins toBottomCtaBand, never a card; a compact nestedActionButtonaction IS allowed (the ≤3 nested-affordance model in the Cards rule above — page-CTA vs balanced nested action).danger= quiet destructive TRIGGER (red outline + red text on transparent) for inline buttons that BEGIN a destructive flow (forfeit / delete / remove / leave).destructive= loud solid-red, reserved for the CONFIRM step (ConfirmSheetstyle:'destructive') and the rare phrase-gated final-commit button (delete-account). Trigger → danger, confirm → destructive; never swap them. Every intent-bearing CTA uses<ActionButton action="…">(from@twomore/ui), never a rawvariant— it maps INTENT (primary|secondary|neutral|cancel|tertiary|destructive) → variant viaACTION_VARIANT(the single source), so intent can't drift to the wrong color; SIZE still follows placement, not intent.cancel= a DISMISS/skip/undo (→ ghost);tertiary= a low-emphasis FORWARD action that is NOT a dismiss (add-note / approve-all / mark-read / mode-toggle — same ghost look, distinct intent). AdestructiveActionButton MUST be paired with auseConfirmgate at the call site (ACTION_REQUIRES_CONFIRMdocuments this). Parallels the status-badge intent→variant pattern. Sanctioned raw-Buttonexceptions (NOT routed through ActionButton — they carry no product "intent"): (1) dev-panel /__DEV__-gated tooling; (2) icon-only triggers (no text label); (3) selection-state toggles where the variant must FLIP with "is this selected"; (4) disabled, no-onPressstatus placeholders occupying a CTA slot; (5) the loud rawvariant="destructive"confirm/phrase-gated/direct-fire buttons (distinct from ActionButton'sdestructive→danger). Everything else = ActionButton. (Numeric steppers are NOT a raw-Buttoncase at all —Steppernever rendersButton; removed from this list 2026-09-17, PD-02, the same stale claim as the Button line above.) - A page's parts are proved with the real kit (batch 59). A screen test that mocks
@twomore/uicannot 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 withrenderRealUifrom@twomore/test-kit/src/real-ui-harness(imported by that path, never from the index): it renders the REAL@twomore/uiunder a TamaguiProvider with the app's tokens and themes, a ConfirmProvider, a QueryClient and a FlashList stand-in, and mocks only@twomore/appthroughcreateAppMock. Find a control withbuttonNamed(role button, exactly one), a heading withheadingNamed, and check a name withisNameClipped(node, NAME_LINES); pin the date withpinRealUiClock. 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 atextRole(the typography variant;roleis ARIA), never hardcodefontSize/lineHeight/fontWeight. The eleven roles are listed in styling (display hero title headline reading body foot footWrap caption tab count); the default isbody. Nav chrome: theHeader(K4b) draws every bar's title left-aligned in the chrome class — thetitlerole (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) orhero(24/32 · 700). Arole=+ inlinefontSizeoverride is the anti-pattern this rule exists to prevent — if no role fits, the role belongs intext.tsx, not as a per-screen override.fontWeightis the one sanctioned exception:role="…" fontWeight="600"/"700"on top of a role is a legitimate EMPHASIS override (bolding a name, a stat, an active state) — every role already fixes its own base weight, so this never substitutes for picking the right role, it only nudges weight within one;fontSize/lineHeightstay hard-banned (the@twomore/no-raw-fontsize-in-featureseslint-disable idiom is the only escape hatch, reserved for slot-matched metrics a role can't express). - Accessibility vocabulary (Tamagui 2, 2026-09-30). Props are
aria-*androle(COMP-12);Text's typography variant istextRole. Headings carryaria-level: 1 = screen title (theHeaderandCollapsingTopBar), 2 = hero title andSectionHeader(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-descriptionon web) andadjustableActionProps(onStep)(RN actions on native, arrow keys on web — everyrole="slider"uses it). Lint:no-rn-accessibility-props,heading-has-level,tamagui-image-src(Imagetakessrc). - Korean breaks between words (C04, 2026-09-29).
TextsetslineBreakStrategyIOS: 'hangul-word'on iOS, and web setsword-break: keep-all+overflow-wrap: break-wordon.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 v2Tagreplaced theBadge, whose imports are ratcheted out by@twomore/no-legacy-badge-and-chip) → a passive label of state, category or exception (library "Tag"):toneneutral (default) | brand | success | warning | danger | info,tierweak (default; solid only over photos and heroes),sizesm (24, the default) | md (28, hero and identity Blocks only),dottrue |'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 aLineSlot, which centres it on the label's first line — never analignSelfat the call site. Feature and presentation code writes onlyneutral,warninganddanger(@twomore/bounded-variant-enums, COMP-2); every other tone is a domain state, chosen by itsXxxStatusBadgewrapper (the tone table is indocs/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
Iconat the canonical sizes (20pxlgfor row-level actions, 24pxxlfor header slots), tinted$textSecondary/$iconMutedresting. The C feed's ♡/💬/↗ emoji footer and the 🖼 album prompt were the drift this canonized against — replaced with Heart/MessageCircle/Share2/Images. - Tempted to use raw Tamagui
Pressableas button → Use<Button>with correct variant
Empty & loading states
- Using
EmptyState→ page-level = full (64px icon bubble), section-level = compact (44px icon bubble) - Screens with async data → render
SkeletonCard/SkeletonHero/SkeletonRowduringisLoading. Never letEmptyStateflash before queries resolve. - 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 profileStatStrip/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 barethrow. 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 withsetErrorReporter(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(theTabBar, the only navigation; aNotice, which carries a failed write's words and its retry), and theDetailShellbar. 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/srcare 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 newthrow 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
FeedListheader (HomeDisputedAlertCard▸ the viewer has a disputed score;HomeAdminAttentionStrip▸ open admin obligations) and footer (HomeRecommendationsSection). Everything else lives inside the active view. - Live sessions render as plain
SessionCardV2s viaLiveSessionStackinside the 일간 view's 진행 중 section, gated to the real present day — there is no separate live banner or live card component (HomeLiveAlertBannerwas retired with the card feed). Adding a home surface → add a section to the right view, never a new cross-pane card. - Current anatomy: screen-blueprints/home.md.
Tabs & chips
- Tab pages →
MainTabShellfrom@twomore/ui(handles SafeAreaView top-edge,titleheader,headerRight,bottomCta). Passtabs/tabValue/onTabChange/renderTabto get a built-inSegmentedTabsswitcher. Never roll your own SafeAreaView + pageTitle combo for a tab page. A flat v2 tab root opts intochrome="tabs"(W4 batch 6: 경기): the kitTabspin and draw the one edge hairline once the pane scrolls (the Header then none), andsearchputs the Header in its search mode (the panes freeze,childrenis the results). The kitTabBaris the app's tab bar (apps/mobile/app/(tabs)/_layout.tsx), drawn over the panes: a full-bleed pane'sFeedListclears it by itself, and a list FAB isFab placed="tab"withuseFabListTail('tab'). - Segmented tab switching inside a page →
SegmentedTabsfrom@twomore/ui(PillNav-driven,preload="all"mounts all panes post-paint for instant switching). Multi-tab content where each pane has its ownuseQuery/useStatehooks → ALWAYSSegmentedTabs preload="all". Conditional{tab === 'x' && ...}on hook-bearing panes is forbidden — it remounts hooks on every chip tap and blocks the JS thread. Never use conditional{activeTab === 'x' && ...}for tab content that contains hooks. PillNav.onChangeis a direct urgent state update — chip flip must commit on press frame. Deferral of heavy content rendering lives at the SegmentedTabs consumer viauseDeferredValue.SegmentedTabsusesuseDeferredValueinternally — content pane mount is low-priority while the chip stays urgent. Don't addstartTransitionon top.SegmentedTabspreload mode →"visited"by default.preload="all"only when ALL panes have lightweight first-paint cost;useDeferredValuekeeps chips responsive either way.SegmentedTabsconsumers → wrap each pane component inReact.memoAND wraprenderTabinuseCallbackwith stable-reference deps (e.g. hoistuserId ?? ''tosafeUserIdfirst). Without both, every chip tap re-renders all mounted panes (sincepreload="all"keeps them mounted) and the JS thread saturates. Either alone is insufficient:React.memowithout stablerenderTab→ memo bails on referential prop check; stablerenderTabwithoutReact.memo→ component re-renders fully anyway. Seepackages/features/clubs/src/club-list-screen.tsx/packages/features/activity/src/activity-screen.tsx/packages/features/records/src/records-leaderboard-screen.tsxfor canonical examples (commite0ce50b, 2026-05-02).- Chip-style tab controls → use
PillNavfrom@twomore/ui. Don't add per-chipbackgroundColorvariants — the active background slides between positions on the UI thread via a Reanimated worklet. - Choosing from a small set (scope/filter selection, a form's options) → a
Choiceinside aChoiceSetfrom@twomore/ui(W2a; library "Choice", "Choice set"): the set is a namedradiogroup(one choice; one tab stop, arrow keys on web) or agroupof 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);fillshares one line among two to four that fit it; at most five options (seven for a fixed series). Selected = ink fill,$textInverselabel at 700, a leading check. A Choice ▾ (chevron) shows its value and opens a Sheet (DropdownChip/RangeChipare built on it); a removable Choice (onRemove) removes only itself. A loose Choice in a.mapor beside a sibling fails@twomore/choice-in-choice-set. Distinct fromPillNav/SegmentedTabs(navigation chrome). The oldSelectionChipfamily 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) →
SelectionCardfrom@twomore/ui(packages/ui/src/selection-card.tsx, promoted 2026-08-05 from the onboarding play-style fork):selected+ freechildren,Pressable variant="card"chrome, selected state = the SelectionChip family recipe ($primaryborder /$primarySubtlebg). Never hand-roll a per-screen tile — the create-club banner presets, create-session format/vibe tiles, and onboarding play-style cards were three divergent forks of exactly this, consolidated onto it. - Boolean toggle →
Switchfrom@twomore/ui(packages/ui/src/switch.tsx; K2a, library "Switch") in a toggleCellfor a setting that applies at once; a yes or no inside a Save form is aCheckRow kind="check"(library §1.22 rule 1). ONE toggle visual app-wide; the display-onlyTogglePill(promoted 2026-08-05, retired byno-legacy-rowsin K2b), the byte-duplicateFeeTogglePill(sessions) and the club wizard's text-pill toggle are retired. - Bounded-integer −/+ control →
Stepperfrom@twomore/ui(clamps internally;formatrenders arbitrary display text — tier names, "N명"). Never hand-roll aButton shape="circle"−/+ pair; the create-club and create-session wizard forks were consolidated onto it 2026-08-05. - Wizard chrome: disabled-CTA hint text →
WizardShell'sprimaryHintprop (pinned with the CTA band) — never improvised in step bodies where it scrolls away; in-flight primary CTA state ridesButton's ownloading/loadingLabel. - Progressive reveal (owner law, 2026-08-06): tooling that depends on a gate choice stays HIDDEN until that choice is made — default = not rendered, never disabled-but-visible (a wall of pending controls overwhelms; the create-club logo generator renders only after the 문양 사용 tile is selected). Applies generally: reveal dependent controls on selection, collapse them when the gate deselects.
- Sequential wizard reveal — THE CONTRACT (owner law, 2026-08-08, via
RevealStackin@twomore/ui; grounded in the Toss signup engineering writeup toss.tech/article/toss-signup-process): wizard steps present ONE decision at a time, and the next section reveals ONLY on explicit confirm — a confirm-button tap or keyboard done, never mid-typing validity (Toss: "다음 버튼을 누르면 … 필드를 자동으로 추가"). The stack renders the active section's confirm row itself (uniform across pages); optional fields are first-class sections with a visible 건너뛰기, never silently bypassed by a gate condition; selection forks whose tap IS the commit setselfConfirming. Choreography: answered sections stack UPWARD as a visible, editable trail; the active section stays keyboard-anchored (bottom of stack, host-scrolled above the keyboard) — NOT viewport-centered, per Toss's reversed-stack finding that centering puts the active input under the keyboard. Motion: therevealpreset (~250ms decelerate timing, both platform configs), no spring settle tail.confirmedstate is controlled by the step (resume with valid data starts confirmed). Applications: create-club steps 1–3 (이름 → 꾸미기; courts-only single input; the 일정 live-fill loop), create-session steps 1 (날짜 → 시작 → 종료) and 4 (참가비 → 참석 마감 → 반복 → 한 줄 소개) — the full wizard-facing contract lives in wizards.md. - Venue-pick canon set (owner, 2026-08-24; COMPONENTIZED 2026-08-25; UNIFIED into
VenueFinder2026-09-01; SPLIT 2026-11-08): since W4 batch 6VenueFinderis pick-only and the 경기 tab's 테니스장 pane isVenueExplorePane(@twomore/app,venue-explore/*: Feed items fromvenueFeedItemModel, the filter grammar above, its own Search takeover); what follows describes the pick finder. The canonical finder wasVenueFinder(@twomore/app,intent: 'explore' | 'pick') — ONE surface for the 경기-tab directory (explore: rows navigate to venue detail) and wizard picking (pick: rows emitVenuePickResult, incl. the Naver adopt-on-pick double-emit contract;pickedVenueIds/selectedVenueIdselected state,finderHint,initialRegions/initialDistrictsmount-once filter seeding, controlledquery). Grammar (owner-ratified via /wireframes/venue-finder):VenueFilterBar(SearchBar with the filter toggle INSIDE it viaExpandableFilterBar— never a separate filter row) + 전체/코트/연습장/즐겨찾기 nav chips + province-grouped browse + the shared dual-source search leg.VenuePickPanel(검색/둘러보기 mode chips + region-drill browse) is DELETED — never rebuild it; consumers own only "what a pick means". Per-venue court config isCourtUsagePanel(which courts + tap-to-rename). Both wizard step-2s consume them — extend the components, never re-assemble the pieces. The pieces, for reference —SearchBaroverusePublicCourts(curated-first + Naver) result rows,RegionDistrictFilterfor explicit browse (search vs browse is a user CHOICE, never chips under a search bar),VenueRowrows that ALWAYS carrylatitude/longitude(the photo → static-map → glyph cascade needs coords — a coord-less row falls to the name glyph and reads detached), andVenueSaveButtonbookmarks with the 즐겨찾는 코트 list fed byuseSavedVenueIds+useVenuesByIds(optimistic ids +keepPreviousData+ lite-entry merge so a new bookmark renders on the press frame). First assembled in create-club step2 + create-session step2 — extend those, never fork. Presentation (owner-directed 2026-09-01): inside a wizard step the finder is NEVER rendered inline in the step's scroll — the step shows a search-field-shaped trigger row andVenueFinder(pick intent) mounts insideVenueSearchTakeover(@twomore/app), the full-screen search overlay (see wizards.md "Search takeover law"). New search-over-open-data surfaces reuseVenueSearchTakeover's shell shape rather than embedding a live search field in a long scroll. The Naver-source marker on a result row isNaverBadge(@twomore/ui, promoted 2026-08-24 from two screen-local copies) — a 22px$naver-tinted "N" square; use it, don't refork it. The step's ANSWER is a picked row or free text explicitly committed via the edit scope's 저장 (venueFreeTextCommitted) — typed search text alone never enables 다음, and a committed answer always collapses into the selected-card state so it is visible (owner 2026-08-25). - Region → district cascading filter (시/도 → 시/군/구 multi-select) →
RegionDistrictFilter(packages/app/src/presentation/components/region-district-filter.tsx, aDropdownChippair over the canonicalREGIONSsource with shared cascade-reset behavior — deselecting a region prunes its now-orphaned districts). The single source for the 번개 pickup filter bar and the venue-directory filter bar (unified 2026-07-12, ~90 duplicated lines removed); lives inpackages/app(notpackages/ui, which stays primitive-pure, and not a featureshared/dir, since two sibling feature packages both mount it — ARCH-2). Encoded-value separator is parameterized (region-district-filter.ts) — venue matches it against a hyphen-composite DB slug, pickup uses a pure client-side comparison key.
Clickables
The base is the only touchable (owner law, 2026-08-08 — COMP-8): every touchable extends
@twomore/ui'sPressable(or a component built on it:Button,ActionButton,Choice,SelectionChip, …). RN offers NO global configuration for raw touchables — press physics, web cursor/hover/focus, the tonalstateLayer, and theTOUCH_TARGET-derived icon hit-slop floor exist only in the base, so a rawPressable/Touchable*ships with none of them and forces per-site hardcoding. Enforced by@twomore/no-raw-rn-pressable-in-featuresacross features, presentation, ANDpackages/ui/src(sole exemption:pressable.tsxitself).Contextual footer CTA (owner law, 2026-08-08): a wizard's footer primary reflects the INNERMOST open editing scope — while an attached edit panel (or any inline editor) is open, the slot becomes 저장 (closing that scope only) and reverts to 다음 when it closes. Never dual-fire save-and-advance in one tap: the wizard is a live-draft model, so 다음 already means "commit this step and continue" — the only ambiguity is a nested editing scope, and the slot follows it. Bridge pattern: the step surfaces
{complete, close}via anonEditStateChangecallback (seeStep3Props.ScheduleEditState); the screen swaps label/disabled/onPress, never the shell.Item removes (owner law, 2026-08-08): the row/card item-remove family renders ONLY via
RemoveIconButton(@twomore/ui) — an 18px md Trash2 in$textSecondarycentered in a 36px circular state-layer surface (hover/press paint a visible tinted circle; the bare-glyph scale/opacity dim was invisible). Icon vocabulary: trash = delete an item; ✕ = dismiss a surface, NEVER a remove; person-removal passesicon={UserMinus}. The warning step is built in: passconfirm={{title, message?, confirmLabel, cancelLabel}}and the press opens the canonicalConfirmSheetwith a destructive action — REQUIRED for removes that destroy multi-input work (a composed schedule, a fee tier) or persistent data (kick member); one-tap-to-recreate selections (a picked court) may omit it. stopPropagation is built in (nested-affordance contract). Family boundaries: in-chip removes (RemovableChip) stay xs ✕-proportional; surface closes (ModalPanel, wizard header) are dismiss semantics; input clears (SearchBar) are their own idiom.Affordance law (owner-directed 2026-09-06) — every tappable shows exactly ONE resting cue. Press feedback is transient; on the flat canvas a tappable with no resting cue "renders and behaves like a normal component". The cue is chosen by intent, never omitted: a navigation row/tile/avatar shows a trailing chevron (
ListRowauto-chevrons whenever it hasonPressand notrailing; atrailingslot alone supplies the cue instead — an action button — and a nav row that also carries a content MARKER (a status badge, a value) passestrailing={marker} chevron: the marker sits beside the one chevron cue, 2026-09-21 — the primitive composes it, callers never hand-rollChevronRightintotrailingasAdminNavRow/SettingsRowonce 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 showsmediaHint— aMarkerPillbottom-left on the band (the same chrome asVenueCard's 지도 보기 tile hint;MarkerPilllives in@twomore/uisince 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 isSectionHeader link={{ label, onPress }}(text + chevron, never a hand-rolledPressable > Textinright); an in-place action carries button chrome (Button/ActionButton) or, when text-only,$primary600 ink withaffordance="link"; a user-authored body (공지, DM bubble, post body, club description) renders throughAutoLinkText(@twomore/app,LinkifiedText+openExternalUrl) so pasted URLs are tappable links —$primary600 underlined, the text-only in-place action ink — never a bareText, 2026-09-21; a tappable Well/banner usesWell 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 — declaredaffordance="surface", no chevrons on feed cards; media that opens a viewer usesaffordance="surface".Pressable affordanceis the semantic marker (chevron | button | link | surface | none;surfacewaslayeruntil the tint band retired, 2026-09-28);noneis legal only when an adjacent element supplies the cue, with a same-line comment saying which. Enforced by@twomore/pressable-needs-affordance(error + ratchet); the 2026-09-06 sweep applied it to all 101 legacy sites.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
stateLayerprop and the$surfaceTertiarywash thatrow/cardpressables 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
hoverStyleunder@media (hover: hover)) is one gentle dim,HOVER_OPACITY0.85, shared byPressableandButton, plus the pointer cursor; - reduced motion keeps the dim and drops the settle,
Buttonincluded; - 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.
- it settles (scale on the press spring) and dims (opacity), with one row of values per variant in
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 wasSectionHeader variant="list"'s ownpaddingBottom(SEAM.item); a host never adds padding under it (the 추천 클럽 header stackedpaddingBottom $3on it: 20pt).GroupedFeedListdraws 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'sSectionSeparatorComponent.VirtualizedSectionListkeeps a separator's props in state set once per cell, so after a re-sort (내 클럽 sorts by next session) the leading separator kept a staleleadingItemand 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
onPressInto the matchingprefetchXhelper from@/presentation/cache. Press intent populates cache before nav resolves.Press feedback runs on the UI thread via the Moti animation driver — Tamagui
pressStyle+animation="quick"is safe for all shared primitives;Pressablefrom@twomore/uiis the only clickable (MotionPressablewas deleted 2026-05-09).Clickable surfaces → ALWAYS one of these primitives, never raw
onPresson a styledStackor inlinepressStyle={{ scale, opacity }}in feature code:- Page / form CTA →
Buttonfrom@twomore/ui(variants × sizes × shapes) - Tab strip / chip group →
PillNav/SegmentedTabsfrom@twomore/ui - Anything else clickable (cards, rows, chips, icons, FABs) →
Pressablefrom@twomore/uiwithvariant∈card | button | pill | row | icon | fab Pressableis the canonical clickable primitive. It encodes: requiredaccessibilityLabel(TS-enforced), per-variant scale + opacity press feedback on the UI thread (Reanimated worklet), reduced-motion fallback (opacity-only), web focus ring on$primary, standardized disabled state (0.5 opacity + non-interactive + busy a11y), andloadingprop that renders disabled state.- Inline
pressStyle={{ ... }}on rawXStack/YStackis forbidden inpackages/features/**. UsePressable variant="…"so the press feedback is consistent across the app. - Disabled state is rendered identically everywhere — opacity 0.5,
pointerEvents="none",accessibilityState={{ disabled }}. Don't roll your own. - Tap targets ≥44pt — enforce via outer container size when the Pressable wraps a small icon. iOS HIG / Material 48dp baseline. The recipe (2026-08-23 audit — the C-surface build shipped ~20-24px hand-rolled chips/footer actions and re-learned this): rows/tabs get
minHeight={TOUCH_TARGET.MIN}(orROW_HEIGHT_TOUCH); compact visuals (32px chips, 20px glyphs, text actions) keep their size and close the gap with symmetrichitSlop={(TOUCH_TARGET.MIN - visualPx) / 2}— the same mathHeaderIconButtonandSelectionChipencode. Filter chips are NEVER hand-rolled:SelectionChip size="sm"already carries the floor.
- Page / form CTA →
Adding an admin tab to a detail screen → follow the
AdminTabnav-hub pattern (named component, PillNav-driven, aCellper destination). Gate tab visibility oncanManageMembers || 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
Pressableis a button inside a button: axenested-interactiveon 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'sPressablein a relative wrapper, placed at the slot's measured origin (the sum ofonLayoutoffsets 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 ispressed(aria-pressed), neverselected.hitSlopon web is realised byPressableitself (an invisible out-of-flow child), so the 44pt recipe above holds on both platforms.ProgressBarrequires anaccessibilityLabel: the fact already beside the bar.A header icon is a registry glyph the shell's
Headerdraws (K4b), never a hand-built control. A page passesactionstoDetailShell,MainTabShellorCollapsingTopBar: at most TWO of the eleven glyphs (back and close lead the title and are the shell's), each a press (icon,onPress, optionalbadgedot or count,selectedfor a toggle such as the bookmark,coveredByWebNavfor an entry the web nav carries) or the ⋯ / + with amenu. 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,HeaderActionsMenuandAppHeaderare 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
personLabelalone. It returns the printed name and the face name together (spread intoPersonRowand 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.tsrefuses any other file that readsmatchResult.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(lefta tonal Button — 취소 in the back button's place — andrighta primary one: 완료, 저장; see the edit-state archetype below); the page's primary action stays inBottomCtaBand. A secondary navigation task with no glyph (초대 코드) is the bar's one tonalmode.rightButton 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 TamaguiPopover. The JS-portalPopoverz-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 bottomMenuSheet; from 600 it is the anchored Menu (240 wide, r12, a 1 edge, no shadow) rendered inside RN's nativeModaland positioned from the trigger'smeasureInWindow(), 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 bespokeModalPanel, not this menu.
Lists
Scrollable feeds (flat or grouped-by-section) →
FeedList/GroupedFeedListfrom@twomore/ui(wraps FlashList (FeedList) and SectionList (GroupedFeedList) with virtualization tuning, loading-more footer, and empty/loading slots). Never use bareFlatList/ScrollViewfor 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 isFeedEnd doorsas 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 firstSectionHeadcarries the 필터 / 필터 N Choice, and one removable Choice per active value sits under it) opensFilterSheet(@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 시/군/구;formatRegionPickwrites 서울 강남구 외 1곳) and the date one (DateFilterSheet). Never an always-on filter bar:ExpandableFilterBar,DropdownChipfilter rows andRegionDistrictFilterretire as each discovery list converts. The filtered-empty state's one action isfilters.reset(필터 초기화).Row metadata is one line; explanation the reader decides on is shown whole (D6, 2026-09-29).
PersonRow/TextRowline 2 (l2Lines) default to one line; helper copy that says what a setting does, or a report reason a moderator acts on, passesEXPLANATION_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/SectionBlocktaketitleLinesfor 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/GroupedFeedListused 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 theui-stringsbridge 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 ONEGroupedFeedListfor 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 auseCallbackwith stable-reference deps — never inline. An inline callback is a fresh reference on every parent render, so the list re-renders every visible row on each parent state tick (a sheet open/close, a chip flip, a countdown). Declare theuseCallbacks ABOVE any early return so they obey rules-of-hooks; if a callback closes over another handler, hoist that handler into auseCallbacktoo (otherwise the dep array churns and defeats the memo). Pair withReact.memoon the row component for full effect —useCallbackalone keeps the prop reference stable,React.memois what skips the row's re-render. Enforced by@twomore/require-memoized-list-render-callbacks(Wave H).Mixed-type
FeedList/FlashListMUST passgetItemType. When one list renders structurally different item kinds (e.g. a{kind:'header'}~30px row vs a{kind:'match'}~150px row), passgetItemType={(item) => item.kind}. FlashList v2 keeps ONE recycler key-pool + ONE height-average bucket PER item type; with nogetItemTypeeverything shares the'default'pool, so on scroll a recycled header view gets reassigned to a match row → React sees the same key with a different tree → full unmount+remount + a Fabric layout reflow on every such recycle (this was the 544msscroll:scorecard-livejank, 2026-06-03; canonical fix:packages/features/sessions/src/scorecard/live-tab.tsx).getItemTypeis NOT inFeedListProps' Omit list, so it passes straight through to FlashList — noFeedListchange needed. React Compiler does NOT mitigate this — a key reassignment remounts regardless of memoization. Not lint-enforced — reviewer discipline when a list'sdatais a discriminated union.K3b (2026-11-02):
SectionBlockandSectionHeaderare deleted. A titled group is aSectionHeadover its rows in aYStack(title,count, oneactiondoor,choice,bulk,expand,info,note; notone="danger"and noleading: the danger shows in a destructive Button or Cell). The head pads its own rail, so in a railed column or list it sits inRailBleed(lintno-double-rail);GroupedFeedList's default section header andDataSection'stitledo.RailBleedonly cancels a rail the CALLER adds (aFlatColumn, arailedListpreset): a rail-owning part in an unrailed list (SessionDayHeaderin 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.tsxholds 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 →
SectionBlockfrom@twomore/ui(title, optionalrightslot,tone="danger"for destructive sections — theiconprop was REMOVED 2026-06; section titles are text-first). Section titles always live outside cards (in-card section titles were tried on the club home 2026-08-22 and rejected on device — "a big mistake"; the seam-void problem they targeted is a canvas/figure-ground issue, not a title-placement issue).SectionBlockdelegates its header row to theSectionHeaderprimitive (packages/ui/src/section-header.tsx—variant="block"displaySm$text/variant="list"cardTitle for virtualized list section headers,accessibilityRole="header"). UseSectionHeaderdirectly ONLY when a title row cannot wrap its body (aGroupedFeedListrenderSectionHeader, aFeedListheader slot) — never hand-roll section-label typography in feature code, and no decorative leading icons (therightslot is for real counts/actions/config only).SectionBlockalso takesvariant(passes through toSectionHeader) andinset('none'default — the parent padded column owns the $4 inset;'all'— the block insets title AND content, for unpadded full-bleed pages like the club home;'title'— title inset only, content full-bleed, for horizontal rails that own their own edge bleed).Section-rhythm law (2026-08-21o; K3b 2026-11-02:
SectionBlockandSectionHeaderbelow are the retired pair, the head isSectionHead) — every vertical seam has exactly ONE owner; never sum two spacing sources across a boundary. The canonical seams: section→section =$4(owned by the page's ONE gap-bearing column — or by a single list-slot padding where a native list slot (FlashList header/footer) breaks the gap chain); title→content =$2(owned bySectionBlock— as the stack gap forvariant="block", asSectionHeader's ownpaddingBottomforvariant="list";SectionBlockzeroes its gap in list mode so the two can never stack); list item→item =$3(FeedList/GroupedFeedList'sDefaultSeparator); page chrome→content =$4(MainTabShelltitle block,SegmentedTabschip→pane); controls→list-title =$3. Drift signature to reject in review: a wrapperpadding*around aSectionHeader/SectionBlock(the doubled seam —$3wrapper +$2header = an uncanonical 20px), or apaddingTopthat duplicates a sibling gap. Corollary: a conditional section rendersnull, never an empty container — an emptyYStackstill consumes the parent's gap slot (the 32px rail→소식 seam bug, 2026-08-21o). 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 aFeedList/GroupedFeedListowns its bottom seam (SectionHeaderlist/flat/densepaddingBottom $2), so the host's separator after it is a doubled seam —GroupedFeedList's defaultsectionSeparatoris nowSectionSeamSeparator(nothing after a header — RN's leading section separator arrives withleadingItemundefined — andSEAM.rail16px before the next header), and a flatFeedListwhose rows include headers passesitemSeparator={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 theirdisplaySmline (the$7= 32px floor hid 2px of dead air under every hub title);denseownspaddingBottom $2like 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. ARowList/FactListtrims its first row's top padding (the edge rule below), so the header keeps its 8 over it — tappableListRows 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 explicitedgeprop wins,ModalPanelresets it) — the clone alone stopped at the wrapper. A padded row a host renders WITHOUTRowList(aFeedListitem,MemberRow/DuesRow/applicant groups) has no trimmed edge: its ownSEAM.itemtop padding is the seam, and over those the header isflush(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. ADividerbetween 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
$surfaceSecondarytiles, ≤5 items) with a더보기escape into the vertical browsing page. A shelf may NEVER be the sole path to content — every rail is backed by a complete vertical list one tap away (the original "no horizontal scroll for my stuff" rule is this law's browsing-surface half). Rationale: shelf grammar keeps the user IN the room (belonging — Spotify/Netflix home model); a vertical feed on a place surface hands the page over to the items and duplicates the browsing page.Seam ladder + two rails (owner-directed 2026-09-06) — a seam's value comes from the relationship, never from the screen; one owner per seam.
SEAM(@twomore/uiseams.ts) is the single source: element$1=4 (title→sub, icon→label) · item$2=8 (element→element, row padding, section title→content, chip/icon-item gap) · block$3=12 (block peers, leading→text, Well/Callout inner padding, button pairs, chrome→content) · rail/hero/band$4=16 (the text rail, hero identity→first section, content→CTA band, band padding) · section$6=24 (each side of the full-bleed section divider) · tail$9=48 (scroll tail with no band). Alignment has exactly two rails: the text rail at 16 for every text and control (section titles carry no inset of their own) and full-bleed at 0 for media, section dividers, the CTA hairline, the nav hairline. Edge rule: a section's first child carries no top padding and its last no bottom padding (RowList/FactListcloneedgeonto rows), section headers carry no min-height slack, no block adds an outer margin — so every visible gap equals its rung whatever child sits at the edge. Hero zone: stat strips and hero facts go inFlatColumn lead, never as a section (a section divider above AND below would frame them). Call-site gating: aFlatColumnchild is gated where it is placed (cond ? <Section/> : null); a component that returns null internally still earns a divider. A contract test (seam-contracts.test.ts) locks every primitive seam to its rung; post detail is on this system too — there is no second grammar.Content insets are configuration (2026-09-06 rhythm audit, docs/design/rhythm-alignment-audit-2026-09-06.md) — a scroll container never types a number.
contentContainerStylecannot take$Ntokens, so@twomore/uiexports the ladder's pixel values once (CONTENT_INSET) and the only presets a screen may compose (SCROLL_CONTENT.page= the shell default, so a screen restating it passes nothing;list/railedListfor a list inside a shell;banded/railedBandedunder a pinned band, where the tail collapses to the band seam;sheetfor aModalPanel.ScrollView). Lint@twomore/no-raw-content-inset(error, no waivers) rejects a numeric padding/gap inside acontentContainerStyleliteral or ascrollContentstyle object in features. Before this rule ~50 screens each pasted their own tail and it had forked across 16/24/32/48/56/96/100/104 px. Same family:HAIRLINE({width:1, color:'$borderSubtle'}) is the one recipe every chrome line,DividerandFlatColumnread;ListRow subtakesstring | readonly string[](one fact per line) exactly like herometa;MiniBarMeteris the one bar-meter shape;BADGE_SIZE.NAVER,RADIUS_PILL,SLIDER_GEOMETRYand the recordsRANK_COLUMNconstants replace the last unnamed sizes. A raw number that must stay carries a// reason:comment (box-sizes.ts convention).Divider law (owner-directed 2026-09-06, final) — dividers separate sections; peers are separated by spacing. A section boundary on a flat page is a FULL-BLEED 1px
$borderSubtlehairline withSEAM.section($6, 24px) above and below, drawn byFlatColumnbetween its children (one owner), under aSectionHeader variant="flat"title atnavTitle700. Where a list renders its own section boundary (aSectionListheader, a list-header block), it rendersDivider seam="section"(bleedinside a railed container), never a stack drawn as a line, and never a literal-valued border (borderBottomWidth={1} borderBottomColor="$borderSubtle") used as the separator either — a real chrome edge reads those two literals fromHAIRLINE.width/HAIRLINE.color, so a literal-valued border is always the hand-rolled recipe, never a legitimate exception:Divideris the ONE hairline component and lint@twomore/no-hand-hairline(error, F4 2026-09-07; widened 2026-09-10 color/type audit MEDIUM-2 to also catch the literal-border shape; widened again 2026-09-17 primitive-drift audit PD-05 to also catch that same width/color pair behind a ternary —borderBottomWidth={hairline ? 1 : 0}, the actual shape nearly every row used — any$border*token beyond$borderSubtle, andpackages/app/src/presentation/**alongside features) rejects bothheight={HAIRLINE.width}and a hand-rolledborder{Bottom,Top}Width/border{Bottom,Top}Colorpair in features. Rows of the same kind inside a section (ListRow,FactRow, link rows, roster rows) carry NO line between them — their$2vertical padding is the rhythm (list rows 40px floor with hitSlop to 44, fact rows 36; owner 2026-09-06 "make the inter-peer gap tighter"; a fact list with multi-line rows takes$3, see "Rows read as separate by proximity") (RowListstacks them with no separators). The one exception: when each peer is itself a multi-component block (a match row with a scoreboard, a review block, a correction row with chips), a divider between blocks is allowed (RowList dividers). Club detail's home tab is the precedent: its blocks are divided by a line; the rows inside are not. Nothing frames aWell,StatStrip, or hero — the block's own edge does that. Cell dividers are not section dividers: a horizontal strip (StatStrip) has no row rhythm, so a 1px vertical hairline between its cells is the device that groups a value with its label (App Store / Naver Place model); it spans only the cell content, insetSEAM.elementat each end, it sits centered in the 8px gap between cells (4 · line · 4), and every cell carriesSEAM.itempadding inside so content never touches its own edge (owner 2026-09-06). History: the 2026-09-05 build drew hairlines between rows and only whitespace between sections, which made a section edge read like one more row; whitespace-only seams read crammed on device; the tinted band stays a documented escape hatch only.Meta text grammar (owner-directed 2026-09-06) — one table, read by every primitive.
META_TEXT(@twomore/uitext-grammar.ts): primary meta (hero identity lines)cardBody$textSecondary; secondary/sub (row sub, fact label, stat label, section meta)cardMeta$textTertiary; eyebrowcardMeta$textTertiary700; caption (fine print, empty-state subtitle)cardMeta$textSecondary.TypeHero,MediaHero,ListRow,FactRow,SectionHeader,StatTile,EmptyStateread it; call sites importMETA_TEXTrather than restating a role and color; a contract test locks the table.Graphics lead (owner-directed 2026-09-06) — avatars, logos, flags, thumbnails, and any inline graphical asset render to the LEFT and TOP of their block, never the right.
TypeHero leading(56pxAVATAR_SIZE.HERO, top-aligned) replaced the retiredrightslot;MediaHero avataralready leads; rows carry graphics inleading. The trailing edge is for status, counts, chevrons, and actions only.No middle-dot joins (owner-directed 2026-09-06; restated 2026-09-14: "proper visual breaks for each semantically meaningful piece of information") — facts are not glued together with
·, and there are no exceptions. A meta line holds ONE fact; several facts stack as lines (TypeHero/MediaHerometaaccepts an array — one line per entry) or become chips/fact rows. When short facts share one line (date + time, court + round, paid + pending),MetaSegments(@twomore/ui) is the one primitive that puts them there: each fact is its ownTextand the break between two facts isMetaBreak, the same 1pxHAIRLINEtheStatStripdraws between cells — a surface with its own text role composesMetaBreakdirectly (the network banner, theVenueLinkpill). An i18n leaf therefore never carries two facts: split it into one key per fact (arrays of facts arereadonly string[]) or rewrite the sentence as prose.@twomore/no-middle-dot-joinflags' · 'in rendered strings and i18n tables at error with zero disables (the 2026-09-06 "single-string composite" waivers were all swept on 2026-09-14).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/PersonRowsubtakes it as a segment line (an entry that is itself an array renders asMetaSegments). Member posts (PostFeedRow) and system rows (feed-auto-rows.tsx) use this oneListRowheader, 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 throughAutoLinkText, 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 (DisclosureLinkis 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 sharesExpandChevron); 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.blockeach side standalone; aFactListthat owns it throughdisclosurepasses its own row rhythm, so glyph-to-row equals row-to-row and the section seam follows), and the 44pt floor ishitSlop, 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").ExpandChevronpoints down closed and turns up open on thequickspring; what opens enters throughExpandReveal(therevealanimation, 8pt slide + fade), orFactList revealedItemsfor fact rows (same list, same rhythm); both rest under reduced motion. The accessible name says what the press does now ("자세히 보기" / "접기" / "왜 이 숫자죠?", never shown) withexpandedstate. Where another control must stay the target (a chip, a notification row shell, a sticky round header), it keeps its container and drawsExpandChevronwithexpandedstate 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 thelabelof anExpandToggle, is an error outsideexpand-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 thelabelcount form (a tonal Button, "4개 더 보기", 접기 once open) and the chevron-only form above is retired by@twomore/no-legacy-headsas 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 (FeedListonEndReached) 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'sleadslot as one stack (SEAM.herobetween 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 intone="destructive", each still behind its confirm; no tool list or 위험 구역 section in a detail page's body (the session page's 호스트 도구/위험 구역 retired intobuildSessionHostMenuActions; 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$cardand$backgroundin every theme (Material 3's surfaceContainerHighest-over-surface step, IBM Carbon's dark tag background step), text on them ≥4.5:1 — gated bycheck:color-contrast'sfillpairs (light fills read by hue and are reported only). The quietsurfaceBadge 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/TypeHerorender every badge in a single row directly under the title, before the meta lines, in the order the caller passes (convention: entity state → verification → category); the title line and the media overlay carry none —titleBadgeandMediaHero statusare both retired. Category/kind (실내 · 테니스장 · 샵) is a badge in that row, or eyebrow text, never both. Row-level status (RSVP · paid · waitlist) is the row's trailing slot, vertically centered, and replaces the chevron. Counts and facts (12명 · 4면) are meta text or stat cells, never badges. Two badges max in a hero; overflow moves to the meta line or a fact row. Hero composition (same ruling — "noncanonical top padding, contents skewed to the left"): every hero opens with theSEAM.hero(16) seam under the chrome hairline or the media band (neverSEAM.chrome, locked inseam-contracts.test.ts), sits on the 16 text rail, and carries no bottom padding (the page'sFlatColumn topowns the next seam). Order: eyebrow → identity row → nothing else. The eyebrow spans the rail ABOVE the identity row, so aleadinggraphic (56pxAVATAR_SIZE.HERO, on the rail,SEAM.blockto the text) top-aligns with the TITLE line, never with the eyebrow; title, badge row and meta lines all start on the same x. A screen never wraps a hero in padding or margin of its own; the hero is the first child under the header (or under a terminal-stateBanner).Hero identity pattern (owner ruling, 2026-09-08) — supersedes the Badge placement law above's "one badge row under the title": no rows of flat metadata under a hero title. A hero's title line carries the title plus ONE inline key badge (
titleBadge— the entity state, e.g. live/모집 중/마감 — anXStack alignItems="center" gap="$2"where the title shrinks/truncates and the badge holds its size), never a badge row. Directly under it, ONEGlyphFactRow(facts,SEAM.itembelow the title) — up to 4 facts, each a glyph + one value with no separate text label (the glyph IS the category: 📅 date, ⏰ time, 🌤 weather, 🎾 format, …) — replaces the old stackedmetalines entirely. Secondary numeric stats (counts, capacity, price) stay OUT of the hero; they belong in the page'sStatStrip(FlatColumn lead), never a hero fact or badge. A hero must never restate a generated/derived title as its own title when a more concrete identity (a place, a person, a real name) is available — e.g. the session hero's title is the venue/place name, not the generatedsessionTitle()string (format + style), which now renders as the 🎾 fact instead (session-hero-section.tsx).MediaHero/TypeHero'stitleBadge/factsprops (backed byGlyphFactRow,packages/ui/src/glyph-fact-row.tsx) implement this; theirmeta/badgesprops are@deprecatedand removed once every caller has migrated. Glyph rows are never clipped (owner device QA 2026-09-18): Android paints a color emoji taller thanGlyphSlot's 16px box, so a clipping ancestor one text line tall (overflow="hidden"on a 20pxcardMetarow) cuts the glyph's bottom edge flat (the 🎯 on the session card). Nooverflow="hidden"on an inlineGlyphFactRowor any one-line row hosting aGlyphSlot— horizontal containment isminWidth={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 aTextonly inside its own line box and vendor emoji fonts render up to ~1.4× the fontSize, so every emojiTextsizes its lineHeight throughemojiLineHeight(fontSize)(= fontSize + 8;GlyphSlot's emoji slot is 12 in a 20px box,TierBadgeChromethe same rule) — neverfontSize + 2, never a text role's own line. An emoji beside text goes throughGlyphSlot, not a bareText textRole="caption"(the oldbadgerole).Group-spread law (owner-directed 2026-09-05) — a horizontal group of peers fills the rail. Tabs, segmented controls, action-icon rows, CTA-band button pairs, stat strips, and single-line chip sets span the page rail edge to edge with the GAP as the constant and the ITEM WIDTH as the variable (owner 2026-09-06 final: "items in a row should have canonical spacing; the size of each item should be adjusted instead"): equal
flex={1}columns separated by the canonical gap ($2for icon items and chips,$3for button pairs, 0 for hairline cells), the first column starting on the rail and each next one where the previous gap ends, with the CONTENT CENTERED inside its column (owner: "items should centrally align the contents") — ONE layout primitive,CellRow(equal columns, centered content, an 8px GAP between cells, 8px PADDING inside each cell — gap and padding are different things: the gap keeps neighbouring containers apart, the padding keeps content off its own edge — and an optional 1px cell divider centered in the gap, 4 · line · 4), thatStatStrip(dividers on) andActionIconRow(dividers off — a circle is its own figure) compose; only the item differs. Neverspace-between(which makes the gap the variable) and never a centered fixed-width cluster; a group centered with fixed-width items (crammed in the middle) is the drift signature to reject. Overflowing chip sets wrap left-aligned; horizontal rails start at the rail and bleed right.ActionIconRow,StatStrip,PillNav variant="underline", andBottomCtaBandpairs implement it.Canvas law (owner-directed 2026-09-02) — ONE flat non-grey ground, and components go flat WITH it. Every product surface rides the flat
$cardground; the tinted grey$backgroundretreats to placeholders and recessed wells. This supersedes the browsing-surface half of the place-surface law above (which kept browsing surfaces on tinted$background) and the figure-ground law recorded indetail-shell.tsx(2026-08-22c). Read the history before touching this — a flat canvas was tried once and reverted: the 2026-08-22 pilot flattened the GROUND while leaving card-boxed components on it, so the cards lost the surface they sat on and "read as floating objects", and tinted was restored the next day. The mitigation that makes this attempt different is not a lighter grey — it is that the components flatten too: rows become bare hairline-separated blocks on the canvas, not boxes. Flattening the ground WITHOUT flattening the components reproduces the 2026-08-22 failure exactly; the two halves ship together or not at all. The grammar already exists on the club home (its C-redesign is the precedent, 당근 모임 model): rows sit directly on the canvas (club-schedule-rows.tsx— "utility rows on the bare canvas, NOT inside a Card"), separated by bottom-hairline-only blocks (a trailing<Divider />— the top hairline was removed 2026-08-24 as lopsided framing; the block itself was a hand-rolledborderBottomWidth={1} borderBottomColor="$borderSubtle"until the 2026-09-10 color/type audit repointed it at the primitive), and canonical cards expose a boxless variant for embedding (SessionCardV2 variant="embedded", the "E1 flat embed" — full card anatomy, no outer box). What STAYS aCard: only a genuine figure that must lift off the canvas — the detail hero, and a surface whose whole job is to be a distinct object. A list row, a settings row, a stat strip, a section body: never boxed. Section titles stay OUTSIDE any card (existing rule, unchanged). Migration status:PlaceShell(flat$card) is the target shell andDetailShell(tinted) is the legacy one — ~60 screens still ride the tinted ground. The remaining work is smaller than a raw<Card>count suggests, and mis-scoping it is the trap: the LIST-ROW level already complies (the 2026-08-25 row-grammar-parity wave leftDuesRow/MemberRowas bare hairline rows with zeroCardboxes), so what is actually still boxed is (a) the ground itself and (b) the surrounding utility chrome — summary cards, month selectors, section heroes, entry/nav rows. Of the ~189<Card>instances in features, many are legitimately staying (form sections, modals, and genuine lifted figures); they are spread thin (~4–7 per screen), not wrapping list rows. Migrate in staged batches — shell first, then chrome — behind the/wireframes/canvas-migrationboard rather than as one sweep, and audit per screen rather than trusting a global grep. Until a screen is migrated it keeps its current ground — a half-flattened screen (flat ground, boxed chrome) is the one state this law forbids. Ruling 2026-09-07 (owner): one grammar, keyed on content shape, not page type. Flat sections for every detail page and every homogeneous list — the club 운영 screens (members, attendance, dues, sessions, venues, challenges, the 관리 entry), the guest review flow and the venue finder's chrome included; object cards (SessionCardV2,ClubCard,DirectoryVenueCard) remain only inside browsing collections. Headline numbers on a flat page are aStatStripinFlatColumn lead, never the boxedStatGrid. The migration proceeds by risk (shell first, then chrome, flat-row screens first) and every migrated screen must satisfy the group-spread law (equalflex={1}cells, the gap constant, content centered, neverspace-between) and the seam ladder — the owner's condition for the go-ahead was "component positions, spacing, alignment, equal distribution on the same row". Enforcement (2026-09-07, flat-layout plan F0): the tinted-shell ratchet ran the migration to zero and was deleted with F8 (2026-09-07) —DetailShellhas nogroundprop; every detail screen is the canvas, andMainTabShellalone keeps atintedoption for the main tabs;scripts/__tests__/wireframe-kit-parity.test.mjslocks the wireframe kit's Fl* metrics toseams.tsso an approved board never describes spacing the app cannot produce. The slice order lives indocs/architecture/flat-layout-implementation-plan.md.
Shells & detail screens
- 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;initialRouteNameis 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-rollcanGoBack()branches. Push targets are keyed by signal TYPE (signal-routes.ts, asatisfies 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) →
DetailShellfrom@twomore/ui(SafeAreaView top-edge, theHeaderback/title/actions, optionalscrollprop). Never duplicate SafeAreaView + a header manually in a detail screen. - Owner ruling D1 (2026-09-08): a
MediaHerodetail page usesDetailShell header="collapsing"—CollapsingTopBaroverlays the hero (expanded/transparent over the media, no static title repeating the hero's own) and flips to solid chrome once the hero scrolls under it, driven by the shareduseCollapsingTopBarthreshold hook (@twomore/ui); aTypeHeropage (no media) keeps the staticAppHeader.header="collapsing"only self-wires whenDetailShellowns theScrollView(thescrolldefault) — a screen with a nested list (FeedList,scroll={false}) composesCollapsingTopBar+useCollapsingTopBardirectly instead. Live consumers:session-detail-screen.tsx(conditional on hero mode),directory-venue-detail-screen.tsx,venue-detail-screen.tsx,public-club-profile-screen.tsx; club detail's own two-state bar (use-collapsing-banner.ts) shares the same threshold resolver. - A fact row answers ONE question (owner 2026-09-27: "consolidate and compact the information"). A
FactRowstates one question's answer (when? what game? what level? what cost?). Short facts of the same answer share its main line asFactSegments(value style) or its second line (secondaryValueas 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 sameFactList(revealedItems) behind oneExpandToggle(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 aFactListwhose rows carry a second line or a note spaces EVERY rowSEAM.blockper side (24 between rows,FactListRhythm'roomy'); a list of one-line rows keepsSEAM.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
FactRowin 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 theEditCuepencil (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) —editCuecarries 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.noteis also the point-of-decision line on an editable row ("만든 뒤에는 바꿀 수 없어요" on a wizard review).valueAccessoryputs 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;EditFieldRowstays 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 inphoto-scrim-visual.ts) darkens the photo edge behind any control drawn over it:- Top:
CollapsingTopBarin 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.tschecks. - Rejected: per-glyph circles, retired 2026-08-23 as noise.
- Ink: anything drawn on a photo takes
ON_PHOTO_INK(the$whitetoken), never$textInverse, which flips dark in the dark theme and left dark glyphs on the dark scrim.
- Top:
- 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 everyMediaHero— photo, album AND map (session, venue, directory venue) — andheroBandCollapseThresholdflips their bars (DetailShell's defaultcollapseAt). 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 asubline) isheaderHeight()(@twomore/ui) — a screen that must derive layout math from the bar's footprint (e.g. aKeyboardAvoidingView'skeyboardVerticalOffset) 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 bySectionHeader variant="list"(aFeedList/GroupedFeedListheader slot orrenderSectionHeader) or bySectionBlock variant="block"(a composed feed such as the home views) — the two render the samecardTitletitle and both own the title→first card =SEAM.item($2) seam; card→card =SEAM.block($3) is owned by the list'sDefaultSeparator(or the section body'sgap), never by a card margin; section→section =SEAM.rail($4) is owned by the ONE gap-bearing container (the composed feed's columngap, or a native list header slot's padding when a list breaks the gap chain), never by a header's ownpaddingTopplus a previous card'spaddingBottom; the scroll tail is owned by the scroll container'sSCROLL_CONTENTpreset alone (a view never adds its own trailing padding under it);$5is not a rung and never appears. Every hub collection has a title (ActivityListControls-style filter bars are controls, not titles). A dashboard widget on a hub keeps its card but its title moves out of the card (SectionBlock).seam-contracts.test.tslocks the header variants to$2and the list separator to$3. - 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 placedFab(what it adds above the tab bar's report, or above the bare inset) andPinnedBottomBar(the box a composer sits in) calluseBottomChromeReport()(packages/ui/src/bottom-chrome-report.ts);BottomChromeReportRootin@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 absolutebottom) goes through one of these primitives;bottom-chrome-report-scan.test.tslists 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. Theirroutes.*factory may accept aclubIdfor callers' convenience but ignores it in the path (e.g.clubSession(_clubId, sessionId) => /sessions/${sessionId}, likematchBoard/scorecard). Only screens that are genuinely a sub-view of ONE tab (club members/dues/board/settings under 클럽) stay nested. Lesson from 2026-05-25: session detail was nested under(clubs)and anchored every entry point to the 클럽 tab.
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_MEDIAis themediaof BOTH Tamagui configs (native through@tamagui/react-native-media-driver'screateMedia), so style props read mobile-first:$mediumfrom 600,$expandedfrom 840,$largefrom 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.cssrepeats the numbers;web-frame-contract.test.mjsfails on drift. - Widths come from
LAYOUT_WIDTH, never a literal:reading680 (detail pages, forms, 프로필),pane340,list360,wizard640,control480 (a segmented strip, a period pager, the search bar),shell1200 (a tab root),rail80,sidebar240. A cap is justmaxWidth— 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(alwaysreading, 680: a tab root's tabs and rows sit in the reading column),DetailShell(widthreadingdefault,widefor boards and directories; theScrollViewspans the page so a wheel scrolls anywhere, its content caps and centres),PlaceShell(reading),WizardShell(640, header and CTA included).AppHeaderandCollapsingTopBarkeep their fill full width and line their glyphs up with the column (contentMaxWidth); the collapsing bar's photo scrim covers only the column.BottomCtaBandspans 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 readsuseContentWidth()(the window before the first measurement, so a phone's first frame is unchanged).@twomore/no-window-width-in-layoutflagsuseWindowDimensions/Dimensions.getinpackages/uiandpackages/featuresoutside 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
readingcolumn and its day-sectioned session rows are one column at every width (batch 63, N2);FeedList/GroupedFeedListhave no grid mode (the opt-ingridprop andfeedGridColumnswere 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 oneComposerFieldfrom@twomore/ui(a 40-tall rounded field on$gray100, the board kit'sFvkComposer); a page never builds a second one. The role$surfaceSecondarywas 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'svisiblepair holds$gray100against the page. - A not-found wall's header is the back arrow alone.
NotFoundStatedeclares the wall (useDeclareWall) andDetailShelldraws 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,
Pressableis a tab stop and presses on Enter or Space. The primitive setstabIndexand 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'shitSlopis 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 asidedocks 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), andBottomCtaBandsheds its band chrome there (useInSupportingPane). The summary is a projection of the page's own model (session detail:SessionActionPane— fee viaformatWonOrFree, the card'sRecruitmentMeter). - 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 isListRow selected(a$primarySubtlefill +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/linkthrough Tamagui'srender,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 passesDetailShell navRootso its back button hides the same way.
Wizards & confirms
- Multi-step creation flows (
CreateSessionScreen,CreateClubScreen, future onboarding) →WizardShellfrom@twomore/ui(handles dismiss + progress bar + bottom CTA row). Form fields inside steps →LabeledField(label + optionallabelRightslot for a char counter + optionalcaptionslot for a below-control info line + input + optionalerror) wrappingStyledInput(themed RNTextInput), both from@twomore/ui. A label row with a123 / 500counter isLabeledField+labelRight, NOT a hand-rolledXStack. Below-control caption canon (research-grounded 2026-08-18 against Material Design's supporting-text spec and NN/g's placeholder-text findings — see citations in the linked section) — wizards.md field grammar, including the caption-vs-AttachedPanelordering rule: when a field has both, order is control →AttachedPanel→ caption (never caption between them — pass the caption as a manual trailing line after the panel instead of viaLabeledField'scaptionprop). 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 labelt().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-choiceWizardDraftBanneris 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 viaresumeClubDraft(packages/features/clubs/src/create-club/resume-draft.ts). The final 확인 (review) step's per-field rows → a flatFactListof editableFactRows (editCue+onPressback to the step that asks the field; noCard, no summaryCallout— a consequence sits on its own field's row asnote), 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 inpackages/ui/src/labeled-field.tsxandpackages/ui/src/fact-row.tsx(the draft-resume mechanism above has nopackages/uicomponent any more — it lives in the feature-level lifecycle hooks cited there). - Confirmation alerts →
useConfirmfrom@twomore/ui(imperativeconfirm.show({title, message?, buttons})). NeverAlert.alertdirectly — native Alert ignores design tokens and renders as an OS-styled centered dialog.ConfirmSheet(the file name survives; since 2026-06 it is ModalPanel-based, not a Tamagui Sheet) is the canonical replacement:$-tokens, dark-mode aware, hardware-back dismiss, backdrop press deliberately does NOT dismiss (dismissOnOverlayPress={false}— matchesAlert.alertsemantics), single-flight (only one prompt visible at a time). Button-style map:cancel→outline,destructive→destructive (red),primary→primary CTA,default|omitted→primary — UNLESS it is the ONLY button in the prompt (a lone non-cancel/non-primary button), which falls back to outline, matching a native Alert's single-button treatment (variantForinconfirm-sheet.tsxis the authoritative branch order; PD-08, primitive-drift-audit-2026-09-17).ConfirmProvidermust be mounted in_layout.tsxabove any screen that callsuseConfirm.useConfirmworks from insideModalPanelcontent — RN<Modal>keeps children in the React tree, so context is reachable. The old portal caveat (context unreachable inside portaled content — the 2026-05-23 dev-panel TelemetryPane crash) now applies ONLY to the dev panel's TamaguiSheet: there, calluseConfirmfrom the component that OWNS the Sheet, or use an inline confirm. - Screen-archetype selection — the same KIND of interaction MUST use the same archetype across the app. A club-ready app can't surprise users with "edit opens a sheet here but a full screen there." The four archetypes + their canonical interaction kinds:
- Create-entity / multi-step flow → full-screen wizard (
WizardShell). Create session, create club, onboarding. - Edit-entity — standalone, multi-field, reached from a menu or route → full-screen route (
DetailShell). 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-fieldModalPanelthat 적용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/CollapsingTopBarleft), 완료 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.
- Create-entity / multi-step flow → full-screen wizard (
- 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 callsuseLeaveGuardwith its own discard step. While active, the iOS swipe-back is locked (native-stackgestureEnabled: 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 callsrelease()first. NeverusePreventRemoveon 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 isuseWizardBackHandler'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 sameMatchRulesEditorbody; full-screenMatchRulesScreenis the standalone session-level entry — the split is intentional and documented). - Manage a roster (add / remove / role-change people) → full-screen route. Participants, members.
- Confirm a destructive / irreversible action → ALWAYS
useConfirm. Never a bespoke confirm panel that re-implements title + destructive button + cancel (anti-pattern fixed 2026-05-27: club member removal used a customConfirmRemoveSheetwhile every sibling — delete session/club, remove friend/participant, forfeit, end-session, logout, revoke consent, delete account — useduseConfirm; consolidated).useConfirmis for confirming a DESTRUCTIVE intent — don't stretch it into a generic multi-option action menu for non-destructive choices. - Pick from a small set of contextual options, or a list picker →
ModalPanel. Dues action cascade (dues-action-modals.tsx), score call/edit, swap teams, referee-recipient picker, discovery/pickup filters (viaDropdownChip/RangeChip, which embed ModalPanel), add-round. - Read-only info / explanation →
ModalPanel. Tier info, playability TMI, match-profiles. - View "see more" of low-stakes list content → inline expand (no navigation). Roster preview, FAQ, round accordion.
- Archetype scales with complexity + context: a 2-option no-input row action is inline buttons (session-payments 납부/면제); a 4-option-with-sub-inputs action is a ModalPanel cascade (club dues paid/partial/waive/note); a single text field is a quick ModalPanel; a multi-field standalone edit is a full screen. When the same editor appears embedded-in-flow vs standalone, the embedded one MAY be a ModalPanel and the standalone a full screen — but that split must be deliberate + documented, not accidental. Before adding a new interaction, find the matching kind above and use its archetype; if you're about to diverge, document why. (Until the 2026-06 Codex batch these overlay archetypes were Tamagui bottom sheets; every one is now
ModalPanel— see the Tamagui/Styling rule.)
- Edit a single field in-place → the bottom EDIT SHEET (
- A
ModalPanelconsumer that renders its own back/close header MUST passshowCloseButton={false}toModalPanel.Frame. The Frame's built-in X close button is redundant (and visually stacks) once the consumer's own header already carries a dismiss control — a back chevron (the scorecard sheets'AppHeader chrome="bare") or an equivalent affordance (match-profiles, match-rules-editor). All five current sheets that render their own header (ScoreCallSheet,ScoreCorrectionSheet,ScoreEditSheet,match-profiles-sheet.tsx,match-rules-editor-sheet.tsx) pass it; a new custom-header ModalPanel consumer must too. ModalPanel's built-in close button's defaultaccessibilityLabel/closeAccessibilityLabelis the Korean literal'닫기'(packages/ui/src/modal-panel.tsx) — nott()(theuipackage has no@twomore/appdependency), matchingWizardShell's owncloseAccessibilityLabel = '닫기'default precedent. Fixed 2026-08-18: it had leaked the English'Close panel'as the announced label on the ~39 of 45ModalPanel.Frameconsumers that never pass an explicit override — a screen-reader-only i18n gap invisible to sighted QA. Pass an explicitcloseAccessibilityLabelonly when a more specific label reads better in context; the shared default otherwise covers every consumer.ModalPanel.Frametitle row (owner audit, 2026-08-18): a left-aligned title Text istextRole="headline" marginBottom="$4" paddingRight="$8"— thepaddingRightclears the Frame's built-in top-right close button (absolute-positioned attop="$3" right="$3"sized$8), which a long title otherwise runs under. Reference implementations:recurrence-editor.tsx,group-editor.tsx,regroup-preview.tsx. Applies to every default-headerModalPanel.Frametitle; exempt: a custom-header consumer (showCloseButton={false}, rule above) and a centered/symmetric confirmation dialog (challenge-accept-modal.tsx) where adding one-sidedpaddingRightwould visibly off-center the text — those keep short, static copy instead. Audit swept and fixed the drift (missingfontWeight/paddingRight, ormarginBottom="$3"instead of"$4") inedit-field-modal.tsx,dues-action-modals.tsx,dues-attest-modal.tsx,club-guests-section.tsx's two modals,gallery-section.tsx,regular-meets-section.tsx,club-groups-section.tsx, andtransfer-ownership-modal.tsx.- Edit-box grammar — which surface for a committed value:
AttachedPanel(packages/ui/src/attached-panel.tsx, canon in wizards.md) is LIVE-COMMIT — it edits the draft/entity directly as the user interacts, no separate save step; reserve it for a control that is a DEPENDENT of the field it's attached to (a day-of-month stepper under a nonzero fee, a court-count picker under a picked venue).ModalPanelis STAGED — nothing commits until an explicit 저장/제출 press (or the cascade's own terminal action); reserve it for an independent entity edit (a regular meet, a group, a single settings field). Never blend the two: aModalPanelform must not auto-commit keystrokes into the live entity, and anAttachedPanelmust not gate its content behind its own 저장 button — closing it (folding the row) IS the save. AttachedPanelspacing spec (owner-ratified, 2026-08-18 — closes a "differs box to box" drift audit): all three numbers are baked into the component, not caller-parameterized. Outer:marginTop="$2"(8px) between the owning control and the panel — the component's own root margin, never a per-call-site override (the notch's existing offset still bridges this gap and reads as pointing at the control). Internal:padding="$3"(12px) on the panel box, plus agap="$3"(12px) content container the component owns aroundchildren— pass children directly, never re-wrap them in a caller-localYStackwith its owngapto get row spacing (an ad-hocgap="$1"wrapper around a suggestion-dropdown's rows was the drift instance that prompted this audit; collapsed in the same pass). A component nested one level inside the panel (e.g. aRevealStackcomposing its own sequential sections) may still own its own internalgap— that governs a different concern (its own item reveal rhythm) and should match this same$3baseline rather than an arbitrary larger value.
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/ListRowviatitleLines, 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:- blank spacers and reserved columns (the leaderboard's 24pt trailing spacer is gone, and
나sits beside the name); - columns a header already states (the 조 column in the 조별 view, where the section header names the group);
- secondary figures, which fold under the name or move behind a tap;
- the headline figure;
- 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.- blank spacers and reserved columns (the leaderboard's 24pt trailing spacer is gone, and
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'sFvkPersonRow, onAvatar,ThumbandMeta— and the appPersonRowbelow is a thin wrapper over it;@twomore/no-legacy-people-rowsratchets the oldListRow-based forms out, see the plan's "K5b sweep: the people rows") — every list of people renders through the ONEPersonRowonListRow(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 threeParticipantRowvariants, 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 primitivePlayerIdentityChip(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.mjsis[]— the round-4 cleanup migrated every listed file). The rule flags ROWS (anXStackorflexDirection="row"container holding anAvatarBubbleand a nameText); 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 rideListRowandPlayerIdentityCard. A vertical identity block (a centered chat intro, a page hero) is a hero, not a row: it composes throughTypeHero'sleadingslot (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 bymatchHistoryResultRowProps) is the ONE match row (F3/GM-16, 2026-09-23;MatchHistoryRowretired 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 →
QueryBoundaryfrom@twomore/app(the canonical skeleton → error+retry → content flow). Pass the screen's critical-path query results + a shapedloadingskeleton; it renders the localized error+retry surface (QueryErrorState) when ANY query errors, the skeleton until all settle (viauseScreenReady/Protocol G), else the content. Never leave a data screen with noisErrorbranch (a failed load must show retry, not a permanent skeleton). For LIST screens, passerror={q.isError}+errorState={<QueryErrorState onRetry={() => void q.refetch()} />}toFeedList/GroupedFeedListinstead (the error renders in the empty slot; stale data stays on background-refetch errors).QueryErrorStateis the single error surface — never hand-roll an error EmptyState per screen. Composer-backed live screens (useLiveSessionData) use the composer'sisError/retrydirectly. Screens that render a list via manual.map()or gate it behind alength > 0branch: add the error branch to that conditional with<QueryErrorState />when next touched. - Offline is a fourth branch, not a variant of empty (2026-09-11 page audit, ~70 files). TanStack v5 reports
isLoading=falseANDisError=falsewhile a query is offline-paused (fetchStatus === 'paused', the defaultnetworkMode: 'online'), so a surface whose only branches are loading → error → empty renders a false claim on a cold start offline ("no venues", "venue doesn't exist", "no permission", "club not found"). Every query-gated surface therefore reads one offline primitive:QueryBoundary(owns all five phases), FeedList/GroupedFeedListisPausedOffline={q.isPaused}+offlineState={<OfflineEmptyState onRetry=… />}, an earlyif (q.isPaused && !q.isFetched) return <OfflineEmptyState />placed BEFORE the loading branch and before any permission gate, or a composer'sisPausedOffline. A data hook that returnsisLoadingalso returnsisPausedOffline. Enforced by@twomore/require-offline-branch-with-loading-gate(error;// offline-safe: <reason>waives a read that is genuinely not a network query). 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.QueryBoundaryrenderschildrenforoffline-staleandOfflineEmptyStateonly foroffline-cold; itsoffline?: 'cached' | 'hidden'prop (defaultcached) is the private-read switch:hiddenrendersOfflineEmptyStatefor any offline phase. Private (hidden-class) reads are the staff queues and snapshots (신고 관리, 가입 심사, 입금 확인, theclubAdmincounts 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 anonlinegate 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/uireads one context (WriteGateProvider, supplied at the app root byOfflineWritesProviderfrom the source the strip reads):Fab, theCtaBandprimary,Celltoggle/destructive,Button write, aMenuSheetrow or anIconButton/row control/answer markedwriterender soft-disabled while offline unless the call site passesofflineOk(a write that queues durably, or a press that only navigates). A page never keeps its ownstale/readOnlyflag 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);QueryBoundaryis 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 drawsOfflineEmptyState,QueryErrorStateorNotFoundStatefor its own phase beside it.DataSectionanduseScreenGateare deleted (no importer); never gate a section render on an outerisLoadingflag, 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'spageState, andClubGateView(shared/club-gate-view.tsx) draws it with the doors ofclub-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 passeshasRecordand 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 classhidden(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 arecached. A page never computes offline fromisPaused,isPausedOfflineoruseIsOnline:useReadOfflineonly (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 inguarded-exit.ts). It returnsexit(leave): while a submit runs every way out is held, with something typed the edit guard asks andleaveruns only on 그만두기 (soleavemust clear the draft), with nothing typedleaveruns at once. A Sheet'sonOpenChangeis(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.tspins 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,useSendDmMessagecallclearSentInlineDraft(key, sentText)): a per-callmutatecallback 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,GroupedFeedListandShellScrollViewregister their scroll handle throughuseScrollToTopRef; the app that owns the tab bar mountsScrollToTopProviderwith the navigation library'suseScrollToTop(the phone: first route of the focused tab only, so a deeper page never scrolls) or the web nav callsscrollRegisteredToTop()only when the pathname is the destination's root. Every tab root's scroll container must be one of the three (the 기록 tab usesShellScrollView). - The copy action is
copyText(text)from@twomore/app(expo-clipboardon the phone, the browser clipboard on the web; resolvesfalsewhen declined).CopyableValueshows 복사 on every platform; a declined copy shows thehintline, never a Toast. The test kit'sCellmock draws thecopycontrol. - 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-growthholds 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, themenuframes) 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.wrapcard(wraps inCard tone="default") |flat(bare, for a tile already inside a carded parent);ordervalue-first|label-first;valueRole/valueSlot(override the value line with an arbitrary node, e.g. a tier badge — passvaluetoo as the text fallback);subValue;progress. Use for any numeric-stat-+-caption cell (profile hero, dues summary columns, records cards) instead of a localYStack+Textpair. ExpandableFilterBar(@twomore/app) — the canonical SearchBar + filter-toggle-badge + expanding-panel chrome shared by pickup discovery, club discovery, and the venue directory (all three hand-rolled the identical shell before this). Panel CONTENT stays feature-owned (passed aschildren); only the toggle button + panel shell + header/reset row is shared. Use whenever a list screen needs a SearchBar with a collapsible filter panel behind a toggle.ListRow size="person"(deleted 2026-11-02, K5b, in favour of the uiPersonRow; 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 (Metafoot 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) andcontrol(a trailing IconButton, the row's second stop outside its press area). 알림's rows are the first; the card-eraNotificationRowShellis gone. The same pass addedSectionHeader variant="feed"(the flat v2 Section head: 32 above, 12 below,first12 under the Header,commandas a tonal sm Button andchoiceon the title's line),ValueSheet(the Sheet aChoice▾ opens; DropdownChip is retired),FeedEndandSkeletonRow 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 frompackages/features/home(2026-09-01) to@twomore/uioncepackages/features/clubs' duesMonthSelectorneeded the identical idiom — features can't import each other, so a primitive two feature packages both need lives in@twomore/ui. Pure presentation (label/handlers/a11y strings are caller-supplied props); used by home'sDayCalendarPicker/MonthYearPicker/WeekMonthPickerand clubs' duesMonthSelector. PeoplePickerBody/PeoplePickerSheet(@twomore/ui,people-picker-body.tsx,people-picker-sheet.tsx; numbers inpeople-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.MatchListControlsmodule (records,packages/features/records/src/shared/match-list-controls.tsx) — one filter/sort/group state machine + controls ModalPanel sheet + count-trigger, shared byRecordsHistoryScreen(unbounded, multi-month feed) andRecordDetailScreen(single bounded period). Chronological grouping grain stays TWO distinctGroupModevalues ('month'for the unbounded history feed,'date'for an already-bounded period) — a genuine divergence the consolidation preserves rather than collapses.MarkerPill(@twomore/app,packages/app/src/presentation/components/sessions/marker-pill.tsx) — the canonical small-pill chrome ($7-radius,toneprimary|neutral) for a marker/hint pill.DDayChip(the file wasdday-chip.tsx) has been retired —MarkerPillis exported directly, with no wrapper. Reach forMarkerPilldirectly for any new marker/hint pill instead of re-deriving the bg/radius/padding recipe.- 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 overStatTile(single source for the value/label anatomy) — adds a$surfaceSecondarycard fill, equal-flex/stretch sizing, and an optional signeddeltaline (▲/▼) that always renders (opacity 0 when a cell carries no delta) so every cell in the row shares one height. Use for any "headline numbers row" instead of a localXStackof ad-hocYStack+Textcells. - 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 optionalselectedIndex/onScrub/onScrubEnd— tap or drag snaps to the nearest point (native: the slider cores'Gesture.Pangrammar; web:onMouseMove/onClick), drawing a small filled marker + hairline vertical guide at the selected index and turning the chart into oneaccessibilityRole="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'sloading/loadingLabel(@twomore/ui,packages/ui/src/button.tsx) — the canonical in-flight CTA treatment:loadingswaps the leading icon for anActivityIndicatorand forces the disabled state; optionalloadingLabelswaps the button's text too (e.g. "저장 중…"). An ad-hocActivityIndicatorcomposed into an icon slot per call site is retired — useloading/loadingLabelonButton/ActionButtoninstead.usePickImages(@twomore/app) is the one library-pick flow — permission dialog, pick, resize; features never importexpo-image-pickerdirectly (lint@twomore/no-raw-image-picker).- The offline strip is ONE place: the app's root.
NetworkBanner(@twomore/app, built on the kit'sNotice, grounded on the board'sFvkOfflineStrip) 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 (OfflineStripandFormOfflineStripare retired); a cold page drawsEmptyState kind="offline"instead. Enforced by@twomore/no-legacy-feedback(aNetworkBannerimport 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 aScrimand lifts the page's own target (the FAB) above it. Words are props; the page decides once-only throughuseCoachmarkSeen. ReplacesCoachmarkTooltipand the tour library for new marks.FeedItem onLongPress+longPressLabel(@twomore/ui, W4 batch 16) carry a command the board draws no button for (홈's 숨기기):Pressabletakes 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/uiholds 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 ofBlock kind="match"tomatchWords()and the live Tag: the scorecard'sMatchStateBlockand 홈'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.