> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Mobile Gesture Guide

> 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-INTEGRATI…

**Version:** 2.0.0
**Last Updated:** 2026-04-19
**Spec Reference:** [PF-37 — Mobile Swipe Gestures](../../specs/pf/specs/PF-37-mobile-swipe-gestures.md)
**Integration Doc:** [`docs/architecture/integrations/PF-37-phase-2-3-INTEGRATION.md`](../architecture/integrations/phase-2-3-integration-pf-37.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 Health OS platform under **PF-37**. All gestures use [`@use-gesture/react`](https://use-gesture.netlify.app/) 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](#quick-start)
2. [Hooks Catalog](#hooks-catalog)
3. [Components Catalog](#components-catalog)
4. [Decision Tree — When to Use Each Pattern](#decision-tree--when-to-use-each-pattern)
5. [User Preferences (Phase 2)](#user-preferences-phase-2)
6. [Haptic Feedback (Phase 2)](#haptic-feedback-phase-2)
7. [Analytics (Phase 2)](#analytics-phase-2)
8. [Pinch & Multi-touch (Phase 3)](#pinch--multi-touch-phase-3)
9. [Custom Gesture Registry (Phase 3)](#custom-gesture-registry-phase-3)
10. [Accessibility & Reduced Motion](#accessibility--reduced-motion)
11. [Performance Guidelines](#performance-guidelines)
12. [Browser Compatibility](#browser-compatibility)
13. [Troubleshooting](#troubleshooting)
14. [Related Documentation](#related-documentation)

***

## Quick Start

### Import Gestures

```typescript theme={null}
import {
  // Phase 1 — Hooks
  useSwipeToDismiss,
  useSwipeActions,
  usePullToRefresh,
  useEdgeSwipe,
  // Phase 1 — Components
  SwipeableSheet,
  SwipeableListItem,
  PullToRefresh,
  MobilePullToRefresh,
  // Phase 2 — Preferences / haptics / analytics
  useGesturePreferences,
  useHapticFeedback,
  useGestureAnalytics,
  useGestureIntegration,
  // Phase 3 — Pinch & multi-touch
  usePinchToZoom,
  useMultiTouch,
  useDoubleTap,
  ZoomableImage,
  ZoomableImageViewer,
  // Phase 3 — Custom gestures
  useCustomGesture,
  CustomGestureProvider,
  useCustomGestureRegistry,
  // Constants
  GESTURE_THRESHOLDS,
  ANIMATION_CONFIG,
  ACTION_COLORS,
  GESTURE_ARIA_LABELS,
  // Shared list-item shell (recommended)
  SwipeableCardShell,
} from '@/platform/gestures';
```

### Basic Usage

```tsx theme={null}
// Swipe-to-dismiss bottom sheet
<SwipeableSheet open={open} onOpenChange={setOpen} side="bottom" ariaLabelledBy="sheet-title">
  <h2 id="sheet-title">Sheet title</h2>
</SwipeableSheet>

// Mobile-only swipe-revealed actions on a list card (recommended pattern)
<SwipeableCardShell
  rightActions={[{ id: 'delete', label: 'Delete', icon: 'Trash2', colorClass: 'destructive', onClick: handleDelete }]}
>
  <NotificationItem ... />
</SwipeableCardShell>

// Pull-to-refresh that auto-disables on desktop / pointer-fine devices
<MobilePullToRefresh onRefresh={async () => await refetch()}>
  <ScrollableList />
</MobilePullToRefresh>
```

***

## Hooks Catalog

### Phase 1 — core gesture hooks

| Hook                                                                          | Purpose                                 | Honors preferences?           | Honors reduced-motion?   |
| ----------------------------------------------------------------------------- | --------------------------------------- | ----------------------------- | ------------------------ |
| [`useSwipeToDismiss`](../../src/platform/gestures/hooks/useSwipeToDismiss.ts) | Swipe sheets/dialogs/toasts to dismiss  | ✅ via `useGestureIntegration` | ✅ snap-back duration → 0 |
| [`useSwipeActions`](../../src/platform/gestures/hooks/useSwipeActions.ts)     | iOS-Mail-style reveal of action buttons | ✅                             | ✅ reveal duration → 0    |
| [`usePullToRefresh`](../../src/platform/gestures/hooks/usePullToRefresh.ts)   | Pull down at scroll-top to refetch      | ✅                             | ✅                        |
| [`useEdgeSwipe`](../../src/platform/gestures/hooks/useEdgeSwipe.ts)           | Swipe from left edge to navigate back   | ✅                             | ✅                        |

### Phase 2 — preferences, haptics, analytics

| Hook                                                                                  | Purpose                                                                                               |
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| [`useGesturePreferences`](../../src/platform/gestures/hooks/useGesturePreferences.ts) | Read/write user preferences (sensitivity, toggles, haptics)                                           |
| [`useHapticFeedback`](../../src/platform/gestures/hooks/useHapticFeedback.ts)         | Trigger Vibration API feedback when available                                                         |
| [`useGestureAnalytics`](../../src/platform/gestures/hooks/useGestureAnalytics.ts)     | Read aggregated user analytics for the settings dashboard                                             |
| [`useGestureIntegration`](../../src/platform/gestures/hooks/useGestureIntegration.ts) | **Internal helper** combining preferences + analytics + haptics — Phase 1 hooks call it automatically |

### Phase 3 — advanced gestures

| Hook                                                                        | Purpose                                 |
| --------------------------------------------------------------------------- | --------------------------------------- |
| [`usePinchToZoom`](../../src/platform/gestures/hooks/usePinchToZoom.ts)     | Two-finger pinch to scale an element    |
| [`useMultiTouch`](../../src/platform/gestures/hooks/useMultiTouch.ts)       | Track multi-finger touch state          |
| [`useDoubleTap`](../../src/platform/gestures/hooks/useDoubleTap.ts)         | Detect double-tap with timing tolerance |
| [`useCustomGesture`](../../src/platform/gestures/hooks/useCustomGesture.ts) | Recognize a registered custom pattern   |

***

## Components Catalog

| Component                                                                               | Use case                                                                                 |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| [`SwipeableSheet`](../../src/platform/gestures/components/SwipeableSheet.tsx)           | Sheet/drawer with swipe-to-dismiss + drag handle                                         |
| [`SwipeableListItem`](../../src/platform/gestures/components/SwipeableListItem.tsx)     | Low-level swipe-actions row (prefer `SwipeableCardShell` from `@/platform/gestures`)     |
| **`SwipeableCardShell`** *(new — see below)*                                            | Auto-mobile-detecting wrapper around `SwipeableListItem`; recommended for all list cards |
| [`PullToRefresh`](../../src/platform/gestures/components/PullToRefresh.tsx)             | Pull-to-refresh wrapper (always-on; controlled by `enabled` prop)                        |
| [`MobilePullToRefresh`](../../src/platform/gestures/components/MobilePullToRefresh.tsx) | Pull-to-refresh wrapper that auto-disables on non-mobile (use this for most lists)       |
| [`ZoomableImage`](../../src/platform/gestures/components/ZoomableImage.tsx)             | Pinch-zoomable image                                                                     |
| [`ZoomableImageViewer`](../../src/platform/gestures/components/ZoomableImageViewer.tsx) | Full-screen image viewer with pinch + double-tap zoom                                    |

### 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:

```tsx theme={null}
import { SwipeableCardShell } from '@/platform/gestures';

<SwipeableCardShell
  leftActions={[{ id: 'complete', label: 'Complete', icon: 'Check', colorClass: 'success', onClick: onComplete }]}
  rightActions={[{ id: 'delete', label: 'Delete', icon: 'Trash2', colorClass: 'destructive', onClick: onDelete }]}
>
  <MyCard ... />
</SwipeableCardShell>
```

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

```
Is this a list/row that needs row-level actions?
├─ Yes → SwipeableCardShell (mobile-only, auto-disables on desktop)
└─ No
   ├─ Bottom sheet / drawer that should swipe-to-close?
   │  └─ SwipeableSheet (also adds drag handle on bottom side)
   ├─ Scrollable list / page that should refresh on pull-down?
   │  └─ MobilePullToRefresh (auto-mobile)
   ├─ Detail/edit page where left-edge swipe should go back?
   │  └─ Already wired into the mobile <main>; no per-page work needed
   ├─ Image / media that should pinch-zoom?
   │  └─ ZoomableImage or ZoomableImageViewer (full-screen modal)
   └─ Anything else (custom pattern, multi-finger, double-tap)
      └─ Phase 3 hooks (use sparingly — almost everything is covered above)
```

### 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`](../../src/platform/settings/pages/GesturePreferencesPage.tsx)).

Schema stored in `pf_profiles.preferences.gestures`:

```typescript theme={null}
interface GesturePreferences {
  enabled: boolean;                      // master toggle
  sensitivity: 'low' | 'medium' | 'high';
  sensitivity_multiplier: number;        // 0.5 – 2.0
  haptic_feedback: {
    enabled: boolean;
    intensity: 'none' | 'light' | 'medium' | 'strong';
    patterns: { success: boolean; error: boolean; warning: boolean };
  };
  gesture_toggles: {
    swipe_to_dismiss: boolean;
    swipe_actions: boolean;
    pull_to_refresh: boolean;
    edge_swipe: boolean;
  };
}
```

How it cascades:

```text theme={null}
useSwipeToDismiss → useGestureIntegration ─┬─ preferences.enabled
useSwipeActions   ─┘                       ├─ gesture_toggles.<type>
usePullToRefresh  ─┐                       ├─ sensitivity_multiplier (clamped 0.1–5.0)
useEdgeSwipe      ─┘                       └─ haptic_feedback.intensity / patterns
```

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):

```tsx theme={null}
import { useHapticFeedback } from '@/platform/gestures';

const { vibrate } = useHapticFeedback();
vibrate('success');  // or 'tap' | 'selection' | 'warning' | 'error' | 'longPress'
```

Browser support matrix:

| Browser / OS                               | Vibration API | Notes                                                |
| ------------------------------------------ | ------------- | ---------------------------------------------------- |
| Chrome (Android)                           | ✅             | Honors `navigator.vibrate(pattern)`                  |
| Firefox (Android)                          | ✅             | Same                                                 |
| Edge (Android)                             | ✅             | Same                                                 |
| **Safari (iOS) — all versions**            | ❌             | Vibration API is a no-op; hook silently does nothing |
| Chrome / Edge / Firefox / Safari (desktop) | ❌             | No-op                                                |

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`](../../src/platform/settings/pages/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)

```tsx theme={null}
import { ZoomableImage } from '@/platform/gestures';

<ZoomableImage src={signedUrl} alt="Patient ID card" maxScale={4} />
```

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:

```tsx theme={null}
<CustomGestureProvider>
  <YourFeature />
</CustomGestureProvider>
```

Then register and consume:

```tsx theme={null}
const registry = useCustomGestureRegistry();
useEffect(() => {
  registry.register({
    id: 'circle',
    pattern: { type: 'shape', shape: 'circle' },
    onMatch: () => console.log('Circle drawn'),
  });
  return () => registry.unregister('circle');
}, [registry]);
```

This is intentionally low-traffic; for almost all product features, the named hooks above are simpler.

***

## Accessibility & Reduced Motion

### WCAG compliance

| Guideline                                                                                                         | Implementation                                                                         |
| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html)                                       | Every gesture has a keyboard alternative (`Esc`, `Delete`, `Enter`, `Tab`).            |
| [2.5.1 Pointer Gestures](https://www.w3.org/WAI/WCAG21/Understanding/pointer-gestures.html)                       | Single-pointer fallback (tap on action button, click button, button-trigger refresh).  |
| [2.5.2 Pointer Cancellation](https://www.w3.org/WAI/WCAG21/Understanding/pointer-cancellation.html)               | Drag-back-to-cancel works; below-threshold swipe snaps back.                           |
| [2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html) | `prefers-reduced-motion: reduce` skips snap/dismiss animations and reveals (jump-cut). |

### Keyboard alternatives

| Gesture                 | Keyboard alternative                                             |
| ----------------------- | ---------------------------------------------------------------- |
| Swipe to dismiss        | `Esc` (or browser back)                                          |
| Swipe-to-delete         | `Delete` key when focused                                        |
| Swipe to reveal actions | `Tab` to focus, `Enter`/`Space` to activate                      |
| Pull to refresh         | Refresh button, `Ctrl+R`, or browser refresh                     |
| Edge swipe back         | `Backspace`, browser back, on-screen back button                 |
| Pinch to zoom           | `+` / `-` keyboard shortcuts in viewers, on-screen zoom controls |

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

| Metric                        | Target          | Status                                    |
| ----------------------------- | --------------- | ----------------------------------------- |
| Gesture recognition latency   | \< 16 ms        | ✅                                         |
| Animation frame rate          | 60 fps          | ✅                                         |
| Bundle size (gestures module) | \< 15 KB gzip   | ✅ (`@use-gesture/react` is \~5.3 KB gzip) |
| Memory overhead               | \< 5 MB         | ✅                                         |
| Analytics flush overhead      | \< 1 ms / batch | ✅ (fire-and-forget)                       |

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

| Browser         | Version | Touch              | Mouse fallback |
| --------------- | ------- | ------------------ | -------------- |
| iOS Safari      | 14+     | ✅ Full             | n/a            |
| Android Chrome  | 90+     | ✅ Full             | n/a            |
| Desktop Chrome  | 90+     | n/a                | ✅              |
| Desktop Firefox | 90+     | n/a                | ✅              |
| Desktop Safari  | 14+     | n/a                | ✅              |
| Microsoft Edge  | 90+     | ✅ touch on hybrids | ✅              |

### 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`.

***

## Related Documentation

* [PF-37 Specification](../../specs/pf/specs/PF-37-mobile-swipe-gestures.md)
* [PF-37 Phase 2/3 Integration Doc](../architecture/integrations/phase-2-3-integration-pf-37.md)
* [Mobile Navigation Guide](./mobile-navigation-guide.md)
* [UI/UX Standards](./UI_UX_STANDARDS.md)
* Platform Gestures README
* [Gesture Preferences Page (UI)](../../src/platform/settings/pages/GesturePreferencesPage.tsx)
* [Gesture Analytics Page (UI)](../../src/platform/settings/pages/GestureAnalyticsPage.tsx)
* [WCAG 2.5.1 Pointer Gestures](https://www.w3.org/WAI/WCAG21/Understanding/pointer-gestures.html)
* [`@use-gesture/react` documentation](https://use-gesture.netlify.app/)

***

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