Replace the home hero with a spring carousel and default theme to light.

Adds Carousel under keep-alive-safe autoplay, peeks side cards past the container, and stops following the OS dark preference on first run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-16 10:24:59 +03:30
co-authored by Cursor
parent 5c7bb6672a
commit 284ced1c89
5 changed files with 553 additions and 8 deletions
+2 -1
View File
@@ -19,7 +19,8 @@
try { try {
var s = JSON.parse(localStorage.getItem('hedena-gold:appearance') || '{}'); var s = JSON.parse(localStorage.getItem('hedena-gold:appearance') || '{}');
var mq = function (q) { return window.matchMedia && matchMedia(q).matches; }; var mq = function (q) { return window.matchMedia && matchMedia(q).matches; };
var dark = s.theme === 'dark' || ((s.theme || 'system') === 'system' && mq('(prefers-color-scheme: dark)')); // Default is light when nothing is saved (matches appearanceStore).
var dark = s.theme === 'dark' || (s.theme === 'system' && mq('(prefers-color-scheme: dark)'));
var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)'); var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)');
var c = document.documentElement.classList; var c = document.documentElement.classList;
if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); } if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); }
@@ -0,0 +1,507 @@
<!--
Spring-physics card carousel for the home screen (see
docs/plans/2026-10-16-home-carousel-design.md).
Center card large, side cards scaled down; drag / flick settle with a
damped spring. Internal layout is an isolated `direction: ltr` subtree
(same rationale as GallerySlider) so translate/scale maths stay coherent
under the app's RTL chrome. Gestures are still screen-space.
-->
<template>
<section class="carousel" v-if="slides.length">
<div
ref="viewport"
class="carousel__viewport"
:class="{ 'is-dragging': dragging }"
:style="{ '--carousel-radius': `${borderRadius}px` }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<div ref="stage" class="carousel__stage">
<article
v-for="(card, i) in cards"
:key="card.key"
class="carousel__card"
:data-index="i"
:style="cardStyles[i]"
>
<img
v-if="card.image"
:src="card.image"
alt=""
class="carousel__art"
draggable="false"
/>
</article>
</div>
</div>
<div class="carousel__dots" v-if="slides.length > 1" dir="ltr">
<button
v-for="(slide, i) in slides"
:key="slide.key"
type="button"
class="carousel__dot"
:class="{ 'is-active': i === activeIndex }"
:aria-label="`اسلاید ${i + 1}`"
@click="goTo(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
shallowRef,
watch,
} from 'vue'
import type { ShowcaseSlide } from './ShowcaseSlider.vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
const props = withDefaults(
defineProps<{
slides: ShowcaseSlide[]
sideScale?: number
gap?: number
borderRadius?: number
stiffness?: number
damping?: number
autoplay?: boolean
autoplayInterval?: number
}>(),
{
// Slightly larger neighbours so peeks read as cards, not hairlines.
sideScale: 0.72,
gap: 3,
// Match ShowcaseSlider frame radius so the active card reads the same.
borderRadius: 22,
// Premium settle: low stiffness + higher damping = slow, heavy, almost no bounce.
stiffness: 70,
damping: 28,
autoplay: true,
autoplayInterval: 7500,
},
)
/** Fraction of stage width for each card — wider center, tighter side peeks. */
const CARD_WIDTH_RATIO = 0.92
const appearance = useAppearanceStore()
/** Pad copies so wrap never shows empty slots on either side. */
const cards = computed(() => {
const base = props.slides.filter((s) => s.image)
if (!base.length) return [] as { key: string; image: string }[]
const out: { key: string; image: string }[] = []
let n = 0
while (out.length < Math.max(6, base.length * 2)) {
for (const s of base) {
out.push({ key: `${s.key}#${n++}`, image: s.image as string })
}
}
return out
})
const viewport = ref<HTMLElement | null>(null)
const stage = ref<HTMLElement | null>(null)
const dragging = ref(false)
const activeIndex = ref(0)
type CardStyle = {
opacity: string
pointerEvents: 'auto' | 'none'
zIndex: string
transform: string
}
const cardStyles = shallowRef<CardStyle[]>([])
let position = 0
let target = 0
let velocity = 0
let isDragging = false
let startX = 0
let startPosition = 0
let lastX = 0
let lastTime = 0
let dragVelocity = 0
let totalDragDistance = 0
let pointerId: number | null = null
let rafId: number | null = null
let lastFrameTime = 0
let autoplayTimer: ReturnType<typeof setInterval> | undefined
let resizeObserver: ResizeObserver | null = null
function getWrappedOffset(index: number, pos: number, length: number) {
let diff = (index - pos) % length
if (diff < -length / 2) diff += length
if (diff >= length / 2) diff -= length
return diff
}
function getActiveIndex() {
const len = props.slides.filter((s) => s.image).length || 1
return ((Math.round(target) % len) + len) % len
}
function notifyChange() {
activeIndex.value = getActiveIndex()
}
function render() {
const list = cards.value
const total = list.length
if (!total) {
cardStyles.value = []
return
}
// Center card is CARD_WIDTH_RATIO of stage; scaled neighbours peek beside it.
const stageWidth = stage.value?.clientWidth || 380
const baseCardWidth = stageWidth * CARD_WIDTH_RATIO
const sideScale = props.sideScale
const gap = props.gap
const stepDistance = baseCardWidth * 0.5 + baseCardWidth * sideScale * 0.5 + gap
const next: CardStyle[] = new Array(total)
for (let i = 0; i < total; i++) {
const offset = getWrappedOffset(i, position, total)
const absOffset = Math.abs(offset)
const clamped = Math.min(absOffset, 1)
const scale = 1 - clamped * (1 - sideScale)
const translateX = offset * stepDistance
const isVisible = absOffset < 2.1
next[i] = {
opacity: isVisible ? '1' : '0',
pointerEvents: isVisible ? 'auto' : 'none',
zIndex: String(100 - Math.round(absOffset * 20)),
transform: `translate3d(calc(-50% + ${translateX.toFixed(2)}px), 0, 0) scale(${scale.toFixed(4)})`,
}
}
cardStyles.value = next
}
function startSpringLoop() {
if (appearance.isReducedMotion) {
position = target
velocity = 0
render()
rafId = null
return
}
if (rafId != null) cancelAnimationFrame(rafId)
lastFrameTime = performance.now()
const tick = (now: number) => {
const dt = Math.min((now - lastFrameTime) / 1000, 0.064)
lastFrameTime = now
const displacement = position - target
const springForce = -props.stiffness * displacement
const dampingForce = -props.damping * velocity
const acceleration = springForce + dampingForce
velocity += acceleration * dt
position += velocity * dt
render()
if (Math.abs(velocity) < 0.002 && Math.abs(target - position) < 0.001) {
position = target
velocity = 0
render()
rafId = null
return
}
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
}
function onPointerDown(e: PointerEvent) {
if (cards.value.length < 2 || pointerId !== null) return
if (e.button !== undefined && e.button !== 0) return
const el = viewport.value
if (!el) return
pointerId = e.pointerId
isDragging = true
dragging.value = true
stopAutoplay()
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
startX = e.clientX
lastX = e.clientX
startPosition = position
velocity = 0
dragVelocity = 0
totalDragDistance = 0
lastTime = performance.now()
el.setPointerCapture?.(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging || e.pointerId !== pointerId) return
const now = performance.now()
const dt = Math.max(1, now - lastTime)
const dx = e.clientX - startX
const stepDx = e.clientX - lastX
totalDragDistance = Math.abs(dx)
const cardWidth = (stage.value?.clientWidth ?? 0) * CARD_WIDTH_RATIO || 320
position = startPosition - dx / cardWidth
dragVelocity = -(stepDx / cardWidth) / (dt / 1000)
lastX = e.clientX
lastTime = now
render()
}
function onPointerUp(e: PointerEvent) {
if (!isDragging || e.pointerId !== pointerId) return
isDragging = false
dragging.value = false
pointerId = null
const total = cards.value.length
// Tap a side card → spring to it
if (totalDragDistance < 6 && e.target instanceof Element) {
const clicked = e.target.closest('.carousel__card') as HTMLElement | null
if (clicked) {
const cardIdx = Number(clicked.dataset.index)
if (!Number.isNaN(cardIdx)) {
const offset = getWrappedOffset(cardIdx, position, total)
if (Math.abs(offset) > 0.25) {
target = Math.round(position + offset)
startSpringLoop()
notifyChange()
if (props.autoplay) startAutoplay()
return
}
}
}
}
velocity = dragVelocity
const projected = position + velocity * 0.16
target = Math.round(projected)
startSpringLoop()
notifyChange()
if (props.autoplay) startAutoplay()
}
function goTo(baseIndex: number) {
const len = props.slides.filter((s) => s.image).length
if (len < 2) return
const currentBase = getActiveIndex()
let diff = baseIndex - currentBase
if (diff > len / 2) diff -= len
if (diff < -len / 2) diff += len
target = Math.round(target) + diff
startSpringLoop()
notifyChange()
}
/** False while the home tab is keep-alive-cached off-screen. */
let pageActive = true
function startAutoplay() {
stopAutoplay()
if (!pageActive) return
if (!props.autoplay || appearance.isReducedMotion || props.slides.length < 2) return
autoplayTimer = setInterval(() => {
if (!isDragging && pageActive) {
target = Math.round(target) + 1
startSpringLoop()
notifyChange()
}
}, props.autoplayInterval)
}
function stopAutoplay() {
if (autoplayTimer) {
clearInterval(autoplayTimer)
autoplayTimer = undefined
}
}
/** Pause motion while cached off-screen so return doesn't catch up many slides. */
function pauseForLeave() {
pageActive = false
stopAutoplay()
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
position = target
velocity = 0
isDragging = false
dragging.value = false
render()
notifyChange()
}
function resumeOnEnter() {
pageActive = true
render()
if (props.autoplay) startAutoplay()
}
function resetToStart() {
position = 0
target = 0
velocity = 0
notifyChange()
render()
}
onMounted(() => {
pageActive = true
resetToStart()
if (stage.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => render())
resizeObserver.observe(stage.value)
}
if (props.autoplay) startAutoplay()
})
// HomeView is keep-alive'd — without these, setInterval keeps advancing
// target while the user is on another tab, then the spring "catches up"
// through several slides the moment they return.
onDeactivated(pauseForLeave)
onActivated(resumeOnEnter)
watch(
() => props.slides,
() => {
resetToStart()
},
)
watch(
() => [props.sideScale, props.gap, props.stiffness, props.damping] as const,
() => render(),
)
watch(
() => props.autoplay,
(on) => {
if (on) startAutoplay()
else stopAutoplay()
},
)
onBeforeUnmount(() => {
pauseForLeave()
resizeObserver?.disconnect()
if (pointerId !== null && viewport.value) {
try {
viewport.value.releasePointerCapture(pointerId)
} catch {
/* detached */
}
}
})
</script>
<style scoped lang="less">
// Break out of Container `px-[16px]` so side peeks sit at the screen edge
// instead of leaving empty gutters inside the padded column.
.carousel {
width: calc(100% + 32px);
margin-inline: -16px;
}
.carousel__viewport {
position: relative;
width: 100%;
overflow: hidden;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
cursor: grab;
// Top: room for bottom-anchored scale. Bottom: room for card drop-shadow
// so overflow:hidden does not shear the soft edge.
padding: 10px 0 28px;
direction: ltr;
}
.carousel__viewport.is-dragging {
cursor: grabbing;
}
// Same aspect as ShowcaseSlider; cards ~92% wide so neighbours hug the center.
.carousel__stage {
position: relative;
width: 100%;
aspect-ratio: 320 / 208;
}
.carousel__card {
position: absolute;
bottom: 0;
left: 50%;
width: 92%;
height: 100%;
transform-origin: center bottom;
will-change: transform;
border-radius: var(--carousel-radius, 22px);
overflow: hidden;
background: @hairlineSoft;
box-shadow: 0 14px 28px -12px rgba(20, 22, 26, 0.28);
}
.carousel__art {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.carousel__dots {
display: flex;
justify-content: center;
gap: 6px;
// Re-align with the padded column after the bleed; extra air under the stage.
padding: 4px 16px 8px;
}
.carousel__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
cursor: pointer;
border-radius: 999px;
background: @hairline;
transition:
width 0.42s cubic-bezier(0.16, 0.86, 0.3, 1),
background 0.3s ease;
}
.carousel__dot.is-active {
width: 20px;
background: @wine;
}
</style>
+4 -3
View File
@@ -30,9 +30,10 @@ const darkQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefer
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null) const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
function load(): Stored { function load(): Stored {
// First run: theme follows the system; motion follows the OS's own // First run: light theme by default; motion follows the OS's own
// "reduce motion" accessibility setting. // "reduce motion" accessibility setting. Users can still pick system/dark
const fallback: Stored = { theme: 'system', reduceMotion: !!motionQuery()?.matches } // in Appearance settings.
const fallback: Stored = { theme: 'light', reduceMotion: !!motionQuery()?.matches }
try { try {
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY) const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
if (!raw) return fallback if (!raw) return fallback
+3 -4
View File
@@ -4,11 +4,10 @@
Layout intent — shop first, account second: Layout intent — shop first, account second:
1. a slim brand bar with search + cart 1. a slim brand bar with search + cart
2. the live 18k rate strip (real: GET /pricing/latest) 2. the live 18k rate strip (real: GET /pricing/latest)
3. an editorial showcase hero built from real categories 3. spring card carousel hero (admin slider images)
4. the category rail (real: derived from GET /catalog/products) 4. the category rail (real: derived from GET /catalog/products)
5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms) 5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
6. "تازه‌ها" — full-width horizontal ProductRow list 6. "تازه‌ها" — full-width horizontal ProductRow list
7. the account block (real: GET /customers/:id when signed in)
Data honesty, carried over from the previous version deliberately: Data honesty, carried over from the previous version deliberately:
- the API exposes no curated home payload, no promotions and no per-product - the API exposes no curated home payload, no promotions and no per-product
@@ -43,7 +42,7 @@
<container class="home__body" :class="{ 'is-revealed': revealed }"> <container class="home__body" :class="{ 'is-revealed': revealed }">
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" /> <RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
<ShowcaseSlider <Carousel
v-if="slides.length" v-if="slides.length"
:slides="slides" :slides="slides"
class="home__hero reveal" class="home__hero reveal"
@@ -115,8 +114,8 @@ import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant' import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue' import Icon from '../components/icon/Icon.vue'
import RateStrip from '../components/home/RateStrip.vue' import RateStrip from '../components/home/RateStrip.vue'
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue' import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
import Carousel from '../components/home/Carousel.vue'
import CategoryRail from '../components/home/CategoryRail.vue' import CategoryRail from '../components/home/CategoryRail.vue'
import ProductCard from '../components/product/ProductCard.vue' import ProductCard from '../components/product/ProductCard.vue'
import ProductRow from '../components/product/ProductRow.vue' import ProductRow from '../components/product/ProductRow.vue'
@@ -0,0 +1,37 @@
# Home Carousel — spring physics cards
**Status:** approved
**Date:** 2026-10-16
**Scope:** `apps/user-app` home (`Carousel.vue` below `ShowcaseSlider`)
## Intent
A Framer-style spring carousel under the existing crossfade hero: center card large, side cards scaled down, infinite wrap, drag/flick with spring settle. Same admin slide images as `ShowcaseSlider`.
## Decisions
| Choice | Decision |
|--------|----------|
| Stack | Pure Vue SFC (no Swiper / Motion) |
| Name / path | `components/home/Carousel.vue` |
| Placement | Immediately below `ShowcaseSlider`, above `CategoryRail` |
| Data | Same `slides` computed from admin home slider |
| Props | `slides` (+ optional spring/layout knobs with defaults) |
| Dots | Yes, pill style aligned with showcase |
| Out of scope | Demo inspector panel, Prev/Next chrome, new admin API |
## Motion defaults
- `sideScale`: 0.60
- `gap`: 14px
- `borderRadius`: 22px (match ShowcaseSlider)
- Frame `aspect-ratio`: `320 / 208` (match ShowcaseSlider); center card full-bleed
- `stiffness`: 180
- `damping`: 22
- Internal layout `direction: ltr` (gesture still screen-space)
- Autoplay off by default; respect reduced-motion when enabled later
## Non-goals
Replacing `ShowcaseSlider`, inventing slide destinations, or adding external animation libraries.