feat(mobile): mobile UX polish with bottom sheet, haptics, gestures, PWA (M16.1-M16.10)
- BottomSheet.vue: gesture-driven with 40/80/100% snap points, backdrop - Swipe navigation: left/right to switch conversations, 80px threshold - Pull-to-refresh: custom glass spinner, haptic on release - Haptic feedback: useHaptics() composable with pattern presets - Web Share API: native share with glass fallback sheet - Pinch-to-zoom: usePinchZoom() composable, 1x-4x, double-tap reset - iOS PWA: safe area insets CSS utilities, black-translucent status bar - Long-press context menus: 500ms trigger via bottom sheet - Scroll position memory: per-route Map, auto-restore on mount - Landscape mode: CSS split layout, orientation change detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
72f93e4e94
commit
6be4fbe081
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="isOpen" class="fixed inset-0 z-50" @click.self="close">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="close" />
|
||||||
|
|
||||||
|
<!-- Sheet -->
|
||||||
|
<div
|
||||||
|
ref="sheetRef"
|
||||||
|
class="absolute bottom-0 left-0 right-0 rounded-t-2xl bg-[#0a0a0a] border-t border-white/10 overflow-hidden"
|
||||||
|
:style="{ maxHeight: `${snapHeight}dvh`, height: `${currentHeight}dvh` }"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div class="flex justify-center py-3 cursor-grab" @mousedown="onMouseDown">
|
||||||
|
<div class="w-10 h-1 rounded-full bg-white/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="overflow-y-auto custom-scrollbar" :style="{ maxHeight: `calc(${currentHeight}dvh - 40px)` }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
snapPoints?: number[]
|
||||||
|
}>(), {
|
||||||
|
snapPoints: () => [40, 80, 100],
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
'update:isOpen': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sheetRef = ref<HTMLElement | null>(null)
|
||||||
|
const currentSnap = ref(0) // index into snapPoints
|
||||||
|
const currentHeight = ref(props.snapPoints[0])
|
||||||
|
const snapHeight = ref(100)
|
||||||
|
|
||||||
|
let startY = 0
|
||||||
|
let startHeight = 0
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
currentSnap.value = 0
|
||||||
|
currentHeight.value = props.snapPoints[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
startY = e.touches[0].clientY
|
||||||
|
startHeight = currentHeight.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
const dy = startY - e.touches[0].clientY
|
||||||
|
const vh = window.innerHeight / 100
|
||||||
|
const newHeight = startHeight + dy / vh
|
||||||
|
currentHeight.value = Math.max(10, Math.min(100, newHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
// Snap to nearest point
|
||||||
|
const nearest = props.snapPoints.reduce((prev, curr) =>
|
||||||
|
Math.abs(curr - currentHeight.value) < Math.abs(prev - currentHeight.value) ? curr : prev
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentHeight.value < 15) {
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHeight.value = nearest
|
||||||
|
currentSnap.value = props.snapPoints.indexOf(nearest)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
startY = e.clientY
|
||||||
|
startHeight = currentHeight.value
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const dy = startY - ev.clientY
|
||||||
|
const vh = window.innerHeight / 100
|
||||||
|
currentHeight.value = Math.max(10, Math.min(100, startHeight + dy / vh))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
onTouchEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet-enter-active {
|
||||||
|
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.sheet-leave-active {
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
.sheet-enter-from,
|
||||||
|
.sheet-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export function usePinchZoom(elementRef: Ref<HTMLElement | null>) {
|
||||||
|
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)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { useHaptics } from './useHaptics'
|
||||||
|
|
||||||
|
export function usePullToRefresh(
|
||||||
|
elementRef: Ref<HTMLElement | null>,
|
||||||
|
onRefresh: () => Promise<void> | 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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
|
||||||
|
const scrollPositions = new Map<string, number>()
|
||||||
|
|
||||||
|
export function useScrollMemory(key: string, elementRef: Ref<HTMLElement | null>) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ===== */
|
/* ===== REDUCED MOTION ===== */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
Reference in New Issue
Block a user