diff --git a/packages/app/src/components/ui/BottomSheet.vue b/packages/app/src/components/ui/BottomSheet.vue
new file mode 100644
index 00000000..892cc2d1
--- /dev/null
+++ b/packages/app/src/components/ui/BottomSheet.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/composables/useHaptics.ts b/packages/app/src/composables/useHaptics.ts
new file mode 100644
index 00000000..9a797f27
--- /dev/null
+++ b/packages/app/src/composables/useHaptics.ts
@@ -0,0 +1,30 @@
+import { useSettingsStore } from '@/stores/settings'
+
+export function useHaptics() {
+ const isSupported = 'vibrate' in navigator
+
+ function vibrate(pattern: number | number[]) {
+ if (!isSupported) return
+ try {
+ const settings = useSettingsStore()
+ if (settings.settings.notificationsEnabled === false) return // reuse notifications toggle
+ navigator.vibrate(pattern)
+ } catch { /* ignore */ }
+ }
+
+ function messageSend() { vibrate(10) }
+ function favoriteToggle() { vibrate(15) }
+ function error() { vibrate([50, 50, 50]) }
+ function pullRefresh() { vibrate(20) }
+ function longPress() { vibrate(20) }
+
+ return {
+ isSupported,
+ vibrate,
+ messageSend,
+ favoriteToggle,
+ error,
+ pullRefresh,
+ longPress,
+ }
+}
diff --git a/packages/app/src/composables/useLandscape.ts b/packages/app/src/composables/useLandscape.ts
new file mode 100644
index 00000000..ac8478e3
--- /dev/null
+++ b/packages/app/src/composables/useLandscape.ts
@@ -0,0 +1,33 @@
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+
+export function useLandscape() {
+ const windowWidth = ref(window.innerWidth)
+ const windowHeight = ref(window.innerHeight)
+
+ const isLandscape = computed(() => windowWidth.value > windowHeight.value)
+ const isMobile = computed(() => Math.min(windowWidth.value, windowHeight.value) < 768)
+ const isMobileLandscape = computed(() => isMobile.value && isLandscape.value)
+
+ function onResize() {
+ windowWidth.value = window.innerWidth
+ windowHeight.value = window.innerHeight
+ }
+
+ onMounted(() => {
+ window.addEventListener('resize', onResize)
+ window.addEventListener('orientationchange', onResize)
+ })
+
+ onUnmounted(() => {
+ window.removeEventListener('resize', onResize)
+ window.removeEventListener('orientationchange', onResize)
+ })
+
+ return {
+ isLandscape,
+ isMobile,
+ isMobileLandscape,
+ windowWidth,
+ windowHeight,
+ }
+}
diff --git a/packages/app/src/composables/usePinchZoom.ts b/packages/app/src/composables/usePinchZoom.ts
new file mode 100644
index 00000000..a1255b50
--- /dev/null
+++ b/packages/app/src/composables/usePinchZoom.ts
@@ -0,0 +1,73 @@
+import { ref, onMounted, onUnmounted, type Ref } from 'vue'
+
+export function usePinchZoom(elementRef: Ref) {
+ const scale = ref(1)
+ const translateX = ref(0)
+ const translateY = ref(0)
+
+ let initialDistance = 0
+ let initialScale = 1
+ let lastTapTime = 0
+
+ function getDistance(t1: Touch, t2: Touch): number {
+ return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
+ }
+
+ function onTouchStart(e: TouchEvent) {
+ if (e.touches.length === 2) {
+ e.preventDefault()
+ initialDistance = getDistance(e.touches[0], e.touches[1])
+ initialScale = scale.value
+ } else if (e.touches.length === 1) {
+ // Double-tap detection
+ const now = Date.now()
+ if (now - lastTapTime < 300) {
+ // Double-tap: reset
+ scale.value = 1
+ translateX.value = 0
+ translateY.value = 0
+ }
+ lastTapTime = now
+ }
+ }
+
+ function onTouchMove(e: TouchEvent) {
+ if (e.touches.length === 2) {
+ e.preventDefault()
+ const currentDistance = getDistance(e.touches[0], e.touches[1])
+ const newScale = initialScale * (currentDistance / initialDistance)
+ scale.value = Math.max(1, Math.min(4, newScale))
+ }
+ }
+
+ function onTouchEnd() {
+ if (scale.value <= 1.05) {
+ scale.value = 1
+ translateX.value = 0
+ translateY.value = 0
+ }
+ }
+
+ onMounted(() => {
+ const el = elementRef.value
+ if (!el) return
+ el.addEventListener('touchstart', onTouchStart, { passive: false })
+ el.addEventListener('touchmove', onTouchMove, { passive: false })
+ el.addEventListener('touchend', onTouchEnd)
+ })
+
+ onUnmounted(() => {
+ const el = elementRef.value
+ if (!el) return
+ el.removeEventListener('touchstart', onTouchStart)
+ el.removeEventListener('touchmove', onTouchMove)
+ el.removeEventListener('touchend', onTouchEnd)
+ })
+
+ return {
+ scale,
+ translateX,
+ translateY,
+ transform: () => `scale(${scale.value}) translate(${translateX.value}px, ${translateY.value}px)`,
+ }
+}
diff --git a/packages/app/src/composables/usePullToRefresh.ts b/packages/app/src/composables/usePullToRefresh.ts
new file mode 100644
index 00000000..bc426741
--- /dev/null
+++ b/packages/app/src/composables/usePullToRefresh.ts
@@ -0,0 +1,68 @@
+import { ref, onMounted, onUnmounted, type Ref } from 'vue'
+import { useHaptics } from './useHaptics'
+
+export function usePullToRefresh(
+ elementRef: Ref,
+ onRefresh: () => Promise | void
+) {
+ const isPulling = ref(false)
+ const isRefreshing = ref(false)
+ const pullDistance = ref(0)
+
+ const haptics = useHaptics()
+ const TRIGGER_DISTANCE = 80
+
+ let startY = 0
+ let tracking = false
+
+ function onTouchStart(e: TouchEvent) {
+ const el = elementRef.value
+ if (!el || el.scrollTop > 0) return
+ startY = e.touches[0].clientY
+ tracking = true
+ }
+
+ function onTouchMove(e: TouchEvent) {
+ if (!tracking || isRefreshing.value) return
+ const dy = e.touches[0].clientY - startY
+ if (dy < 0) {
+ tracking = false
+ return
+ }
+ pullDistance.value = Math.min(dy * 0.5, 120)
+ isPulling.value = pullDistance.value > 20
+ }
+
+ async function onTouchEnd() {
+ if (!tracking) return
+ tracking = false
+
+ if (pullDistance.value >= TRIGGER_DISTANCE) {
+ haptics.pullRefresh()
+ isRefreshing.value = true
+ await onRefresh()
+ isRefreshing.value = false
+ }
+
+ isPulling.value = false
+ pullDistance.value = 0
+ }
+
+ onMounted(() => {
+ const el = elementRef.value
+ if (!el) return
+ el.addEventListener('touchstart', onTouchStart, { passive: true })
+ el.addEventListener('touchmove', onTouchMove, { passive: true })
+ el.addEventListener('touchend', onTouchEnd)
+ })
+
+ onUnmounted(() => {
+ const el = elementRef.value
+ if (!el) return
+ el.removeEventListener('touchstart', onTouchStart)
+ el.removeEventListener('touchmove', onTouchMove)
+ el.removeEventListener('touchend', onTouchEnd)
+ })
+
+ return { isPulling, isRefreshing, pullDistance }
+}
diff --git a/packages/app/src/composables/useScrollMemory.ts b/packages/app/src/composables/useScrollMemory.ts
new file mode 100644
index 00000000..0d039051
--- /dev/null
+++ b/packages/app/src/composables/useScrollMemory.ts
@@ -0,0 +1,41 @@
+import { onMounted, onUnmounted, type Ref } from 'vue'
+
+const scrollPositions = new Map()
+
+export function useScrollMemory(key: string, elementRef: Ref) {
+ function savePosition() {
+ const el = elementRef.value
+ if (el) {
+ scrollPositions.set(key, el.scrollTop)
+ }
+ }
+
+ function restorePosition() {
+ const el = elementRef.value
+ if (!el) return
+ const saved = scrollPositions.get(key)
+ if (saved !== undefined) {
+ requestAnimationFrame(() => {
+ el.scrollTop = saved
+ })
+ }
+ }
+
+ onMounted(() => {
+ restorePosition()
+ const el = elementRef.value
+ if (el) {
+ el.addEventListener('scroll', savePosition, { passive: true })
+ }
+ })
+
+ onUnmounted(() => {
+ savePosition()
+ const el = elementRef.value
+ if (el) {
+ el.removeEventListener('scroll', savePosition)
+ }
+ })
+
+ return { savePosition, restorePosition }
+}
diff --git a/packages/app/src/composables/useSwipeNavigation.ts b/packages/app/src/composables/useSwipeNavigation.ts
new file mode 100644
index 00000000..74bfb3cb
--- /dev/null
+++ b/packages/app/src/composables/useSwipeNavigation.ts
@@ -0,0 +1,77 @@
+import { ref, onMounted, onUnmounted } from 'vue'
+
+export function useSwipeNavigation(options: {
+ onSwipeLeft?: () => void
+ onSwipeRight?: () => void
+ threshold?: number
+ velocityThreshold?: number
+}) {
+ const { threshold = 80, velocityThreshold = 0.3 } = options
+ const isSwiping = ref(false)
+ const swipeOffset = ref(0)
+
+ let startX = 0
+ let startY = 0
+ let startTime = 0
+ let tracking = false
+
+ function onTouchStart(e: TouchEvent) {
+ startX = e.touches[0].clientX
+ startY = e.touches[0].clientY
+ startTime = Date.now()
+ tracking = true
+ swipeOffset.value = 0
+ }
+
+ function onTouchMove(e: TouchEvent) {
+ if (!tracking) return
+ const dx = e.touches[0].clientX - startX
+ const dy = e.touches[0].clientY - startY
+
+ // Only track horizontal swipes
+ if (Math.abs(dy) > Math.abs(dx)) {
+ tracking = false
+ return
+ }
+
+ swipeOffset.value = dx
+ isSwiping.value = Math.abs(dx) > 20
+ }
+
+ function onTouchEnd() {
+ if (!tracking) {
+ isSwiping.value = false
+ swipeOffset.value = 0
+ return
+ }
+
+ const elapsed = Date.now() - startTime
+ const velocity = Math.abs(swipeOffset.value) / elapsed
+
+ if (Math.abs(swipeOffset.value) > threshold || velocity > velocityThreshold) {
+ if (swipeOffset.value > 0) {
+ options.onSwipeRight?.()
+ } else {
+ options.onSwipeLeft?.()
+ }
+ }
+
+ tracking = false
+ isSwiping.value = false
+ swipeOffset.value = 0
+ }
+
+ onMounted(() => {
+ document.addEventListener('touchstart', onTouchStart, { passive: true })
+ document.addEventListener('touchmove', onTouchMove, { passive: true })
+ document.addEventListener('touchend', onTouchEnd)
+ })
+
+ onUnmounted(() => {
+ document.removeEventListener('touchstart', onTouchStart)
+ document.removeEventListener('touchmove', onTouchMove)
+ document.removeEventListener('touchend', onTouchEnd)
+ })
+
+ return { isSwiping, swipeOffset }
+}
diff --git a/packages/app/src/composables/useWebShare.ts b/packages/app/src/composables/useWebShare.ts
new file mode 100644
index 00000000..881d1bbd
--- /dev/null
+++ b/packages/app/src/composables/useWebShare.ts
@@ -0,0 +1,48 @@
+import { ref } from 'vue'
+
+export function useWebShare() {
+ const isSupported = 'share' in navigator
+ const showFallback = ref(false)
+ const fallbackText = ref('')
+ const fallbackUrl = ref('')
+
+ async function share(data: { title?: string; text?: string; url?: string }) {
+ if (isSupported) {
+ try {
+ await navigator.share(data)
+ return true
+ } catch {
+ // User cancelled or not supported
+ }
+ }
+
+ // Fallback: show glass share sheet
+ fallbackText.value = data.text ?? data.title ?? ''
+ fallbackUrl.value = data.url ?? ''
+ showFallback.value = true
+ return false
+ }
+
+ async function copyToClipboard(text: string) {
+ try {
+ await navigator.clipboard.writeText(text)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ function closeFallback() {
+ showFallback.value = false
+ }
+
+ return {
+ isSupported,
+ showFallback,
+ fallbackText,
+ fallbackUrl,
+ share,
+ copyToClipboard,
+ closeFallback,
+ }
+}
diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css
index b02d9a55..334201b8 100644
--- a/packages/app/src/styles/main.css
+++ b/packages/app/src/styles/main.css
@@ -807,6 +807,41 @@ input:focus-visible {
}
}
+/* ===== iOS PWA SAFE AREA ===== */
+
+.safe-area-top {
+ padding-top: env(safe-area-inset-top, 0px);
+}
+
+.safe-area-bottom {
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.safe-area-left {
+ padding-left: env(safe-area-inset-left, 0px);
+}
+
+.safe-area-right {
+ padding-right: env(safe-area-inset-right, 0px);
+}
+
+.safe-area-inset {
+ padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
+}
+
+/* ===== LANDSCAPE LAYOUT ===== */
+
+@media (orientation: landscape) and (max-height: 500px) {
+ .landscape-split {
+ display: flex;
+ flex-direction: row;
+ }
+ .landscape-split > * {
+ flex: 1;
+ min-width: 0;
+ }
+}
+
/* ===== REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {