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.createwith Tamagui — tokens don't resolve - Never use inline
style={{}}with variables — breaks compiler flattening. Use variants. - Never define
styled()outsidepackages/ui— compiler won't extract it - Never use
classNameor NativeWind — pure Tamagui - All tokens via
$prefix:<Stack padding="$4" backgroundColor="$gray100" />(a theme role, never a palette step:tokens.colorholds 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/$largemedia props (packages/ui/src/window-size.ts, both configs) — for STRUCTURE only (a pane appears, the nav changes form); a width cap is a plainmaxWidthfromLAYOUT_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.
| Job | Role | Note |
|---|---|---|
| Text, controls, focus ring, links, selected marks, progress and ring fill | $text | ink; the focus ring and an inline link are ink, never the brand green |
| Meta, labels from 13 up | $textSecondary | 6.5:1 and up |
| Placeholder, chevron and lock glyphs | $textTertiary | 4.5:1; text at 16 and up |
| Track of a progress bar, meter or ring; any neutral fill | $gray100 | the neutral track; a dark soft fill (below) |
| The one action (FAB, CTA, primary button), the block figure | $ball, label $onBall | the only coloured action on a screen |
| Done, paid, a win | text $successText; a dot or glyph fill $successFill; a solid Tag $success with $textInverse | dark $successFill is #00796A, never the dark text colour |
| Deadline, money owed, a task waiting (the one amber) | text and glyph $warningText; fill $warningFill with ink | the legacy $warning is deleted |
| Error, destructive, unread | $error, text $errorText | |
| Play happening now | $live | with the word and the dot |
| Waits on someone else | $info, tint $infoWeak | |
| Medals | fill $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 | $borderControl | 3:1 |
| Court dots, chart series | $courtHard $courtClay $courtGrass, $series1..$series5 | a 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:FeedListDefaultSeparator 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",MainTabShelltitle-rowpaddingBottom="$4".$5(20) — hero card padding (size="hero")$6(24) — section ↔ section divider, scroll bottom buffer$8(40) — page-level breathing room (e.g.paddingBottomon 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.$textTertiarydoes 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,captionorfootrole 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 thecountrole (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): theSelectionChipfamily's own selection-state cue —ChipLabel(@twomore/ui, backingSelectionChip/RangeChip/RemovableChip) — pairs thecaptionrole (the oldbadge) at 500 resting / 700 selected, a deliberate two-value binary that signals "is this chosen", not a drifted weight, the same way theMETA_TEXTcarve-out below is a named exception to thecardMetacolor default. Before flagging a chip-family 500/700 pairing as the badge-weight bug, check whether it routes throughChipLabelfirst. A barefontWeightoverride on atextRole="caption"Text(the oldbadgerole) OUTSIDEChipLabel(a loneActionPill, 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 inpackages/features/**/packages/app/src/presentation/**by@twomore/no-badge-weight-override(primitives inpackages/ui/src, where a weight is actually DEFINED rather than overridden, are out of the rule's scope by construction). Staged, not yeterror: 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'ineslint.config.mjsuntil 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
Iconwrapper — never raw<LucideIcon size={number}>. A raw numericsizebypasses 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 aneslint-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-tokentextSecondary/textTertiarythat 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 thecardMetarole bakes).$textTertiary(#607565) = TERTIARY hints, placeholders, deliberately-quiet text. Putting$textTertiaryon acardMeta/cardBodyText 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 ofdocs/design/meta-text-and-graphics-audit-2026-09-06.md) deliberately pincardMetato$textTertiaryfor those two tiers — a named, considered choice for the meta/sub-text grammar, not drift. Before flagging acardMeta+$textTertiarypairing as the double-mute bug, check whether it's routing throughMETA_TEXTfirst. - Dividers are
<Divider />(withinset="full" | "card") — never a hand-rolled<YStack height={1} backgroundColor="$borderSubtle">or aborderBottomWidth/borderTopWidthinter-row separator. The lone exception isEditFieldRow(@twomore/ui, a sanctioned iOS-grouped-list primitive — promoted 2026-08-25 from the byte-identicalsettings/InfoRow+edit-session-screen.tsx's localEditRowcopy). Vertical separators have no primitive yet — a bare 1pxYStack width={1}is acceptable there. (5 strayborderTopWidth={1}sites inclub-settings/info-section.tsxmigrated to<Divider inset="card" />in the 2026-07-19 drift-audit consolidation —EditFieldRowremains the one sanctioned exception, not a growing list of them.) - Hand-rolled selectable pills are retired — use a
Choicein aChoiceSet(W2a; theSelectionChipthat replaced the hand-rolled pills in the 2026-07-19 consolidation is itself ratcheted out). A bespokePressable-styled-as-a-pill for scope/filter selection is the same anti-pattern the Tabs & chips rule already names — see the Choice rule indocs/canon/components.md. - Theme-invariance — NEVER hardcode a hex. No
'#006633'-style fallback behind auseTheme()read; nocolor="white"(use$textInverse); chart series colors resolve viathemeHex(theme, 'primary'|'success'|'error'|…), never aDEFAULT_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 +$primarySubtlepill) — never an ad-hoc<Badge label={tierLabel(...)} variant={...}>(which renders an anonymous gray pill, the bug a deadtierBadgeVariantstub caused across the leaderboard + profile surfaces).getEmojiFontSizelives once intier-badge-utils.tsx— private to the sharedTierBadgeChromebothEloTierBadge/TrustTierBadgerender through, not an exported utility. ModalPanel.Framedefaults to$background(gray) — that IS the canonical overlay-frame surface (it sits on the dark$overlayBackdropscrim); don't restate it per-consumer, and don't override to$card. It is told from the dimmed page by its 1px$hairlineEdgeedge (OVERLAY_PANEL_EDGE,modal-panel-visual.ts), the same edge theDialogbox 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), neverflex={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; spreadingEQUAL_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
Textpicks a role; no rawfontSizeon a<Text>— enforced by@twomore/no-raw-fontsize-in-featuresandtext-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.