Skip to content

Primitive drift audit — visual and behavioural

Status: Accepted · Date: 2026-09-17 · Owner brief: "Run adversarial, research-based audit of all the visual components and primitives and find drifts from best practices and from each other, in terms of their visual and behavior, the way you did for the cards. Find the gaps and fix them in a canonical way."

Resolution: PD-01–PD-11 all built 2026-09-17 (StyledInput focus/error border via one shared FOCUS_RING; ChipLabel + @twomore/no-badge-weight-override at error after a 41-site sweep; ActionIconButton loading; StatTile seam; Badge md = cardMeta 14/20; ConfirmSheet/AppHeader/MetaBreak fixes; canon sentences; no-hand-hairline widened + 18 rows moved to Divider). Commit aee4d359 · preview OTA b8186450.

Method

Read the ratified law first (docs/canon/components.md, docs/canon/styling.md, AGENTS.md COMP-*/STYLE-*), then five prior primitive/visual audits (2026-07-19 primitive-drift batches 1–5, 2026-09-06 visual/rhythm/seam/color/meta-text passes, 2026-09-10 design-harmony color-type/spacing/motion) to avoid re-reporting what's already found, and re-verified each still-cited finding against the LIVE file rather than trusting its doc status. Then read every primitive's actual source (not its docstring) across packages/ui/src and the named presentation/components composites, comparing the same anatomical slot (touch target, disabled/focus/loading treatment, size-scale name, text role, colour token, hitSlop formula, a11y prop) across sibling families, and grepped for hand-rolled bypasses (borderBottomWidth, raw fontSize, raw opacity, missing focusStyle). No code was changed — findings and canonical fixes only.

