Skip to content

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(() => { ... });        // :397

pill-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

tsx
<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 fires useConfirm directly (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.show with cancel + destructive removeMember.mutate) is reached only through a trailing kebab-menu tap ("Action modal — kebab menu", line 333) opening onRemove={handleRemovePress}; there is no onLongPress anywhere 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:

ts
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 a refreshing/onRefresh pair (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 toggleSessionSaveButton and VenueSaveButton (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 = true everywhere (modal-panel.tsx:81); the only override in the codebase is ConfirmSheet's deliberate dismissOnPress={false} (documented in components.md as matching Alert.alert semantics). One transfer-ownership-modal.tsx call passes the (redundant) default explicitly — not a real divergence.
  • Confirm-dialog dismiss-then-fire timingconfirm-sheet.tsx:181-190 closes 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 native Alert.alert behavior everywhere it's used.
  • Mutation error feedback — the createMutationHook factory (packages/app/src/presentation/hooks/create-mutation-hook.ts) always shows an error toast + error haptic on failure by default (falls back to raw err.message when no errorMessage is 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's successMessage/errorMessage fields rather than calling showToast inline. The global MutationCache/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 ModalPanel traps focus consistently on the web build (RN Modal semantics 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 wires isPending into its own CTA disable was not exhaustively checked past the join-cta-band.tsx instance in finding #2 and the clean danger-zone-section.tsx example (which does correctly wire disabled={isArchivePending || isTransferPending} on both buttons).

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