Skip to main content
Version: 2.0.0 Last Updated: 2026-04-19 Spec Reference: PF-37 — Mobile Swipe Gestures Integration Doc: docs/architecture/integrations/PF-37-phase-2-3-INTEGRATION.md Status: ✅ Phase 1 + Phase 2 + Phase 3 Complete (PF-37); ongoing platform consolidation in this branch
Major changes vs v1.0.0:
  • Adds Phase 2 (preferences, haptics, batched analytics) and Phase 3 (pinch-to-zoom, multi-touch, custom gestures) coverage.
  • Adds the new SwipeableCardShell primitive and the deprecation of hand-rolled *Swipeable.tsx wrappers.
  • Documents prefers-reduced-motion behavior across Phase 1 hooks.
  • Adds cross-references to /settings/gestures and /settings/gestures/analytics.
  • Aligns CSS-var examples with production (--mobile-nav-height: 76px, --mobile-header-height: 48px).

Overview

This guide covers mobile gesture patterns implemented in the Encore OS platform under PF-37. All gestures use @use-gesture/react for performant touch handling, are mobile-first, accessibility-aware, and honor:
  • User preferences stored under pf_profiles.preferences.gestures (see PF-37 Phase 2).
  • Haptic feedback via the Vibration API where supported.
  • Batched analytics to pf_gesture_analytics_events (5-second flush, no PHI).
  • Reduced motion via prefers-reduced-motion: reduce.
  • Keyboard alternatives per WCAG 2.1.1, 2.5.1, and 2.5.2.

Table of Contents

  1. Quick Start
  2. Hooks Catalog
  3. Components Catalog
  4. Decision Tree — When to Use Each Pattern
  5. User Preferences (Phase 2)
  6. Haptic Feedback (Phase 2)
  7. Analytics (Phase 2)
  8. Pinch & Multi-touch (Phase 3)
  9. Custom Gesture Registry (Phase 3)
  10. Accessibility & Reduced Motion
  11. Performance Guidelines
  12. Browser Compatibility
  13. Troubleshooting
  14. Related Documentation

Quick Start

Import Gestures

Basic Usage


Hooks Catalog

Phase 1 — core gesture hooks

Phase 2 — preferences, haptics, analytics

Phase 3 — advanced gestures


Components Catalog

Why SwipeableCardShell?

We had 5 nearly identical hand-rolled wrappers (OnboardingTaskCardSwipeable, NotificationItemSwipeable, DraftCardSwipeable, SwipeableWidget, TodoCardSwipeable), each repeating the same useIsMobile() + SwipeableListItem boilerplate. SwipeableCardShell gives one canonical primitive:
Behavior:
  • On desktop (or when enabled === false): renders children unwrapped (zero gesture overhead).
  • On mobile: wraps with SwipeableListItem and forwards all action / threshold / className props.
  • Honors useGestureIntegration automatically (because the underlying hook does).

Decision Tree — When to Use Each Pattern

When NOT to use gestures

  • ❌ Forms with unsaved changes (use a confirmation dialog).
  • ❌ Destructive actions without undo (require confirmation or 5-second toast undo).
  • ❌ Complex multi-step interactions (use a wizard).
  • ❌ Desktop-only interfaces (most gestures auto-disable, but don’t go out of your way).
  • ❌ When keyboard-only users are the primary audience for that surface.

User Preferences (Phase 2)

Each user can tune gestures at /settings/gestures (page: GesturePreferencesPage.tsx). Schema stored in pf_profiles.preferences.gestures:
How it cascades:
If the master toggle is off, all gestures no-op silently. The hooks still mount; they just don’t bind.

Haptic Feedback (Phase 2)

Use the new hook (preferred):
Browser support matrix: The legacy Phase-1 functions (hapticTap, hapticSuccess, …) are still exported from @/platform/gestures for backward compatibility but prefer useHapticFeedback in new code.

Analytics (Phase 2)

