Motion, Interaction & State-Behavior Disharmony Audit
Status: Active · Owner ask: "are there any components or styles or behaviors that are not in sync or harmony?" — motion/interaction/state-behaviour dimension. All findings open as of 2026-09-10.
Scope: gesture consistency, mutation feedback, loading/empty states, animation timing, focus/keyboard, disabled/pending affordances. Adversarial pass — only reporting behavior a user would actually feel as inconsistent, not contextual variation.
Findings, ranked by how often a user hits them
1. [HIGH] PillNav's active-indicator animation is completely ungated on reduced motion — the highest-traffic animated surface in the app
Files: packages/ui/src/pill-nav.tsx:394,397,477
PillNav drives every tab strip and chip group in the app (SegmentedTabs, top tab bar, club tabs, session tabs, records tabs, filter chip rows). Its active-indicator slide always fires:
animatedIndex.value = withSpring(activeIndex, SPRING_CONFIG); // :394, :477
const indicatorStyle = useAnimatedStyle(() => { ... }); // :397pill-nav.tsx imports only MOTION_SPRING — it never imports or calls useReducedMotion() (confirmed: zero references to "reduced"/"a11y" motion gating anywhere in the file). Compare packages/ui/src/motion.ts:94-97: "Every motion site MUST gate non-essential animation on this" — and compare the three primitives that actually do (Pressable, ProgressBar, data-section.tsx, skeleton.tsx, remote-image.tsx, pulse-dot.tsx — all call useReducedMotion()).
What the user feels: a user with iOS "Reduce Motion" / Android "Remove animations" / web prefers-reduced-motion enabled gets the spring-glide indicator on literally every tab tap and chip selection app-wide, while every other animated primitive in the design system correctly goes still for them. This is the single largest surface-area violation of the reduced-motion rule because of how central PillNav is (SegmentedTabs — used on nearly every multi-pane screen — is built on it).
Canonical fix: add const reducedMotion = useReducedMotion(); in pill-nav.tsx and gate the withSpring calls the same way Pressable does (reduced-motion → snap the shared value directly via withTiming(..., { duration: 0 }) or a plain assignment, no spring).
2. [MEDIUM-HIGH] Club-join CTA uses a hand-rolled '...' pending label instead of the canonical Button/ActionButton loading treatment
File: packages/features/clubs/src/public-club-profile/join-cta-band.tsx:70,98,126
<ActionButton ... disabled={applyMutation.isPending} ...>
{applyMutation.isPending ? '...' : strings.applySubmit}
</ActionButton>
...
{joinOpenMutation.isPending ? '...' : t().clubDetail.joinCta}This is the only pending-CTA in the codebase using a literal, non-i18n '...' string swap. components.md's "Shared primitives" section is explicit: "An ad-hoc ActivityIndicator composed into an icon slot per call site is retired — use loading/loadingLabel on Button/ActionButton instead." 47 other files in packages/features correctly pass loading={} to Button/ActionButton (spinner replaces the leading icon, forces disabled); this is the one holdout using a bare-text placeholder instead of a spinner.
What the user feels: every non-member visiting a club's public profile (open or approval-policy club) who taps 가입/신청 sees the button's label snap to a bare ... with no spinner — visually inconsistent with the spinner treatment used on every other pending CTA in the app (create-club submit, create-session submit, session-payments confirm, etc.), and the literal '...' never resolves through i18n like everything else on the button.
Canonical fix: <ActionButton loading={joinOpenMutation.isPending} loadingLabel={...} disabled={...}>.
Related, same root cause (severity note, not a separate line item): this join flow is also the one membership-lifecycle action in the app that is NOT optimistic — the user waits for the round trip with only a text swap as feedback — while comparable "flip my relationship to this entity" actions (useRsvp's upsert/cancel, useUpdateDues, useUpdateMemberRole, useUpdatePreferences, useVenueBridge's save-toggle) are all optimistic with an instant UI flip (see packages/app/src/presentation/hooks/mutations/use-rsvp.ts:280-360). Not flagged as its own top finding — RSVP's optimism is justified by its own comment ("user should tap again explicitly", retry: false) and club-join genuinely has more server-side gating (approval policies) that makes waiting defensible — but it means "join a club" and "RSVP to a session," which read as the same class of commit-to-attend action to a user, feel different: one is instant, one is a wait-with-ellipsis.
3. [MEDIUM] "Remove a person" uses two different gestures depending on which list they're in
Files:
packages/features/profile/src/friends-screen.tsx:158-170,180— long-press on the row firesuseConfirmdirectly (no intermediate menu):onLongPress={handleLongPress}→confirm.show({ title, buttons: [cancel, destructive→remove.mutate] }).packages/features/clubs/src/club-detail/members-pane.tsx:272-287,333-342— the identical destructive-confirm shape (confirm.showwith cancel + destructiveremoveMember.mutate) is reached only through a trailing kebab-menu tap ("Action modal — kebab menu", line 333) openingonRemove={handleRemovePress}; there is noonLongPressanywhere in this screen.
What the user feels: removing a person is the same intent-and-confirm shape in both places (title + cancel + destructive button), but the gesture to reach it is different — long-press on Friends, tap a trailing ⋯ menu on Club Members. A user who learns "long-press a row to remove someone" on one screen gets no affordance and no result long-pressing a member row on the other, and vice versa (tapping for a kebab that doesn't exist on a friend row).
Canonical fix: pick one gesture for "remove a person from a roster" app- wide — given components.md's own header-menu / kebab conventions and that club member rows already support multiple actions (role change, group assign, remove) via the kebab, the more scalable and precedented pattern is the kebab-menu; friends-screen.tsx's long-press-to-destructive-confirm is the outlier (also the only "remove" flow in the app that skips a menu step between the gesture and the destructive confirm).
4. [LOW — informational, not currently user-visible] Skeleton shimmer duration is hardcoded and disagrees with the design system's own named token
Files: packages/ui/src/skeleton.tsx:55-56, packages/ui/src/remote-image.tsx:107-108, vs. packages/ui/src/motion.ts:61-62
motion.ts defines MOTION_DURATION.shimmer = 900 ("Skeleton shimmer half-cycle") as part of the file's stated contract ("single source of truth ... never hand-declare duration/easing constants elsewhere"). Neither actual shimmer implementation uses it — both independently hardcode duration: 800:
Animated.timing(opacity, { toValue: 0.9, duration: 800, useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0.6, duration: 800, useNativeDriver: true }),MOTION_DURATION.shimmer has zero consumers anywhere in the repo (confirmed via grep) — it is dead, undocumented-as-dead, and describes a value (900ms) that is not what ships (800ms).
Why this is ranked low despite being real drift: the two actual implementations (Skeleton, RemoteImage's shimmer placeholder) agree with each other at 800ms, so no user perceives inconsistency today. This is a latent maintainability trap, not a felt disharmony: the next person who "tunes" MOTION_DURATION.shimmer expecting it to control shimmer speed will change nothing, and the two literals can silently diverge on a future edit since neither reads from the other.
Fix: either both files import MOTION_DURATION.shimmer (and someone decides 900 vs 800 is correct), or the dead token is deleted from motion.ts.
Categories checked and found CLEAN (with evidence)
- Pull-to-refresh — fully unified.
usePullToRefresh(packages/app/src/presentation/hooks/use-pull-to-refresh.ts) is the sole path to arefreshing/onRefreshpair (77 direct call sites + 4 more via composite hooks that wrap it internally —use-session-detail-model.ts,use-match-board-data.ts,use-directory-venue-detail.ts,round-section-list.tsx's parent), enforced by@twomore/no-background-refetch-spinner. Spinner only shows on an actual pull, never on a background refetch, everywhere checked. - Bookmark/save toggle —
SessionSaveButtonandVenueSaveButton(packages/app/src/presentation/components/sessions/{session,venue}-save-button.tsx) are intentionally byte-parallel: same optimistic hook shape, same spring "pop" (withSequence(withTiming(1.28,120), withSpring(1))), same reduced-motion gate, same no-toast-on-save convention. Genuinely harmonious. - Overlay dismiss-on-outside-tap — default is
dismissOnOverlayPress = trueeverywhere (modal-panel.tsx:81); the only override in the codebase isConfirmSheet's deliberatedismissOnPress={false}(documented incomponents.mdas matchingAlert.alertsemantics). Onetransfer-ownership-modal.tsxcall passes the (redundant) default explicitly — not a real divergence. - Confirm-dialog dismiss-then-fire timing —
confirm-sheet.tsx:181-190closes the panel synchronously on any button tap, then awaits the handler — identical for every destructive confirm in the app (leave club, remove member, delete account, archive club, etc.), matching nativeAlert.alertbehavior everywhere it's used. - Mutation error feedback — the
createMutationHookfactory (packages/app/src/presentation/hooks/create-mutation-hook.ts) always shows an error toast + error haptic on failure by default (falls back to rawerr.messagewhen noerrorMessageis configured) for all ~73 factory-built mutations; a first grep suggesting 60+ mutation files "have no toast" was a false signal — those files configure toast via the factory'ssuccessMessage/errorMessagefields rather than callingshowToastinline. The globalMutationCache/QueryCache(query-client.ts) additionally logs every error to Sentry regardless of whether the per-mutation toast fired. - Keyboard dismiss on wizard field commit — centralized in
useEditScope(packages/app/src/presentation/hooks/use-edit-scope.ts:128) and the venue-edit scope; not reimplemented per-field.
Not independently verifiable in this pass
- Whether
ModalPaneltraps focus consistently on the web build (RNModalsemantics vs. DOM focus trapping) would need an actual browser session — not something a code read confirms either way. - Double-submit protection on
mutate()(fire-and-forget) call sites for high-stakes actions (end session, forfeit, cancel-as-host) — the factory disables nothing on the caller's behalf; whether every consuming screen wiresisPendinginto its own CTA disable was not exhaustively checked past thejoin-cta-band.tsxinstance in finding #2 and the cleandanger-zone-section.tsxexample (which does correctly wiredisabled={isArchivePending || isTransferPending}on both buttons).