Skip to content

Tamagui & Styling Conventions ​

Status: Active Last reviewed: 2026-10-19

How TwoMore styles every surface — the Tamagui-only foundation, the 4px-grid spacing system, typography roles, icon discipline, the ModalPanel overlay contract, visual-consistency single-source choices, and the React-Compiler-first render-performance model. These are the canonical, single-source rules; feature code follows them and never reaches for StyleSheet.create, NativeWind, raw hex colors, or numeric spacing literals. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (STYLE-1..4, COMP-1/2).

Tamagui-only rules ​

  • Never mix StyleSheet.create with Tamagui — tokens don't resolve
  • Never use inline style={{}} with variables — breaks compiler flattening. Use variants.
  • Never define styled() outside packages/ui — compiler won't extract it
  • Never use className or NativeWind — pure Tamagui
  • All tokens via $ prefix: <Stack padding="$4" backgroundColor="$gray100" /> (a theme role, never a palette step: tokens.color holds only $white)
  • All typography via the 5 semantic role system (see Typography below)
  • Dark mode: only via <TamaguiProvider defaultTheme={colorScheme} /> — no per-component theme toggling
  • Window size classes: the $medium / $expanded / $large media props (packages/ui/src/window-size.ts, both configs) — for STRUCTURE only (a pane appears, the nav changes form); a width cap is a plain maxWidth from LAYOUT_WIDTH. Rules and shells: components.md § Window size classes.

Colour tokens (scheme S2 "Ball & Ink") ​

