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
SwipeableCardShellprimitive and the deprecation of hand-rolled*Swipeable.tsxwrappers.- Documents
prefers-reduced-motionbehavior across Phase 1 hooks.- Adds cross-references to
/settings/gesturesand/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
- Quick Start
- Hooks Catalog
- Components Catalog
- Decision Tree — When to Use Each Pattern
- User Preferences (Phase 2)
- Haptic Feedback (Phase 2)
- Analytics (Phase 2)
- Pinch & Multi-touch (Phase 3)
- Custom Gesture Registry (Phase 3)
- Accessibility & Reduced Motion
- Performance Guidelines
- Browser Compatibility
- Troubleshooting
- 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:
- On desktop (or when
enabled === false): renderschildrenunwrapped (zero gesture overhead). - On mobile: wraps with
SwipeableListItemand forwards all action / threshold / className props. - Honors
useGestureIntegrationautomatically (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:
Haptic Feedback (Phase 2)
Use the new hook (preferred):
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 topf_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+beforeunloadflush.
/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 optionalsubtypeparameter oftrackGestureEvent. The TypeScript signature constrains it to aSwipeDirection-shaped value; do not widen it.
Pinch & Multi-touch (Phase 3)
- Image lightboxes (PF-98 headshot viewer; document image viewer in
@/platform/documents). - Charts you want to zoom into.
- Signature previews.
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:Accessibility & Reduced Motion
WCAG compliance
Keyboard alternatives
Reduced-motion behavior
Setprefers-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.
Performance Guidelines
Best practices
- Use CSS transforms only. All hooks emit
transform: translateX/Y(...)and never modifywidth/height/left/top. - Avoid re-renders during drag.
useDragcallbacks update component state viasetState; keep the consumer subtree small. - Don’t add
will-changeunless DevTools shows compositing issues — Safari has known memory regressions. - 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
- iOS Safari edge-swipe collision. The native iOS edge-back gesture lives in roughly the leftmost 30 px. Our
useEdgeSwipezone 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. - iOS Vibration API. Permanently unsupported.
useHapticFeedbackis a no-op there. - Android system back gesture (Android 10+). Coexists with our hooks; no conflicts seen in QA.
Troubleshooting
Gesture isn’t firing
- Check the user has not disabled the gesture in
/settings/gestures. - Check the
enabledprop / option (default istrue). - Check the threshold isn’t too high; lower it temporarily and retest.
- Make sure
touch-actionon a parent isn’tpan-x/pan-yblocking the relevant axis. - Try wrapping the parent in
<MobilePullToRefresh enabled>to confirm touch handling is reaching the area.
Janky animation
- Confirm only
transform/opacitychange during the gesture. - Open Chrome DevTools → Rendering → “Paint flashing” to spot non-composited repaints.
- Verify the consumer subtree isn’t re-rendering on every progress tick.
Gesture conflicts with native scroll
- Pull-to-refresh only fires at
scrollTop === 0. - Edge-swipe only fires inside the 20 px left zone.
- Sheet swipe-to-dismiss respects axis (
pan-xorpan-y).
Keyboard shortcut doesn’t work
- Ensure the swipeable container is focusable (
tabIndex={0}or focusable child). - Verify no parent calls
event.stopPropagation()on key events. - For destructive actions, the keyboard handler is on the action button, not the row — focus the button via
Tab.
Related Documentation
- PF-37 Specification
- PF-37 Phase 2/3 Integration Doc
- Mobile Navigation Guide
- UI/UX Standards
- Platform Gestures README
- Gesture Preferences Page (UI)
- Gesture Analytics Page (UI)
- WCAG 2.5.1 Pointer Gestures
@use-gesture/reactdocumentation
Document Status: Active Maintained By: Platform Foundation Team Last Reviewed: 2026-04-19