Prior work status (verified live, not from doc claims — several audits' own fixes have since shipped):

  • 2026-07-19 primitive-drift audit (docs/history/ui-primitive-drift-2026-07-19.md): batches 1–3 shipped (commits cd51677a, 0391ec60). Batch 4 (SessionCompactRow dead dot-status branch, doc/consistency nits) — the dot-status branch is gone from the current session-compact-row.tsx (superseded by the SessionCardV2/RecruitmentMeter redesign); not re-chased. "Batch 5"/"6" in project memory refer to later, separately-committed docstring/consolidation passes (35e3f146, 26689481), not a numbered continuation of this doc.
  • 2026-09-06 visual-design audit: podium medal-contrast fix, button.tsx secondary/outline contrast fix ($borderStrong), and the "two lg buttons in a row" width landmine are all shipped — confirmed by reading current button.tsx:86-113 and button-visual.ts. FactRow gained both tabularNums and onPress hitSlop compensation (fact-row-visual.ts:39-62) — both prior findings closed.
  • 2026-09-10 motion-harmony audit: PillNav's reduced-motion gate is shipped (pill-nav.tsx:377, imports useReducedMotion). The Skeleton/RemoteImage shimmer-duration fork is shipped — both now read one useShimmerOpacity() hook (shimmer.ts) at MOTION_DURATION.shimmer = 800.
  • 2026-09-10 spacing-harmony audit's P7 (DetailShell swallowing a "pinned" BottomCtaBand inside its own ScrollView) — shipped: detail-shell.tsx now has a dedicated footer prop rendered outside the ScrollView.
  • 2026-09-10 color-type-harmony audit: HIGH-1 (dual live-red), HIGH-2 (hard-court purple swatch), MEDIUM-1 (icon.muted interim tone in 3 themes) — not independently re-verified this pass (out of the visual-component scope below); MEDIUM-2 (hand-rolled border dividers) is re-verified and widened — see PD-05.

Everything below is either newly found this pass or a confirmed-still-open item from a prior audit that directly bears on primitive-to-primitive consistency.

Inventory

packages/ui/src currently holds 150 files (ls packages/ui/src, run at the start of this audit). Excluding pure math/hooks/web-variant/config/chart-internal modules (e.g. *-visual.ts logic files, band-slider-core-math.ts, use-scroll-perf.ts, motion.ts, tokens.ts, theme-hex.ts) that have no independent visual anatomy of their own, the visual/behavioural primitives actually compared below:

PrimitiveFileSize scaleTouch targetAffordance (resting cue)DisabledFocus (web)A11y
Pressable (base)pressable.tsxvariant: card/button/pill/row/icon/fab (not xs–lg)icon variant: hitSlop 15 default ((44-14)/2); other variants: caller-ownedaffordance prop; row/card default a $surfaceTertiary state-layer ONopacity 0.5 (DISABLED_OPACITY) + pointerEvents:none + accessibilityState.disabled$primary 2px solid outline, outlineOffset:2 (own copy #1)accessibilityLabel required (TS-enforced), accessibilityRole="button" default
Buttonbutton.tsxxs 28h · sm 36h(ROW_HEIGHT_HAIRLINE) · md 44h(ROW_HEIGHT_TOUCH) · lg 56hxs/sm carry no hitSlop back up to 44pt (only md/lg meet it by height alone)chrome fill/border IS the cuedisabledStyle opacity 0.5 + cursor:not-allowedown copy #2, identical values to Pressable'sicon-only Buttons have no built-in label — caller must supply
ActionButtonaction-button.tsxinherits Buttoninherits Buttoninherits Buttoninherits Buttoninherits Buttoninherits Button
ActionIconButtonaction-icon-button.tsxfixed 36px surface (no xs–lg)hitSlop 4 (0 horiz in flush mode)stateLayer forced onno disabled/loading prop at allinherits PressableaccessibilityLabel required; stopPropagation built in
ActionPillaction-pill.tsxfixed 28px heighthitSlop 8pill variant scale/opacitynone exposedinherits PressableaccessibilityLabel defaults to label
HeaderIconButtonheader-icon-button.tsxfixed 24px icon (no xs–lg)hitSlop 10icon itself (chrome convention)none exposedinherits Pressableno default label — caller supplies
HeaderTextActionheader-text-action.tsxfixed minHeight=44 (real layout, not slop)native 44, no slop neededaffordance="link" (accent tint)inherits Pressableinherits Pressabledefaults label to label
InfoHintButtoninfo-hint-button.tsxfixed 14px icon (sm)hitSlop 15 (Pressable default)icon itselfnone exposedinherits PressableaccessibilityLabel required
RemoveIconButtonremove-icon-button.tsxdelegates to ActionIconButton (36px)delegates (4)delegates + built-in ConfirmSheet gateno loading state (PD-04)inherits PressableaccessibilityLabel required; stopPropagation built in
DisclosureLinkdisclosure-link.tsxtext-sized, no fixed boxnone setaffordance="link"n/ainherits PressableaccessibilityState.expanded wired
Stepper ± buttonsstepper.tsxfixed 32px circle (hand-rolled, NOT Button)hitSlop 6stateLayer forced on + 1px borderatMin/atMaxdisabledinherits PressableaccessibilityValue on the cell; per-button label required
SelectionChipselection-chip.tsxsm 32h · md 40h (CHIP_MIN_HEIGHT_PX)hitSlop 6 (sm) / 2 (md)border+fill flips on selectedaccessibilityState.disabledinherits PressableaccessibilityState={{selected,disabled}}
DropdownChip/RangeChipdropdown-chip.tsx/range-chip.tsxinherits SelectionChip triggerinherits SelectionChiptrigger chevron rotates/tints on activen/a on triggerinherits Pressable; option rows have noneoption rows: accessibilityRole="radio"/"checkbox" + state (fixed 2026-08-31)
RemovableChipremovable-chip.tsxfixed $8(40h) body, xs(12px) × glyphhitSlop 16 on the ×always "selected" lookdisabled dims via Pressableinherits Pressablebody label + separate × label
TogglePilltoggle-pill.tsxfixed 44×24 trackdisplay-only, no target of its ownthumb position + fill colourn/a (row above it owns disabled)n/a (not focusable itself)none — the wrapping row must supply it
Badgebadge.tsxxs 10px text · sm 12px · md 13pxn/a (non-interactive)n/an/an/apassive; no built-in label (icon+text only)
StatusBadge familystatus-badges.tsx + status-variant-maps.tsinherits Badge xs (single-file canon-protected)n/an/an/an/ainherits Badge
ListRowlist-row.tsx/list-row-visual.tsregular 40h(ROW_HEIGHT_LIST) / dense 36h(ROW_HEIGHT_HAIRLINE)hitSlop 2 (regular) / 4 (dense), edge-awareauto-chevron when onPress + no trailingopacity 0.5 (LIST_ROW_DISABLED_OPACITY)inherits Pressabledefault label = title+sub
FactRow/FactListfact-row.tsx/fact-row-visual.tsfixed 36h (FACT_ROW_MIN_HEIGHT)hitSlop 4 base, edge-aware, only when onPresschevron only if onPress + no trailingn/a (display-first)inherits Pressabledefault label = label
EditFieldRowedit-field-row.tsxno fixed minHeight (content-driven, ~46px)none computed (relies on content height)trailing $primary text (editLabel)disabled → Pressable inert pathinherits PressableaccessibilityLabel = label
CellRowcell-row.tsx/cell-row-visual.tsequal-flex columns, 8px gap/paddingn/a (layout only)per-cell onPress optionalper-cell accessibilityStaten/aper-cell optional a11y props
StatStripstat-strip.tsxthin CellRow wrapperinherits CellRowaffordance="layer" when onPressn/ainherits Pressablelabel falls back to cell.label
StatTilestat-tile.tsxwrap: card/flat (not xs–lg)n/an/an/an/anone built in
StatGridstat-grid.tsxthin StatTile wrap="card" wrappern/an/an/an/anone built in
AvatarBubbleavatar-bubble.tsxXS28·SM32·MD40·LG44·HERO56·XL80·2XL88 (AVATAR_SIZE)n/a (non-interactive)n/an/an/aaccessibilityLabel = name on the wrapper only
Cardcard.tsxsm 12p · md 16p(default) · lg 20p · none 0pn/an/an/an/anone (pure display)
Wellwell.tsxfixed $4 radius / SEAM.block(12) paddinginherits Pressable when tappableaffordance="chevron" when tappablen/ainherits PressableaccessibilityLabel required when tappable
Calloutcallout.tsxfixed $3 radius / SEAM.block paddingn/a (never actionable)n/an/an/anone (prose only)
Bannerbanner.tsxfixed $3 radius / SEAM.block paddinginherits Button (sm actions)button chromeinherits Button per-actioninherits Buttonnone on the banner itself; actions inherit Button
ModalPanelmodal-panel.tsxFrame auto-height, capped 88%close-button $8(40px) box, no extra slopclose button chrome + backdrop dismissn/ainherits Pressable (close btn)closeAccessibilityLabel (registry default, not t())
ConfirmSheetconfirm-sheet.tsxButton sizes (unspecified size = default)inherits Buttonbutton chromeinherits Buttoninherits Buttontitle/message read by screen reader via Text
EmptyStateempty-state.tsxfull (64px icon bubble) / compact (44px ROW_HEIGHT_TOUCH bubble)n/an/an/an/aicon decorative; CTA inherits Button
NotFoundStatenot-found-state.tsx (app)thin EmptyState wrapperinherits EmptyStateinherits EmptyStaten/an/ainherits EmptyState
OfflineEmptyStateoffline-empty-state.tsx (app)thin EmptyState wrapperinherits EmptyStateinherits EmptyStaten/an/ainherits EmptyState
Skeleton/SkeletonCard/Row/Heroskeleton.tsxraw px bars (16/12/14/18/13/32…), 2 circles on AVATAR_SIZEn/an/a (loading only)n/an/anot announced (no accessibilityLabel/live region)
ToastHosttoast-host.tsx (app)fixed pill, $6 radiusaction button hitSlop 4 (44pt-derived)swipe + tap, accessibilityRole="alert"n/an/a (not keyboard-focusable)accessibilityLiveRegion="polite" + label = message
FeedList/GroupedFeedListfeed-list.tsx + feed-list/*n/a (list virtualization)n/ascroll-hint badge, loading-more footerisPausedOffline branchn/aerror/offline/empty slots per DATA-17
AppHeaderapp-header.tsx56h / 64h (resolveAppHeaderHeight)back/right via HeaderIconButtonback chevron + right iconsn/ainherits HeaderIconButtonbackAccessibilityLabel from registry
DetailShell/WizardShell/MainTabShell/PlaceShellrespective filesshell chrome onlydelegate to AppHeader/WizardShell headerdelegatedelegatedelegatedelegate
LabeledField/StyledInputlabeled-field.tsxsize: sm/lg (label only, not the input box)native RN TextInput hit areanone — see PD-01n/a (no visual disabled state read)none — see PD-01error rendered as sibling text only
SearchBarsearch-bar.tsxfixed row heightnative input + icon clear buttonclear ✕ iconn/anot verified this passnot verified this pass

Findings

PD-01 — StyledInput has no focus-visible or error-border treatment, unlike every other interactive primitive [FIX-NOW]

Evidence: packages/ui/src/labeled-field.tsx:263-267,373-374StyledInput accepts onFocus/onBlur callback props and forwards them to the caller, but never uses them to change its own visual state; borderColor: themeHex(theme, 'border') (labeled-field.tsx:392) is a single, static assignment with no conditional branch for focus or for the error prop (error only ever renders as a separate red Text line, labeled-field.tsx:223). Compare: pressable.tsx:303-309 sets a $primary 2px focusStyle unconditionally for every interactive Pressable; button.tsx:66-71 and pill-nav.tsx:157,243 each carry their own identical copy. A repo-wide grep -rl "focusStyle" packages/ui/src returns exactly 3 files (button.tsx, pill-nav.tsx, pressable.tsx) — labeled-field.tsx is not one of them, and nothing else in the file substitutes an equivalent (no border-color swap, no shadow, no outline).

Why it is drift: WCAG 2.2 SC 2.4.7 (Focus Visible) requires a visible indicator on keyboard focus, and apps/web (Next.js 15) is a real, shipping target — this is not a native-only app. Every other clickable/typeable family compared in the inventory above gets a focus ring on web (inherited from Pressable/Button/PillNav's shared default); the ONE family that a keyboard user on web is most likely to tab through sequentially — a multi-field wizard or settings form built from LabeledField — has none. The error gap is the same shape of problem one level down: canon nowhere bans a border-colour error cue, and every benchmark cited elsewhere in canon (Material, HIG, Toss) pairs an errored field with a tinted border, not text alone — right now StyledInput is the one field-like primitive whose error state is carried by a single channel (adjacent text) while its sibling primitives (Badge, Banner, StatusBadge) all pair colour + text for state.

Severity: fix-now — real accessibility gap, on the one primitive every wizard/settings/edit screen in the app depends on, on a platform that ships.

Canonical fix: give StyledInput internal isFocused state (the onFocus/onBlur props are already threaded through — just also set local state from them) and switch borderColor to $primary when focused and $error when error is set, with the errored state taking precedence. Extract the {outlineColor:'$primary', outlineWidth:2, outlineStyle:'solid', outlineOffset:2} object that Pressable, Button, and PillNav currently each type out independently into one exported constant (e.g. FOCUS_RING in pressable-visual.ts) so StyledInput composes the SAME ring on web instead of inventing a fourth copy, and the existing three collapse to one.

Prevention: a labeled-field.test.tsx case asserting StyledInput renders a different borderColor for resting/focused/errored; consider a lint rule flagging a new text-input-shaped styled()/component that accepts onFocus but never derives a style from it.

PD-02 — Canon's own "stepper = xs + circle + iconOnly" rule describes a Button shape the canonical Stepper stopped using over a month ago [WAVE]

Evidence: docs/canon/components.md ("Buttons, text & badges"): "Using Button → page CTA = lg + primary... stepper = xs + circle + iconOnly." Button size="xs" shape="circle" resolves to a 28px circle (button.tsx SIZE_STYLE.xs.height = 28 combined with shape.circle's aspectRatio:1). But the actual Stepper primitive (stepper.tsx:30,70-101) does not render Button at all for its ± controls — its own v2 doc comment states "the ± buttons are VISIBLE TOUCH TARGETS — a 32px circular bordered surface... not bare glyphs" and the code hand-rolls Pressable variant="icon" at a fixed BUTTON_SIZE = 32, one size-name and 4px removed from what canon describes. A repo-wide grep for size="xs" combined with circle/stepper across packages/features + packages/app/src/presentation returns zero hits — nothing in the shipping app actually builds a stepper (or anything else) out of Button size="xs" shape="circle"; all 10 stepper-needing screens import the real Stepper component.

Why it is drift: this is a canon-vs-code mismatch, not a code-vs-code one — but AGENTS.md states canon "wins on conflict" and is the thing agents are told to build against. A future engineer who reads the rule literally and reaches for Button size="xs" shape="circle" to build a "canonical stepper button" would ship a 28px control sitting next to (or replacing) the real Stepper's 32px ones — the exact "two families disagree on the same anatomy" case the brief asked to hunt for, except here one of the two doesn't even exist in code, only in the doc.

Severity: wave — no live visual bug (nothing currently uses the stale pattern), but the rule is actively misleading and will keep being misleading until corrected.

Canonical fix: delete "stepper = xs + circle + iconOnly" from the Button line in docs/canon/components.md and replace it with a pointer to Stepper itself ("bounded ±1 numeric control → Stepper (@twomore/ui), never Button" — which the same doc already states two bullets later in the Tabs & chips section: "Bounded-integer −/+ control → Stepper... Never hand-roll a Button shape='circle' −/+ pair"). The two sentences in the same file already contradict each other; keep the second, correct one.

Prevention: yarn lint:agents-md/docs:check already gate doc staleness mechanically for AGENTS.md; this is the softer canon layer, so the practical guard is folding this correction into the next canon pass rather than a new mechanical rule.

PD-03 — "badge"-role text weight is fragmented into three values (500/600/700) across the chip/pill family, against canon's own explicit "no per-site overrides" rule [WAVE]

Evidence:

  • text.tsx:99-104 bakes the badge role at fontWeight: '600' — the one weight canon documents: "EVERY badge-class text — chip labels, tier labels, counters, count numerals — is the badge role at its own 600, no per-site weight overrides (a 700 numeral breaks the family the same way a second 600 breaks the ramp)" (docs/canon/components.md, Buttons/text/badges section).
  • badge.tsx's own BadgeText correctly hardcodes fontWeight: '600' unconditionally across every variant/size (badge.tsx:105) — the canonical component complies with its own rule.
  • selection-chip.tsx:114-118: <Text role="badge" ... fontWeight={selected ? '700' : '500'}> — neither value is 600.
  • range-chip.tsx:198-205 mirrors the identical 500/700 split on its leading label.
  • removable-chip.tsx:71: permanently fontWeight="700" — the component's own doc justifies this as "always represents something already chosen," i.e. it's always in SelectionChip's selected state, so this one is internally consistent with the chip family's own convention, not an isolated bug.
  • action-pill.tsx:61: permanently fontWeight="700" with no selected/unselected state to justify departing from 600 — ActionPill is a single-state utility trigger, not a toggle.
  • session-live-anchor.tsx:84,93,101 (built today, 2026-09-17): three more <Text role="badge" fontWeight="700" .../> facts on the new live band, again unconditional.

Why it is drift: this is precisely the "render the same anatomy from two families side by side and ask why they differ" test from the brief. Badge (the canonical status/label component) is weight-invariant at 600 exactly as documented; every chip/pill sibling that also reaches for role="badge" text instead re-derives its own weight, and the canon sentence describing this slot is now factually stale — it states an absolute rule that at least 6 live call sites don't follow. The SelectionChip/RangeChip/RemovableChip 500-or-700 split is at least internally consistent across those three and reads like a deliberate, load-bearing "is this chosen" cue — but it was never written into canon as a ratified exception the way META_TEXT.secondary/eyebrow's $textTertiary carve-out was (docs/canon/styling.md:88, added specifically to stop a near-identical rule from being misread as violated). ActionPill and SessionLiveAnchor have no such state to point to — they're the two true, ungrounded outliers.

Severity: wave — no visible bug (the chip family reads fine on its own), but a systemic canon/code gap that keeps reproducing itself (a third instance shipped in today's own commit), and two sites can be normalized immediately.

Canonical fix: (1) amend the styling.md/components.md badge-family sentence to name the SelectionChip family's 500(resting)/700(selected) pairing as a ratified exception, the same way the META_TEXT carve-out was documented after a comparable finding. (2) Drop ActionPill's and SessionLiveAnchor's bare fontWeight="700" back to the role default (600) — neither has a selected/unselected binary to justify the override.

Prevention: extend @twomore/bounded-variant-enums (or a small sibling rule) to flag a fontWeight prop on any <Text role="badge"> outside the three named chip-family files; cheaper in practice, give the chip components themselves a weight?: 'resting' | 'selected' prop so 500/700 is expressed once in selection-chip.tsx and reused, not re-typed at each new call site.

PD-04 — Icon-action buttons have no loading state, unlike Button/ActionButton's canonical treatment [WAVE]

Evidence: action-icon-button.tsx and remove-icon-button.tsx accept no loading prop and have no code path that disables the button or swaps the glyph while an async onPress/onRemove is in flight. Compare button.tsx:239-254: loading swaps the icon slot for an ActivityIndicator sized/coloured to the variant AND forces the disabled state — documented in docs/canon/components.md's "Shared primitives" section as THE canonical in-flight treatment: "An ad-hoc ActivityIndicator composed into an icon slot per call site is retired — use loading/loadingLabel on Button/ActionButton instead."

Why it is drift: that canonical treatment was written for, and only extended to, the Button/ActionButton family — the OTHER family of tap-triggers-a-mutation controls (ActionIconButton, and RemoveIconButton which delegates its surface to it) has no equivalent, so a caller wiring a slow onRemove (e.g. removing a club member, a kick-from-session action — both real destructive, server-round-tripping actions this component is built for) has no primitive-level way to show pending state, and either ships silently-re-tappable icons during the wait or reinvents a local spinner.

Severity: wave.

Canonical fix: add an optional loading?: boolean to ActionIconButtonProps — swap the Icon for a small centered ActivityIndicator matching iconColor, and force disabled (which the component doesn't currently expose either, so this is the same edit). RemoveIconButton inherits it for free since it already delegates its surface to ActionIconButton.

Prevention: none beyond the prop existing; note it alongside the existing RemoveIconButton/ActionIconButton doc comments so the next reviewer knows to wire it for a genuinely slow mutation.

PD-05 — Hand-rolled borderBottomWidth/borderTopWidth-as-divider bypass is materially larger than the tracked count, and the lint gap that lets it in is still open [WAVE]

Evidence: the 2026-09-10 color/type harmony audit's MEDIUM-2 named 9 feature files hand-rolling borderBottomWidth={1} borderBottomColor="$borderSubtle" as a row/section separator instead of <Divider/>. A fresh repo-wide grep for the identical pattern in packages/app/src/presentation + packages/features — excluding those 9, the sanctioned EditFieldRow exception, and the further ~9 sites the 2026-09-06 rhythm-alignment audit separately tracked under "divider loops"/"private divider idiom" — still surfaces previously-undocumented live hits: presentation/components/leaderboard-row.tsx, features/clubs/src/club-challenges-screen.tsx, features/clubs/src/club-members/member-row.tsx, features/clubs/src/club-settings-screen.tsx, features/clubs/src/partner-suggester-screen.tsx, features/profile/src/announcements-screen.tsx, features/profile/src/components/policy-document-page.tsx, features/profile/src/contact-screen.tsx, features/profile/src/settings-consent-management-screen.tsx, features/sessions/src/components/participant-row.tsx — at least 10 more, none of them EditFieldRow.

Why it is drift: MEDIUM-2 already diagnosed the exact mechanism — @twomore/no-hand-hairline only catches the "drawn as a 1px YStack line" recipe, not the "literal-valued borderBottomWidth={1} + $borderSubtle pair used as a separator" recipe — and that lint gap is still open, so the real count keeps growing past what any single audit has scoped (now ~19+ sites across three separate passes, not the 9 MEDIUM-2 named).

Severity: wave — currently pixel-identical to Divider's own rendering (per MEDIUM-2's own finding, so no live visual bug), but a live regression waiting on the next hairline-colour tweak, and new screens have nothing stopping them from adding to the pile.

Canonical fix: unchanged from MEDIUM-2 — migrate every site to <Divider inset="card"/> or a bare <Divider/>.

Prevention: widen @twomore/no-hand-hairline (or add a sibling rule) to also flag a literal 1 + "$borderSubtle" pair on border{Bottom,Top}Width/border{Bottom,Top}Color in packages/features/** and packages/app/src/presentation/**. This was already proposed in MEDIUM-2; this audit's larger count is the evidence it's overdue.

PD-06 — StatTile wrap="card" double-pads vertically — two owners for one seam, live via StatGrid [COSMETIC]

Evidence: stat-tile.tsx:174-186: <Card tone="default" flex={...}> is given no size prop, so it defaults to size="md" = padding: '$4' = 16px on all four sides (card.tsx:57). The YStack it wraps then ALSO sets paddingVertical={SEAM.element} (SEAM.element = '$1' = 4px, seams.ts:41) on top of that. Net effect: 20px top/bottom vs 16px left/right inside every StatTile wrap="card" cell — live via stat-grid.tsx:43-59, whose own doc comment names a real consumer ("member-stats-modal.tsx's 클럽 ELO+delta · 순위 · 승률 top-3"). StatTile's OTHER path (wrap="flat", the one StatStrip uses) correctly adds no padding of its own, relying on CellRow's single-owner padding — so the same component has one path that respects single seam-ownership and one that doesn't.

Why it is drift: directly matches docs/canon/styling.md's "Spacing ownership rule — when two adjacent containers can each contribute padding to a gap... exactly ONE of them owns the gap... Doubling = visible bug," and the "doubled seam" anti-pattern named in components.md's seam-ladder section.

Severity: cosmetic — the delta is 4px, not dramatic, but it is a genuine unowned seam.

Canonical fix: drop the inner YStack's paddingVertical on the wrap === 'card' path (Card's own padding is the sole seam owner there); keep gap={SEAM.element} — that governs a different concern (spacing between the value/label/delta lines), not the card's outer inset.

Prevention: extend seam-contracts.test.ts (the existing single enforcement point locking every primitive's seam to the SEAM ladder) with a StatTile wrap="card" case asserting its total rendered padding equals Card's own size value with no addition.

PD-07 — Badge size="md" renders an undocumented 12th type size (13/18), and its own comment misattributes it to cardMeta [COSMETIC]

Evidence: badge.tsx:39-41 documents md as "standalone (13px text, 4/12px padding)," and badge.tsx:126-129's BadgeText md variant (fontSize: '$3' → 13px, lineHeight: 18 per fonts.ts's real size/lineHeight tables) carries the comment "13px/18px — same fontSize as the cardMeta role (13/18 500)." But text.tsx:91-97's actual cardMeta role is fontSize: '$4' (14px) / lineHeight: '$4' (20px) — i.e. 14/20, not 13/18. xs (10/14) correctly matches the micro role and says so; sm (12/18) correctly matches the badge role. md matches no role in the 11-role scale at all.

Why it is drift: canon's entire typography section exists to guarantee "no one-off fontSize... single source of truth" — Badge's three sizes are supposed to be that system's badge-class citizens, but md is a fourth, independently-invented number with a comment that asserts a false equivalence — the same "component's own docstring lies about its actual behavior" class the project fixed before (commit 35e3f146, "correct 3 lying primitive docstrings").

Severity: cosmetic (1-2px, not visually alarming) but cheap to fix alongside PD-03 in the same file.

Canonical fix: either retarget md's fontSize/lineHeight to the real cardMeta value (14/20) if that was the actual intent, or correct the comment to state the true, deliberate 13/18 value and drop the false "matches cardMeta" claim.

Prevention: a unit test asserting every BadgeText size's resolved fontSize/lineHeight pair equals a real Text role's pair.

PD-08 — ConfirmSheet's own doc comment states an untrue button-style mapping, and disagrees with canon's own restatement [COSMETIC]

Evidence: confirm-sheet.tsx:41-56's doc table states 'default' → outline. The actual variantFor() (confirm-sheet.tsx:158-164) falls through to isOnlyCancel ? 'outline' : 'primary' for a default/omitted style — i.e. 'default' resolves to primary in the common (2+ button) case, and only to outline in the rare single-button/lone-cancel edge case. docs/canon/components.md's own restatement ("default|omitted→primary") is the common-case-accurate version but doesn't name the isOnlyCancel branch either — so all three sources (component doc, canon doc, code) currently say something slightly different from each other.

Why it is drift: the same "lying docstring" class as PD-07, one level up in a component every destructive/confirm flow in the app depends on. A future engineer adding a confirm dialog with an unstyled non-cancel button, trusting the file's own comment, would expect an outline button and get a primary one.

Severity: cosmetic — doc-only; the code's actual behaviour is reasonable and arguably the more thoughtful of the two documented versions.

Canonical fix: correct the doc comment to state the real, isOnlyCancel-aware rule; add the same one-line caveat to canon's restatement so code, component doc, and canon doc agree.

Prevention: none mechanical — a reviewer note that doc comments describing branching logic must name every branch.

PD-09 — AppHeader's bare-chrome mode conditions the right slot's 44pt reservation on content, but not the left slot's [COSMETIC]

Evidence: app-header.tsx:86: rightMinWidth = bare && !right ? undefined : TOUCH_TARGET.MIN. app-header.tsx:101: the left XStack always carries minWidth={TOUCH_TARGET.MIN}, regardless of chrome or showBack. The file's own doc comment explains exactly why the right slot's width is made conditional — "a bare header with only a back button centers its title against just that one slot... instead of reserving invisible space that mirrors an unused right slot" — but the identical logic is never applied to the left slot when showBack=false.

Why it is drift: the component states a symmetry principle for one of its two slots and doesn't apply it to the other — a chrome="bare" header with no back button and no right content would center its title ~44px off-axis instead of dead-center, the exact symptom the right-slot conditional exists to prevent on the mirrored side.

Severity: cosmetic/latent — no current chrome="bare" consumer (the scorecard sheets) combines showBack=false with no right, so nothing is visibly broken today.

Canonical fix: mirror the conditional on the left slot: leftMinWidth = bare && !showBack ? undefined : TOUCH_TARGET.MIN.

Prevention: a layout test asserting a bare AppHeader with neither showBack nor right centers its title (left inset === right inset).

PD-10 — MetaBreak's on-tint opacity has two different values for the identical anatomical role [COSMETIC]

Evidence: presentation/components/network-banner.tsx:68: <MetaBreak color="$badgeWarningText" opacity={0.4} />. presentation/components/sessions/session-live-anchor.tsx:80,92,100: <MetaBreak color="$textInverse" opacity={0.35} /> (×3, internally consistent within the new component). Both are the same primitive, used for the same purpose — a ·-replacement segment divider — on a saturated/dark background; the two consumers independently picked 0.4 and 0.35.

Why it is drift: a direct instance of the "same anatomy, two families, why do they differ" test — a receded hairline on a tinted fill has exactly one other precedent to compare against, and it doesn't match.

Severity: cosmetic.

Canonical fix: name one constant (e.g. META_BREAK_ON_TINT_OPACITY) in text-grammar.ts/meta-segments-visual.ts and have both call sites import it instead of choosing their own value.

Prevention: none beyond the constant existing — too narrow a case to lint.

PD-11 — Size-scale names (xs/sm/md/lg) are per-family local vocabulary with no shared pixel meaning, and nothing says so [COSMETIC]

Evidence: compiled from the Inventory table above — sm means 36px height in Button, 32px height in SelectionChip, 12px font in Badge, and 14px glyph in Icon; md means 44px height in Button, 40px height in SelectionChip, 13px font in Badge, and 16px glyph in Icon. EmptyState doesn't use the ladder at all (full/compact), and Text roles (pageTitle/cardBody/…) use a completely different, size-unrelated naming axis.

Why it is drift: the brief specifically asked to check "whether each family's scale maps to the same px." It does not, in any of the four families that share the xs/sm/md/lg vocabulary — and nothing in docs/canon/styling.md states that this is by design (a locally-scoped name per component, not a cross-app pixel promise), so a reader who sees size="sm" on two different primitives has no documented reason not to assume parity.

Severity: cosmetic — unifying the actual pixel values across four independently-tuned families would be a large, low-value redesign for no functional gain (Button and SelectionChip are rarely composed side-by-side); the realistic fix is documentation, not code.

Canonical fix: add one sentence to docs/canon/styling.md's Typography/spacing preamble stating explicitly that xs/sm/md/lg size-variant names are local to each component (a Tamagui variant-prop convention, not a shared pixel scale) — mirroring the file's own existing "Casing is a deliberate signal, not inconsistency" note about AVATAR_SIZE/DOT_SIZE constants, which already draws exactly this kind of "looks unified, isn't, and that's fine" distinction for a different axis.

Prevention: none mechanical — a documentation clarification closes the ambiguity.

Already canonical

Verified compliant this pass — checked, not re-flagged:

  • Pressable's press/disabled/focus/reduced-motion system (pressable.tsx) — one variant table, one disabled recipe, one focus ring, UI-thread press feedback, reduced-motion opacity-only fallback; every family above composes it rather than reinventing it.
  • Touch-target hitSlop math — formula-driven and consistently (TOUCH_TARGET.MIN - visiblePx) / 2 across HeaderIconButton, ActionIconButton, Stepper, SelectionChip, RemovableChip, ListRow, FactRow, ActionPill, and ToastHost's action button — nine independent components, one shared formula, zero drift in the values themselves.
  • Card's no-elevation law and two-tone/four-size system — zero raw elevation/shadow* found on Card anywhere in scope; default/flat tones and sm/md/lg/none sizes are exactly as documented.
  • Divider/HAIRLINE — the single hairline recipe itself is correct and consistently consumed everywhere it IS used (the bypass volume is tracked separately in PD-05).
  • SEAM ladder + seam-contracts.test.ts — a real, tested, single source; every primitive read in this pass that claims a SEAM.* value actually renders it (the one exception, StatTile wrap="card", is PD-06).
  • ToastHost — fully realized: swipe-to-dismiss with distance/velocity thresholds, reduced-motion gating, keyboard-height awareness, additive bottom-chrome accounting, accessibilityLiveRegion. No notes.
  • ModalPanel's auto-height/88%-cap layout contract — internally consistent, matches its own documented contract exactly.
  • Button's two previously-documented landmines are both fixed: the secondary/outline non-text contrast failure (now $borderStrong, 4.69:1 light / 3.51:1 dark) and the "two lg buttons in a row collapse to equal widths" bug (resolveButtonWidth now derives 'auto' vs '100%' from whether flex is present).
  • PillNav's reduced-motion gate and the Skeleton/RemoteImage shimmer-duration fork — both previously-flagged (2026-09-10) gaps are shipped and closed.
  • DetailShell's dedicated footer slot — the structural fix the 2026-09-06 seam audit's P7 proposed (a footer rendered outside the shell's own ScrollView) has shipped.
  • FactRow's tabularNums prop and onPress hitSlop compensation — both close prior LOW/MED findings from the 2026-09-06 visual-design audit.
  • AvatarBubble's proportional internal text (getTextStyle/getGlyphSize, off the 11-role scale) — a deliberately documented exception ("Avatar-internal text is the one exception: it scales with the bubble"), not drift.
  • Icon's one wrapper/one scale — zero raw <LucideIcon size={number}> bypass found in any file read this pass; every icon goes through <Icon component={...} size="...">.
  • StatusBadge family (status-badges.tsx/status-variant-maps.ts) — single-file, canon-protected, one variant table per domain enum; the lint-exempted internal <Badge> composition is the intended design, not a loophole.
  • TogglePill's track radiusborderRadius="$3" (12px) against a 24px track height is an exact half, i.e. a true capsule — initially suspected as a possible drift, confirmed correct against tokens.ts's real radius scale.
  • Raw fontSize/opacity bypass sweep — a targeted adversarial grep for <Text fontSize={ and opacity={0. across presentation+features turned up only sanctioned, // reason:-commented escape hatches (a 9px data-density heatmap tick, a 9px tiebreak superscript) that match docs/canon/styling.md's own worked example for a legitimate exception — no undocumented bypasses found in this sample.

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