One palette, light and dark, in packages/app/src/config/themes (classic-grass.theme.ts and s2-palette.ts are the values; tamagui.ts is the role list). A page names a ROLE for its job, never a hex and never a palette step. The values are the owner's and do not change in a readiness or cleanup pass (the dark soft fills below were changed on the owner's word of 2026-11-15, batch 66); a token that nothing reads is deleted ($surfaceSecondary was the page's own ground under another name; it is deleted in batch 63, and a fill that must be seen against the page is $gray100, one that sits flush is $background), and a key that is the same in light and dark must be on the named list in themes-complete.test.ts with its reason.

JobRoleNote
Text, controls, focus ring, links, selected marks, progress and ring fill$textink; the focus ring and an inline link are ink, never the brand green
Meta, labels from 13 up$textSecondary6.5:1 and up
Placeholder, chevron and lock glyphs$textTertiary4.5:1; text at 16 and up
Track of a progress bar, meter or ring; any neutral fill$gray100the neutral track; a dark soft fill (below)
The one action (FAB, CTA, primary button), the block figure$ball, label $onBallthe only coloured action on a screen
Done, paid, a wintext $successText; a dot or glyph fill $successFill; a solid Tag $success with $textInversedark $successFill is #00796A, never the dark text colour
Deadline, money owed, a task waiting (the one amber)text and glyph $warningText; fill $warningFill with inkthe legacy $warning is deleted
Error, destructive, unread$error, text $errorText
Play happening now$livewith the word and the dot
Waits on someone else$info, tint $infoWeak
Medalsfill $medalGold $medalSilver $medalBronze $medalPlatinum (library 1.8), glyph MEDAL_GLYPH[tier]white on bronze and platinum, ink on gold and silver; the same fills in dark, except platinum (#56738B in dark, #4A6378 in light); a locked or hidden tile is a 1.5px $border outline with no fill (1.5:1 or more against page and card in both themes), so earned and locked differ by fill and not by colour
Control boundary, Switch off track$borderControl3:1
Court dots, chart series$courtHard $courtClay $courtGrass, $series1..$series5a category, never a status

A soft fill is seen against whatever it sits on (owner 2026-11-15, batch 66). In dark, $gray100, $surfaceTertiary, $tagNeutralFill, $brandWeak, $successWeak and $infoWeak read 1.50:1 or more against the page AND against a card ($gray100 #363E38, $brandWeak #3A4220, $successWeak #234340, $infoWeak #40365F); each dark Notice tint (NOTICE_DARK_MIX 0.24) reads 1.50 on the page; an overlay panel (Dialog, the ModalPanel frame) carries a 1px $hairlineEdge edge in both themes, drawn once; and $textTertiary (3.64:1 there) never sits on a soft fill: placeholders and real text on one use $textSecondary (5.1), and the text held on a dark soft fill is 4.5:1 (not the 6.5 of small text on the page). Light is unchanged. Two limits: a neutral Tag, an Avatar or a tonal Button on a $gray100 surface shares its fill, as in light (the label or the face carries it); and a Notice tint is held against the page only, because on a card live would need a mix that drops its title under 4.5. The result scale's ramps start from $resultBase, the old dark neutral, so its numbers keep 4.5:1.

The contrast gate is yarn check:color-contrast (scripts/check-color-contrast.mjs). It measures every pair in scripts/lib/s2-contrast-pairs.json (text roles on their grounds, marks, the Tag, Button and Notice tones, the ink Block, the inverse surface, the medals) in light and dark, with the library's thresholds: 6.5:1 for text at 14 and below, 4.5:1 for text, 3:1 for a mark or boundary, 1.5:1 for a dark chip and for every dark soft fill against the page and the card (and a dark overlay panel's edge against the scrimmed page), CIE76 dE 36 for an earned medal against a locked one in light. A canary pair that must fail keeps the gate honest; a failing pair needs a written reason in scripts/color-contrast-baseline.json. A new role or a new Tag, Button or Notice tone is a line in the spec (contrast-pairs.test.ts holds the copies to the kit).

Spacing system ​

Spacing system — every padding / margin / gap value comes from the 4px-grid space tokens at packages/ui/src/tokens.ts: $0=0 $1=4 $2=8 $3=12 $4=16 $5=20 $6=24 $7=32 $8=40 $9=48 $10=64. Never hard-code a number for spacing — always pick a $N. Semantic conventions:

  • $1 (4) — tight inline gap (badge ↔ icon)
  • $2 (8) — action button row gap, label ↔ input pair
  • $3 (12) — card-internal gap, section-internal gap, inter-card rhythm in a list (canonical: FeedList DefaultSeparator height={12}), inter-card rhythm in a <YStack> of stacked cards
  • $4 (16) — screen horizontal gutter (paddingHorizontal="$4"), default card padding (size="default"), chrome → content section break (title → chip, chip → content). Canonical: SegmentedTabs paddingBottom="$4", MainTabShell title-row paddingBottom="$4".
  • $5 (20) — hero card padding (size="hero")
  • $6 (24) — section ↔ section divider, scroll bottom buffer
  • $8 (40) — page-level breathing room (e.g. paddingBottom on long scrolls)

Proximity hierarchy rule — section breaks MUST be looser than intra-list rhythm. Items in the same group must be visually tighter than the boundary between groups. The canonical hierarchy:

  • Chrome → content boundaries (title → chip, chip → list): $4 (16px)
  • Intra-list rhythm (item → item in a list, card → card in a stacked YStack): $3 (12px)
  • If you ever set a chrome→content gap smaller than the intra-list gap, you've inverted proximity — the list items will read as more-separated than the list-vs-chrome boundary, breaking visual hierarchy.

Spacing ownership rule — when two adjacent containers can each contribute padding to a gap (e.g. chips + content pane), exactly ONE of them owns the gap. Convention: the upstream chrome (the chip wrapper, the section header, the card) owns its bottom padding; the downstream content does NOT add a matching paddingTop. Doubling = visible bug. The canonical chip-content gap is SegmentedTabs paddingBottom="$4" (16px) — temporal panes / tab content render directly under it with no paddingTop.

Directional spacing — vertical spacing flows DOWN. Components in packages/features/** must not declare paddingTop. Inter-child rhythm comes from parent gap; chrome-to-content gaps come from the chrome's paddingBottom. First-child top spacing belongs to the container, not the child. This is the "Lobotomized Owl" pattern — making it impossible for two adjacent boxes to both contribute to the same gap. Enforced by @twomore/no-padding-top-in-features ESLint rule (existing usages ratcheted; new code blocked). When a feature genuinely needs first-child anchoring with no upstream owner, opt out with // eslint-disable-next-line @twomore/no-padding-top-in-features and a comment justifying why.

Numeric spacing values are forbidden — use $N tokens. Padding / margin / gap props in packages/features/** and packages/ui/** must use 4px-grid tokens ($0..$10), not literal numbers (paddingTop={12}, gap={6}). The token system encodes the design system; numeric drift bypasses it silently. Enforced by @twomore/no-numeric-spacing-in-features ESLint rule. Off-grid intentional cases (hit-slop tricks, optical alignment, native-platform safe-area clearance) require an inline // eslint-disable-next-line @twomore/no-numeric-spacing-in-features directly above the violating attribute (NOT the JSX element line — the disable applies to the next physical line, so it must immediately precede the offending prop) plus a comment justifying why no token suffices. The rule does NOT inspect inline style={{}} objects — that's covered by @twomore/no-inline-style-with-vars.

Box-dimension constants — width / height for avatars, status dots, and small icon frames use named constants from packages/ui/src/box-sizes.ts, not magic numbers. The 4px-grid $N tokens cover spacing only — avatar dimensions (28, 32, 40, 44, 80, 88) and status-dot dimensions (7, 8) are intentionally off the spacing scale. Use AVATAR_SIZE.{XS|SM|MD|LG|XL|2XL} and DOT_SIZE.{XS|SM} from @twomore/ui. For circle avatars, set borderRadius={AVATAR_SIZE.X / 2} so the rounding tracks the size constant. Add to box-sizes.ts only when the dimension is used in 2+ places AND can't be expressed as a $size token; one-offs stay as inline literals with a // reason: comment. (Not yet ESLint-enforced — width/height use sound judgment, not the magic-number ban that covers spacing.)

Casing is a deliberate signal, not inconsistency. box-sizes.ts constants (AVATAR_SIZE, DOT_SIZE) are UPPERCASE because they're fixed pixel constants — a lookup table of one true value per name. Component variant props (Icon size="md", Button size="lg", Badge size="sm") stay lowercase because they're Tamagui/TS union-literal prop values, not exported constants — the two naming conventions mark two different kinds of thing and shouldn't be unified.

Size-variant names are per-component local vocabulary, not a shared pixel scale (2026-09-17, PD-03/PD-11 primitive-drift audit). size="sm"/"md" is a Tamagui variant-prop convention each component tunes to its own anatomy — sm is 36px tall on Button, 32px on SelectionChip, 12px text on Badge, 14px on Icon; md is 44px on Button, 40px on SelectionChip, 13px text on Badge, 16px on Icon. This is intentional, the same way the casing split above marks two different kinds of thing: a per-family scale, tuned to that family's own content, not a cross-app pixel promise. Don't assume two primitives sharing a size name share a pixel value, and don't propose unifying the ladders — that would be a large, low-value redesign (the families are rarely composed side-by-side) for no functional gain.

Typography ​

All typography is one flat ladder of eleven roles, table in packages/ui/src/text-roles.ts (size/line · weight): display 40/48 · 700, hero 24/32 · 700, title 20/28 · 700, headline 17/24 · 600, reading 16/24 · 400, body 15/22 · 400, foot 13/18 · 500, footWrap 13/20 · 500, caption 12/18 · 500, tab 12/16 · 500, count 12/16 · 700 (digits in a badge only). Using Text → pick a textRole, never hardcode fontSize/lineHeight/fontWeight. A Text without textRole is a failing test (text-source.test.ts also fails a textRole literal off the ladder and any hand-set fontSize under 12); the default role is body. A hand-set size is a named exception with a comment (a glyph beside text, initials scaled to a box), never under 12. The eleven roles are held to the board kit's FVK_TYPE and the component library §1.4 by text-roles.test.ts and text-scale.test.ts. The old names (pageTitle, cardTitle, navTitle, cardBody, cardMeta, badge, micro, displaySm to displayXl) and the heading font no longer exist.

Fonts. Four Pretendard weights only, one table (font-faces.ts): 400 Regular, 500 Medium, 600 SemiBold, 700 Bold; 800 does not exist. font-weights.test.ts holds every role's weight, every fontWeight literal in production source, the useFonts list in apps/mobile/app/_layout.tsx, the expo-font plugin list in app.json, the OG fonts and the web @font-face to that table. Large text. One table, text-scale.ts: every role carries its scale-class cap, and every part on the bare Tamagui Text names its own maxFontSizeMultiplier. Digits that must line up (display, count, scores, money) are tabular. Initials in a fixed box (Avatar, club-logo-bubble, flag-medallion) cap at 1 and do not grow with the system text size.

Old role names in the dated notes elsewhere in the canon (pageTitle, cardTitle, cardBody, cardMeta, badge, navTitle, displaySm…) are history: read each as the nearest role of this ladder.

Enforced by @twomore/no-raw-fontsize-in-features (a fontWeight override for genuine state-based emphasis — winning ? '700' : '500' — is the sanctioned exception).

Raw colour and shadow (batch 59). @twomore/no-raw-colour-or-shadow (in lint:strict, scoped to packages/ui/src/** and packages/app/src/presentation/**; zero findings, zero baseline entries) refuses a whole-hex or rgb(/rgba( string and any shadow*, elevation, boxShadow or textShadow* property. It exempts only tokens.ts and theme-hex.ts, the two files that define or read the palette; a colour a Tamagui prop cannot take (an SVG stroke) comes from themeHex(theme, key) or whiteHex() in theme-hex.ts. Tests and mocks are exempt.

Per-component type budget (2026-08-04) ​

The role scale is a system-wide VOCABULARY, not license to use many roles in one component. A single card/list-item/component budgets (research-grounded: M3's title/body/label triad, NN/g ≤3 sizes & ≤3 contrasts, Refactoring UI's two-weights rule, Polaris's 6→2 consolidation):

  • ≤3 sizes — title role · one meta role · count-fixed. Not a ramp of five.
  • 2 weights — the component's ONE emphasized element (usually the title) carries the heavy weight; everything else stays at the role default. Two 600s in one card means weight no longer signals "most important."
  • 2 ramp grays — $text + $textSecondary. $textTertiary does not join a dense card's ramp (three near-value grays at one size/weight is the NN/g hierarchy-collapse anti-pattern).
  • Adjacent levels differ in exactly ONE channel — size XOR weight XOR color; stacking channel-changes between neighbors reads as a competing hierarchy, i.e. noise.
  • State colors live in FIXED COMPONENTS only (status badges, the activity indicator, strip bands) — never as ad-hoc text-color variants scattered through the ramp. One state → one color → one location.
  • Tag and chip text is component-fixed (W2a: the Tag's label is 13/18·600 at sm and 15/22·600 at md, the Choice's 15/22 at 500 and 700 selected, both in the label scale class, cap 2.0; the Badge is the tab, caption or foot role by size) and sits OFF the ramp — it doesn't count against the budget and never flexes with card context (Nord/Telerik convention). Inside the family the fix is literal: EVERY badge-class text — chip labels, tier labels, counters — is the role its size names, no per-site weight overrides; the digit of a count badge is the count role (12/16 · 700). Avatar-internal text is the one exception: it scales with the bubble (its proportional rule), it's an avatar, not a badge. Ratified exception (2026-09-17, PD-03): the SelectionChip family's own selection-state cue — ChipLabel (@twomore/ui, backing SelectionChip/RangeChip/RemovableChip) — pairs the caption role (the old badge) at 500 resting / 700 selected, a deliberate two-value binary that signals "is this chosen", not a drifted weight, the same way the META_TEXT carve-out below is a named exception to the cardMeta color default. Before flagging a chip-family 500/700 pairing as the badge-weight bug, check whether it routes through ChipLabel first. A bare fontWeight override on a textRole="caption" Text (the old badge role) OUTSIDE ChipLabel (a lone ActionPill, a status fact with no selected/unselected state to point to) has no such binary to justify it and stays the drift this rule bans — mechanically caught in packages/features/**/packages/app/src/presentation/** by @twomore/no-badge-weight-override (primitives in packages/ui/src, where a weight is actually DEFINED rather than overridden, are out of the rule's scope by construction). Staged, not yet error: a real tree-wide run found ~40 pre-existing sites (plus at least one ratified exception — avatar-internal text — this structural check can't itself distinguish from a bug) the original audit's manual grep missed; the rule ships registered and documented at 'off' in eslint.config.mjs until a dedicated cleanup pass (or an owner-authorized ratchet-baseline regeneration) closes that gap.

Applied: club cards 2026-08-04 (9 combos → 3 ramp levels + fixed components). Pending sweep: session cards and every other card/list surface — audit against this budget in the standing visual-primitives pass.

Icons ​

Icons: only @tamagui/lucide-icons-2 (Tamagui 2's stable icon package; deep imports @tamagui/lucide-icons-2/icons/<Name>). Always $textMuted or $textSubtle tint unless inside a primary/warning/error surface. Never tint icons green just to mark "my thing"

Use icons SPARINGLY — only where functional: navigation affordances (back/forward chevrons), status indicators (the live PulseDot), form-field leading icons. NEVER as decorative section/beat lead icons (a Calendar before a date, a Users before a count, a MapPin before a venue) and NOT inside content chips/badges by default — section content leads with typography, not icons. An emoji that encodes data (weather ☀️, the TMI traffic-light 🟢🟡🟠🔴) is preferred over a decorative lucide icon for at-a-glance meaning. When in doubt, drop the icon.

Navigation-chrome icons (back chevrons in AppHeader / DetailShell, header-right action icons in MainTabShell / DetailShell) → Icon size="xl" (24px). Use size="2xl" (28px) only when the icon stands alone as the user's primary recognition cue (canonical example: bell on home tab, also a notification entry point). In-content icons (form-field leading icons, list-row affordances, card icons, steppers) keep their context-appropriate sizes — xs / sm / md / lg per the scale documented in packages/ui/src/icon.tsx.

Icons render through the Icon wrapper — never raw <LucideIcon size={number}>. A raw numeric size bypasses the named scale (xs 12 · sm 14 · md 16 · lg 20 · xl 24 · 2xl 28); use <Icon component={X} size="md">. Enforced by @twomore/no-raw-icon-size-in-features (off-scale hero/onboarding icons opt out with an eslint-disable + reason).

Overlays (ModalPanel) ​

Bottom-sheet PRIMITIVES are RETIRED from product surfaces (2026-06 Codex batch — AGENTS.md STYLE-3); the bottom-anchored ModalPanel is the edit sheet (2026-09-28). A single-field edit presents as ModalPanel placement="bottom" (flush to the bottom edge, rounded top, rising in; no drag handle, no snap points, no swipe — every reason the Sheet/gorhom primitives were retired still holds; its own 취소 is its close, so no X), through EditControlModal/EditFieldModal (components.md edit archetypes). Everything below describes the centered default. Overlay/picker/confirm interactions use ModalPanel from @twomore/ui (packages/ui/src/modal-panel.tsx): a centered RN <Modal> (transparent, fade, statusBarTranslucent) with compound parts — ModalPanel.Overlay (full-screen backdrop, dismiss-on-press by default; per-instance dismissOnPress override, root-level dismissOnOverlayPress), ModalPanel.Frame (maxWidth 640, maxHeight 88%, built-in X close button — showCloseButton={false} to hide), ModalPanel.ScrollView. Hardware back always closes. There is deliberately NO drag handle and NO snap points — never recreate them (<ModalPanel.Handle> and the legacy modal/snapPoints/dismissOnSnapToBottom/animation props are lint errors). LAYOUT CONTRACT (the Frame is AUTO-HEIGHT, capped at 88% — learned from the 2026-06-12 "panels don't open fully" bug): content is Frame → [fixed header] → ModalPanel.ScrollView → [fixed footer as a NORMAL sibling]. NEVER flex={1} on the Frame's children (no resolved parent height → they collapse to 0); NEVER height/percentage-maxHeight on the ScrollView (percentages resolve against the auto-height Frame → 0) — the ScrollView bakes flexShrink: 1 (intrinsic when short; shrinks to the cap and scrolls when long; flex/height/maxHeight props on it are lint errors); NEVER position="absolute" footers over the scroll area (with an unconstrained ScrollView the frame clips at the cap, scroll never activates, and the footer permanently covers the bottom rows — the chip-picker bug). Keyboard handling (KeyboardAvoidingView) is baked into the root — text-input consumers need no local handling. The ONLY legal Tamagui Sheet is the dev panel (diagnostics-only); @gorhom/bottom-sheet stays forbidden in shared packages (native-only, crashes the web bundle). Enforced by @twomore/no-bottom-sheet-outside-dev-panel + @twomore/no-gorhom-bottom-sheet-in-shared. NOTE: many converted files keep their historical *-sheet.tsx names (manner-tags-sheet, score-call-sheet, playability-info-sheet, …) but are ModalPanel inside — don't infer the primitive from the filename.

Scrollable content inside the DEV PANEL's Tamagui Sheet (the one legal Sheet) → Sheet.ScrollView, never RN's bare ScrollView (drag-gesture integration — AGENTS.md STYLE-4). In product code use ModalPanel.ScrollView. useSheetBottomPadding is dev-panel-only.

ModalPanel.Frame defaults to $background (gray) — that IS the canonical overlay-frame surface (it sits on the dark $overlayBackdrop scrim); don't restate it per-consumer, and don't override to $card. It is told from the dimmed page by its 1px $hairlineEdge edge (OVERLAY_PANEL_EDGE, modal-panel-visual.ts), the same edge the Dialog box draws; a Dialog stands on ModalPanel's root and Overlay, never inside its Frame, so only one edge is ever drawn.

Visual-consistency canon ​

Visual-consistency canon (codified 2026-06-23 after a full adversarial audit of every visual primitive). The single-source choices, so components don't drift from each other:

  • Icons render through the Icon wrapper — never raw <LucideIcon size={number}>. A raw numeric size bypasses the named scale (xs 12 · sm 14 · md 16 · lg 20 · xl 24 · 2xl 28); use <Icon component={X} size="md">. Enforced by @twomore/no-raw-icon-size-in-features (off-scale hero/onboarding icons opt out with an eslint-disable + reason).
  • Two muted-text tiers — $textSecondary (readable) vs $textTertiary (receded), both theme-aware. (Renamed 2026-06-23 from the ambiguous $textMuted/$textSubtle; the old base-token textSecondary/textTertiary that 8 files used were theme-INVARIANT hex — the rename consolidated everything onto the theme-aware tier and removed that trap.) $textSecondary (#455D49) = SECONDARY body copy + supporting metadata + nav-chrome icon tint (it's what the cardMeta role bakes). $textTertiary (#607565) = TERTIARY hints, placeholders, deliberately-quiet text. Putting $textTertiary on a cardMeta/cardBody Text is a "double-mute" that's almost always unintentional. Nav-affordance chevrons = $textTertiary (a receded secondary affordance); don't randomly alternate the two. Ratified exception: META_TEXT.secondary/META_TEXT.eyebrow (packages/ui/src/text-grammar.ts, owner ruling 2026-09-06, Table B of docs/design/meta-text-and-graphics-audit-2026-09-06.md) deliberately pin cardMeta to $textTertiary for those two tiers — a named, considered choice for the meta/sub-text grammar, not drift. Before flagging a cardMeta + $textTertiary pairing as the double-mute bug, check whether it's routing through META_TEXT first.
  • Dividers are <Divider /> (with inset="full" | "card") — never a hand-rolled <YStack height={1} backgroundColor="$borderSubtle"> or a borderBottomWidth/borderTopWidth inter-row separator. The lone exception is EditFieldRow (@twomore/ui, a sanctioned iOS-grouped-list primitive — promoted 2026-08-25 from the byte-identical settings/InfoRow + edit-session-screen.tsx's local EditRow copy). Vertical separators have no primitive yet — a bare 1px YStack width={1} is acceptable there. (5 stray borderTopWidth={1} sites in club-settings/info-section.tsx migrated to <Divider inset="card" /> in the 2026-07-19 drift-audit consolidation — EditFieldRow remains the one sanctioned exception, not a growing list of them.)
  • Hand-rolled selectable pills are retired — use a Choice in a ChoiceSet (W2a; the SelectionChip that replaced the hand-rolled pills in the 2026-07-19 consolidation is itself ratcheted out). A bespoke Pressable-styled-as-a-pill for scope/filter selection is the same anti-pattern the Tabs & chips rule already names — see the Choice rule in docs/canon/components.md.
  • Theme-invariance — NEVER hardcode a hex. No '#006633'-style fallback behind a useTheme() read; no color="white" (use $textInverse); chart series colors resolve via themeHex(theme, 'primary'|'success'|'error'|…), never a DEFAULT_COLORS = ['#147A45', …] literal (that leaks the Classic-Grass brand onto every other theme). Status colors stay theme-invariant via the status-badge palette.
  • ELO/trust tier ALWAYS renders via EloTierBadge / TrustTierBadge (emoji + $primarySubtle pill) — never an ad-hoc <Badge label={tierLabel(...)} variant={...}> (which renders an anonymous gray pill, the bug a dead tierBadgeVariant stub caused across the leaderboard + profile surfaces). getEmojiFontSize lives once in tier-badge-utils.tsx — private to the shared TierBadgeChrome both EloTierBadge/TrustTierBadge render through, not an exported utility.
  • ModalPanel.Frame defaults to $background (gray) — that IS the canonical overlay-frame surface (it sits on the dark $overlayBackdrop scrim); don't restate it per-consumer, and don't override to $card. It is told from the dimmed page by its 1px $hairlineEdge edge (OVERLAY_PANEL_EDGE, modal-panel-visual.ts), the same edge the Dialog box draws; a Dialog stands on ModalPanel's root and Overlay, never inside its Frame, so only one edge is ever drawn.
  • Equal columns use EQUAL_COLUMN (packages/ui/src/equal-column.ts), never flex={1}. Under the legacy style mode (styleCompat: 'legacy') flex={1} means grow 1, shrink 1, basis auto, so cells with different content get different widths; spreading EQUAL_COLUMN (basis 0, min width 0) on each cell of a calendar, heat map, grid, choice row or tab bar shares the row equally. A lint is not offered: flex={1} is right for a single filling child and a rule cannot tell a grid cell from one without noise, so each equal-column component is held by a source test (equal-column.test.ts).
  • Typography: every Text picks a role; no raw fontSize on a <Text> — enforced by @twomore/no-raw-fontsize-in-features and text-source.test.ts; the ladder is in the Typography section above. Same hero number → same role across cards.

Render performance & animation ​

Render performance is React-Compiler-first (FOUNDATION). React Compiler v1.0 is ENABLED (app.config.js → experiments.reactCompiler: true) — it auto-memoizes every component + hook at build time, so manual React.memo / useCallback / useMemo are NOT required on new code for re-render prevention. Write clean components that follow the Rules of React (no conditional hooks, no mutation during render, stable hook order); the compiler does the memoization. It bails out per-component on any Rules-of-React violation (safe — falls back to normal behavior), and runs only on app code (not node_modules). Healthcheck baseline: 755/755 components compile clean — keep it green by running npx react-compiler-healthcheck if you introduce an unusual hook/render pattern. Operational notes: New Architecture (Fabric) is on (the only architecture since RN 0.83 / Expo 55; now RN 0.86 / Expo 57); the compiler is a pure JS-bundle transform (uses React 19's built-in react/compiler-runtime), so it ships via OTA — no native build needed. What the compiler does NOT replace (these stay required, they're architectural not memoization): list virtualization (FeedList/FlashList), useDeferredValue for heavy tab-content (via SegmentedTabs), and pause-on-blur (Protocol L). The existing manual-memoization rules below + the require-memoized-list-render-callbacks ESLint rule REMAIN as belt-and-suspenders (they also cover any component the compiler bails on) — don't rip them out, but don't treat hand-memoization as the primary mechanism either.

Mobile animation driver is @tamagui/animations-moti (Reanimated worklets, UI thread). Do not switch to @tamagui/animations-react-native — that puts all pressStyle and animation on the JS thread and causes stutter under concurrent renders. Web uses @tamagui/animations-css (GPU compositor) — do not touch.

Entrance animations live on the component itself, never on a wrapper. Use Tamagui's enterStyle + animation props directly on the component being revealed: <XStack animation="medium" enterStyle={{ opacity: 0, y: 4 }}>. Reduced-motion users: gate via usePrefersReducedMotion() and pass undefined for both props. Never re-introduce a MotionReveal/MotionStaggerItem-style wrapper component — those were removed because the wrapper participates in the parent's gap calculation as the measured child, AND the slide-up y transient creates a visible offset on first paint that doesn't appear on sibling surfaces rendering bare cards. The wrapper-vs-bare structural asymmetry is a real layout difference, not a perception bug. Canonical examples: AnimatedCount (animated value transitions); live-banner ScoreRow (entrance fade). Motion tokens are single-sourced in packages/ui/src/motion.ts — never hand-declare duration/easing constants. Two additive-motion surfaces now ship with built-in animation, both gated on useReducedMotion from @twomore/ui/motion.ts: ProgressBar (fill animates 0→ratio on mount + glides on data change via UI-thread scaleX worklet); DataSection (content cross-fades in from the skeleton state, eliminating pop-in). Deferred motion (grounded design decisions, not just technical): SegmentedTabs cross-fade — Material 3 fade-through is designed for UNRELATED content (e.g. bottom-nav between user-profile and shop), and that case is handled by our screen navigator. Our in-page SegmentedTabs switch RELATED views (scopes/filters of the same dataset, segmented controls) where instant switching via useDeferredValue + preload="all" is the deliberate felt-quality goal — adding a cross-fade would undercut the instant-switch architecture (and display:none pane toggling kills opacity transitions anyway). FlashList per-item stagger — confirmed by Shopify FlashList docs: views recycle, so per-item entrance animations re-fire on every scroll-into-view; a true initial-reveal stagger requires a recycling-aware reset and even then the re-fires aren't desirable. The only candidate non-virtualized list (the home feed) is independent self-gating cards with no list index, so stagger doesn't fit there either. Stagger genuinely fits short fixed .map lists, which aren't a high-value surface in this app.

See also ​

  • AGENTS.md — STYLE-1..4 (the machine-enforced styling subset: Tamagui-only, no bottom-sheets in product, Sheet.ScrollView in dev panel, no-gorhom in shared) and COMP-1/2 (component constraints).
  • Screen Blueprint — where these styling rules apply on each screen.
  • CLAUDE.md — orchestration core + the pointer index back to this doc.

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