Shared UI component inventory for the flat v2 library (2026-10-09)
Status: Reference · Date: 2026-10-09 · Evidence appendix for flat-v2-component-library-2026-10-09.md: every @twomore/ui export and shared presentation component with its states in code, geometry, consumer count and v2 disposition. Generator scripts live in the session scratch directory (not committed).
Status: Draft for design · Scope: every export of packages/ui/src/index.ts, every shared component under packages/app/src/presentation/components/**, measured against docs/design/flat-mobile-grammar-v2-2026-10-09.md (§2–§4b) and docs/design/research/list-surfaces-spec-2026-10-09.md §4, with docs/canon/components.md as the current law. Purpose: the component library is designed first (owner: "design all of the components first, including all their interactive behaviours … then design the layouts for all pages based on the new components library"), so this file lists what exists, what each piece does today, and where the v2 target list has no answer yet. Source snapshot: commit 0175c593e, 2026-10-09 (includes the migration plan, §0.2). Nothing under the repo was modified.
Headline (details in §5)
- 255 exported component names: 142 in
packages/ui(94 rows below), 59 shared presentation components (30 rows), 54 single-consumer-group presentation components (appendix). Disposition by name: keep 123 · re-spec 74 · merge 34 · retire 24. - The v2 grammar and the Tag/Choice map name 21 components; the code has 255 names.
mergecollapses 34 names into 7 v2 targets (Tag, Choice, IconButton, Notice, StatStrip, SwitchTrack, Button sm-tonal);retireremoves 24 (17 of them are exported but never rendered today, §4.3). - Top consumers decide the order (§5.2):
Text312 files,ActionButton128,Pressable108,Icon94,EmptyState85,SectionBlock82,Badge73,DetailShell67,SelectionChip56,Card55. - No code exists yet for: FAB (the
fabpress variant has zero uses), BrandBlock, Roster (4-up grid with dashed empty slots), thelevelTag circle, the spaced friend-avatar row. The tab bar exists only inline inapps/mobile/app/(tabs)/_layout.tsx:62-78, not as a shared component. Detail in §4.
0. How to read this file
Path legend. ui: = packages/ui/src/ · pres: = packages/app/src/presentation/components/ · app: = packages/app/src/presentation/ · mobile: = apps/mobile/. file:123 is a line in that file; :123 alone means the file named in the same cell.
Disposition codes. keep = same role, same look, only token re-points; re-spec = same role, new geometry/ink (the v2 component replaces it in place); merge → X = the component dissolves into v2 component X; retire = no v2 counterpart (v2 §6, or dead code).
Consumer counts. files (groups): number of non-test .tsx files under packages/features, apps, packages/app that contain <Name (same regex as the brief, run over all 747 files; script: bench/component-inventory-src/count.mjs (rerun: node count.mjs && node build-tables.mjs && node assemble.mjs from that folder)), and the number of distinct consumer groups among: the 8 feature packages (activity, clubs, home, messaging, onboarding, profile, records, sessions), packages/app (screens/hooks), apps/mobile, apps/web. Uses inside packages/ui are not counted (a primitive used only by other primitives shows 0 (0)). Family rows show one count per member, joined by ·. Caveats: counts are files not call sites; the regex cannot see non-JSX uses (Component({…}), styled(Component)), so a few 0 rows are referenced elsewhere (stated in the note); Text could include non-@twomore/ui Text (312 of 312 files that render <Text also import it from @twomore/ui, checked by import grep).
Counting rules. A row is one design-system component (a file or a file's tightly coupled exports, e.g. ExpandToggle + ExpandChevron + ExpandReveal). A name is one exported identifier that renders UI. export * from 'tamagui' (index.ts:389) is excluded; the three layout stacks are listed but kept out of rankings (YStack 353 files, XStack 260, ZStack 0).
State letters in the tables. P pressed, H hover (web), F focus ring (web), D disabled, L loading, S selected, E error. "passive" = no press behaviour in the component itself.
0.1 The v2 target list used in the "disposition" column
Targets come from grammar §4 (rows), list-surfaces spec §4 (the Tag/Choice migration map) and §2–§3. ✔ = specified by one of those two documents; ◇ = needed by the six screens or by merging, not specified yet; ▢ = a form family the grammar has not touched.
| ID | v2 component | Source | Specified? |
|---|---|---|---|
| T-Type | type scale 12/13/15/17/20/24/40 | grammar §2 | ✔ |
| T-Icon | icons 24 actions · 20 hero facts · 16 chevrons | grammar §4 "Icons" | ✔ |
| T-Seam | hairline tiers (edge #D4D4D4, cell #E5E5E5) + 12pt band | grammar §4b | ✔ |
| T-Header | header 56, title 20/28·700 at the rail, actions 24 in 44, no divider | §4 "Header" | ✔ |
| T-UnderlineTabs | row 44, 15/22 600/500, underline 2 under the label | §4 "Underline tabs" | ✔ |
| T-BrandBlock | 350×144 (ELO 158), eyebrow + numeral + meta | §4 "Brand block" | ✔ |
| T-SectionHeader | 17/24·600, action 13/18·500 + chevron 16, 32 above / 8 below | §4 | ✔ |
| T-MediaRow | padding 12, thumb 56, pitch 80, L1 17/24 L2 15/22, one trailing | §4 | ✔ |
| T-PersonRow | padding 12, avatar 40, pitch 64, L1 15/22 L2 13/18 | §4 | ✔ |
| T-DayHeader | 32 tall, 15/22·600 date + 13/18 relative day | §4 | ✔ |
| T-CellRow | 48, label 15/22·400 / value 15/22·500, cell hairline | §4 "Cell row (facts)" | ✔ |
| T-Tag | 24 tall, pad 0 8, r6, 13/18·600 (list spec says 22/26, r8, 12/18) | §4 · list spec §4 | ✔ (two specs disagree, §6) |
| T-Choice | 36 tall, pad 0 14, full radius, 15/22·500, ink fill + check; modes menu/range/input | §4 · list spec §4 | ✔ |
| T-Button | primary 52, r12, 17/24·600; sm tonal replaces ActionPill | §4 · list spec §4 | ✔ |
| T-BottomCtaBand | button 52 in pad 16 + inset 34, hairline only while scrolling | §4 | ✔ |
| T-FAB | extended pill 48, pad 0 20, 20 from rail, 16 above tab bar | §4 | ✔ |
| T-TabBar | 56 + 34, 24 icons stroke 2, 11/14·500, active = filled glyph + ink | §4 | ✔ |
| T-StatStrip | 80, three columns, 24/32·700 numeral | §4 | ✔ |
| T-Hero | eyebrow 13/18, title 24/32·700, three 17/24·500 glyph facts | §4 | ✔ |
| T-Roster | avatar 48 in a 4-up grid, name 13/18, level Tag | §4 | ✔ |
| T-Chart | 160 tall, inset 16, one ink line | §4 | ✔ |
| T-Avatar | 40 rows · 48 roster · 56 identity | §4 | ✔ |
| T-CountBadge | CornerCountBadge kept | list spec §4 | ✔ |
| T-SwitchTrack | TogglePill replacement | list spec §4 | ✔ (name only) |
| T-Meta | MetaSegments text line | list spec §2 | ✔ |
| T-IconButton | glyph in a 44 target (derived from §4 "Header") | — | ◇ |
| T-Notice | inline banner/callout/well | — | ◇ |
| T-Toast, T-EmptyState, T-Skeleton, T-Progress, T-Menu, T-Overlay, T-List, T-Page, T-AvatarGroup | derived | — | ◇ |
| F-* (Field, Search, Slider, Wizard, Stepper, AddRow, PickerRow, AttachedPanel, ChoiceTile, Check, PeriodStepper) | forms | — | ▢ |
0.2 Phase B families (docs/architecture/flat-grammar-v2-migration-plan.md, commit 0175c593e)
The migration plan landed while this inventory was being written (same day, 15:22). Phase B designs the library in these families and asks for "what each replaces (one-for-one map from the inventory)". The map from today's code to those families:
| Phase B family | Phase B members | Today's code that feeds it |
|---|---|---|
| chrome | header, tabs, tab bar, FAB, CTA band, sticky edges | AppHeader, MainTabShell, DetailShell, PillNav/SegmentedTabs, tab bar inline in mobile:app/(tabs)/_layout.tsx, BottomCtaBand, HeaderIconButton/HeaderTextAction, CollapsingTopBar; FAB and sticky edges have no code |
| surfaces | block, band, stat strip, hero, chart | StatStrip/StatTile, TypeHero/MediaHero, SparkLine, Card/Well/Divider (block and band have no code) |
| rows | media, person, cell, day header, section head, roster | ListRow, PersonRow, FactRow, SessionDayHeader, SectionHeader/SectionBlock, VenueRow, LeaderboardRow, MatchHistoryRow, SessionCardV2/ClubCard (roster has no code) |
| labels | Tag, count | Badge + 16 status wrappers, MarkerPill, tier pills, CornerCountBadge |
| controls | Button, Choice, Switch, Input, TextArea, Picker, Slider, Search, Stepper | Button/ActionButton, SelectionChip family, TogglePill, StyledInput (multiline = TextArea, no separate component), PickerRow + native date picker, four sliders, SearchBar, Stepper |
| overlays | ModalPanel, menu, toast | ModalPanel (+ ConfirmSheet), HeaderActionsMenu, ToastHost |
| states | empty, skeleton, error, offline | EmptyState + QueryErrorState/OfflineEmptyState/NotFoundState, Skeleton*, NetworkBanner, QueryBoundary |
| forms | wizard field grammar | WizardShell, RevealStack, LabeledField, AttachedPanel, AddCard, PickerRow, RemoveIconButton, EditFieldRow |
The plan also records decisions that bear on dispositions below: no shadows; pure white canvas; rows 80/64 without lines; one passive Tag + one Choice control; scheme S2 "Ball & Ink" (ball actions with ink text, green only as mark and court tile). Where those overrule current code the conflict table (§6) says so.
1. Primitive table
1a. packages/ui/src — every exported component
Order follows the file's role (type, actions, tags and controls, surfaces and rows, chrome and shells, forms, charts, infra). Text-heavy cells are deliberate: they carry the file:line evidence.
| Component | File | Purpose | Variants / props that change the look | States implemented (read from source) | Sizes / geometry today | Consumers files (groups) | v2 disposition |
|---|---|---|---|---|---|---|---|
| Text | ui:text.tsx | The one text node; 11 semantic roles | textRole ×11: pageTitle 24/32·700 (:70), cardTitle 16/24·600 (:78), navTitle 17/22·600 (:98), cardBody 14/22·400 (:105), cardMeta 14/20·500 secondary (:112), badge 12/18·600 (:120), micro 10/14·600 (:133), displaySm 20/28 (:140), displayMd 28/36 (:155), displayLg 36/44 (:172), displayXl 48/56 (:189); tabularNums (:203) | passive; iOS hangul-word line breaks (:66) | Pretendard 400/500/600/700; 800 maps to Bold (fonts.ts:30); tracking −0.1…−0.7 (fonts.ts:71-97) | 312 (10) | re-spec → T-Type. Body is 14 today, 15 in v2; v2 adds 13/18, 12/16, 40/48 and drops micro 10, displayLg/Xl; the role set shrinks 11 → 7. |
| Icon | ui:icon.tsx | Lucide wrapper | size xs12 sm14 md16 lg20 xl24 2xl28 (icon-sizes.ts:12-19); color, fill, strokeWidth | passive | default md 16; header/tab icons stroke 1.8 (header-icon-button.tsx:54) | 94 (10) | re-spec → T-Icon. v2 uses three sizes only: 24 actions, 20 hero facts, 16 chevrons, stroke 2. |
| YStack / XStack / ZStack | ui:stack.ts | Tamagui layout stacks re-exported | Tamagui props | passive | — | 353·260·0 | keep. Layout, not design-system components; counted separately (YStack 353 files, XStack 260, ZStack 0). |
| LinkifiedText | ui:linkified-text.tsx | User text with tappable URLs (wrapped as AutoLinkText) | $primary 600 underlined link segments (canon) | link press via Pressable | inherits parent role | 1 (1) | keep |
| MetaSegments / MetaBreak | ui:meta-segments.tsx | Several short facts on one line, separated by a 1px hairline | tier secondary/primary/eyebrow/caption (text-grammar.ts:40-45) | passive; accessible with joined aria-label (:86-87) | gap $2 (:84), MetaBreak 1px stretch (:59), on-tint opacity 0.35 (text-grammar.ts:55) | 29·5 | keep → T-Meta. v2 boards print ·; code law bans middle-dot joins (canon components.md:139), so MetaBreak stays the separator. |
| Dot / PulseDot / GroupTierDot | ui:dot.tsx · pulse-dot.tsx · group-tier-dot.tsx | Status dots; PulseDot = live indicator | size (Dot 8 :25), color | PulseDot loops scale 1→2, opacity .55→0 over 1600ms (pulse-dot.tsx:67,81; motion.ts:69); reduced motion = static .55 | DOT_SIZE XS 7 / SM 8 (box-sizes.ts:43-46) | 10·6·3 | keep. Live pulse dot rides inside the danger Tag (list spec §2). |
| Pressable | ui:pressable.tsx · pressable-visual.ts | The only touchable; owns press physics, hover, focus, disabled | variant card/button/pill/row/icon/fab (pressable-visual.ts:43-50); affordance chevron/button/link/surface/none (lint only) | P (UI-thread scale spring + opacity timing, pressable.tsx:286-301) · H web 0.85 (:324) · F web ring (:325) · D/L inert path 0.5, not-allowed (:216,214) · pressed aria via aria-pressed (:319) | icon variant default hitSlop 15 (:112); web hitSlop realised as an invisible child (:77-90) | 108 (9) | keep. Carries over unchanged; v2 needs the same six physics rows. |
| Button | ui:button.tsx | Labelled action (wraps Tamagui Button) | variant primary/secondary/outline/ghost/danger/destructive (:77-128); size xs/sm/md/lg (:188-224); shape default r12 / pill / circle (:130-134) | P pressStyle .97/.92 (:62) · H .85 (:67) · F ring (:68) · D .5 + not-allowed (:74) · L spinner + loadingLabel (:289, :297) · reduced motion drops scale (:292) | height xs 28 / sm 36 / md 44 / lg 56 (:201,207,213,222); label sizes via Tamagui $2…$5 (:181); radius 12; lg = width 100% unless flex (button-visual.ts:25) | 22 (5) | re-spec → T-Button. v2 primary is 52 (not 56), 17/24·600; secondary is a 1px $borderStrong neutral fill (:97-101); ActionButton intent map stays on top. |
| ActionButton | ui:action-button.tsx | Intent wrapper → Button variant (primary/secondary/neutral/cancel/tertiary/destructive→danger) | action ×6 (:ACTION_VARIANT) | as Button | as Button | 128 (7) | keep. #2 by consumer count; keep API, re-skin through Button. |
| ActionPill | ui:action-pill.tsx | Small utility trigger pill | — | P pill .96/.85; stopPropagation (:49) | min-height 28 (:27), hitSlop 8, padding $3, fill $surfaceSecondary, label badge 12/18·600 | 2 (2) | merge → T-Button sm tonal. Spec: ActionPill → Button size="sm" tone="tonal" (list spec §4). |
| ActionIconButton | ui:action-icon-button.tsx | Round icon button (base of RemoveIconButton) | iconColor, flush | P icon .92/.78 · D · L spinner (:111) | 36 circle (:42), hitSlop 4 → 44, icon md 16 | 6 (2) | merge → T-IconButton. Merge with HeaderIconButton + InfoHintButton into one icon-button family (24 glyph in 44 target). |
| HeaderIconButton | ui:header-icon-button.tsx | Every header glyph (back, bell, ⋯) | color default $iconMuted, strokeWidth 1.8 (:54) | P icon .92/.78 · H · F | glyph 24 (:50), hitSlop 10 → 44 | 15 (7) | merge → T-Header action / T-IconButton. Behaviour carries: glyph 24 in a 44 target, spacing by container gap. |
| HeaderTextAction | ui:header-text-action.tsx | One labelled text action in a header slot (초대 코드, 취소, 완료) | prominent (600/400), color default $primary (:58) | P button .97/.92 · F | min-height 44 (:66), padding $2, label cardBody 14/22 | 5 (2) | keep → T-Header action |
| InfoHintButton | ui:info-hint-button.tsx | ⓘ explainer trigger | — | P icon | icon sm 14 $textTertiary, default icon hitSlop 15 | 2 (2) | merge → T-IconButton |
| RemoveIconButton | ui:remove-icon-button.tsx | Row/item remove with optional ConfirmSheet (wizard field grammar) | icon (Trash2 :74 / UserMinus), confirm, flush | as ActionIconButton · L | 36 circle, 16 glyph | 13 (4) | keep → T-IconButton. Keep the confirm-before-destroy behaviour; re-base on the merged icon button. |
| CameraBadge | ui:camera-badge.tsx | Edit-photo badge on an avatar | — | P icon · static when no onPress | 28 circle (:25), 1.5px $border, hitSlop 8 | 1 (1) | keep |
| AddCard | ui:add-card.tsx | Dashed "+ add" slot in a wizard field list | disabled | P row .98/.70 · D .5 + $textTertiary | min-height 52 (:41), 1.5px dashed $primary (:45-46), radius $4 | 7 (2) | re-spec → F-AddRow. Form family; no v2 spec yet (see section 3). |
| HeaderActionsMenu | ui:header-actions-menu.tsx · header-actions-menu-layout.ts | ⋯ overflow dropdown anchored under the header trigger | actions[] with tone default/destructive, triggerIcon | P rows · D rows · web keyboard (Escape :287, arrows, focus restore) · one Divider before the destructive group (:410) | RN Modal fade (:342), width 200–260 (layout.ts:14-15), rows ≥44 (:422), radius $3, shadow .12/r10/elev 8 (:71-76) | 7 (4) | keep → T-Menu. The menu panel is a shadowed floating surface; the one place the app uses a shadow on purpose. |
| Badge | ui:badge.tsx · badge-visual.ts | Passive label pill (12 variants) | variant neutral/warning/live/accent/success/info/premium/muted/primarySolid/surface/primarySubtle/outline (:58-87); size xs/sm/md | passive (no press); numberOfLines=1 (:203); optional shrink | xs 10/14 + pad 8×2 r8 = 18 tall (:89-93, visual:15); sm 12/18 + 8×4 r8 = 26 (:94); md 14/20 + 12×4 r12 = 28 (:95) | 73 (7) | merge → T-Tag. Spec: Badge → Tag; call sites use neutral 39, warning 11, live 3, surface 5, xs 33, sm 30. |
| CornerCountBadge | ui:corner-count-badge.tsx | Red/amber count bubble pinned to a corner | tone unread/attention | passive, aria-hidden | 18 circle (:31), top −2 right −2, micro 10/14·700 | 2 (2) | keep → T-CountBadge. Spec keeps it as CountBadge. |
| NaverBadge | ui:naver-badge.tsx | Naver provenance square | — | passive | 22 square $naver (box-sizes.ts:54) | 1 (1) | keep |
| MarkerPill | ui:marker-pill.tsx | Hint pill on a media band | tone primary/neutral | passive | badge 12/18, pad 8×4, full radius, fill $primarySubtle/$card | 3 (2) | merge → T-Tag. Spec lists MarkerPill → Tag. |
| ChipLabel | ui:chip-label.tsx | The chip label text (internal to the chip family) | selected | S: $primary 700 vs $textSecondary 500 (chip-label-visual.ts:19-24) | badge 12/18 | 3 (2) | merge → T-Choice |
| SelectionChip | ui:selection-chip.tsx | Selectable DATA chip (filters, scopes) | size md 40 / sm 32 (:25); leading/trailing; role radio/checkbox/tab | P pill .96/.85 · H · F · D .5 + aria-disabled (:98) · S border $primary + fill $primarySubtle + 700 label (:122-123); aria-pressed/checked/selected by role; web radio arrow keys (:17) | min-height 40/32, hitSlop 2/6, pad 12×8 / 12×4, radius full, 1px border | 56 (5) | merge → T-Choice. v2 Choice: 36 tall, pad 0 14, 15/22·500, 1px $border rest, ink fill + check selected. |
| RemovableChip | ui:removable-chip.tsx | Chip with an inner ✕ (applied filters) | — | P pill; inner remove icon .92/.78, stopPropagation | min-height 40, glyph 12 (:34), always the selected look | 2 (2) | merge → T-Choice mode=input |
| DropdownChip | ui:dropdown-chip.tsx | Filter chip that opens a ModalPanel of radio/checkbox options | multi, options, sheetTitle, controlled open | chip as SelectionChip · rows aria-selected/aria-checked · 적용 button | rows pad $3×$2 with Check 16 (:209); panel via ChipPickerPanel | 11 (5) | merge → T-Choice mode=menu |
| RangeChip | ui:range-chip.tsx | Min/max picker chip | options ascending | as DropdownChip; pending min>max auto-raises max | two columns of option rows in a ModalPanel | 2 (2) | merge → T-Choice mode=range |
| ChipPickerPanel | ui:chip-picker-panel.tsx | ModalPanel shell for chip pickers | title | ModalPanel states | center dialog, $background, title cardTitle centered | 0 (0) | merge → T-Choice panel. ui-internal only. |
| SelectionCard | ui:selection-card.tsx | Larger pick-one tile (format, preset, play style) | selected; free children | P card .98/.90 · S fill $primarySubtle + border $primary 2px (:44) · aria by role | padding $4, radius $3, border 2 | 9 (3) | re-spec → F-ChoiceTile. $primarySubtle surface leaves in v2; selected cue must change. |
| SelectionCheck | ui:selection-check.tsx | Checkbox/radio glyph | shape square/circle | S fill $primary + Check | 24 (:34), square r6 / 1.5px (:40-41), circle 1px | 2 (2) | keep → F-Check |
| TogglePill | ui:toggle-pill.tsx | Display-only switch visual (host row owns the press) | value | S on $primary / off $border (:40); NO thumb animation, NO role="switch"/aria-checked | track 44×24 r12, thumb 20, inset 2 (:17-20) | 9 (3) | merge → T-SwitchTrack. Spec: TogglePill → SwitchTrack. On/off is invisible to assistive tech today (hosts are ListRow onPress; ListRowProps has no checked prop, list-row.tsx:54-112). |
| Stepper | ui:stepper.tsx | −/+ bounded integer control | format, fullWidth, editable, valueMinWidth 48 (:113) | P icon · D at min/max (:174) · aria-value* (:195) | buttons 32 circle 1px $border (:31), hitSlop 6; fullWidth min-height 48 (:167) | 17 (3) | keep → F-Stepper |
| PillNav | ui:pill-nav.tsx · pill-nav-underline.tsx · pill-nav-shared.ts | Tab strip: sliding pill (≤4 items) or scrolling pills (>4) or underline (UnderlineNav is the underline variant) | variant pill/underline; badge dot | P pill .96/.85 · F · active indicator springs (navSlide 22/280/.8, motion.ts:33) · tab ARIA (tab-accessibility.ts) · reduced motion = snap | pill: container pad 4, radius $5, fill $surfaceTertiary (pill-nav.tsx:51,76-81), 36 tall (:59), label 14/600 (:126-127); underline: 48 + 1px hairline (pill-nav-underline.tsx:24), tabs flex 1 (:57), 2px $text underline (:64), 14px 800/600 label (:81-82), 6px badge dot (shared.ts:83) | 7 (4) | re-spec → T-UnderlineTabs. v2 underline row is 44, 15/22 600/500, underline only under the label width. The pill variant is also the form segmented control in 6 files (match-rules-editor ×5 instances, create-session-step4, create-tournament-modal ×2, practice-log-screen ×2, match-list-controls-sheet, records-screen) and stays there (gap G12). |
| SegmentedTabs | ui:segmented-tabs.tsx | PillNav + keep-alive panes | chrome pill (default, :81,92) / underline (:245) / none; preload visited/all; fill; screenFocused | tab switch under useDeferredValue; hidden panes Freezed; no swipe between panes | pane-reveal perf threshold 120ms (:15) | 6 (5) | keep → T-UnderlineTabs host |
| Card | ui:card.tsx | Bordered surface | tone default/flat(dashed); size sm 12 / md 16 / lg 20 / none (:56-59) | passive; never onPress (wrap in Pressable) | radius $4 = 16 (:29), 1px $borderSubtle, no shadow (canon components.md:10) | 55 (7) | retire. v2: "cards on list surfaces leave" (grammar §6). 55 files; migrate to rows/blocks, keep nothing but dashed empty slots. |
| Well | ui:well.tsx | Tinted inset panel, optional tap (chevron) | tone neutral/primary/warning | P card .98/.90 when onPress | radius $4, padding $3 (:41) | 21 (3) | merge → T-Notice. Banner + Callout + Well are three shapes of one inline notice. |
| Callout | ui:callout.tsx | Actionless tinted note | tone neutral/info/warning; emoji icon | passive | radius $3, padding $3 (:53), cardBody | 5 (3) | merge → T-Notice |
| Banner | ui:banner.tsx | Inline notice with ≤2 buttons | tone primary/warning/error/neutral/muted; actions[] | actions are Button sm (P/H/F/D) | padding $3 (:106), radius $3, buttons right-aligned gap $3 (:136); only primary has a 1px border (:83) | 14 (3) | merge → T-Notice |
| SummaryCard | ui:summary-card.tsx | $primarySubtle summary box | — | passive | padding $3, radius $4, fill $primarySubtle (:27) | 0 (0) | retire. Dead: no JSX consumer (mentions are comments). |
| SpecList | ui:spec-list.tsx | Old label/value list with emoji column | valueTone | passive | 20px emoji column, gap $3 | 0 (0) | retire. Dead: no consumer anywhere; superseded by FactRow. |
| Divider | ui:divider.tsx · divider-visual.ts · hairline.ts | The one hairline | inset full/card, seam="section" (24 above/below, divider-visual.ts:39), bleed (−16, :21) | passive | 1px $borderSubtle (hairline.ts:18-20) | 54 (7) | re-spec → T-Seam. v2 has two tiers (edge #D4D4D4, cell #E5E5E5 inset to the rail) + one 12pt band; the section line is retired (D9/D10). |
| FlatColumn | ui:flat-column.tsx · flat-column-defaults.ts | Detail-page column: rail + sections | top, gap, divider, lead | passive | rail 16 (:104), top 16 (defaults.ts:17), section gap 24 (:23), bleed Divider between sections (:87) | 42 (5) | re-spec → T-Page. v2: rail 20, section gap 32, no divider between sections. |
| RowList | ui:row-list.tsx | Stacks rows; injects first/last edge context | dividers (default off) | passive | edge trimming of first/last padding | 44 (4) | keep. Edge-trim behaviour carries (box height = pitch). |
| ListRow | ui:list-row.tsx · list-row-visual.ts | Generic leading + title + sub + trailing row (base of PersonRow, settings rows) | dense, tone, chevron, edge, hairline, titleLines, subLines | P row .98/.70 · D .5 (visual.ts:241) · S fill $primarySubtle + aria-current (:201,216) · auto chevron when pressable and no trailing | title cardBody 14/22·600 (dense cardMeta) (:154); sub cardMeta 14/20·500 $textTertiary (:165); gap 12 (:190); pad-y 8 / dense 4; min-height 40 / dense 36; hitSlop to 44 (visual.ts:34-39) | 34 (5) | re-spec → T-MediaRow / T-PersonRow / T-CellRow (+ gap G10 nav row). v2 splits into media 80, person 64, cell 48; the chevron/toggle settings row has no v2 spec yet. |
| FactRow / FactList / FactSegments | ui:fact-row.tsx · fact-row-visual.ts | Label-left / value-right fact rows | rhythm compact/roomy, tone, locked, editCue, secondaryValue, note | P row when onPress · chevron or edit cue | min-height 36 (visual.ts:30); pad-y 8 / roomy 12 (:51); label cardMeta tertiary, value cardBody 600 | 19·4·2 | re-spec → T-CellRow. v2 "Cell row (facts)" = 48 tall, 15/22 400 label / 500 value, cell hairline. Not the current CellRow. |
| CellRow / CellRowGrid | ui:cell-row.tsx · cell-row-visual.ts | Equal-column strip primitive (group-spread law), base of StatStrip/ActionIconRow | dividers, columns | passive | cell pad-x 8, gap 8 / 4·1px·4 with dividers (cell-row-visual.ts:21,25,38) | 0·1 | keep. Name collision: v2 "Cell row" is the fact row, not this. |
| StatStrip | ui:stat-strip.tsx | Row of value + label cells with hairline dividers | columns wraps to a grid; per-cell tone, onPress | cell P row when pressable | value displaySm 20/28·700, label cardMeta 14/20 tertiary (stat-tile.tsx:132) | 13 (4) | re-spec → T-StatStrip. v2: 80 tall, numeral 24/32·700, label 13/18·500, 1×24 hairline. |
| StatTile / StatGrid | ui:stat-tile.tsx · stat-grid.tsx | One stat (value+label+delta+progress) / card-wrapped grid | wrap card/flat, order, delta (micro 10 ▲▼, :115) | passive | — | 1·1 | merge → T-StatStrip. StatGrid (1 file) is a boxed variant of the same. |
| MiniBarMeter | ui:mini-bar-meter.tsx | Signal-bar meter (n of m) | count, filled | role="img" when labelled | bar 4 wide (:47), max 14 tall (:46), gap 4 (:48) | 3 (3) | keep. No v2 counterpart; not on the six screens. |
| ProgressBar | ui:progress-bar.tsx | Linear progress | variant primary/muted; fillColor; height | animated scaleX 300ms ease-out-cubic (:83); reduced motion snaps; role=progressbar + valuenow (:103) | height 6 (:70), radius $2, track $primarySubtle / $borderSubtle (:112) | 13 (5) | re-spec → T-Progress. Height already 6. v2 track is the block ink at α0.2 on the brand block, so track colour becomes a prop. |
| AvatarBubble | ui:avatar-bubble.tsx · avatar-bubble-visual.ts | Initials/photo/silhouette avatar | size token or px; tone primary/neutral/muted; outlined | image → glyph fallback layers; error/loaded tracked per URL | XS 28 · SM 32 (default, :159) · MD 40 · LG 44 · HERO 56 · XL 80 · 2XL 88 (box-sizes.ts:24-40); initials 11/14/18/32 (:94-96); no 48 | 20 (6) | re-spec → T-Avatar. v2 needs 40 / 48 / 56 (48 missing); default should be 40; tone primary uses $primarySubtle (:151). |
| AvatarStack | ui:avatar-stack.tsx | Overlapping faces +N, or two-face footprint | fit overlap/footprint, max 8 (:73) | passive, hidden from a11y tree | overlap −6 (:117); footprint faces 0.7× | 9 (5) | keep → T-AvatarGroup |
| FlagMedallion | ui:flag-medallion.tsx | Generated club flag/monogram | preset, shape, hue | optional enter animation gated by reduced motion | size px | 4 (1) | keep → T-Avatar (club logo r12) |
| DateTile | ui:date-tile.tsx | Calendar-leaf tile (date strip + weekday) | strip colours | passive | 48 wide (:52), strip $primarySubtle | 1 (1) | retire. 1 file; v2 rows carry no date tile. |
| DayDotsRow | ui:day-dots-row.tsx | Week/day cell row with active dots | appearance cell/…, variant full/compact | active fill $primarySubtle | cell 36 default | 1 (1) | keep. 1 consumer; calendar widget. |
| EmptyState | ui:empty-state.tsx | Empty / error / offline panel | variant full/compact; optional icon, 1–2 actions | buttons P/H/F/D | full: icon disc 64 (:75) $primarySubtle, glyph 28 $iconMuted, title 16/24·600, pad 40×24; compact: disc 44, title 14/22; buttons md | 85 (10) | re-spec → T-EmptyState. Wrapped by QueryErrorState / OfflineEmptyState / NotFoundState (45/45/19 files). |
| Skeleton family | ui:skeleton.tsx · shimmer.ts | Placeholder blocks | width/height/borderRadius/circle | opacity loop .6↔.9, 800ms half-cycle (shimmer.ts:21,32; motion.ts:67); reduced motion fixed .75 (shimmer.ts:14) | default 16 tall r8 (skeleton.tsx:41-42); fill $surfaceTertiary (:64); SkeletonRow = 40 avatar + 2 bars; SkeletonCard/Hero wrap Card | 5·21·35·14 | re-spec → T-Skeleton. Shapes must be redrawn to 80/64 row pitch; SkeletonCard/SkeletonHero (35/14 files) die with Card. |
| SkeletonFlatPage | ui:skeleton-flat-page.tsx | Detail-page skeleton (hero + stat strip + rows) | kind | shimmer | media hero 140 (layout.ts:14) | 15 (4) | re-spec → T-Skeleton |
| RemoteImage | ui:remote-image.tsx | expo-image wrapper with status machine | fallback, showSkeleton | idle/loading/loaded/error; skeleton while loading | fills parent | 15 (4) | keep |
| PhotoScrim | ui:photo-scrim.tsx | Legibility gradient over photos | edge | passive | top .50→.45→0 (+40 fade), bottom .45→0 over 56 (photo-scrim-visual.ts:33,36) | 2 (1) | keep |
| MediaHero | ui:media-hero.tsx · media-hero-visual.ts | Detail hero with a map/photo band | media, titleBadge, facts, mediaHint | media P card when onMediaPress | band 2:1, max 280 (visual.ts:38-39), default 132 | 4 (2) | re-spec → T-Hero. v2 hero is text-only (eyebrow, 24/32 title, 3 glyph facts); no band. |
| TypeHero | ui:type-hero.tsx | Text hero: eyebrow, title, facts | titleBadge, facts, leading 56 | passive | pageTitle 24/32·700 (:85), eyebrow cardMeta 700 tertiary, rail 16, top 16 | 9 (4) | re-spec → T-Hero. Title size already matches v2 (24/32·700); eyebrow, facts and spacing change. |
| GlyphFactRow / GlyphSlot | ui:glyph-fact-row.tsx | Glyph + value facts | density chip(→Badge surface)/inline; emoji or icon | passive | slot 16 / emoji slot 20 (:7-17) | 2·4 | re-spec → T-Hero facts (20 glyph + 8). chip density is deleted in v2; vocabulary 21 → 4 concrete glyphs. |
| ActionIconRow | ui:action-icon-row.tsx | 3–5 icon-circle actions | active | P card · D .4 (visual.ts:52) | circle 40 (:12) $primarySubtle/$primary | 2 (1) | retire. 2 files, 1 consumer group; v2 puts actions in the header and CTA band. |
| ExpandToggle | ui:expand-toggle.tsx | Collapse/expand row; chevron rotates; content reveals | with/without children, summary | P row · aria-expanded · chevron rotates 180° on quick (:57) · reveal 8px slide+fade on reveal (:156) | 44 floor (:114) | 14·7·10 | keep |
| EditFieldRow | ui:edit-field-row.tsx | Label/value row with pencil cue (+ exported EditCue pencil) | showDivider | P row · D | pad-y 8, pad-x 16, pencil md | 1 (1) | re-spec → T-CellRow edit mode |
| SectionHeader / SectionBlock | ui:section-header.tsx · section-header-metrics.ts · section-block.tsx | Section title + meta/link/right slot | variant block (displaySm 20/28) / list (cardTitle 16/24) / flat (navTitle 17/22·700) (metrics.ts:56-57); link = cardMeta 700 $primary + chevron sm (:162-165) | link P row · role=heading aria-level=2 (:133) | list min-height 24 (metrics:114), title→content 8 (:93) | 82·16 | re-spec → T-SectionHeader. v2: 17/24·600 title, 13/18·500 secondary action + chevron 16, 32 above / 8 below. Link ink changes from $primary to secondary. |
| SectionEyebrow | ui:section-eyebrow.tsx | Small overline label | — | passive | cardMeta 600 tertiary | 0 (0) | retire. Dead: no consumer. |
| AppHeader | ui:app-header.tsx · app-header-visual.ts | Static top bar | chrome default/bare, subtitle, left, right, showBack | actions via HeaderIconButton/HeaderTextAction | height 56 / 64 with subtitle (visual.ts:18-20); title CENTERED navTitle 17/22·600 (:160-164); rail 16; slot min 44; permanent bottom hairline (:118) | 4 (2) | re-spec → T-Header. v2: title 20/28·700 at the rail (left-aligned), no divider, hairline only while content scrolls under. |
| MainTabShell | ui:main-tab-shell.tsx | Tab-root page scaffold: title row + SegmentedTabs + body + CTA | tabs, headerRight, bottomCta | — | title pageTitle 24/32·700 left, pad 16, no hairline (:100-103) | 5 (5) | re-spec → T-Header + T-Page. Header height 56 + title 20/28 in v2. |
| DetailShell | ui:detail-shell.tsx | Detail/form page scaffold: safe area, header, scroll, pinned CTA | header default/collapsing, aside, width reading/wide | iOS automaticallyAdjustKeyboardInsets (:281); scroll-perf hooks | ContentColumn max width per window class | 67 (8) | re-spec → T-Page. #8 by consumers (67 files). Header/CTA re-spec flows through. |
| PlaceShell / CollapsingTopBar / CollapsingHeaderLayer | ui:place-shell.tsx · collapsing-top-bar.tsx · collapsing-header-layer.tsx | Photo-first place page with a bar that turns solid on scroll | collapsed | bar chrome opacity animates on quick; hysteresis flip (use-collapsing-top-bar.ts) | bar row 48 (collapsing-top-bar-visual.ts:13) | 1·1·1 | re-spec → T-Header (collapsing). Club home only (1 consumer each). |
| BottomCtaBand | ui:bottom-cta-band.tsx · bottom-cta-band-visual.ts | Pinned bottom action band | children = 1 Button or a pair (gap 12, visual.ts:28) | inset added only if no ancestor reserved it (:98) | pad 16 all sides + safe-area (visual.ts:25-27); PERMANENT top hairline (:117); $card fill; max width reading column | 37 (7) | re-spec → T-BottomCtaBand. v2: button 52, band top 40 after content, hairline only while content scrolls beneath it. |
| ModalPanel | ui:modal-panel.tsx · modal-panel-visual.ts | The only overlay: RN Modal with Overlay + Frame + ScrollView | placement center/bottom; dismissOnOverlayPress; Frame showCloseButton | backdrop tap dismisses (:99) · Android back (:111) · bottom frame slides y:48 on quick unless reduced motion (:209) · KeyboardAvoidingView padding (:117) | RN fade (:108), overFullScreen (:109); frame ≤640 wide, ≤88% high (:189-190), $background, 1px $borderSubtle, r16 / bottom r24 top only (:207), pad 20×16, close X 40 circle at top 12 right 12; no drag, no grabber | 45 (7) | keep → T-Overlay. Not in the §4 table, but every filter/edit/info surface depends on it (45 files). |
| ConfirmProvider / useConfirm | ui:confirm-sheet.tsx | Imperative confirm dialog on ModalPanel | buttons[] cancel/destructive/primary; ≥3 buttons stack vertically (:201) | backdrop does NOT dismiss (:180); hardware back closes (:128); single-flight; options cleared 250ms after close (:142) | title cardTitle 700 centered, message cardBody tertiary, Button md | 2 (2) | keep → T-Overlay confirm |
| WizardShell | ui:wizard-shell.tsx | Multi-step flow frame | primaryHint, onSkip, back/close slot | segments tappable for completed steps; KAV padding (:391); footer reports height | header buttons 40 + hitSlop 2 (:65), 4px segments (:316), single full-width lg CTA | 3 (3) | keep → F-Wizard. Header owns back/skip (wizard-nav law); footer = one CTA. |
| RevealStack | ui:reveal-stack.tsx | Sequential reveal of wizard sections | items[] confirmed/optional/selfConfirming | new section enters on reveal 250ms (opacity 0,y 12; :165); VoiceOver announcement | gap $5, confirm row: secondary flex 1 + primary flex 2 (:178) | 13 (2) | keep → F-Wizard |
| AttachedPanel | ui:attached-panel.tsx | Live-commit panel attached under a field (with notch) | notchRight | passive | 1.5px $primary border + $primarySubtle fill (:74), pad 12, margin-top 8, notch 12 (:39-51), radius $4 | 10 (3) | re-spec → F-AttachedPanel. $primarySubtle fill must go. |
| PickerRow | ui:picker-row.tsx | Pick-row that opens a picker | active | P row · active border 1.5 $primary (:78) · D | min-height 44 (:66), 1px $border, radius $3, pad 12 | 2 (1) | re-spec → F-PickerRow |
| LabeledField / StyledInput | ui:labeled-field.tsx · labeled-field-logic.ts | Form field: label + control + caption + error; themed TextInput | size sm/lg, required, labelRight, collapsed; input multiline, bare, invalid | rest · F border $primary + web ring (:432) · E border $error, aria suffix, aria-live text (:227, :372) · D editable=false .5 (:464) | input radius 12, 1px, pad 12×12, font 15 (:449-455), multiline min 120 (:459); label cardMeta 14/20 secondary (sm) or cardTitle 16/24 (lg), caption/error cardMeta | 44·48 | re-spec → F-Field. Font 15 already equals v2 body. The only text-input component (no Tamagui Input in use). |
| SearchBar | ui:search-bar.tsx | Search field with clear and optional right accessory (filter toggle) | rightAccessory, editable, inputRef | F border $primary on quick (:114-117) · D .5 (:118) · clear ✕ shows when non-empty | min-height 44 (:110), pad 12×8, radius $3, 1px $borderSubtle, search icon sm 14, input 15 (:149), max width reading | 3 (3) | re-spec → F-Search |
| TimeBandSlider / TimeBandField / RangeBandSlider / HueSlider | ui:time-band-slider.tsx · time-band-field.tsx · range-band-slider.tsx · hue-slider.tsx | Custom gesture sliders (time band, tier band, value, hue) — cores: BandSliderCore, SingleBandSliderCore | handle style trim/knob; veil colour; domain | drag (Pan) + a11y adjustable actions; value label; commit on interaction end | bar 44 hit, track 28, trim handle 11, knob 18, thumb hit ±22 (box-sizes.ts:115-143); | 3·1·1 | keep → F-Slider. Consumers: TimeBandField 3, RangeBandSlider 1 (+ TierRangeSelector), HueSlider 1; TimeBandSlider is used only through TimeBandField. |
| FeedList / GroupedFeedList / StickySectionList | ui:feed-list.tsx · feed-list/*.ts(x) · sticky-section-list.tsx | Virtualised lists (FlashList-backed; sticky variant uses SectionList) — parts: DefaultSeparator, SectionSeam, ListSlot, GridRowView | recycling, columns, separators, section footers | native RefreshControl via refreshing/onRefresh; end reached 0.6, draw distance 800 (tuning.ts:38,44) | default item separator 12 (tuning.ts:16), section seam 16 (:27) | 36·8·1 | re-spec → T-List. v2 rows own their pitch (80/64) so the card→card separator goes to 0; day headers own their own seam. |
| PeriodScrubberShell | ui:period-scrubber-shell.tsx | Card + ‹ label › stepper header for period pickers | — | P icon chevrons | $surface card | 4 (2) | re-spec → F-PeriodStepper |
| DaumPostcodeModal / VenueLocationPicker | ui:daum-postcode-modal.tsx · venue-location-picker.tsx | WebView address search / draggable-pin map | platform variants | — | full-screen modal | 1·2 | keep. Native WebView: EAS-build gated. |
| Logo | ui:logo.tsx | Wordmark / mark | variant default/reverse/mark | passive | wordmark 120, mark 32 | 1 (1) | keep |
| ErrorBoundary | ui:error-boundary.tsx | Render-error catcher | fallback | — | — | 11 (3) | keep. Infra. |
| Infra: PerfProfiler, Freeze, ContentColumn, SupportingPane, WebNavCovered, TabPaneActive, BottomInsetReservation | ui:perf-profiler.tsx · freeze.tsx · content-width.tsx · supporting-pane.tsx · web-nav-covered.tsx · tab-pane-active.tsx · bottom-inset-reservation.tsx | Non-visual or layout infrastructure — members: PerfProfiler, Freeze, ContentColumn, ContentWidthProvider, SupportingPane, WebNavCovered, TabPaneActiveProvider, BottomInsetReservationProvider | — | — | — | 13·3 | keep. No visual spec needed. |
| Charts: SparkLine | ui:charts/SparkLine.tsx · SparkLine.shared.ts | SVG trend line with scrub | color (auto trend green/red), selectedIndex, onScrub | Pan scrub (activeOffsetX biased) sets a marker; reduced-motion n/a | default 120×48 (shared.ts:54-55), stroke 2 (:98) | 1 (1) | re-spec → T-Chart. v2 chart is 160 tall, inset 16, one ink line, no fill. |
| Charts: CapacityRing / SegmentedDonutChart / TriRingProgress | ui:charts/*.tsx | Ring/donut progress | segments, size | passive | CapacityRing 20, stroke 3 (CapacityRing.tsx:52-53) | 2·4·1 | keep. Not on the six v2 screens; capacity leaves rows. |
| Charts: CourtLinesTile / CelebrationOverlay | ui:charts/CourtLinesTile.tsx · CelebrationOverlay.tsx | Court-surface graphic / confetti | surface kind | confetti auto-plays | — | 6·1 | keep |
| ValueSlider | ui:value-slider.tsx | Single-value flat-fill slider | — | drag + a11y adjust | bar 28, track 6, knob 24 (value-slider.tsx:22-24) | 0 (0) | retire. Dead: no JSX consumer (create-club-step6.tsx:384 mentions it as retired). |
| Dead exports (no consumers) | ui:empty-slot-row.tsx · banner-capacity-cluster.tsx · participation-cluster.tsx · charts/DiagonalGradientFill.tsx · charts/FormatGlyph.tsx | Exported, never rendered | — | — | — | 0·0·0·0·0 | retire. Zero JSX consumers repo-wide (ParticipationCluster is referenced only by the dead BannerCapacityCluster). |
| DataSection (retired 2026-11-13: deleted, no importer; the state helper replaced it) | ui:data-section.tsx | Loading / empty / content switch with optional SectionBlock title | hideWhenAbsent, skeleton, empty | cross-fade medium spring on state change, off under reduced motion | default skeleton = Card + SkeletonRow | 1 (1) | re-spec → T-Skeleton host. 1 consumer; default skeleton still draws a Card. |
1b. index.ts exports that are not components
These also need a v2 decision because they carry the numbers the components read.
Export (index.ts line) | What it holds | Today | v2 |
|---|---|---|---|
tokens, bodyFont, headingFont (:15-16) | colour, space, size, radius, zIndex tokens; Pretendard sizes 11–32 and line-heights | space 0/4/8/12/16/20/24/32/40/48/64 (ui:space-scale.ts:6-18); radius 0/4/8/12/16/20/24/9999 (ui:tokens.ts:157-166); font sizes 11,12,13,14,15,16,18,20,24,28,32 (ui:fonts.ts:35-48) | space ladder already covers 4…40; font size set lacks 17 and 40 (17 and 36/48 are literals in text.tsx:98-100,172-189) |
SEAM, HAIRLINE, CONTENT_INSET, SCROLL_CONTENT (:17-20) | spacing roles, the one hairline, scroll padding | rail $4=16 (ui:seams.ts:44), section $6=24 (:47), block $3=12, item $2=8, element $1=4, tail $9=48; hairline 1px $borderSubtle (ui:hairline.ts:18-20) | rail 20, section 32, one more hairline tier, band |
WINDOW_*, LAYOUT_WIDTH, windowSizeClass, ContentColumn (:21-31) | desktop-web size classes | reading column 680, shell 1200 (ui:window-size.ts:45,56) | web follows later (grammar §6) |
META_TEXT, META_BREAK_ON_TINT_OPACITY (:33-34) | meta-text tiers | primary cardBody/secondary, secondary cardMeta/tertiary, eyebrow 700, caption (ui:text-grammar.ts:40-45) | re-point to 15/22, 13/18, 12/16 |
EMOJI_GLYPH, emojiLineHeight, NAME_LINES, EXPLANATION_LINES (:37,41) | emoji line box fontSize+8; names wrap to 2 lines; explanations to 4 | ui:text.tsx:38-56, ui:text-lines.ts:27,37 | emoji only in the weather exception |
MOTION_SPRING, MOTION_DURATION, MOTION_STAGGER, staggerDelay, useReducedMotion, USE_NATIVE_DRIVER (:50-57) | motion tokens | values in §2.3; staggerDelay/MOTION_STAGGER have no consumer | keep; prune unused |
AVATAR_SIZE, DOT_SIZE, BADGE_SIZE, TOUCH_TARGET, TERMINAL_MUTE_OPACITY, ROW_HEIGHT_*, RADIUS_PILL, SLIDER_GEOMETRY (:60-71) | named box sizes | avatar 28/32/40/44/56/80/88 (ui:box-sizes.ts:24-40); touch 44 (:74); row heights 36/40 (:88,93); mute .55 (:104) | avatar 40/48/56; row pitches 80/64/48 replace row heights |
DISABLED_OPACITY, HOVER_OPACITY, FOCUS_RING (:47-48) | interaction constants | .5 (ui:pressable.tsx:99), .85 (ui:pressable-visual.ts:71), 2px $primary ring offset 2 (:84-89) | keep |
accessibility*Props, adjustableActionProps (:112-118) | aria mappers | selectionStateProps maps role → aria-checked/selected/pressed (ui:selection-accessibility.ts:2-18) | keep |
Hooks: useConfirm (:105), useScrollRevealTarget (:106), useSheetBottomPadding (:108), useWizardBackHandler (:180), useLiveBandCommit (:279), useCollapsingTopBar (:317-323), useLayoutPathOrigin (:58), useTabPaneActive (:244), useContentWidth (:30), useBottomInsetReserved (:237), useScrollPerf (:354) | behaviour hooks | — | keep (behaviour carries over) |
setUiStrings, uiStrings (:352) | injectable close/back/search/apply strings | ui:ui-strings.ts | keep |
themeHex, rawColor (:385) | token → hex for raw RN styles | needed for TextInput, Animated.View | keep |
setPerfSink, reportUiPerf (:355-361) | perf telemetry | tab-settle 250ms threshold (ui:pill-nav-shared.ts:12) | keep |
| time-band maths (:256-277) | DEFAULT_DOMAIN, SNAP_MINUTES… | — | form family |
1c. packages/app/src/presentation/components — shared (≥ 2 consumer groups)
Rows below are the shared components; the 16 live XxxStatusBadge wrappers sit in one row because they are one variant table (the 4 club badges, the club-alarm badge and the dead ApprovalModeBadge are in the appendix). Components with a single consumer group are in the appendix (§1d).
| Component | File | Purpose | Variants / props that change the look | States implemented (read from source) | Sizes / geometry today | Consumers files (groups) | v2 disposition |
|---|---|---|---|---|---|---|---|
| QueryErrorState | pres:query-error-state.tsx | Error panel with retry; falls back to the offline panel when offline | variant full/compact | retry button (Button md) | = EmptyState + AlertCircle icon (:48) | 45 (8) | keep → T-EmptyState. Wrapper; follows EmptyState re-spec. |
| OfflineEmptyState | pres:offline-empty-state.tsx | No-connection panel | variant full/compact | optional retry | = EmptyState + WifiOff | 45 (8) | keep → T-EmptyState |
| NotFoundState | pres:not-found-state.tsx | Missing entity panel with 돌아가기 | — | Button → router.back | = EmptyState full | 19 (4) | keep → T-EmptyState |
| QueryBoundary | pres:query-boundary.tsx | 5-state network cascade (offline-cold, loading, error, empty, content) | loading skeleton prop | renders skeleton → error+retry → content | — | 25 (5) | keep. No visual of its own. |
| PersonRow | pres:person-row.tsx | ListRow + AvatarBubble; default tap → public profile with prefetch on press-in | avatarSize, badge, trailing, dense, edge | as ListRow (P row .98/.70, D .5, chevron when badge+press) | avatar default SM 32 (avatar-bubble.tsx:159) unless the caller passes MD; title NAME_LINES 2 (text-lines.ts:27); pitch = ListRow: pad-y 8+8 around max(avatar, title 22 [+4 + sub 20]) = 48–62 depending on sub (list-row-visual.ts:34-36, list-row.tsx:152-165) | 23 (5) | re-spec → T-PersonRow. v2 person row: avatar 40, L1 15/22·600, L2 13/18·500, trailing 15/22·700 or one Tag, pitch 64. |
| AutoLinkText | pres:auto-link-text.tsx | LinkifiedText bound to openExternalUrl | — | link press | inherits | 7 (3) | keep |
| EloTierBadge / TrustTierBadge / TierBadgeChrome | pres:elo-tier-badge.tsx · trust-tier-badge.tsx · tier-badge-utils.tsx | Emoji + label tier pill (two tier systems) | size xs/sm/md | passive | hand-drawn pill: pad 8×4, radius $2, emoji line box = size+8 (tier-badge-utils.tsx:47-59) | 7·6·2 | merge → T-Tag (neutral + tier dot on hero/profile; plain text in rows). Spec: TierBadgeChrome → neutral Tag. Counts: 7 / 6 / 2 files. |
| XxxStatusBadge family (16 live wrappers) | pres:status/status-badges.tsx (+ status/badges/club.tsx, club-alarm.tsx, tier-range-badge.tsx) | Canonical domain-status → Badge variant maps (canon: status renders only via these) — members: SessionStatusBadge, RsvpStatusBadge, WinResultBadge, MatchStatusBadge, EloDeltaBadge, ScoreStatusBadge, VenueTrustBadge, SessionPaymentStatusBadge, ConsentStateBadge, DuesStatusBadge, AttendanceStatusBadge, RecruitmentStatusBadge, ConfidenceTierBadge, AchievementStateBadge, ConsentRequirementBadge, SessionStripChipBadge | size xs/sm; variant tables (e.g. SESSION_STATUS_VARIANT) | passive | = Badge xs/sm | 3·5·4·4·3·3·3·2·2·1·1·1·1·1·1·1 | keep → T-Tag (re-point the variant tables to Tag tones). Keep the wrapper layer; swap Badge for Tag inside. Counts shown per wrapper in order: Session 3, Rsvp 5, WinResult 4, Match 4, EloDelta 3, Score 3, VenueTrust 3, Payment 2, Consent 2, Dues 1, Attendance 1, Recruitment 1, Confidence 1, Achievement 1, ConsentReq 1, StripChip 1. (ApprovalModeBadge is dead and listed in the appendix.) |
| TierRangeBadge | pres:status/tier-range-badge.tsx | NTRP/ELO band label (dots + numbers) | inline vs badge density | passive | — | 2 (2) | merge → T-Tag tone=level. v2 level Tag is a 22px circle with a numeral (list spec §4). |
| PlayerIdentityChip / PlayerIdentityCard | pres:player-identity-chip.tsx · player-identity-card.tsx | Avatar + name + tier, compact (XS avatar) or card | — | P row when pressable | chip: avatar XS 28 | 6·2 | re-spec → T-PersonRow compact |
| EditControlModal / EditFieldModal | pres:edit-field-modal.tsx | Per-field bottom edit sheet: title + control + 취소/저장 | commit apply/save | ModalPanel placement=bottom; scrim/취소 closes; no drag | full width, flush bottom, pair of Buttons | 6·2 | keep → T-Overlay edit sheet. This is the v2 "filter/edit sheet → ModalPanel" pattern. |
| VenueRow | pres:venue-row.tsx | Venue row: photo → static map → glyph tile + name + meta + chevron | variant card (bordered Card) / plain; dense | P card or row; chevron cue | tile 44 (32 dense) r12 (venue-row.tsx:137,156) | 6 (3) | re-spec → T-MediaRow |
| TierInfoSheet / TrustTierInfoSheet | pres:tier-info-sheet.tsx · trust-tier-info-sheet.tsx | ModalPanel explainer of tiers (current tier highlighted) — members: TierInfoSheet, ProfileTierInfoSheet, ProfileTrustTierInfoSheet (TrustTierInfoSheet is in the appendix) | — | ModalPanel | rows with $primarySubtle highlight | 5·2·2 | keep → T-Overlay |
| SessionCardFromEntry (→ SessionCardV2) | pres:sessions/session-card-from-entry.tsx · session-card-v2.tsx | The session list card: venue tile + L1 + one chip + bookmark + facts + recruitment meter | variant card / embedded; live band | P card .98/.90 · live band · bookmark is a sibling Pressable at a measured slot | Card r16, padding 12 (session-card-v2.tsx:549); ≈163 pt per card incl. gap (list spec §2) | 5 (4) | retire → T-MediaRow. Replaced by the 80pt media row. SessionCardV2 has 1 direct consumer (this wrapper); SessionCompactRow (1) and ClubSessionCard (0) die with it. |
| TierRangeSelector | pres:tier-range-selector.tsx | Continuous tier band slider + explanation | — | drag, snap, a11y adjust | band bar 44, knobs 44 hit | 4 (2) | keep → F-Slider |
| VenueSearchTakeover | pres:venue-search-takeover.tsx | Full-screen venue search overlay for wizard picks | — | RN Modal, imperative autofocus on Android, SearchBar + VenueFinder | full-screen | 4 (2) | keep → T-Overlay takeover |
| RegionDistrictFilter | pres:region-district-filter.tsx | 시/도 → 시/군/구 DropdownChip pair | — | cascade reset | two Choice chips | 3 (3) | keep → T-Choice mode=menu |
| SessionDayHeader | pres:sessions/session-day-header.tsx | Day group header: date + relative qualifier | first | passive | SectionHeader variant=list: title cardTitle 16/24, min-height 24, meta cardMeta tertiary right; top pad 4 unless first | 3 (3) | re-spec → T-DayHeader. v2 day header: 32 tall, 15/22·600 ink date + 13/18·500 secondary relative day at right, 16 above. |
| ExpandableFilterBar | pres:expandable-filter-bar.tsx | SearchBar + filter toggle (icon + count) + expanding panel | — | aria-expanded toggle; panel expands | panel: $card, pad 12, radius $3, gap 16 | 3 (3) | re-spec → F-Search + filter sheet |
| LeaderboardRow / LeaderboardPodium | pres:leaderboard-row.tsx · leaderboard-podium.tsx | Ranked row (28px right-aligned rank) / top-3 podium | leading slot | P row when pressable | rank cardTitle 16/24 secondary, width 28 (leaderboard-row.tsx:115) | 3·2 | re-spec → T-PersonRow (rank column 24). Podium has no v2 counterpart on the six screens. |
| WonAmountField / SuggestedCountField | pres:won-amount-field.tsx · suggested-count-field.tsx | Money input grammar / suggestion-following Stepper | commit modes | edit scope, blur or save commit | StyledInput / Stepper geometry | 3·2 | keep → F-Field |
| ToastHost | pres:toast-host.tsx · stores/toast.store.ts | Single bottom toast with swipe-down dismiss and action | variant success/error/warning/info; optional action | enter/exit y:12 on reveal 250ms (:333-334); drag-dismiss ≥40px or 500px/s (:164-165); timer pauses on touch (:198); role alert aria-live polite (:336) | radius $6, pad 16×12 (:343-345), max 560 (:183), shadow .16/r10 (:350), 8 above bottom chrome/keyboard (:182), text cardBody 2 lines (:355), 2800ms (toast.store.ts:78) | 2 (2) | re-spec → T-Toast. Dark inverse surface; icon + text + ghost action. |
| CopyableValue | pres:copyable-value.tsx | Copy-to-clipboard value (web real copy, native hint) | — | P | — | 2 (2) | keep |
| AnimatedCount | pres:motion/animated-count.tsx | In-place numeric update | — | — | — | 2 (2) | keep |
| ElapsedClock / TourRefInjector | pres:elapsed-clock.tsx · tour-ref-injector.tsx | Ticking clock leaf / coachmark tour ref | — | — | — | 3·3 | keep. Infra. |
| SessionSaveButton / SaveButton / VenueSaveButton | pres:sessions/session-save-button.tsx · save-button.tsx · venue-save-button.tsx | Bookmark toggle (optimistic) | variant card/header | aria-pressed; P icon | card size SAVE_BUTTON_CARD_SIZE | 2·2·2 | merge → T-IconButton (toggle). Bookmark leaves rows; stays in the detail header (D6). |
| RecruitmentMeter | pres:sessions/session-compact-row.tsx | Bar + N/M count | — | passive | ProgressBar height 5 muted (:131) | 2 (2) | retire. Capacity bar leaves rows; N/M becomes the trailing text or a stat. |
| VenueDirectoryContent / PickedVenueCard / CourtUsagePanel | pres:sessions/venue-directory-content.tsx · picked-venue-card.tsx · court-usage-panel.tsx | Venue directory body / picked venue + court config | — | — | compose VenueRow, AttachedPanel, SelectionChip grid | 2·2·2 | keep. Re-skin through the primitives they compose. |
| MatchHistoryRow / MatchHistoryScopeSelector | pres:match-history-row.tsx · match-history-scope-selector.tsx | Match result row with avatars, badges, score / scope facet fields | — | P row | avatar stack, chips, delta badge, chevron | 2·2 | re-spec → T-PersonRow (L1 opponents, trailing score + W/L Tag) |
| ReportModal | pres:report-modal.tsx | Report sheet: category chips + detail field + submit | — | submit disabled until a category is chosen | ModalPanel + SelectionChip + StyledInput | 2 (2) | keep → T-Overlay |
1d. Appendix — presentation components with one consumer group or none (54 names)
Dispositions here are coarse (the component is feature-shaped); the point is to see which of them the six v2 screens replace.
| Component | File | Consumers files (groups) | v2 disposition |
|---|---|---|---|
| MatchScoreboard | pres:sessions/match-scoreboard.tsx | 7 (1) | keep → scoreboard, feature-specific |
| MatchRowHeader | pres:sessions/match-row-header.tsx | 4 (1) | keep |
| StyleOptionRow | pres:sessions/style-option-row.tsx | 3 (1) | re-spec → T-CellRow option |
| SessionLiveAnchor | pres:sessions/session-live-anchor.tsx | 3 (1) | re-spec → live band stays on detail |
| VenueFinder | pres:sessions/venue-finder.tsx | 3 (2) | keep → canonical venue search; chrome re-skin |
| PeriodNavPanel | pres:period-nav-panel.tsx | 3 (1) | re-spec → F-PeriodStepper |
| SessionLiveBand | pres:sessions/session-strip-ui.tsx | 2 (1) | re-spec → live → danger Tag (D7) |
| SourceChips | pres:sessions/venue-confidence.tsx | 2 (1) | merge → T-Tag |
| EloFactRow | pres:elo-fact-row.tsx | 2 (1) | re-spec → T-CellRow |
| UpdateRequiredScreen | pres:update-required-screen.tsx | 1 (1) | keep |
| NetworkBanner | pres:network-banner.tsx | 1 (1) | re-spec → T-Notice system strip |
| ClubAlarmBadge | pres:status/badges/club-alarm.tsx | 1 (1) | merge → T-Tag |
| ClubJoinabilityBadge | pres:status/badges/club.tsx | 1 (1) | merge → T-Tag |
| ClubRecruitBadge | pres:status/badges/club.tsx | 1 (1) | merge → T-Tag |
| ChallengeStatusBadge | pres:status/badges/club.tsx | 1 (1) | merge → T-Tag |
| MatchStatusDot | pres:status/match-status-dot.tsx | 1 (1) | keep → dot |
| ActivityMetricSelector | pres:activity-metric-calendar.tsx | 1 (1) | keep |
| ActivityMetricCalendar | pres:activity-metric-calendar.tsx | 1 (1) | keep → calendar grid |
| DmThreadRow | pres:dm/dm-thread-row.tsx | 1 (1) | re-spec → T-PersonRow |
| VenueSearchResultsList | pres:venue-search-results-list.tsx | 1 (1) | keep |
| ReliabilityPill | pres:reliability-pill.tsx | 1 (1) | merge → T-Tag |
| SessionLeadBadge | pres:sessions/session-strip-ui.tsx | 1 (1) | merge → T-Tag |
| VenueFilterBar | pres:sessions/venue-filter-bar.tsx | 1 (1) | re-spec → F-Search |
| DirectoryVenueCard | pres:sessions/directory-venue-card.tsx | 1 (1) | re-spec → T-MediaRow (venue) |
| ConfidenceBars | pres:sessions/venue-confidence.tsx | 1 (1) | keep |
| VenueFinderHeader | pres:sessions/venue-finder/venue-finder-header.tsx | 1 (1) | re-spec → F-Search + Choice |
| VenueFinderSearchResults | pres:sessions/venue-finder/venue-finder-search-results.tsx | 1 (1) | keep |
| VenueFinderBrowseList | pres:sessions/venue-finder/venue-finder-browse-list.tsx | 1 (1) | keep |
| VenueFinderContributionRow | pres:sessions/venue-finder/venue-finder-contribution-row.tsx | 1 (1) | keep |
| VenueTile | pres:sessions/venue-tile.tsx | 1 (1) | keep → map-thumb cascade; 48/56 thumb |
| SessionCompactRow | pres:sessions/session-compact-row.tsx | 1 (1) | retire → dies with the card (T-MediaRow) |
| SessionCardV2 | pres:sessions/session-card-v2.tsx | 1 (1) | retire → T-MediaRow |
| VenueCard | pres:venue-card.tsx | 1 (1) | keep → detail tile; re-skin |
| VenueSearchResultRow | pres:venue-search-result-row.tsx | 1 (1) | re-spec → T-MediaRow (venue) |
| VenueLink | pres:venue-link.tsx | 1 (1) | merge → T-Tag / meta text |
| AppVersionGate | pres:app-version-gate.tsx | 1 (1) | keep |
| MatchHistoryScopeFields | pres:match-history-scope-selector.tsx | 1 (1) | keep |
| MatchHistoryScopeSheet | pres:match-history-scope-sheet.tsx | 1 (1) | keep → T-Overlay |
| DevPanel | pres:dev-panel/index.tsx | 1 (1) | keep → dev only (the one legal Tamagui Sheet) |
| DiagnosticsPane | pres:dev-panel/diagnostics-pane.tsx | 1 (1) | keep → dev |
| LogsPane | pres:dev-panel/logs-pane.tsx | 1 (1) | keep → dev |
| TelemetryPane | pres:dev-panel/telemetry-pane.tsx | 1 (1) | keep → dev |
| ScenariosPane | pres:dev-panel/scenarios-pane.tsx | 1 (1) | keep → dev |
| VenueMonogram | pres:venue-monogram.tsx | 1 (1) | keep |
| CoachmarkTooltip | pres:coachmark-tooltip.tsx | 0 (0) | keep → coachmark |
| ApprovalModeBadge | pres:status/status-badges.tsx | 0 (0) | retire → dead (referenced only in comments) |
| ClubRoleBadge | pres:status/badges/club.tsx | 0 (0) | retire → dead (barrel export only) |
| RecapOutcomePanel | pres:recap-outcome-panel.tsx | 0 (0) | retire → dead (barrel export only) |
| MatchFormatScopeSelector | pres:match-format-scope-selector.tsx | 0 (0) | retire → dead (0 consumers) |
| ClubSessionCard | pres:sessions/club-session-card.tsx | 0 (0) | retire → dead (0 consumers) |
| VenueHero | pres:sessions/venue-hero.tsx | 0 (0) | retire → dead (barrel export only) |
| VenueTrustRing | pres:sessions/venue-confidence.tsx | 0 (0) | retire → dead (0 consumers) |
| HourlyWeatherStrip | pres:sessions/hourly-weather-strip.tsx | 0 (0) | retire → dead (0 consumers) |
| TrustTierInfoSheet | pres:trust-tier-info-sheet.tsx | 0 (0) | keep |
Feature-local surfaces outside the brief's scope but replaced by v2 rows (consumer files in parentheses; each has one consumer group): ClubCard (1) packages/features/clubs/src/shared/club-card.tsx; ClubAvatar (5) …/club-avatar.tsx; ClubLogoBubble (1); ArchivedClubRow (1); PostFeedRow (1) …/post-row.tsx; ParticipantRow (3) packages/features/sessions/src/components/participant-row.tsx; NotificationRowShell (2) packages/features/home/src/shared/notification-row-shell.tsx; FriendPickerRow (1); EntryIconBubble (2); MemberRow (1); DuesRow (1); MatchCard (1); home period pickers DayCalendarPicker, MonthYearPicker, WeekMonthPicker (1 each).
1e. Foundation deltas — today's numbers versus grammar v2
These are the values every re-spec above is changing; recorded once so component designs do not re-derive them.
| Item | Today (file:line) | Grammar v2 |
|---|---|---|
| Rail | 16: SEAM.rail (ui:seams.ts:44), CTA band sides (ui:bottom-cta-band-visual.ts:26) | 20 (§1) |
| Body text | cardBody 14/22·400 (ui:text.tsx:105) | 15/22·400 |
| Meta text | cardMeta 14/20·500 (ui:text.tsx:112) | footnote 13/18·500-600, caption 12/16 |
| Row line 1 | ListRow title 14/22·600 (ui:list-row.tsx:154) | media 17/24·600, person 15/22·600 |
| Section title | flat navTitle 17/22 with weight 700 (ui:section-header-metrics.ts:57,77), list cardTitle 16/24 (:58) | 17/24·600 |
| Screen title | AppHeader centered navTitle 17/22·600 (ui:app-header.tsx:160-164); tab roots left pageTitle 24/32·700 (ui:main-tab-shell.tsx:102) | 20/28·700 at the rail in a 56 header |
| Hero title | pageTitle 24/32·700 (ui:type-hero.tsx:85) | 24/32·700 (unchanged) |
| Header | 56 (64 with subtitle) + permanent bottom hairline (ui:app-header-visual.ts:18-20, ui:app-header.tsx:118) | 56, no divider |
| Tab bar | 56 + inset + 8, labels 11/600, active tint $primary, 1px $borderSubtle top border, paddingTop 6 (mobile:app/(tabs)/_layout.tsx:62-78) | 56 + 34, 11/14·500 (600 active), ink tint, hairline only while content lies below |
| Primary button | lg 56, r12, label size from Tamagui $5 = 15 in bodyFont (ui:button.tsx:222,131,181; ui:fonts.ts:40) | 52, 17/24·600, r12 |
| Section seam | 24 + full-bleed hairline (ui:flat-column-defaults.ts:23, ui:flat-column.tsx:87) | 32 of air, no line (§4b) |
| Item seam | DefaultSeparator 12 between cards (ui:feed-list/tuning.ts:16) | none — row pitch 80/64 owns it |
| Radii | Card 16 (ui:card.tsx:29), Button 12 (ui:button.tsx:131), Badge sm 8 (ui:badge.tsx:94), venue tile 12 (pres:sessions/venue-tile.tsx:92), chips full | tag 6, thumb 8, club logo 12, button 12, block 16 |
| Icon sizes | 12/14/16/20/24/28 (ui:icon-sizes.ts:12-19) | 24 / 20 / 16 |
| Avatars | 28/32/40/44/56/80/88, default 32 (ui:box-sizes.ts:24-40, ui:avatar-bubble.tsx:159) | 40 / 48 / 56 |
| Hairline | one tier $borderSubtle, always drawn (ui:hairline.ts:18-20) | edge #D4D4D4 + cell #E5E5E5, edge only under scrolling content |
$primarySubtle as a surface | 23 files in packages/ui/src, 11 in presentation, 22 in features (92 occurrences repo-wide) | leaves (list spec §3 / grammar §6) |
| Brand-tinted ink on controls | selected chip $primary, section link $primary, tab bar $primary, HeaderTextAction $primary | neutral ink; one coloured action per list screen |
Also record: ui:segmented-tabs.tsx:150 embeds a literal NUL byte (join('^@')), so file/GNU grep/ugrep treat the file as binary and skip it silently; any audit tooling that greps components will miss this file.
2. Interaction behaviours today
Every number below is read from source. The v2 library must reproduce the behaviours marked carry; the others are listed so the redesign knows what it is replacing.
2.1 Press physics — Pressable is the only touchable (carry)
- Six variants, one table.
VARIANTS(ui:pressable-visual.ts:43-50): card scale 0.98 / opacity 0.90 · button 0.97 / 0.92 · pill 0.96 / 0.85 · row 0.98 / 0.70 · icon 0.92 / 0.78 · fab 0.96 / 0.90.Buttongets the same numbers through TamaguipressStyleon the Reanimated animation driver (mobile:tamagui.config.ts:9,24-33).Pressable,ButtonandPillNavall read it (ui:button.tsx:62,ui:pill-nav.tsx:115,186,209-210). - Settle = spring, dim = timing, both on the UI thread. Press-in spring
{damping 20, stiffness 400, mass 0.7}, press-out{15, 300, 0.7}(ui:motion.ts:29,31); opacity fades 80 ms in, 150 ms out (ui:motion.ts:42,44). Wiring:withSpring(config.pressScale, SPRING_IN)andwithTiming(config.pressOpacity, { duration: PRESS_IN_MS })(ui:pressable.tsx:286,288), reversed on press-out (:299,301). - No tint band, ever. The
stateLayerwash was retired 2026-09-28 (ui:pressable-visual.ts:7-22; canondocs/canon/components.md:100-105). The one persistent selected fill on a list row isListRow selected, which paints$primarySubtleand setsaria-current(ui:list-row.tsx:201,216). - Reduced motion keeps the dim, drops the settle (
ui:pressable.tsx:286,299;ui:button.tsx:292-294). A lint rule (@twomore/require-reduced-motion-gate, describedui:motion.ts:129-146) requires everywithSpring/withTiming/animation=/enterStyleinpackages/ui/srcto be gated or carry// motion-exempt:. - Hover (web, real pointers only): opacity 0.85 on every Pressable and Button (
ui:pressable-visual.ts:71;ui:pressable.tsx:324;ui:button.tsx:67) pluscursor: pointer(ui:pressable.tsx:311). - Focus ring (web only):
FOCUS_RING= 2px solid$primary, offset 2 (ui:pressable-visual.ts:84-89), shared by Pressable (:325), Button (ui:button.tsx:68), PillNav (ui:pill-nav.tsx:119,187) and StyledInput (ui:labeled-field.tsx:432). Native has no focus indicator. - Disabled / loading = the inert path: a plain
View, opacity 0.5,cursor: not-allowed,pointerEvents: none,aria-disabled,aria-busywhile loading, no press feedback (ui:pressable.tsx:99,164,187-233). A Pressable with noonPressis also inert (:164). - Selection a11y by role (carry):
selectionStatePropsmaps radio/checkbox/switch →aria-checked, tab/option/row →aria-selected, button/none →aria-pressed(ui:selection-accessibility.ts:2-18). Web radios get arrow-key movement (ui:keyboard-navigation.ts:20-29,47, wired inui:selection-chip.tsx:17,96). - Nested targets must call
e.stopPropagation()first (canoncomponents.md:17); a control on a tappable card is its sibling placed withuseLayoutPathOrigin(canoncomponents.md:119;ui:index.ts:58).
2.2 Hit slop and tap targets (carry)
One formula everywhere: hitSlop = (TOUCH_TARGET.MIN − visual) / 2 with TOUCH_TARGET.MIN = 44 (ui:box-sizes.ts:74). Instances: icon variant default 15 for a 14px glyph (ui:pressable.tsx:112); header glyph 24 → 10 (ui:header-icon-button.tsx:50-51); chip md 40 → 2 and sm 32 → 6 (ui:selection-chip.tsx:25,85); list row 40 → 2, dense 36 → 4 (ui:list-row-visual.ts:34-39); stepper 32 → 6 (ui:stepper.tsx:31,96); action icon 36 → 4 (ui:action-icon-button.tsx:42-43); camera badge 28 → 8 (ui:camera-badge.tsx:25); action pill 28 → 8 (ui:action-pill.tsx:27); removable-chip ✕ 12 → 16 (ui:removable-chip.tsx:34). On web the slop is realised by an invisible out-of-flow child inside the Pressable, because the DOM has no hitSlop (ui:pressable.tsx:77-90; ui:pressable-visual.ts:91-114). First/last rows in a RowList add their trimmed padding to the slop (ui:list-row-visual.ts:70-83).
2.3 Motion tokens
- Springs (
ui:motion.ts:27-34): pressIn, pressOut, navSlide{22, 280, 0.8}(PillNav indicator,ui:pill-nav.tsx:327). Tamagui presets (mobile:tamagui.config.ts:25-32):quickspring {20, 250},medium{18, 170},slow{15, 100},reveal= 250 ms timing (no spring tail; owner QA 2026-08-08). Used by:quick→ ModalPanel bottom entry (ui:modal-panel.tsx:209), SearchBar border (ui:search-bar.tsx:117), ExpandChevron (ui:expand-toggle.tsx:56-57);reveal→ RevealStack (ui:reveal-stack.tsx:165), ExpandReveal (:156), ToastHost (pres:toast-host.tsx:333);medium→ DataSection (ui:data-section.tsx:120). - Durations in
MOTION_DURATION(ui:motion.ts:40-74). Used: pressIn 80, pressOut 150, slow 300 (ProgressBar,ui:progress-bar.tsx:83), shimmer 800 (ui:shimmer.ts:33,38), pulseDot 1600 (ui:pulse-dot.tsx:81), exitFast 100 (toast drag-out,pres:toast-host.tsx:279-282). Unused: fast 150, standard 220, entrance 250, exitStandard 180, modal 300, modalExit 200, liveDot 500, badgePulse 800, and the whole stagger set (MOTION_STAGGER50 ms/item, max 300, first 6 —ui:motion.ts:81-93) have no consumer. - Screen transitions are native-stack defaults: no
animation,presentationorgestureEnabledoption is set in anyapps/mobile/app/**/_layout.tsx. - Reduced motion comes from
useReducedMotion(ui:motion.ts:147-170:AccessibilityInfoon native,prefers-reduced-motionon web).
2.4 Loading patterns
- Skeleton:
Animatedopacity loop 0.6 ↔ 0.9, 800 ms per half-cycle, native driver off on web (ui:shimmer.ts:21,32-38;ui:motion.ts:67,178); reduced motion pins 0.75 (ui:shimmer.ts:14). Fill$surfaceTertiary(ui:skeleton.tsx:64), default 16 × 100%, radius 8 (:41-42).RemoteImagereuses the same hook for its loading pulse (ui:remote-image.tsx, skeleton while status = loading). - Spinners: RN
ActivityIndicator size="small"only — insideButton loading(replaces the icon slot, button auto-disabled, optionalloadingLabel;ui:button.tsx:288-289,297) andActionIconButton loading(ui:action-icon-button.tsx:111); 8 other files use a rawActivityIndicator. No branded spinner, no determinate loader exceptProgressBar. - Screen cascade:
QueryBoundaryrenders offline-cold → skeleton → error+retry → content (pres:query-boundary.tsx); errors areEmptyState+ AlertCircle, offline isEmptyState+ WifiOff (pres:query-error-state.tsx:48,pres:offline-empty-state.tsx).NetworkBanneris the one root-mounted strip:$badgeWarningBg, pad 16×8, WifiOff glyph,role="alert"(pres:network-banner.tsx:64-69). - Content entrance: only RevealStack/ExpandReveal/DataSection (retired 2026-11-13) animate in; list rows do not stagger (the stagger tokens are unused).
2.5 Disabled and muted recipes
0.5 opacity is the single disabled value: Pressable (ui:pressable.tsx:99), Button (ui:button.tsx:74), ListRow (ui:list-row-visual.ts:241), StyledInput editable={false} (ui:labeled-field.tsx:464), SearchBar (ui:search-bar.tsx:118), AddCard (ui:add-card.tsx, border drops to $borderSubtle, ink to $textTertiary). Outliers: ActionIconRow disabled 0.4 (ui:action-icon-row-visual.ts:52); terminal-state muting 0.55 via TERMINAL_MUTE_OPACITY (ui:box-sizes.ts:104); reduced-motion shimmer 0.75.
2.6 Toast / snackbar (ToastHost)
One toast at a time, from useToastStore (app:stores/toast.store.ts). Duration 2800 ms default (toast.store.ts:78); variants success/error/warning/info (:3); optional text action rendered as a ghost Button sm with weight 800 (pres:toast-host.tsx:361-363). Position: absolute, centered, bottom = max(tab-bar chrome height or safe inset + 8, keyboard height + 8) (:182,220-223; chrome height published from mobile:app/(tabs)/_layout.tsx:47,57). Enter/exit: opacity 0 and y 12 on reveal 250 ms (:333-334). Dismiss: swipe down — drag past 40px or 500px/s flings it out in 100 ms, upward drag resisted ×0.15 (:164-171,259-294); the auto-dismiss timer pauses while touched (:198, pauseToastTimer). Surface: inverse $inverseSurface, radius $6 (24), pad 16×12, max width 560, shadow opacity .16 radius 10 offset 4 (:343-352), role="alert" aria-live="polite" (:336); text cardBody 14/22, 2 lines (:355). It is the only floating surface besides the header menu that carries a shadow.
2.7 Pull-to-refresh
Native RefreshControl through FeedList/GroupedFeedList refreshing/onRefresh (ui:feed-list/grouped-feed-list.tsx:61-62,157-158; ui:feed-list.tsx:92-93,148-149); 9 screens also mount a raw <RefreshControl> (list below). The spinner is a gesture acknowledgement only: usePullToRefresh sets refreshing from the pull until that refetch settles and never for background refetches (app:hooks/use-pull-to-refresh.ts:39-70; lint no-background-refetch-spinner). There is no custom pull visual, colour or threshold anywhere. Raw <RefreshControl> files: club-challenges, public-club-profile, session-detail, directory-venue-detail, match-board, profile, records, record-day-detail, record-detail.
2.8 Keyboard handling
- Shells:
ModalPanelwraps inKeyboardAvoidingView behavior="padding"(ui:modal-panel.tsx:117);WizardShellwraps content + footer in the same (ui:wizard-shell.tsx:391) so the CTA lifts above the keyboard;DetailShellsets iOSautomaticallyAdjustKeyboardInsets(ui:detail-shell.tsx:281) and relies on AndroidsoftwareKeyboardLayoutMode: "pan"(mobile:app.json:80; commentui:detail-shell.tsx:279-280). - Taps while typing:
ModalPanel.ScrollView keyboardShouldPersistTaps="handled"(ui:modal-panel.tsx:244);DetailShellforwards the prop (ui:detail-shell.tsx:120,142,269). - Android autofocus in a Modal is unreliable, so
VenueSearchTakeoverfocuses imperatively throughSearchBar inputRef(pres:venue-search-takeover.tsx:83-87,186). ToastHostlistens tokeyboardDidShow/Hideto float above the keyboard (pres:toast-host.tsx:209).react-native-keyboard-controller1.22.5 is installed (mobile:package.json:55, plugin:80) but imported nowhere — dormant, per project memory; v2 forms should not assume it.- Submit behaviour:
StyledInputforwardsreturnKeyType/onSubmitEditing(ui:labeled-field.tsx:259-260,394-395);RevealStackconfirms a section on keyboard done, never on validity (canoncomponents.md:88).
2.9 Overlays (carry the behaviours)
| Overlay | Mechanism | Dismiss | Motion | Geometry |
|---|---|---|---|---|
ModalPanel center dialog | RN Modal transparent, overFullScreen, statusBarTranslucent (ui:modal-panel.tsx:105-111) | backdrop tap (default on, :99), Android back (:111), built-in ✕ | Modal fade (:108) | frame ≤640 × ≤88%, $background, 1px $borderSubtle, r16, pad 20×16 (:189-197); ✕ 40 circle at top 12 right 12 (:214-231) |
ModalPanel placement="bottom" (edit sheet) | same | backdrop / 취소; no drag, no grabber | fade + frame slides y: 48 on quick (:209) | flush bottom, top radii 24, pad-bottom 20 + safe inset (:199-207), no ✕ by default (ui:modal-panel-visual.ts:22) |
ConfirmSheet (useConfirm) | ModalPanel center (ui:confirm-sheet.tsx) | backdrop does not dismiss (:180); Android back closes (:128) | fade | title cardTitle 700 centered; ≥3 buttons stack (:201) else row |
HeaderActionsMenu | RN Modal fade, transparent, anchored by measureInWindow (ui:header-actions-menu.tsx:342, layout ui:header-actions-menu-layout.ts) | backdrop, Escape (web, :287), item press | fade | width 200–260, rows ≥44, r12, shadow .12/r10/elev 8 (:71-76,384,422); one Divider before the destructive group (:410) |
ToastHost | absolute view in the root | swipe / timer / action | reveal | §2.6 |
| Dev panel | Tamagui Sheet (the one legal Sheet) | drag | — | dev only (pres:dev-panel/index.tsx:414-430) |
| Date/time | react-native-modal-datetime-picker used directly in 6 feature files (web shim at apps/web/lib/shims/react-native-modal-datetime-picker.tsx); locale="ko_KR", iOS confirm/cancel strings (packages/features/sessions/src/create-session/step1/create-session-step1.tsx:280-288) | native | native | no shared wrapper |
| Coachmark | react-native-spotlight-tour + pres:coachmark-tooltip.tsx | tour controls | — | home FAB spotlight (packages/features/home/src/views/home-today-view.tsx:64,124-137) |
Backdrop colour is $overlayBackdrop = rgba(0,0,0,0.5) (ui:tokens.ts:128).
2.10 Haptics
expo-haptics is wrapped once (app:utils/haptics.ts:8-43: light, medium, heavy, success, warning, error, selection). Primitives fire none: Pressable, Button, PillNav, SelectionChip have no haptic call. They fire from mutation outcomes: createMutationHook plays haptic.success() by default and haptic.error() on failure (app:hooks/create-mutation-hook.ts:88-95,182-188,243-244), plus 5 direct calls (RSVP, post reaction, DM send, poll select, booking alert — haptic.light/selection in app:hooks/mutations/use-rsvp.ts:458, …/post/reactions.ts:216, …/post/poll.ts:100, app:hooks/use-booking-alert.ts:27, …/use-dm-mutations.ts:202) and two haptic.warning() in the create-club submit. Toast has no haptic of its own.
2.11 Scroll-linked and list behaviours
CollapsingTopBar: expanded transparent over the hero, flips to solid pastheroBand − 48with a hysteresis band (ui:use-collapsing-top-bar.ts:37-59;ui:collapsing-top-bar-visual.ts:13); the solid layer's opacity animates, not the colour (ui:collapsing-top-bar.tsx:122-124). Club home only.SegmentedTabs:useDeferredValuekeeps the tab flip urgent, hidden panes are frozen with ReactActivity(ui:freeze.tsx:1-10), no horizontal swipe between panes.FeedList: FlashList, draw distance 800, end-reached threshold 0.6 (ui:feed-list/tuning.ts:38,44);recycling={false}is the fix for the Fabric blank-cell bug (ui:feed-list.tsx:83, memory note). Web variant is separate (ui:feed-list.web.tsx).- Prefetch on press-in: rows pass
onPressInto aprefetchXhelper (pres:person-row.tsxdefault; canoncomponents.md:108).
3. Forms — what a v2 form family must cover
The grammar has not specified forms. Below is every control the app renders today with its geometry, so the family can be designed once. v2 anchors worth reusing: body 15/22 (the input font is already 15, ui:labeled-field.tsx:455), button radius 12 (inputs are already r12, :449), Choice 36, Primary button 52.
| Control | File | Geometry today | States today | Notes for v2 |
|---|---|---|---|---|
Field wrapper LabeledField | ui:labeled-field.tsx | label row: sm = cardMeta 14/20 secondary, lg = cardTitle 16/24 (:173-181); required * in $error; labelRight slot; gap $1/$2 (:198); caption/error cardMeta (:222,227); collapsible label row min 44 with ExpandChevron (:210) | error = text + aria-live="polite" (:227), input gets an aria-label suffix (:365); no icon, no success state | one wrapper for label, caption, error; caption goes after any AttachedPanel (canon wizard rule) |
Text input StyledInput | ui:labeled-field.tsx:359-445 | raw RN TextInput: 1px $border, r12, pad 12×12, 15px, placeholderTextColor $textSecondary; no min-height single-line (≈44–46 from padding + line); multiline min 120, top-aligned (:449-461); bare removes border/pad | rest · focus border $primary (+web ring :432) · invalid border $error (:372) · editable={false} 0.5 (:464) | v2 field height, label placement and error ink undefined; focus border colour is brand $primary |
Search SearchBar | ui:search-bar.tsx | min-height 44, pad 12×8, r12, 1px (:110-114), Search icon 14, input 15 (:149), clear ✕ hitSlop 15 (:169), rightAccessory slot | focus border $primary on quick (:114-117); disabled 0.5 (:118) | used inside ExpandableFilterBar with a filter toggle + count badge as the accessory |
| Money / count | pres:won-amount-field.tsx, pres:suggested-count-field.tsx, ui:stepper.tsx | Stepper: buttons 32 circle 1px $border fill $surfaceSecondary (:31,59-72), value min-width 48 (:113), fullWidth variant min 48 r12 (:167) | disabled at bounds; aria-valuemin/max/now (:195) | digits-first edit, comma display on commit |
Switch TogglePill | ui:toggle-pill.tsx | track 44×24 r12, thumb 20, inset 2 (:17-20) | on $primary / off $border (:40); no thumb animation, no role="switch", no aria-checked; host row (ListRow onPress) owns the press; 9 files | needs motion (a 200 ms toggle duration token exists, ui:motion.ts:48, but nothing uses it), switch semantics, disabled and focus states |
Check / radio SelectionCheck | ui:selection-check.tsx | 24; square r6 border 1.5, circle border 1 (:34-41) | checked = fill $primary + Check | used in 2 files; option lists mostly use a trailing Check 16 (ui:dropdown-chip.tsx:209) |
Choice chip SelectionChip | ui:selection-chip.tsx | §1a | §1a | → T-Choice (36 / 14 / 15/22·500) |
Choice tile SelectionCard | ui:selection-card.tsx | padding 16, r12, border 2 (:44), fill $primarySubtle when selected | P card; S border+fill | 9 files (format tiles, presets, play style) |
| Dropdown | ui:dropdown-chip.tsx, ui:range-chip.tsx, ui:chip-picker-panel.tsx | chip → center ModalPanel with title, option rows (pad 12×8, Check 16), primary lg 적용 button (dropdown-chip.tsx:320) | single aria-selected, multi aria-checked; "전체" always first | picker is a centered dialog, not a bottom sheet |
Picker row PickerRow | ui:picker-row.tsx | min 44, 1px $border, r12, pad 12; active border 1.5 $primary (:63-78) | active/disabled | opens a native date/time picker or modal; 2 files |
Edit row EditFieldRow | ui:edit-field-row.tsx | pad 8×16, label 14/20 tertiary over value 14/22 (2 lines), pencil 16 | P row | settings edit entry |
| Add / remove | ui:add-card.tsx, ui:remove-icon-button.tsx, ui:action-icon-button.tsx | AddCard min 52, 1.5px dashed $primary; remove = 36 circle Trash2 16 with built-in confirm (remove-icon-button.tsx:74) | D 0.5 | canon: trash = delete item, ✕ = dismiss |
| Attached panel | ui:attached-panel.tsx | 1.5px $primary border + $primarySubtle fill, pad 12, margin-top 8, 12px notch (:39-74) | passive; live-commit | fill must change in v2 |
| Reveal stack | ui:reveal-stack.tsx | section gap 20 ($5, :109); confirm row = skip (flex 1) + confirm (flex 2) (:178) | new section enters on reveal | sequential-reveal contract (canon components.md:88) |
| Wizard frame | ui:wizard-shell.tsx | header: 40 back/close square + hit slop 2 (:65), 4px tappable progress segments (:316), count text; footer: one full-width lg Button (+ primaryHint) | back → ✕ on step 0; skip in header | nav law: header owns movement, footer one CTA |
| Sliders | ui:time-band-slider.tsx, ui:time-band-field.tsx, ui:range-band-slider.tsx, pres:tier-range-selector.tsx, ui:hue-slider.tsx, ui:value-slider.tsx | bar 44 hit, track 28, trim handle 11, knob 18, handle hit ±22 (ui:box-sizes.ts:115-143); ValueSlider track 6 knob 24 (dead) | drag via Pan, snap + live label, a11y adjustable actions | "no scroll + drag on one axis" rule; four custom sliders, no shared base spec |
| Date / time | react-native-modal-datetime-picker in 6 feature files (+ a web shim, apps/web/lib/shims/react-native-modal-datetime-picker.tsx) + home calendars | native wheel (iOS) / dialog (Android); home: DayCalendarPicker, MonthYearPicker, WeekMonthPicker over PeriodScrubberShell card | native | no shared wrapper component at all |
| Composer | packages/features/messaging/src/dm-thread/compose-bar.tsx | raw RN input r20, pad 14×10, min 40 / max 100 (:116-123); send = 40 circle $primary with ↑ glyph (:97-103) | send disabled when empty | feature-local; the post comment box is separate |
| Score entry | packages/features/sessions/src/multi-set-score-entry.tsx, match-card.tsx | 10 raw RNTextInputs with local styles | — | numeric inputs with number-pad; only other raw inputs in the repo |
| Search chrome | pres:expandable-filter-bar.tsx, pres:sessions/venue-filter-bar.tsx, pres:sessions/venue-finder/venue-finder-header.tsx, pres:venue-search-takeover.tsx | SearchBar + toggle (SlidersHorizontal + count) + panel $card pad 12 r12 gap 16 (expandable-filter-bar.tsx:161-163); header: RemovableChips + nav SelectionChips wrap with gap 8 (venue-finder-header.tsx:77-116); takeover = full-screen Modal | toggle aria-expanded | venue finder is the one canonical search surface (canon components.md:89) |
| Edit sheet | pres:edit-field-modal.tsx | bottom ModalPanel with title, control, 취소 · 저장 pair | scrim/취소 closes | the v2 "filter sheet / edit sheet" |
Form-wide laws that must survive: wizard nav law (footer = one CTA, header owns back/skip); sequential reveal on explicit confirm; live-draft vs staged-commit split (AttachedPanel live, ModalPanel staged); useUnsavedChangesGuard for editors (canon components.md Wizards & confirms); error text below the control; captions after attached panels.
4. Gaps
4.1 Components the six v2 screens imply but the grammar does not specify
"Today" cites what exists; "Must decide" is what the library design owes before page layouts start.
| # | Component | Where the six screens need it | Today | Must decide |
|---|---|---|---|---|
| G01 | Search field | 경기 header search; 클럽 탐색 | SearchBar min 44, r12, pad 12×8, focus border $primary (ui:search-bar.tsx:110-118) | header-takeover vs inline; field height/radius at the 20 rail; focus ink (neutral or brand); where the filter toggle + count live |
| G02 | Filter / edit sheet | list spec §2 turns scopes into underline tabs, but region/format/fee/tier filters have no tab form | DropdownChip/RangeChip open a centered ModalPanel with 적용 (ui:dropdown-chip.tsx:320); EditControlModal is a bottom ModalPanel (pres:edit-field-modal.tsx) | one sheet grammar (bottom, scrim closes, no drag, pair of buttons) vs the two in use; Choice menu/range modes inside it |
| G03 | Toast | every mutation | ToastHost (§2.6): inverse surface r24, shadow, bottom of screen, swipe-down | colour/ink, radius, shadow allowed? (grammar §4b says no shadow on tab bar/CTA; toast and header menu are the only shadowed surfaces), position relative to FAB and tab bar |
| G04 | Notice | detail hero zone alerts, payment/approval notices, offline strip | four shapes: Banner, Callout, Well, NetworkBanner — all fill with $primarySubtle / badge tints (ui:banner.tsx:83-91, ui:well-tone.ts) | one inline notice from the weak Tag tone table; with/without action; whether a tappable notice shows a chevron |
| G05 | Empty and error states | 기록 친구 tab, no sessions, offline | EmptyState icon disc 64 on $primarySubtle (ui:empty-state.tsx:75) | icon disc fill (surface leaves), title 17/24 vs 15/22, button size; one component for empty/error/offline/not-found |
| G06 | Skeleton at v2 pitches | all lists | skeleton rows ≈56 pitch (avatar 40), SkeletonCard/SkeletonHero wrap Card (ui:skeleton.tsx:77-135) | shapes for media row 80, person row 64, block 144/158, stat strip 80, chart 160, hero |
| G07 | Pull-to-refresh | every list | native RefreshControl, no styling (§2.7) | tint colour, whether the brand block participates |
| G08 | FAB behaviour | 홈, 경기 | none; variant="fab" physics exists, zero uses; canon line says "no FABs" (docs/canon/components.md:13) | scroll hide/shrink, keyboard, tab-bar offset 16, safe area, coachmark target, label length |
| G09 | Tab-bar badge / dot | 홈 live dot, 클럽 unread | HomeTabIcon 7px $error dot with 1.5px $card ring (mobile:app/(tabs)/_layout.tsx:168-194); no count form | dot vs count; ink; whether tab bar may carry colour |
| G10 | Overflow menu | detail header ⋯ | HeaderActionsMenu shadowed dropdown 200–260 wide (§2.9) | surface fill, radius, shadow vs hairline, 20 rail alignment |
| G11 | Settings / navigation row | settings, admin hubs, link lists (ListRow is in 34 files) | ListRow with chevron or trailing control (§1a) | v2 has media 80, person 64, cell 48 only. Needs: nav row with chevron, toggle row, value+chevron row, destructive row — pitch, type, hairline between cells |
| G12 | Segmented control (form) | settings and wizards (list spec: "remains a form control") | PillNav pill variant: track $surfaceTertiary r20 pad 4, sliding $primary indicator, scrolls past 4 items (ui:pill-nav.tsx:51,76-81,68); used as a form control in match-rules-editor (5 instances), create-session-step4, create-tournament-modal, practice-log, match-list-controls-sheet, records-screen | geometry and ink without brand fill; keep or fold into Choice |
| G13 | Count in a Tag | 클럽 row "trailing unread count Tag (neutral)" | CornerCountBadge 18 circle (ui:corner-count-badge.tsx:31), formatBadgeCount caps at 99+ (ui:badge-visual.ts:27) | variable-width count Tag vs the corner badge; 99+ rule |
| G14 | Avatar row / group | 프로필 friends row (40 × 6, gap 8), 세션 상세 roster | AvatarStack overlaps −6, max 8 (ui:avatar-stack.tsx:73,117); participants are a RowList of PersonRow (packages/features/sessions/src/session-detail/sections/participation-section.tsx:146) | spaced avatar row; roster 4-up grid with dashed empty slot (new component) |
| G15 | Progress in the brand block | 기록 ELO block | ProgressBar height 6, track $primarySubtle/$borderSubtle, fill fillColor (ui:progress-bar.tsx:70,112) | optic fill on an α0.2 track needs a track-colour prop |
| G16 | Chart behaviour | 기록 trend | SparkLine 120×48, stroke 2, trend colour, pan-scrub marker (ui:charts/SparkLine.shared.ts:54-55, SparkLine.tsx:98) | scrub readout, selected marker, empty/one-point state, axis labels (none in spec) |
| G17 | Venue / map thumbnail | session and club rows | VenueTile: photo → static map → glyph cascade, aspect-bound to the text column (pres:sessions/venue-tile.tsx:15-92), r12 | 56 r8 fixed tile, map tile inset border, club logo r12 variant |
| G18 | Photo strip / viewer | feed, gallery | FeedPhotoStrip is local to packages/features/clubs/src/shared/post-row.tsx:200-253 (1 photo 4:3, 2 squares, 3+ squares with +N); no shared full-screen viewer exists | whether v2 needs a viewer |
| G19 | Composer | DM and comments | feature-local raw input (…/dm-thread/compose-bar.tsx:66-123) | shared composer or leave feature-local |
| G20 | Bottom CTA pair | wizard confirm, banner actions, destructive confirms | CTA_PAIR_GAP 12, secondary flex 1 + primary flex 2 (ui:bottom-cta-band-visual.ts:28; ui:reveal-stack.tsx:178) | v2 says one CTA per detail page; wizards and sheets still need the pair |
| G21 | Edge hairline on scroll | sticky header, tab row, CTA band, tab bar | borders are static (ui:app-header.tsx:118, ui:bottom-cta-band.tsx:117, tab bar borderTopWidth: 1) | new behaviour: per-shell scroll-offset state; the hysteresis hook useCollapsingTopBar (ui:use-collapsing-top-bar.ts:53-59) is the nearest precedent |
| G22 | Period / calendar controls | 홈 period pickers, dues month selector | PeriodScrubberShell card + feature pickers (ui:period-scrubber-shell.tsx; home day-calendar-picker.tsx etc.) | flat restyle of the card |
| G23 | Date / time picker wrapper | create-session, venue hours, settings | react-native-modal-datetime-picker called raw in 6 files, no wrapper | one wrapper or leave native |
| G24 | Achievements tile / grid | 프로필 업적 | AchievementsGalleryScreen + AchievementStateBadge (feature-local) | tile size, locked state (TERMINAL_MUTE_OPACITY .55 today) |
| G25 | Rank column | 기록 ranking rows | LeaderboardRow rank 16/24 in a 28 column (pres:leaderboard-row.tsx:115); podium | v2 rank 13/18·600 in a 24 column |
| G26 | level Tag | 기록, 프로필, roster | no 22px numeral circle exists; TierRangeBadge prints dots + numbers (pres:status/tier-range-badge.tsx) | circle vs 24 tall box (the two specs and the board disagree, §6) |
| G27 | Live indicator | 경기 rows | SessionLiveBand ribbon on the card + PulseDot (ui:pulse-dot.tsx) | danger-solid Tag with pulse dot in the trailing slot (D7); where the pulse sits inside a 24 Tag |
| G28 | Coachmark / tooltip | first-run | CoachmarkTooltip (pres:coachmark-tooltip.tsx) with react-native-spotlight-tour | skin only |
| G29 | TextArea / multiline | Phase B lists TextArea as its own control | StyledInput multiline: min-height 120, top-aligned (ui:labeled-field.tsx:459-461); no character counter inside (counter goes in LabeledField labelRight) | height, counter placement, auto-grow (the composer grows 40→100: …/compose-bar.tsx:121-122) |
4.2 Components the app has that have no v2 counterpart yet
Kept in the "keep" bucket only because nothing replaces them; each needs either a v2 skin or an explicit "stays as is" ruling: Stepper, TimeBandSlider/TimeBandField, RangeBandSlider (+ TierRangeSelector), HueSlider, WizardShell, RevealStack, AttachedPanel, AddCard, PickerRow, EditFieldRow, SelectionCard, SelectionCheck, ModalPanel (+ ConfirmSheet), HeaderActionsMenu, PeriodScrubberShell, DayDotsRow, FlagMedallion, CapacityRing/SegmentedDonutChart/TriRingProgress, MiniBarMeter, CourtLinesTile, CelebrationOverlay, CopyableValue, MatchScoreboard (live scorecard), LeaderboardPodium, VenueFinder family, TierInfoSheet family, DaumPostcodeModal, VenueLocationPicker.
4.3 Dead and unused
- Exported but never rendered (zero references outside the definition, comments and the barrel): in
packages/ui:SummaryCard,SpecList,SectionEyebrow,ValueSlider,EmptySlotRow,BannerCapacityCluster,DiagonalGradientFill,FormatGlyph, andParticipationCluster(used only by the deadBannerCapacityCluster); in presentation:ApprovalModeBadge,ClubRoleBadge,ClubSessionCard,HourlyWeatherStrip,MatchFormatScopeSelector,RecapOutcomePanel,VenueHero,VenueTrustRing. All 17 are in the retire list; delete them before the library is redesigned so nobody re-spec's code nothing renders. - Tokens with no consumer:
staggerDelay/MOTION_STAGGER;MOTION_DURATIONfast, standard, entrance, exitStandard, modal, modalExit, liveDot, badgePulse (§2.3). - Press variant with no consumer:
fab(ui:pressable-visual.ts:49).
5. Counts
Method: names and rows as defined in §0; dispositions assigned per row in §1; numbers computed by script from the same data that produced the tables, so they match the rows.
5.1 Totals and dispositions
| Population | Rows | Names | keep | re-spec | merge | retire |
|---|---|---|---|---|---|---|
packages/ui exports (§1a) | 94 | 142 | 59 | 52 | 19 | 12 |
| Shared presentation, ≥ 2 consumer groups (§1c) | 30 | 59 | 39 | 11 | 7 | 2 |
| Presentation, one consumer group or none (§1d) | 54 | 54 | 25 | 11 | 8 | 10 |
| Total | 178 | 255 | 123 | 74 | 34 | 24 |
The same split without the K that are infrastructure or layout (stacks, Freeze, ErrorBoundary, PerfProfiler, ContentColumn, providers, dev panel, WebView modals): 33 of the 123 keep names are layout, infrastructure, dev-only or platform-WebView pieces with no v2 visual, leaving 90 keep names that still need a token re-point/skin pass (colour, radius, type) even though their role and geometry survive.
5.2 Top 15 by consumer files (decides migration order)
| # | Component | Files | Groups | v2 disposition | File |
|---|---|---|---|---|---|
| 1 | Text | 312 | 10 | re-spec → T-Type | ui:text.tsx |
| 2 | ActionButton | 128 | 7 | keep | ui:action-button.tsx |
| 3 | Pressable | 108 | 9 | keep | ui:pressable.tsx |
| 4 | Icon | 94 | 10 | re-spec → T-Icon | ui:icon.tsx |
| 5 | EmptyState | 85 | 10 | re-spec → T-EmptyState | ui:empty-state.tsx |
| 6 | SectionBlock | 82 | 5 | re-spec → T-SectionHeader | ui:section-header.tsx |
| 7 | Badge | 73 | 7 | merge → T-Tag | ui:badge.tsx |
| 8 | DetailShell | 67 | 8 | re-spec → T-Page | ui:detail-shell.tsx |
| 9 | SelectionChip | 56 | 5 | merge → T-Choice | ui:selection-chip.tsx |
| 10 | Card | 55 | 7 | retire | ui:card.tsx |
| 11 | Divider | 54 | 7 | re-spec → T-Seam | ui:divider.tsx |
| 12 | StyledInput | 48 | 6 | re-spec → F-Field | ui:labeled-field.tsx |
| 13 | ModalPanel | 45 | 7 | keep → T-Overlay | ui:modal-panel.tsx |
| 14 | QueryErrorState | 45 | 8 | keep → T-EmptyState | pres:query-error-state.tsx |
| 15 | OfflineEmptyState | 45 | 8 | keep → T-EmptyState | pres:offline-empty-state.tsx |
Ranks 16–25 for context: RowList 44 · LabeledField 44 · FlatColumn 42 · BottomCtaBand 37 · FeedList 36 · SkeletonCard 35 · ListRow 34 · MetaSegments 29 · QueryBoundary 25 · PersonRow 23.
What the order implies: (1) Text, Icon, Pressable, Button/ActionButton are touched by 94–312 files each, so the type scale, icon sizes and button geometry must land as additive roles/sizes first (a one-shot swap of cardBody 14 → 15 changes 312 files at once); (2) Badge → Tag (73 files, plus the 16 live XxxStatusBadge wrappers that keep their API and swap the inner component, plus 14 names merged in) and SelectionChip → Choice (56 files + DropdownChip 11 + RemovableChip 2 + RangeChip 2) are the two big merges; (3) EmptyState (85) and its three wrappers (45/45/19) are one visual change; (4) shells and seams — SectionBlock 82, DetailShell 67, Divider 54, FlatColumn 42, BottomCtaBand 37 — change together because the rail (16 → 20), section seam (24 + line → 32) and header change together; (5) Card (55) and the card-shaped surfaces (SessionCardFromEntry 5, SkeletonCard 35) retire last, as pages move to rows.
5.3 What merges into what
| Merge target | Names absorbed | Names (consumer files) |
|---|---|---|
| T-Tag | 14 | Badge 73, MarkerPill 3, EloTierBadge 7, TrustTierBadge 6, TierBadgeChrome 2, TierRangeBadge 2, VenueLink 1, SessionLeadBadge 1, SourceChips 2, ReliabilityPill 1, ClubAlarmBadge 1, ClubJoinabilityBadge 1, ClubRecruitBadge 1, ChallengeStatusBadge 1 |
| T-Choice | 7 | ChipLabel 3, SelectionChip 56, RemovableChip 2, DropdownChip 11, RangeChip 2, ChipPickerPanel 0, ChipChevronIcon 0 |
| T-IconButton | 6 | ActionIconButton 6, HeaderIconButton 15, InfoHintButton 2, SessionSaveButton 2, SaveButton 2, VenueSaveButton 2 |
| T-Notice | 3 | Well 21, Callout 5, Banner 14 |
| T-StatStrip | 2 | StatTile 1, StatGrid 1 |
| T-Button (sm, tonal) | 1 | ActionPill 2 |
| T-SwitchTrack | 1 | TogglePill 9 |
5.4 Retire list
| Component | Consumer files | Why |
|---|---|---|
Card | 55 | v2: "cards on list surfaces leave" (grammar §6). 55 files; migrate to rows/blocks, keep nothing but dashed empty slots. |
SummaryCard | 0 | Dead: no JSX consumer (mentions are comments). |
SpecList | 0 | Dead: no consumer anywhere; superseded by FactRow. |
DateTile | 1 | 1 file; v2 rows carry no date tile. |
ActionIconRow | 2 | 2 files, 1 consumer group; v2 puts actions in the header and CTA band. |
SectionEyebrow | 0 | Dead: no consumer. |
ValueSlider | 0 | Dead: no JSX consumer (create-club-step6.tsx:384 mentions it as retired). |
EmptySlotRow | 0 | Zero JSX consumers repo-wide (ParticipationCluster is referenced only by the dead BannerCapacityCluster). |
BannerCapacityCluster | 0 | Zero JSX consumers repo-wide (ParticipationCluster is referenced only by the dead BannerCapacityCluster). |
ParticipationCluster | 0 | Zero JSX consumers repo-wide (ParticipationCluster is referenced only by the dead BannerCapacityCluster). |
DiagonalGradientFill | 0 | Zero JSX consumers repo-wide (ParticipationCluster is referenced only by the dead BannerCapacityCluster). |
FormatGlyph | 0 | Zero JSX consumers repo-wide (ParticipationCluster is referenced only by the dead BannerCapacityCluster). |
SessionCardFromEntry | 5 | Replaced by the 80pt media row. SessionCardV2 has 1 direct consumer (this wrapper); SessionCompactRow (1) and ClubSessionCard (0) die with it. |
RecruitmentMeter | 2 | Capacity bar leaves rows; N/M becomes the trailing text or a stat. |
SessionCompactRow | 1 | dies with the card (T-MediaRow) |
SessionCardV2 | 1 | T-MediaRow |
ClubSessionCard | 0 | dead (0 consumers) |
ApprovalModeBadge | 0 | dead (referenced only in comments) |
RecapOutcomePanel | 0 | dead (barrel export only) |
VenueHero | 0 | dead (barrel export only) |
HourlyWeatherStrip | 0 | dead (0 consumers) |
MatchFormatScopeSelector | 0 | dead (0 consumers) |
VenueTrustRing | 0 | dead (0 consumers) |
ClubRoleBadge | 0 | dead (barrel export only) |
6. Conflicts between sources to settle before building
The brief's three documents and the code disagree in these places. The grammar (round 2, 2026-10-09) is the newest and is assumed to win; each line states what that would overrule.
| # | Topic | Source A | Source B | Consequence |
|---|---|---|---|---|
| C1 | Tag geometry | grammar §4: 24 tall, pad 0 8, r6, 13/18·600 | list spec §4: sm 22 / md 26, r8, 12/18; code Badge xs 18 / sm 26 / md 28 (ui:badge.tsx:89-95); board FvTag 24/r6/13/18 (wireframes/flat-v2.jsx:309-337) | pick one; level is a 22 circle in the list spec and a 24 box with a 1px edge in the board |
| C2 | Choice geometry | grammar §4: 36, pad 0 14, 15/22·500 | list spec §4: pill 32/40 + 44 slop, 14/500, $text fill + leading check; code 40/32, 12/18, $primary tint (ui:selection-chip.tsx:25,122-123) | grammar's 36 sits between the two |
| C3 | Brand block | grammar §4: 350 wide, pad 20, 144 / 158, eyebrow 13/18·600 | list spec §3: margin 16, pad 16, heights 120 / 146 / 124, eyebrow 14/20·700 | list spec §3 is stale |
| C4 | Row pitch | grammar §4: media 80 (thumb 56), person 64 | list spec §2: compact 72 (tile 48), discovery 96 (photo 72) | grammar wins; spec rows 72/96 are superseded |
| C5 | Underline tabs vs group-spread law | grammar §4: label padding 0 12, underline under the label width | canon components.md:151: tabs span the rail with equal flex columns; code flex={1} (ui:pill-nav-underline.tsx:57) | amend the law or draw the board's tabs as spread columns |
| C6 | Section divider | grammar §4b: retired (D9), whitespace + title, one 12pt band for zone change | canon Divider law components.md:139: full-bleed hairline with 24 above/below; code ui:flat-column.tsx:87 | canon needs the amendment the grammar announces |
| C7 | Middle dots | grammar §5 prints · in row meta (e.g. "서초구 · 멤버 42 · NTRP 3.0–4.0") | @twomore/no-middle-dot-join error, zero waivers (components.md:139, MetaSegments) | boards must show MetaBreak, or the rule needs a v2 exception |
| C8 | FAB | grammar §4: the one coloured action on list screens | canon components.md:13: "no FABs" | amend the canon |
| C9 | Tab-bar ink | grammar §4: neutral, never brand | code tabBarActiveTintColor: primary (mobile:app/(tabs)/_layout.tsx:62) | re-point |
| C10 | Rail and widths | grammar §1: rail 20, content 350 at 390 | code rail 16 everywhere (SEAM.rail), CTA band side 16 | the rail change touches every shell |
| C11 | Shadows | grammar §4b: no shadow on tab bar / CTA band; migration plan (docs/architecture/flat-grammar-v2-migration-plan.md, "already decided"): no shadows | toast (pres:toast-host.tsx:350) and header menu (ui:header-actions-menu.tsx:71-76, elevation: 8) carry shadows; canon forbids elevation on global primitives | decided: both lose the shadow; they need a hairline or the ink fill to separate from the page |
| C12 | $primarySubtle | grammar §6 / list spec §3: not a surface | 23 ui files, 11 presentation files, 22 feature files (§1e) | each re-spec row above must pick a replacement cue |
| C13 | Avatar scale | grammar: 40 / 48 / 56 | AVATAR_SIZE has no 48; default is 32 (ui:box-sizes.ts:24-40, ui:avatar-bubble.tsx:159) | add 48, change the default to 40 |
| C14 | "Cell row" name | grammar §4: label/value 48 row | code CellRow = equal-column strip primitive (ui:cell-row.tsx) | rename one of them to avoid a wrong merge |
| C15 | Brand ink on controls | migration plan: scheme S2 "Ball & Ink" — ball-coloured actions with ink text, green only as a mark and court tile | code fills primary Button, selected chips, FAB-to-be, tab-bar tint and the web focus ring with $primary green (ui:button.tsx:79, ui:pressable-visual.ts:85, ui:selection-chip.tsx:122-123) | every re-spec that says "ink" or "primary" waits on the S2 token names; the focus ring colour is undecided |