Mobile Touch Targets, Safe Areas & Gestures
Loquent’s Capacitor-wrapped mobile app follows Apple HIG iOS 17/18 standards for touch targets (≥44×44pt), safe area insets, and core gestures. All mobile-specific UI is gated behind Tailwind breakpoints — desktop remains unaffected.
Safe Area Utilities
Section titled “Safe Area Utilities”The tailwind.css file defines utilities that map to env(safe-area-inset-*) values. Use these on elements that sit near screen edges.
@utility pt-safe { padding-top: env(safe-area-inset-top, 0px); }@utility pb-safe { padding-bottom: env(safe-area-inset-bottom, 0px); }@utility pl-safe { padding-left: env(safe-area-inset-left, 0px); }@utility pr-safe { padding-right: env(safe-area-inset-right, 0px); }@utility pb-safe-kb { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--kb-h, 0px));}When to Use Each
Section titled “When to Use Each”| Scenario | Class | Why |
|---|---|---|
| Fixed/sticky element pinned to bottom | pb-safe-kb | Clears home indicator + keyboard |
| Centered modal backdrop | pb-safe-kb | Lifts centered card above indicator |
| App shell left/right edges (landscape) | pl-safe pr-safe | Avoids side notch overlap |
| Content inside an already-safe parent | None | Don’t double-pad |
Touch Target Floor (44px)
Section titled “Touch Target Floor (44px)”Apply min-h-11 md:min-h-0 to interactive elements that fall below 44px on mobile. The md: modifier restores desktop density at ≥768px.
button { class: "flex items-center gap-2 min-h-11 md:min-h-0 px-3 py-2 \ rounded-md hover:bg-muted active:bg-muted/70", onclick: move |_| { /* action */ }, Icon { size: 20 } span { "Label" }}Add pressed states for tactile feedback:
| Element | Class |
|---|---|
| Card rows | active:bg-muted/70 |
| Accent rows | active:bg-accent/40 |
| Table rows | active:bg-muted/70 |
EntityCard | active:translate-y-0 active:bg-muted/40 |
Pull-to-Refresh
Section titled “Pull-to-Refresh”The use_pull_to_refresh hook adds iOS-style pull-to-refresh on scrollable containers. It activates only when scrollTop === 0 and triggers a Light haptic at the 70px threshold.
Hook Signature
Section titled “Hook Signature”use crate::mods::mobile::{use_pull_to_refresh, PullToRefreshIndicator};
pub fn use_pull_to_refresh( scroll_container_id: &'static str, indicator_id: &'static str, on_refresh: Callback<()>,)#[component]pub fn MyListView() -> Element { let resource = use_resource(|| async { fetch_items().await });
let on_refresh = use_callback(move |_: ()| { resource.restart(); }); use_pull_to_refresh("my-list-scroll", "my-list-ptr", on_refresh);
rsx! { div { id: "my-list-scroll", class: "relative overflow-y-auto max-h-screen-kb", PullToRefreshIndicator { id: "my-list-ptr".to_string() } for item in items { ItemRow { item } } } }}Behavior
Section titled “Behavior”- User drags down from
scrollTop === 0 - Indicator slides in with rubber-band resistance (factor
0.55, capped at 100px) - At 70px threshold — Light haptic fires (once per gesture)
- Release past threshold →
on_refreshcallback fires, resource refetches - Indicator snaps back with a 280ms iOS spring (
cubic-bezier(0.16, 1, 0.3, 1))
The indicator renders with md:hidden — invisible on desktop. The hook coexists with infinite scroll because it only activates at scrollTop === 0.
SwipeableRow
Section titled “SwipeableRow”SwipeableRow wraps list row content and reveals trailing action tiles on horizontal swipe — like iOS Mail or Reminders.
Data Types
Section titled “Data Types”use crate::mods::mobile::{SwipeAction, SwipeableRow};
#[derive(Clone, PartialEq)]pub struct SwipeAction { pub label: String, // Short label below the icon pub icon: Element, // Lucide icon — no width/height classes pub bg_class: String, // e.g. "bg-destructive" pub fg_class: String, // e.g. "text-white" pub on_tap: Callback<()>,}let complete = SwipeAction { label: "Done".into(), icon: rsx! { Check {} }, bg_class: "bg-status-success".into(), fg_class: "text-white".into(), on_tap: Callback::new(move |_| mark_complete(item_id.clone())),};
let delete = SwipeAction { label: "Delete".into(), icon: rsx! { Trash2 {} }, bg_class: "bg-destructive".into(), fg_class: "text-destructive-foreground".into(), on_tap: Callback::new(move |_| delete_item(item_id.clone())),};
rsx! { SwipeableRow { trailing_actions: vec![complete, delete], Link { to: Route::ItemDetail { id }, ItemCard { item } } }}Gesture Thresholds
Section titled “Gesture Thresholds”Each action tile is 80px wide. Thresholds scale with the number of actions:
| Threshold | Value | Behavior |
|---|---|---|
| Reveal | panel_width / 2 | Snap to revealed state |
| Commit preview | panel_width × 1.5 | Last action expands, others fade |
| Commit | panel_width × 2 | Medium haptic, auto-fires last action |
Convention: The last action in the array is the commit lane — it fires on full-swipe. Order actions from least to most destructive: [Complete, Delete].
Direction Locking
Section titled “Direction Locking”The first 8px of pointer movement determines the gesture direction:
- Horizontal (
|dx| > |dy|) → swipe gesture locks in - Vertical (
|dy| > |dx|) → gesture cancels, scroll passes through
Desktop Behavior
Section titled “Desktop Behavior”The wrapper renders as md:contents — it collapses to just children with no gesture handlers or action panel. No desktop regressions.
Visual Details
Section titled “Visual Details”Action tiles include depth cues:
- Top highlight:
bg-gradient-to-b from-white/15 to-transparent - Bottom shadow:
bg-gradient-to-t from-black/10 to-transparent - Hairline border:
ring-1 ring-inset ring-white/10
When a row is open, tapping the row body closes it (with stop_propagation) instead of navigating.
Haptic Feedback
Section titled “Haptic Feedback”Both gestures use haptic_impact() from src/ui/haptic_ui.rs:
| Event | Haptic | When |
|---|---|---|
| Pull-to-refresh threshold | Light | Drag crosses 70px |
| Swipe reveal threshold | Light | Swipe crosses half panel width |
| Full-swipe commit | Medium | Swipe crosses 2× panel width |
Haptics call Capacitor.Plugins.Haptics.impact() — no-op on web.
Platform Conditionals
Section titled “Platform Conditionals”All mobile-specific UI uses CSS breakpoints, not runtime checks:
// Pull-to-refresh indicator — hidden on desktopclass: "md:hidden ..."
// SwipeableRow wrapper — collapses on desktopclass: "md:contents ..."
// Touch target floor — auto height on desktopclass: "min-h-11 md:min-h-0"The only runtime platform check is use_capacitor_back_button in src/shared/capacitor/back_button.rs, which activates Android hardware back navigation. iOS swipe-back is handled natively by the OS.