Gesture events are batched to pf_gesture_analytics_events:
  • Append-only, user-scoped RLS (a user can only insert their own rows).
  • No PHI: only gesture_type, subtype (e.g. 'left', 'right'), success: boolean, device_type, timestamp. Never patient names / MRNs / IDs.
  • Batched client-side: 5-second flush + visibilitychange + beforeunload flush.
Aggregated counts are exposed at /settings/gestures/analytics (GestureAnalyticsPage.tsx) so users can see how often they use each gesture and what their success rate is.
Privacy note: Do not pass any user-typed input or entity identifiers to the optional subtype parameter of trackGestureEvent. The TypeScript signature constrains it to a SwipeDirection-shaped value; do not widen it.

Pinch & Multi-touch (Phase 3)

Use cases:
  • Image lightboxes (PF-98 headshot viewer; document image viewer in @/platform/documents).
  • Charts you want to zoom into.
  • Signature previews.
For full-screen viewing with double-tap zoom + pinch + pan, use ZoomableImageViewer. Avoid useMultiTouch and useCustomGesture unless the existing higher-level components do not cover the case. They are unstable surface area — call PF before adopting.

Custom Gesture Registry (Phase 3)

Wrap a subtree:
Then register and consume:
This is intentionally low-traffic; for almost all product features, the named hooks above are simpler.

Accessibility & Reduced Motion

WCAG compliance

Keyboard alternatives

Reduced-motion behavior

Set prefers-reduced-motion: reduce (system / browser) and the four Phase-1 hooks switch to:
  • Snap-back: transition: none (jump cut).
  • Dismiss animation: 0 ms (immediate cleanup).
  • Reveal animation: 0 ms.
Haptic feedback is independent of reduced-motion and remains controlled by the user-preference toggle.

Performance Guidelines

Best practices

  1. Use CSS transforms only. All hooks emit transform: translateX/Y(...) and never modify width/height/left/top.
  2. Avoid re-renders during drag. useDrag callbacks update component state via setState; keep the consumer subtree small.
  3. Don’t add will-change unless DevTools shows compositing issues — Safari has known memory regressions.
  4. Batch onRefresh side effects. Resolve the promise before triggering query invalidation; the hook will hold the spinner until your promise settles.

Browser Compatibility

Known limitations

  1. iOS Safari edge-swipe collision. The native iOS edge-back gesture lives in roughly the leftmost 30 px. Our useEdgeSwipe zone is 20 px. On iOS the native gesture wins — that’s fine; users still get the expected behavior, it’s just routed through the browser instead of our hook.
  2. iOS Vibration API. Permanently unsupported. useHapticFeedback is a no-op there.
  3. Android system back gesture (Android 10+). Coexists with our hooks; no conflicts seen in QA.

Troubleshooting

Gesture isn’t firing

  1. Check the user has not disabled the gesture in /settings/gestures.
  2. Check the enabled prop / option (default is true).
  3. Check the threshold isn’t too high; lower it temporarily and retest.
  4. Make sure touch-action on a parent isn’t pan-x/pan-y blocking the relevant axis.
  5. Try wrapping the parent in <MobilePullToRefresh enabled> to confirm touch handling is reaching the area.

Janky animation

  1. Confirm only transform / opacity change during the gesture.
  2. Open Chrome DevTools → Rendering → “Paint flashing” to spot non-composited repaints.
  3. Verify the consumer subtree isn’t re-rendering on every progress tick.

Gesture conflicts with native scroll

  1. Pull-to-refresh only fires at scrollTop === 0.
  2. Edge-swipe only fires inside the 20 px left zone.
  3. Sheet swipe-to-dismiss respects axis (pan-x or pan-y).

Keyboard shortcut doesn’t work

  1. Ensure the swipeable container is focusable (tabIndex={0} or focusable child).
  2. Verify no parent calls event.stopPropagation() on key events.
  3. For destructive actions, the keyboard handler is on the action button, not the row — focus the button via Tab.


Document Status: Active Maintained By: Platform Foundation Team Last Reviewed: 2026-04-19