feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet

Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
This commit is contained in:
s.esmaeili
2026-10-04 16:18:56 +03:30
parent c05352d6fd
commit f441ca8289
102 changed files with 4602 additions and 1053 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator>
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
<div class="loading" v-else>
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
<div class="loading keep-motion" v-else>
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
@@ -84,6 +84,14 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
object-fit: contain;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .cats__mark img {
background: @photoMat;
border-radius: 12px;
}
.cats__title {
font-size: 11px;
font-weight: 600;
@@ -50,6 +50,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
export interface HeroSlide {
key: string
@@ -77,6 +78,7 @@ let startX = 0
let pointerId: number | null = null
let timer: ReturnType<typeof setInterval> | undefined
const appearance = useAppearanceStore()
const SWIPE_THRESHOLD = 46
@@ -183,7 +185,9 @@ function stop() {
}
function restart() {
stop()
if (props.autoplay && count.value > 1) {
// No autoplay in reduced-motion mode: a slide changing on its own every
// six seconds is exactly the background motion that mode turns off.
if (props.autoplay && !appearance.isReducedMotion && count.value > 1) {
timer = setInterval(next, 6000)
}
}
@@ -11,7 +11,7 @@
number.
-->
<template>
<article class="panel" @click="emit('open', value)">
<article class="panel" data-product-origin @click="emit('open', value)">
<div class="panel__art">
<img v-lazy="image" :alt="title" />
</div>
@@ -72,6 +72,14 @@ function format(v: number) {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .panel__art img {
background: @photoMat;
border-radius: 12px;
}
.panel__title {
margin: 14px 0 0;
font-size: 14px;
@@ -41,7 +41,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
background: @ink;
background: @heroSurface;
color: #fff;
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
}
@@ -61,8 +61,8 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
padding: 6px 12px 6px 10px;
border-radius: 999px;
box-shadow: none;
background: fade(@primary, 6%);
border: 1px solid fade(@primary, 10%);
background: alpha(@primary, 6%);
border: 1px solid alpha(@primary, 10%);
color: @primary;
}
@@ -70,7 +70,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
font-size: 11px;
font-weight: 500;
// Softer than the number, so the eye lands on the rate first.
color: fade(@primary, 62%);
color: alpha(@primary, 62%);
}
.rate-strip--compact .rate-strip__num {
@@ -95,7 +95,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
.rate-strip--compact .rate-strip__value--muted {
font-size: 11px;
color: fade(@primary, 45%);
color: alpha(@primary, 45%);
}
.rate-strip--compact .rate-strip__unit,
@@ -8,7 +8,7 @@
order time, not stored on the product — printing one here would be invented.
-->
<template>
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
<article class="card" :class="{ 'card--wide': wide }" data-product-origin @click="emit('open', value)">
<div class="card__art">
<img v-lazy="image" :alt="title" />
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
@@ -136,6 +136,14 @@ const wageLabel = computed(() => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .ci__art img {
background: @photoMat;
border-radius: 12px;
}
.ci__artEmpty {
width: 100%;
height: 100%;
@@ -86,6 +86,14 @@ onMounted(async () => {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .oi__art img {
background: @photoMat;
border-radius: 12px;
}
.oi__artEmpty {
width: 100%;
height: 100%;
@@ -35,7 +35,7 @@
layout, minus an invented number.
-->
<template>
<article class="pc" @click="open">
<article ref="root" class="pc" data-product-origin @click="open">
<div class="pc__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pc__artEmpty" aria-hidden="true">
@@ -65,25 +65,13 @@
<span class="pc__wage">{{ wageLabel(wage) }}٪ اجرت</span>
</div>
</article>
<Drawer
v-model:visible="drawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="drawer" :id="id" :key="id" @closeDrawer="drawer = false" />
</Drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
@@ -95,9 +83,10 @@ const props = defineProps<{ value: ProductSummary; wageBefore?: number | null }>
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const drawer = ref(false)
// The detail opens as a morph out of this card — see ProductMorph.vue.
const root = ref<HTMLElement | null>(null)
function open() {
drawer.value = true
openProduct(props.value, root.value)
}
// Whole percents print bare (۱۰٪), fractional ones keep two places (۱۰.۵٪) —
@@ -143,6 +132,14 @@ function wageLabel(v: number) {
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .pc__art img {
background: @photoMat;
border-radius: 12px;
}
.pc__artEmpty {
width: 100%;
height: 100%;
@@ -165,7 +162,7 @@ function wageLabel(v: number) {
cursor: pointer;
border-radius: 50%;
color: @inkFaint;
background: rgba(255, 255, 255, 0.75);
background: alpha(@canvas, 75%);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
@@ -185,7 +182,7 @@ function wageLabel(v: number) {
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
color: @onInk;
background: @ink;
}
@@ -0,0 +1,541 @@
<!--
The product detail overlay, opened as a container transform out of the card
that was tapped — the replacement for the full-height bottom Drawer, which
slid a blank sheet up from off-screen and then jumped when the detail fetch
landed and the page finally rendered.
Three layers move, and only one of them is ever repainted:
- the SHAPE: a plain fill in the card's colour, revealed by animating
`clip-path: inset(... round r)` from the card's exact rect and corner
radius out to the whole screen. It holds no content, so repainting it
per frame costs next to nothing.
- the PAGE (SingleProduct, mounted fully formed — it is seeded with the
card's own ProductSummary, so there is nothing to wait for): it grows out
of the card's centre and fades in, by transform and opacity only, so the
compositor moves it without re-rasterising it.
- the GHOST: one fixed <img> of the card's photo, flown by transform from
where the card drew it to where the gallery draws it.
An earlier version clipped the page itself; that forced a full-screen repaint
of the whole page (photos, blurred buttons and all) on every frame and lagged
on phones. Backdrop blurs inside the page are also switched off while
anything is moving, for the same reason, and the scroll lock and the page's
background refresh both wait until the motion is over.
Closing plays the morph back into the card if it is still on screen, and
falls back to a short drop-and-fade if it is not (the list was scrolled or
re-rendered). `prefers-reduced-motion` gets a plain crossfade.
Mounted once, in App.vue. Opened through composables/useProductMorph.ts.
-->
<template>
<Teleport to="body">
<div v-if="current" class="pm" :class="{ 'is-moving': moving, 'is-closing': closing }" dir="rtl">
<div ref="veil" class="pm__veil" aria-hidden="true"></div>
<div ref="shape" class="pm__shape" aria-hidden="true"></div>
<div ref="sheet" class="pm__sheet no-scrollbar">
<SingleProduct
:id="current.product.id"
:key="current.product.id"
:initial="current.product"
@closeDrawer="close"
/>
</div>
<img v-if="ghostSrc" ref="ghost" class="pm__ghost" :src="ghostSrc" alt="" aria-hidden="true" decoding="sync" />
</div>
</Teleport>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import SingleProduct from './SingleProduct.vue'
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
const route = useRoute()
const current = shallowRef<ProductMorphRequest | null>(null)
const ghostSrc = ref<string | null>(null)
const moving = ref(false)
const closing = ref(false)
const veil = ref<HTMLElement | null>(null)
const shape = ref<HTMLElement | null>(null)
const sheet = ref<HTMLElement | null>(null)
const ghost = ref<HTMLImageElement | null>(null)
// ── Timing ────────────────────────────────────────────────────
// Opening rides a lightly-damped spring (≈1% overshoot): quick off the mark —
// most of the travel is done in the first ~40% — with a short, soft settle.
// Expressed as a CSS `linear()` easing so it still runs on the compositor like
// any other easing; browsers without `linear()` get an ease-out-quint, which
// reads almost the same minus the settle. Closing is shorter and never
// overshoots — going back should feel like letting go, not a second entrance.
const OPEN_MS = 420
const CLOSE_MS = 300
const FALLBACK_OPEN_MS = 300
const FALLBACK_CLOSE_MS = 220
function springEasing(zeta = 0.82, span = 7, samples = 32): string {
const wd = Math.sqrt(1 - zeta * zeta)
const points: string[] = []
for (let i = 0; i <= samples; i++) {
const t = (i / samples) * span
const x = i === samples ? 1 : 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t))
points.push(String(+x.toFixed(4)))
}
return `linear(${points.join(', ')})`
}
const supportsLinear = typeof CSS !== 'undefined' && CSS.supports('animation-timing-function', 'linear(0, 1)')
const EASE_OPEN = supportsLinear ? springEasing() : 'cubic-bezier(0.22, 1, 0.36, 1)'
const EASE_CLOSE = 'cubic-bezier(0.32, 0.72, 0, 1)'
const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'
// How far the page starts shrunk into the card it grows out of.
const PAGE_FROM_SCALE = 0.9
// The in-app "fast & light" switch (html.motion-reduced, see
// stores/appearanceStore.ts) or the OS accessibility setting.
const reducedMotion = () =>
document.documentElement.classList.contains('motion-reduced') ||
(typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
// ── Geometry ──────────────────────────────────────────────────
interface Box {
x: number
y: number
w: number
h: number
}
// Where an <img> actually draws its picture: its box minus padding, then the
// object-fit: contain letterboxing every product photo in this app uses. The
// ghost has to start and end on the drawn picture, not on the element's box,
// or it would visibly snap at both ends.
function drawnBox(img: HTMLImageElement, nw: number, nh: number): Box {
const r = img.getBoundingClientRect()
const cs = getComputedStyle(img)
const pl = parseFloat(cs.paddingLeft) || 0
const pr = parseFloat(cs.paddingRight) || 0
const pt = parseFloat(cs.paddingTop) || 0
const pb = parseFloat(cs.paddingBottom) || 0
const x = r.left + pl
const y = r.top + pt
const w = r.width - pl - pr
const h = r.height - pt - pb
if (cs.objectFit !== 'contain' && cs.objectFit !== 'scale-down') return { x, y, w, h }
let s = Math.min(w / nw, h / nh)
if (cs.objectFit === 'scale-down') s = Math.min(s, 1)
return { x: x + (w - nw * s) / 2, y: y + (h - nh * s) / 2, w: nw * s, h: nh * s }
}
function insetOf(r: DOMRect, radius: string): string {
const vw = document.documentElement.clientWidth
const vh = window.innerHeight
return `inset(${r.top}px ${vw - r.right}px ${vh - r.bottom}px ${r.left}px round ${radius})`
}
const FULL = 'inset(0px 0px 0px 0px round 0px)'
function onScreen(r: DOMRect): boolean {
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight
}
function loaded(img: HTMLImageElement | null | undefined): img is HTMLImageElement {
return !!img && img.complete && img.naturalWidth > 0
}
function sameSrc(a: HTMLImageElement, b: HTMLImageElement): boolean {
return (a.currentSrc || a.src) === (b.currentSrc || b.src)
}
// The gallery image currently showing — the slider keeps every slide in its
// track, so pick the one whose centre sits inside the stage.
function galleryImage(): HTMLImageElement | null {
const stage = sheet.value?.querySelector<HTMLElement>('.pd__stage')
if (!stage) return null
const s = stage.getBoundingClientRect()
const imgs = stage.querySelectorAll<HTMLImageElement>('img')
for (const img of imgs) {
const r = img.getBoundingClientRect()
const cx = r.left + r.width / 2
if (cx > s.left && cx < s.right) return img
}
return null
}
function originImage(origin: HTMLElement | null): HTMLImageElement | null {
return origin?.querySelector<HTMLImageElement>('img') ?? null
}
// Colours and corner radius the shape morphs between: the card's own fill
// (falling back to the page's when the card is transparent) and the page's.
function palette(origin: HTMLElement) {
const pd = sheet.value?.querySelector<HTMLElement>('.pd')
const pageBg = (pd && getComputedStyle(pd).backgroundColor) || '#fff'
const cs = getComputedStyle(origin)
const cardBg = cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? pageBg : cs.backgroundColor
return { pageBg, cardBg, radius: cs.borderRadius || '0px' }
}
function centreOf(r: DOMRect): string {
return `${r.left + r.width / 2}px ${r.top + r.height / 2}px`
}
function ghostFrames(from: Box, to: Box): Keyframe[] {
const t = (b: Box) => `translate(${b.x - to.x}px, ${b.y - to.y}px) scale(${b.w / to.w}, ${b.h / to.h})`
return [{ transform: t(from) }, { transform: t(to) }]
}
function placeGhost(el: HTMLImageElement, at: Box) {
el.style.left = `${at.x}px`
el.style.top = `${at.y}px`
el.style.width = `${at.w}px`
el.style.height = `${at.h}px`
}
// ── Run bookkeeping ───────────────────────────────────────────
// Each open/close takes a new generation; a run that has been superseded by a
// later one skips its own teardown.
let gen = 0
let running: Animation[] = []
let phase: 'idle' | 'opening' | 'open' | 'closing' = 'idle'
let hiddenImgs: HTMLElement[] = []
function hide(el: HTMLElement | null) {
if (!el) return
el.style.opacity = '0'
hiddenImgs.push(el)
}
function unhideAll() {
hiddenImgs.forEach((el) => (el.style.opacity = ''))
hiddenImgs = []
}
function stopRunning() {
running.forEach((a) => a.cancel())
running = []
}
async function settled(anims: Animation[]) {
running = anims
await Promise.all(anims.map((a) => a.finished.catch(() => undefined)))
}
// The page behind must not scroll while the overlay is up. Set on <html>, not
// <body>: SingleProduct's own image preview locks and unlocks <body>, and must
// not be able to release this one. Only applied once the overlay is fully
// open — toggling overflow on the root re-lays-out the page underneath, which
// is a cost the first frame of the morph should not pay.
function lockScroll(on: boolean) {
document.documentElement.style.overflow = on ? 'hidden' : ''
}
// ── Open ──────────────────────────────────────────────────────
async function open(req: ProductMorphRequest) {
const my = ++gen
stopRunning()
unhideAll()
closing.value = false
moving.value = true
phase = 'opening'
const originImg = originImage(req.origin)
const originRect = req.origin?.getBoundingClientRect()
const canMorph = !reducedMotion() && !!req.origin && !!originRect && onScreen(originRect)
ghostSrc.value = canMorph && loaded(originImg) ? originImg.currentSrc || originImg.src : null
current.value = req
// Mounted, laid out and measured inside the same task as the tap — the first
// painted frame is already the first frame of the morph.
await nextTick()
if (my !== gen || !sheet.value || !veil.value) return
let anims: Animation[]
if (canMorph && originRect && req.origin) {
anims = morphOpen(req.origin, originRect, originImg)
} else if (reducedMotion()) {
anims = [
sheet.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
veil.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
]
} else {
anims = [
sheet.value.animate(
{ transform: ['translateY(6%)', 'none'], opacity: [0, 1] },
{ duration: FALLBACK_OPEN_MS, easing: EASE_OUT, fill: 'both' },
),
veil.value.animate({ opacity: [0, 1] }, { duration: FALLBACK_OPEN_MS, easing: 'ease-out', fill: 'both' }),
]
}
await settled(anims)
if (my !== gen) return
// The end state of every animation equals the resting CSS, so dropping them
// changes nothing on screen — it just leaves no clip-path or composited
// layers behind while the page is being read and scrolled.
stopRunning()
unhideAll()
ghostSrc.value = null
moving.value = false
phase = 'open'
lockScroll(true)
}
function morphOpen(origin: HTMLElement, originRect: DOMRect, originImg: HTMLImageElement | null): Animation[] {
const sheetEl = sheet.value!
const shapeEl = shape.value!
const veilEl = veil.value!
const { pageBg, cardBg, radius } = palette(origin)
// Measure everything BEFORE any animation starts: once the page is scaled,
// the gallery's rect would come back scaled too.
const targetImg = galleryImage()
const ghostEl = ghost.value
let flight: { from: Box; to: Box } | null = null
if (ghostEl && loaded(originImg) && targetImg && sameSrc(originImg, targetImg)) {
const nw = originImg.naturalWidth
const nh = originImg.naturalHeight
const to = drawnBox(targetImg, nw, nh)
if (to.w > 0 && to.h > 0) flight = { from: drawnBox(originImg, nw, nh), to }
}
sheetEl.style.transformOrigin = centreOf(originRect)
const anims: Animation[] = [
shapeEl.animate(
{ clipPath: [insetOf(originRect, radius), FULL], backgroundColor: [cardBg, pageBg] },
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
),
veilEl.animate({ opacity: [0, 1] }, { duration: OPEN_MS * 0.6, easing: 'ease-out', fill: 'both' }),
// The page grows out of the card's centre alongside the shape, and fades
// in a beat behind it — the first frames read as the card lifting, then
// the page arriving inside it.
sheetEl.animate(
{ transform: [`scale(${PAGE_FROM_SCALE})`, 'none'] },
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
),
sheetEl.animate(
{ opacity: [0, 1] },
{ duration: OPEN_MS * 0.55, delay: OPEN_MS * 0.1, easing: 'cubic-bezier(0.3, 0, 0.2, 1)', fill: 'both' },
),
]
if (ghostEl && flight && targetImg) {
placeGhost(ghostEl, flight.to)
hide(targetImg)
anims.push(ghostEl.animate(ghostFrames(flight.from, flight.to), { duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' }))
} else {
ghostSrc.value = null
}
return anims
}
// ── Close ─────────────────────────────────────────────────────
async function close() {
if (!current.value || phase === 'closing') return
const my = ++gen
const wasOpening = phase === 'opening'
phase = 'closing'
closing.value = true
moving.value = true
let anims: Animation[]
if (wasOpening && running.length) {
// Closed before it finished opening: play the same motion back from
// wherever it got to, rather than jumping to open and morphing from there.
running.forEach((a) => a.reverse())
anims = running
} else {
stopRunning()
unhideAll()
anims = await closeAnimations()
}
await settled(anims)
if (my !== gen) return
teardown()
}
async function closeAnimations(): Promise<Animation[]> {
const sheetEl = sheet.value
const shapeEl = shape.value
const veilEl = veil.value
if (!sheetEl || !shapeEl || !veilEl) return []
if (reducedMotion()) {
return [
sheetEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
veilEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
]
}
const origin = current.value?.origin ?? null
const originRect = origin?.isConnected ? origin.getBoundingClientRect() : null
if (!origin || !originRect || !onScreen(originRect)) {
return [
sheetEl.animate(
{ transform: ['none', 'translateY(6%)'], opacity: [1, 0] },
{ duration: FALLBACK_CLOSE_MS, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
),
veilEl.animate({ opacity: [1, 0] }, { duration: FALLBACK_CLOSE_MS, easing: 'ease-in', fill: 'forwards' }),
]
}
const { pageBg, cardBg, radius } = palette(origin)
// Measured before anything moves, for the same reason as on the way in.
const originImg = originImage(origin)
const fromImg = galleryImage()
let flight: { from: Box; to: Box } | null = null
if (loaded(originImg) && fromImg && sameSrc(originImg, fromImg) && onScreen(fromImg.getBoundingClientRect())) {
const nw = originImg.naturalWidth
const nh = originImg.naturalHeight
const from = drawnBox(fromImg, nw, nh)
if (from.w > 0 && from.h > 0) flight = { from, to: drawnBox(originImg, nw, nh) }
}
if (flight && originImg) {
ghostSrc.value = originImg.currentSrc || originImg.src
await nextTick()
}
sheetEl.style.transformOrigin = centreOf(originRect)
const anims: Animation[] = [
// The shape fades over its last stretch so the real card — title and all —
// crossfades in under it instead of popping in after it lands.
shapeEl.animate(
[
{ clipPath: FULL, backgroundColor: pageBg, opacity: 1 },
{ opacity: 1, offset: 0.7 },
{ clipPath: insetOf(originRect, radius), backgroundColor: cardBg, opacity: 0 },
],
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
),
sheetEl.animate(
{ transform: ['none', `scale(${PAGE_FROM_SCALE})`] },
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
),
sheetEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS * 0.45, easing: 'ease-in', fill: 'forwards' }),
veilEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS, easing: 'ease-out', fill: 'forwards' }),
]
// The photo flies home only if the gallery is still on screen and showing
// the same picture the card does; otherwise the shape alone carries it back.
const ghostEl = ghost.value
if (ghostEl && flight && fromImg) {
// Based on the gallery box this time, so the frames run identity → card.
placeGhost(ghostEl, flight.from)
hide(fromImg)
anims.push(
ghostEl.animate(ghostFrames(flight.to, flight.from).reverse(), {
duration: CLOSE_MS,
easing: EASE_CLOSE,
fill: 'forwards',
}),
)
}
return anims
}
function teardown() {
stopRunning()
unhideAll()
ghostSrc.value = null
moving.value = false
closing.value = false
phase = 'idle'
current.value = null
lockScroll(false)
if (productMorph.value) productMorph.value = null
}
// ── Wiring ────────────────────────────────────────────────────
watch(productMorph, (req) => {
if (req) open(req)
else if (current.value) close()
})
// Anything inside the page that navigates (the cart button, the category pill)
// leaves the overlay behind; it goes with the screen it was opened over.
watch(
() => route.fullPath,
() => {
if (!current.value) return
++gen
teardown()
},
)
onBeforeUnmount(() => {
stopRunning()
unhideAll()
lockScroll(false)
})
</script>
<style scoped lang="less">
.pm {
position: fixed;
inset: 0;
// Same layer the Drawer it replaces sat on; SingleProduct's own full-screen
// image preview is 2000 and must stay above this.
z-index: 1000;
}
.pm.is-closing {
pointer-events: none;
}
.pm__veil {
position: absolute;
inset: 0;
background: rgba(14, 15, 18, 0.32);
}
// Only drawn while something is moving; at rest the page covers it anyway.
.pm__shape {
position: absolute;
inset: 0;
display: none;
}
.pm.is-moving .pm__shape {
display: block;
}
// The scroller for pages that do not scroll themselves (v1's SingleProduct
// grows with its content; v2's is its own 100vh scroller and never overflows
// this). The page's transform is applied HERE rather than on anything inside:
// this box is already pinned to the viewport, so a `position: fixed` action bar
// inside the page stays exactly where it is while the transform is running.
.pm__sheet {
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
// Backdrop blurs are re-computed on every frame their layer moves, and the page
// has around ten of them (header buttons, action bar…). Their surfaces are
// already ~90% opaque, so dropping the blur for the length of the motion is
// invisible — and it is the single biggest cost the moving page had.
.pm.is-moving :deep(*) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
.pm__ghost {
position: fixed;
z-index: 2;
transform-origin: 0 0;
object-fit: fill;
pointer-events: none;
will-change: transform;
}
</style>
@@ -321,8 +321,11 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ id: string }>()
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
const props = defineProps<{ id: string; initial?: ProductSummary | null }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
const router = useRouter()
@@ -330,7 +333,7 @@ const favorites = useFavoritesStore()
const filters = useFiltersStore()
const cart = useCartStore()
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
// Used two ways, as in legacy: embedded in the ProductMorph overlay by the product
// cards, and as a standalone page for deep links (/product/:id). In the drawer
// case the parent listens for closeDrawer; on the routed page there is no
// parent, so fall back to history.back().
@@ -366,7 +369,7 @@ function onScroll() {
headerSolid.value = el.scrollTop > Math.max(galleryHeight - HEADER_SOLID_OFFSET_PX, 0)
}
const productDetail = useProductDetail(props.id)
const productDetail = useProductDetail(props.id, props.initial)
const loading = productDetail.loading
const flag = productDetail.flag
@@ -532,7 +535,7 @@ onBeforeUnmount(() => {
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
background: @canvas;
background: @page;
// Momentum scrolling on iOS, and stop a scroll at either end from chaining
// out to the page (or the drawer) behind.
-webkit-overflow-scrolling: touch;
@@ -579,7 +582,7 @@ onBeforeUnmount(() => {
}
.pd__header.is-solid {
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom-color: @hairline;
}
@@ -640,7 +643,7 @@ onBeforeUnmount(() => {
cursor: pointer;
text-decoration: none;
color: @ink;
background: rgba(255, 255, 255, 0.9);
background: alpha(@canvas, 90%);
backdrop-filter: blur(10px) saturate(140%);
box-shadow: 0 10px 26px -16px rgba(20, 22, 26, 0.7);
transition: transform 0.18s ease, color 0.28s ease, background 0.28s ease,
@@ -694,7 +697,7 @@ onBeforeUnmount(() => {
min-height: 320px;
max-height: 460px;
overflow: hidden;
background: @hairlineSoft;
background: @photoStage;
// Horizontal drags are the gallery's; vertical ones still scroll the page.
touch-action: pan-y;
}
@@ -1003,7 +1006,7 @@ onBeforeUnmount(() => {
}
.pd__stock.is-in {
color: @green;
background: fade(@green, 8%);
background: alpha(@green, 8%);
}
.pd__stock.is-pre {
color: @wine;
@@ -1085,7 +1088,7 @@ onBeforeUnmount(() => {
color: @inkFaint;
}
.pd__figure--accent .pd__figureLabel {
color: fade(@wine, 65%);
color: alpha(@wine, 65%);
}
/* ── Variant chips ───────────────────────────────────────── */
@@ -1122,7 +1125,7 @@ onBeforeUnmount(() => {
transform: scale(0.95);
}
.pd__chip.is-on {
color: #fff;
color: @onInk;
background: @ink;
border-color: @ink;
}
@@ -1218,7 +1221,7 @@ onBeforeUnmount(() => {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.9);
background: alpha(@canvas, 90%);
backdrop-filter: blur(18px) saturate(140%);
border-top: 1px solid @hairline;
}
@@ -1304,7 +1307,7 @@ onBeforeUnmount(() => {
font-weight: 800;
white-space: nowrap;
text-decoration: none;
color: #fff;
color: @onInk;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
transition: transform 0.18s ease, opacity 0.18s ease;
+474 -179
View File
@@ -13,7 +13,7 @@
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound). Still uses ant-design-vue's Slider for the actual
drag/range behaviour, restyled via :deep() to the wine accent.
drag/range behaviour, restyled to the app's accent.
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
@@ -23,72 +23,139 @@
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
Top padding (120px) accounts for PageBar + the search input row with no
chips row below it (chips are hidden while this sheet is open) — same
bar-chrome math SearchView/OrdersView use for their own #below content.
Presentation: a bottom sheet over a dimmed, see-through backdrop. Full width
and attached to the bottom edge — not a floating island, so it has room to
take more filters later — and only as tall as its content, up to 88% of the
screen, past which the body scrolls under a fixed header and action bar.
It replaced an in-place swap of the search page's body (results hidden with
v-show, the bar retitled and its back button hijacked, the tab bar hidden),
which was neither a page nor a modal. Closes on the backdrop, a swipe down
on its header, the × button, or the hardware/browser back
(close-on-popstate).
STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
identical to user-app's Filters.vue; each app styles the same markup in its
own design language (this one: white sheet with the 32px top radius the
detail page's sheet uses, borderless @hairlineSoft panels via DropDown, @ink
pill buttons and chips, squircle controls, @wine accent).
Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
while the sheet is open, so dragging the gram slider no longer re-filters and
re-renders the whole result grid behind it on every pointer move (it did, and
that was most of the lag). Dismissing without applying discards the draft.
"پاک کردن فیلتر‌ها" clears only what this sheet owns (brands, gram range) —
it used to reset the whole query, wiping the search text and the category
chip along with it.
-->
<template>
<section class="filt">
<div class="filt__body">
<DropDown text="فیلتر بر اساس برند">
<div v-if="availableBrands.length" class="filt__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="filt__chip"
:class="{ 'is-on': modelValue.brands.includes(item.id) }"
@click="toggleBrand(item.id)"
>
{{ item.title }}
<Popup
:show="show"
position="bottom"
teleport="body"
transition="fSheet-slide"
class="fSheet"
overlay-class="fSheet__overlay"
close-on-popstate
@update:show="emit('update:show', $event)"
@closed="dragY = 0"
>
<div
class="fSheet__card"
:class="{ 'is-dragging': dragging }"
:style="cardStyle"
dir="rtl"
role="dialog"
aria-modal="true"
aria-labelledby="fSheet-title"
>
<!-- The drag handle area. Swipe-to-dismiss starts only here, so a
vertical swipe on the body still scrolls the filters. -->
<header
class="fSheet__head"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<span class="fSheet__grip" aria-hidden="true"></span>
<div class="fSheet__headRow">
<h3 id="fSheet-title" class="fSheet__title">
فیلترها
<span v-if="draftCount" class="fSheet__count">{{ draftCount }}</span>
</h3>
<button type="button" class="fSheet__close" aria-label="بستن" @click="close">
<Icon icon="vuesax-linear:add" size="18" />
</button>
</div>
<p v-else class="filt__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
</header>
<DropDown text="فیلتر بر اساس گرم">
<div class="filt__gram">
<Slider
v-model:value="(modelValue.limit_gram as unknown as [number, number])"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
/>
<div class="filt__gramFigures" v-if="modelValue.limit_gram.length > 0">
<span class="filt__gramFigure">
<i>کمترین</i>
{{ modelValue.limit_gram[0] }} گرم
</span>
<span class="filt__gramFigure">
<i>بیشترین</i>
{{ modelValue.limit_gram[1] }} گرم
</span>
<div class="fSheet__body">
<DropDown text="برند" default-open>
<div v-if="availableBrands.length" class="fSheet__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="fSheet__chip"
:class="{ 'is-on': draftBrands.includes(item.id) }"
:aria-pressed="draftBrands.includes(item.id)"
@click="toggleBrand(item.id)"
>
{{ item.title }}
</button>
</div>
</div>
</DropDown>
</div>
<p v-else class="fSheet__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
<div class="filt__bar">
<div class="filt__barInner">
<button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلتر‌ها</button>
<button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
<DropDown text="وزن (گرم)" default-open>
<div class="fSheet__gram">
<!-- Tooltips off: they render in ant's own layer (z-index 1070),
underneath this sheet (vant's 2000+), and the figures below
already show both values live. -->
<Slider
:value="draftGram"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
:tooltip-visible="false"
@update:value="onGram"
/>
<div class="fSheet__gramFigures">
<span class="fSheet__gramFigure">
<i>کمترین</i>
<b>{{ fmt(draftGram[0]) }}</b> گرم
</span>
<span class="fSheet__gramFigure">
<i>بیشترین</i>
<b>{{ fmt(draftGram[1]) }}</b> گرم
</span>
</div>
</div>
</DropDown>
</div>
<footer class="fSheet__bar">
<button type="button" class="fSheet__clear" @click="clear">پاک کردن</button>
<button type="button" class="fSheet__apply" @click="apply">اعمال فیلتر</button>
</footer>
</div>
</section>
</Popup>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { Popup } from 'vant'
import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>(), {})
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const emit = defineEmits<{
(e: 'change', value: { resetFilters: boolean }): void
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
@@ -106,185 +173,413 @@ const gramBounds = computed((): [number, number] => {
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
// The Slider's `range` mode needs a real [start, end] value to place its two
// handles — filtersStore's default `limit_gram: []` has neither, so until
// this seeds it, ant-design-vue's Slider has nothing to derive handle
// positions from and renders no handles at all. Only seeds while still
// empty, so it never clobbers a value the user already dragged.
// ── Draft ─────────────────────────────────────────────────────
const draftBrands = ref<string[]>([])
// Always a real [start, end] pair: ant-design-vue's range Slider places no
// handles at all without one.
const draftGram = ref<[number, number]>([...gramBounds.value])
// Until the user drags, the range follows the catalogue's bounds (they can
// still arrive after the sheet first opens, while the list is loading).
let gramTouched = false
function loadDraft() {
draftBrands.value = [...props.modelValue.brands]
const g = props.modelValue.limit_gram
gramTouched = g.length === 2
draftGram.value = g.length === 2 ? [g[0], g[1]] : [...gramBounds.value]
}
watch(
gramBounds,
(bounds) => {
if (props.modelValue.limit_gram.length === 0) {
props.modelValue.limit_gram = [...bounds]
}
() => props.show,
(open) => {
if (open) loadDraft()
},
{ immediate: true },
)
const toggleBrand = (id: string) => {
const brands = props.modelValue.brands
const index = brands.indexOf(id)
if (index === -1) brands.push(id)
else brands.splice(index, 1)
watch(gramBounds, (bounds) => {
if (!gramTouched) draftGram.value = [...bounds]
})
function onGram(value: [number, number] | number) {
if (!Array.isArray(value)) return
draftGram.value = [value[0], value[1]]
gramTouched = true
}
const closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = []
if (sendQuery) {
if (props.modelValue.brands.length > 0) filterKeys.push('brands')
if (
props.modelValue.limit_gram[0] !== undefined &&
props.modelValue.limit_gram[1] !== undefined &&
(props.modelValue.limit_gram[0] !== gramBounds.value[0] || props.modelValue.limit_gram[1] !== gramBounds.value[1])
) {
filterKeys.push('limit_gram')
}
}
if (sendQuery && filterKeys.length > 0) {
emit('update:filterKeys', filterKeys)
emit('change', { resetFilters: false })
} else {
emit('update:filterKeys', [])
emit('change', { resetFilters: true })
}
// A range that still spans the whole catalogue filters nothing out, so it is
// not counted as a filter (and is stored as "no range" on apply).
const gramActive = computed(() => {
const [lo, hi] = draftGram.value
const [min, max] = gramBounds.value
return Math.abs(lo - min) > 0.001 || Math.abs(hi - max) > 0.001
})
const draftCount = computed(() => draftBrands.value.length + (gramActive.value ? 1 : 0))
function toggleBrand(id: string) {
const i = draftBrands.value.indexOf(id)
draftBrands.value = i === -1 ? [...draftBrands.value, id] : draftBrands.value.filter((b) => b !== id)
}
function fmt(n: number) {
return n % 1 === 0 ? String(n) : String(+n.toFixed(2))
}
// ── Commit ────────────────────────────────────────────────────
// Always assigns fresh arrays rather than mutating the store's in place — see
// filtersStore's reset() for why sharing them went wrong.
function apply() {
const q = props.modelValue
q.brands = [...draftBrands.value]
q.limit_gram = gramActive.value ? [...draftGram.value] : []
const keys: string[] = []
if (q.brands.length) keys.push('brands')
if (q.limit_gram.length) keys.push('limit_gram')
emit('update:filterKeys', keys)
close()
}
function clear() {
draftBrands.value = []
draftGram.value = [...gramBounds.value]
gramTouched = false
apply()
}
function close() {
emit('update:show', false)
}
// ── Swipe down to dismiss (header only) ───────────────────────
// The sheet follows the finger down, rubber-bands up, and on release either
// springs back or leaves from where it was let go.
const DISMISS_DISTANCE = 90
const DISMISS_VELOCITY = 0.5 // px per ms
const dragY = ref(0)
const dragging = ref(false)
let startY = 0
let startTime = 0
const cardStyle = computed(() => (dragY.value ? { transform: `translate3d(0, ${dragY.value}px, 0)` } : undefined))
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).closest('button')) return
startY = e.touches[0].clientY
startTime = Date.now()
dragging.value = true
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
const dy = e.touches[0].clientY - startY
dragY.value = dy > 0 ? dy : dy * 0.12
}
function onTouchEnd() {
if (!dragging.value) return
dragging.value = false
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
if (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY)) close()
else dragY.value = 0
}
</script>
<style scoped lang="less">
.filt {
background: @canvas;
min-height: 100vh;
<style lang="less">
// Global, not scoped: the popup is teleported to <body>. Everything is under
// the fSheet prefix.
/* ── Shell (same in both apps) ──────────────────────────────── */
// See-through dim, no blur: blurring the page behind a fading overlay is
// recomputed every frame and is what made sheets janky on phones.
.fSheet__overlay {
background: rgba(20, 22, 26, 0.42);
}
.filt__body {
.fSheet.van-popup {
background: transparent;
overflow: visible;
}
// The iOS sheet curve: fast out of the gate, long soft landing. Transform only.
.fSheet-slide-enter-active,
.fSheet-slide-leave-active {
will-change: transform;
}
.fSheet-slide-enter-active {
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.fSheet-slide-leave-active {
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
}
.fSheet-slide-enter-from,
.fSheet-slide-leave-to {
transform: translate3d(0, 100%, 0);
}
.fSheet__card {
position: relative;
// Phone: edge to edge. Wider screens: a centred column, same max width the
// app's pages use.
max-width: 768px;
margin: 0 auto;
padding: 120px 16px 110px;
max-height: 88vh;
max-height: 88dvh;
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
&.is-dragging {
transition: none;
}
}
.fSheet__head {
flex: none;
touch-action: none;
}
.fSheet__grip {
display: block;
margin: 0 auto;
}
.fSheet__headRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filt__chips {
.fSheet__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.fSheet__count {
min-width: 20px;
height: 20px;
padding: 0 6px;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
color: #fff;
}
.fSheet__close {
display: grid;
place-items: center;
border: 0;
cursor: pointer;
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.fSheet__close:active {
transform: rotate(45deg) scale(0.92);
}
.fSheet__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
}
.fSheet__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filt__chip {
padding: 8px 14px;
border-radius: 999px;
.fSheet__chip {
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, transform 0.15s ease;
}
.fSheet__chip:active {
transform: scale(0.96);
}
.filt__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.filt__chip:active {
transform: scale(0.97);
}
.filt__empty {
.fSheet__empty {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.filt__gram {
padding: 4px 6px 0;
}
.filt__gram :deep(.ant-slider-track) {
background-color: @wine;
}
.filt__gram :deep(.ant-slider-handle) {
border-color: @wine;
}
.filt__gram :deep(.ant-slider-rail) {
background-color: @hairline;
}
.filt__gramFigures {
.fSheet__gramFigures {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 18px;
}
.filt__gramFigure {
font-size: 12px;
.fSheet__gramFigure i {
font-style: normal;
}
.fSheet__bar {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.fSheet__clear,
.fSheet__apply {
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: transform 0.15s ease;
}
.fSheet__clear:active,
.fSheet__apply:active {
transform: scale(0.98);
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// The detail page's sheet: white, 32px top radius, the same grip.
.fSheet__card {
border-radius: 32px 32px 0 0;
background: @canvas;
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
}
.fSheet__head {
padding: 8px 18px 12px;
}
.fSheet__grip {
width: 38px;
height: 4px;
margin-bottom: 12px;
border-radius: 999px;
background: @hairline;
}
.fSheet__title {
font-size: 16px;
font-weight: 700;
color: @ink;
}
.filt__gramFigure i {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-end: 4px;
.fSheet__count {
background: @wine;
}
.filt__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 16px;
}
.filt__barInner {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.filt__clear,
.filt__apply {
flex: 1;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
transition: transform 0.15s ease;
}
.filt__clear {
color: @inkSoft;
// PageBar's / the detail header's squircle control.
.fSheet__close {
width: 38px;
height: 38px;
border-radius: 12px;
color: @ink;
background: @hairlineSoft;
}
.fSheet__body {
gap: 10px;
padding: 2px 16px 16px;
}
// The detail page's variant chips.
.fSheet__chip {
height: 34px;
padding: 0 14px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 700;
color: @inkMuted;
background: @canvas;
border: 1px solid @hairline;
}
.fSheet__chip.is-on {
color: @onInk;
background: @ink;
border-color: @ink;
}
.fSheet__gram {
padding: 2px 4px 0;
}
// A step darker than @hairline: the rail sits on DropDown's @hairlineSoft panel.
.fSheet__gram .ant-slider-rail {
background-color: @hairlineStrong;
}
.fSheet__gram .ant-slider-track {
background-color: @wine;
}
.fSheet__gram .ant-slider-handle {
border-color: @wine;
}
.fSheet__gramFigures {
padding-top: 12px;
}
// The detail page's figure tiles, on white since they sit on a grey panel.
.fSheet__gramFigure {
flex: 1;
display: flex;
align-items: baseline;
justify-content: center;
gap: 5px;
padding: 9px 12px;
border-radius: 13px;
background: @canvas;
font-size: 11px;
color: @inkFaint;
}
.fSheet__gramFigure i {
font-size: 10.5px;
font-weight: 500;
}
.fSheet__gramFigure b {
font-size: 14px;
font-weight: 800;
color: @ink;
direction: ltr;
}
// The detail page's action bar: an outlined pill beside the dark primary pill.
.fSheet__bar {
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: @canvas;
border-top: 1px solid @hairline;
}
.fSheet__clear,
.fSheet__apply {
height: 48px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 800;
}
.fSheet__clear {
flex: 0 0 auto;
padding: 0 22px;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
}
.filt__apply {
color: #fff;
.fSheet__apply {
flex: 1;
border: 0;
color: @onInk;
background: @ink;
}
.filt__clear:active,
.filt__apply:active {
transform: scale(0.98);
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
}
</style>
+110 -45
View File
@@ -4,20 +4,44 @@
raw Tailwind utility classes with legacy tokens (text-primary,
border-lightGray/20), the one accordion in the app that didn't match the
LESS-token look every other surface uses.
FIXED — the collapse itself. It used to v-if the body in and out and animate
`max-height` from 0 to a hard-coded 240px, which broke three ways: content
taller than 240px was cut off for the whole animation and then jumped to full
height at the end; content shorter than 240px opened fast and then closed
with a dead pause (the first stretch of the shrink happened above the real
height, so nothing moved); and the close used `animation: … reverse` on a
`leave-to` class, which Vue does not time leave transitions by, so it
usually just snapped shut. The body's own padding also popped in and out.
Now the body stays mounted and the panel animates `grid-template-rows` from
0fr to 1fr — the browser interpolates to the content's real height, whatever
it is, in both directions, with no measuring and no cap. All the padding
below the header lives inside the clipped row, so it is part of what
animates. Browsers too old to interpolate grid rows (pre-2022) simply open
and close without the slide.
STRUCTURE IS SHARED, LOOK IS NOT: the template, script and the collapse
mechanics are identical in user-app and user-app-v2; only the "Look" block
at the bottom differs, each in its own app's design language.
-->
<template>
<div class="dd" :class="{ 'is-open': show }">
<button type="button" class="dd__head" @click="show = !show">
<button type="button" class="dd__head" :aria-expanded="show" @click="show = !show">
<h3 class="dd__title">
<slot name="text">{{ text }}</slot>
</h3>
<Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
<span class="dd__chevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-down" size="16" />
</span>
</button>
<transition name="drop" mode="out-in" appear>
<div class="dd__body" v-if="show">
<slot />
<div class="dd__panel" :inert="!show">
<div class="dd__clip">
<div class="dd__body">
<slot />
</div>
</div>
</transition>
</div>
</div>
</template>
@@ -25,21 +49,16 @@
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false)
const props = withDefaults(defineProps<{ text?: string; defaultOpen?: boolean }>(), { defaultOpen: false })
const show = ref(props.defaultOpen)
</script>
<style scoped lang="less">
.dd {
padding: 15px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.dd.is-open {
border-color: @wineSoft;
}
/* ── Mechanics (same in both apps) ──────────────────────────── */
// One curve for the whole collapse: the iOS sheet curve the app's sheets use —
// quick off the mark, soft landing.
@dd-ease: cubic-bezier(0.32, 0.72, 0, 1);
@dd-time: 0.34s;
.dd__head {
display: flex;
@@ -48,50 +67,96 @@ const show = ref(false)
gap: 12px;
width: 100%;
border: 0;
padding: 0;
cursor: pointer;
background: none;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
}
.dd__title {
margin: 0;
font-size: 13px;
}
.dd__chevron {
flex: 0 0 auto;
display: grid;
place-items: center;
transition: transform @dd-time @dd-ease, color 0.2s ease, background-color 0.2s ease;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
}
.dd__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows @dd-time @dd-ease;
}
.dd.is-open .dd__panel {
grid-template-rows: 1fr;
}
// The row's content box: `min-height: 0` is what lets the 0fr row actually
// shrink below the content's height, and the clip hides what is outside it.
.dd__clip {
min-height: 0;
overflow: hidden;
}
// Fades and settles in a touch behind the height, and out a touch ahead of it,
// so the content never shows half-cut at the moving edge.
.dd__body {
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.18s ease, transform @dd-time @dd-ease;
}
.dd.is-open .dd__body {
opacity: 1;
transform: none;
transition: opacity 0.24s ease 0.06s, transform @dd-time @dd-ease;
}
@media (prefers-reduced-motion: reduce) {
.dd__panel,
.dd__body,
.dd__chevron {
transition: none !important;
}
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// A borderless @hairlineSoft panel, the detail page's spec-table radius; the
// chevron sits in a small white squircle that fills @ink when open, the way
// the app's chips do when chosen.
.dd {
border-radius: 22px;
background: @hairlineSoft;
}
.dd__head {
padding: 14px 16px;
}
.dd__title {
font-size: 14px;
font-weight: 700;
color: @ink;
}
.dd__chevron {
flex: 0 0 auto;
color: @inkFaint;
transition: transform 0.2s ease;
width: 28px;
height: 28px;
border-radius: 9px;
color: @inkMuted;
background: @canvas;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
color: @wine;
color: @onInk;
background: @ink;
}
.dd__body {
overflow: hidden;
padding-top: 15px;
}
</style>
<style lang="less">
.drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
}
.drop-leave-to {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1) reverse;
}
@keyframes drop {
0% {
max-height: 0;
}
100% {
max-height: 240px;
}
padding: 0 16px 16px;
}
</style>
+108 -211
View File
@@ -1,14 +1,26 @@
<!--
Floating "island" action sheet: the app's confirm/notice modal.
The app's confirm/notice action sheet (logout, place order, sign in, update,
done).
Replaces DialogVant / DialogVantOneButton (centered Vant dialogs whose
content was poured into img/title/button slots). The sheet rises from the
bottom but stays inset from the screen edges with rounded corners all
round, the same island shape as the ant-Drawer `island-sheet` in
style.less, so the two kinds of sheet read as one family.
content was poured into img/title/button slots).
Structured props instead of slots for the buttons, so every call site gets
the same button styling without passing its own markup and color classes:
RESTYLED into user-app-v2's own design language. It used to be a floating
island inset from the screen edges, with a tinted dot-grid header, dashed
rings, twinkling sparks, gradient buttons with a sheen and a per-sheet
colour (navy / gold / green) — none of which appears anywhere else in this
app. It now uses the same parts as the rest of v2:
- a white sheet attached to the bottom edge, with the 32px top radius and
grip of the product detail sheet and the search filter sheet;
- the illustration centred on a borderless @hairlineSoft panel — the
ProductPanel / catalogue-card treatment of a piece of jewellery;
- a centred title and description under it, as on those cards;
- the detail page's action bar: an outlined pill beside a filled pill.
The filled pill is @ink, or @wine for the destructive tone (logout).
The component name, props, events and illustrations are unchanged, so no
call site had to change.
Usage:
<IslandSheet
v-model:show="open"
@@ -21,18 +33,18 @@
/>
- `art` picks an illustration from SheetArt, and with it the sheet's tone
(header wash + primary button); `tone` overrides it. An `#art` slot can
(the filled button's colour); `tone` overrides it. An `#art` slot can
replace the illustration entirely.
- No `cancel-text` → a single-button sheet.
- No `cancel-text` → a single, full-width button.
- `closeOnClickOverlay` (default true) also governs swipe-down-to-dismiss;
with it off the sheet can only be left through its own buttons, and a
swipe just rubber-bands. No close (×) button: cancel, the backdrop and
the swipe already cover it.
- Performance: every decorative animation is held paused until the slide-in
has finished (`is-ready`, set on Vant's `opened`), so the slide has the
frame budget to itself; the content arrives as one piece, not staggered,
and nothing uses backdrop-filter (blurring the page behind a fading
overlay recomputes every frame and was the main source of jank on phones).
- Performance: the illustration's own animations are held paused until the
slide-in has finished (`is-ready`, set on Vant's `opened`), so the slide has
the frame budget to itself; nothing uses backdrop-filter (blurring the page
behind a fading overlay recomputes every frame and was the main source of
jank on phones).
- Cancel closes the sheet itself and emits `cancel`; confirm only emits
`confirm` — the caller decides whether it closes (e.g. a redirect).
-->
@@ -50,11 +62,10 @@
@update:show="emit('update:show', $event)"
@opened="ready = true"
@closed="onClosed"
@click="onGapClick"
>
<div
class="iSheet__card"
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready }]"
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready, 'is-single': !cancelText }]"
:style="cardStyle"
dir="rtl"
role="dialog"
@@ -64,11 +75,9 @@
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<div class="iSheet__stage">
<span class="iSheet__handle" />
<span class="iSheet__ring iSheet__ring--outer" />
<span class="iSheet__ring iSheet__ring--inner" />
<span v-for="n in 4" :key="n" class="iSheet__spark" :class="`iSheet__spark--${n}`" />
<span class="iSheet__grip" aria-hidden="true"></span>
<div class="iSheet__panel">
<div class="iSheet__art">
<slot name="art">
<SheetArt v-if="art" :name="art" />
@@ -79,13 +88,15 @@
<h3 class="iSheet__title">{{ title }}</h3>
<p v-if="description" class="iSheet__desc">{{ description }}</p>
<!-- The detail page's bar order: the secondary pill first in markup, so
RTL lays it on the right, and the filled pill fills the rest. -->
<div class="iSheet__actions">
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
{{ confirmText }}
</button>
<button v-if="cancelText" type="button" class="iSheet__btn iSheet__btn--ghost" @click="cancel">
{{ cancelText }}
</button>
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
{{ confirmText }}
</button>
</div>
</div>
</Popup>
@@ -96,6 +107,8 @@ import { computed, ref } from 'vue'
import { Popup } from 'vant'
import SheetArt, { type SheetArtName } from './SheetArt.vue'
// Kept as the original five for API compatibility; v2 renders them as two
// looks — destructive (wine) and everything else (ink). See the styles.
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
const props = withDefaults(
@@ -125,7 +138,6 @@ const artTone: Record<SheetArtName, Tone> = {
update: 'navy',
success: 'green',
}
const resolvedTone = computed<Tone>(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
const ready = ref(false)
@@ -144,12 +156,6 @@ function cancel() {
close()
}
// The popup is a full-width transparent strip with the card inset inside it;
// a tap in that inset gap is visually a tap on the backdrop, so treat it as one.
function onGapClick(e: MouseEvent) {
if (props.closeOnClickOverlay && e.target === e.currentTarget) close()
}
// Swipe down to dismiss. Only the card moves while dragging; on release it
// either springs back or the popup's own leave transition carries it off
// from wherever the finger left it (reset once fully closed).
@@ -195,14 +201,14 @@ function onTouchEnd() {
<style lang="less">
// Global, not scoped: the popup is teleported to <body>.
// See-through dim, no blur — the same backdrop as the filter sheet.
.iSheet__overlay {
background: rgba(20, 22, 26, 0.48);
background: rgba(20, 22, 26, 0.42);
}
.iSheet.van-popup {
background: transparent;
overflow: visible;
padding: 0 12px calc(12px + env(safe-area-inset-bottom, 0px));
}
// The iOS sheet curve: fast out of the gate, long soft landing. Transform
@@ -211,81 +217,47 @@ function onTouchEnd() {
.iSheet-slide-leave-active {
will-change: transform;
}
.iSheet-slide-enter-active {
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.iSheet-slide-leave-active {
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
}
// Hold every decorative animation (the stage's rings and sparks, and the
// illustration's own) until the sheet has landed.
.iSheet__card:not(.is-ready) *,
.iSheet__card:not(.is-ready) *::after {
animation-play-state: paused !important;
}
.iSheet-slide-enter-from,
.iSheet-slide-leave-to {
transform: translate3d(0, 100%, 0);
}
// Tones: one accent per job. --t is the accent, --t-rgb its channels for
// translucent uses, --t-hi the lighter top of the button gradient, --t-wash
// and --t-wash2 the header's two background stops.
.iSheet--ink {
--t: @ink;
--t-rgb: 20, 22, 26;
--t-hi: @inkSoft;
--t-wash: #F7F7F8;
--t-wash2: #ECEDEF;
// Hold the illustration's animations until the sheet has landed.
.iSheet__card:not(.is-ready) *,
.iSheet__card:not(.is-ready) *::after {
animation-play-state: paused !important;
}
// Two looks, one per job: the filled button is @wine for the destructive
// action (logout) and @ink — the app's primary surface — for everything else.
.iSheet__card {
--t: @ink;
--t-on: @onInk;
--t-rgb: 20, 22, 26;
}
.iSheet--wine {
--t: @wine;
--t-on: #fff;
--t-rgb: 122, 27, 51;
--t-hi: @wineSoft;
--t-wash: #FDF6F7;
--t-wash2: #F7E2E7;
}
.iSheet--navy {
--t: @primary;
--t-rgb: 28, 45, 86;
--t-hi: #2D4682;
--t-wash: #F5F7FC;
--t-wash2: #E2E8F4;
}
.iSheet--gold {
--t: @secondary;
--t-rgb: 141, 106, 70;
--t-hi: #C39350;
--t-wash: #FDF9F2;
--t-wash2: #F5E7CF;
}
.iSheet--green {
--t: #15803d;
--t-rgb: 22, 163, 74;
--t-hi: #22a052;
--t-wash: #F4FBF6;
--t-wash2: #D9F1E1;
}
// The detail page's / filter sheet's shell: white, attached to the bottom
// edge, 32px top radius, a centred column on wide screens.
.iSheet__card {
position: relative;
max-width: 440px;
max-width: 768px;
margin: 0 auto;
padding: 8px 8px 12px;
border-radius: 30px;
padding: 8px 16px calc(14px + env(safe-area-inset-bottom, 0px));
border-radius: 32px 32px 0 0;
background: @canvas;
text-align: center;
box-shadow:
0 30px 60px -22px rgba(20, 22, 26, 0.5),
0 0 0 1px rgba(20, 22, 26, 0.04);
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
touch-action: none;
@@ -294,84 +266,29 @@ function onTouchEnd() {
}
}
// Header: a tinted window with a faint dot grid that fades out towards the
// edges, two soft rings framing the illustration, and a few twinkles.
.iSheet__stage {
position: relative;
height: 172px;
border-radius: 23px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(130% 95% at 50% 0%, var(--t-wash2) 0%, var(--t-wash) 72%);
&::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(var(--t-rgb), 0.13) 1px, transparent 1.3px);
background-size: 14px 14px;
-webkit-mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
}
}
.iSheet__handle {
position: absolute;
top: 8px;
left: 50%;
.iSheet__grip {
display: block;
width: 38px;
height: 4px;
margin-left: -19px;
border-radius: 4px;
background: rgba(var(--t-rgb), 0.18);
z-index: 2;
margin: 0 auto 12px;
border-radius: 999px;
background: @hairline;
}
.iSheet__ring {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
pointer-events: none;
&--outer {
width: 196px;
height: 196px;
margin: -98px 0 0 -98px;
border: 1.5px dashed rgba(var(--t-rgb), 0.16);
}
&--inner {
width: 132px;
height: 132px;
margin: -66px 0 0 -66px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.55) 62%, rgba(255, 255, 255, 0) 72%);
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0.35);
animation: iSheet-breathe 3.2s ease-in-out infinite;
}
}
.iSheet__spark {
position: absolute;
width: 10px;
height: 10px;
background: rgba(var(--t-rgb), 0.45);
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
animation: iSheet-twinkle 2.6s ease-in-out infinite;
&--1 { top: 30px; right: 24%; }
&--2 { top: 62px; left: 19%; width: 7px; height: 7px; animation-delay: 0.7s; }
&--3 { bottom: 32px; right: 17%; width: 8px; height: 8px; animation-delay: 1.3s; }
&--4 { bottom: 26px; left: 27%; width: 6px; height: 6px; animation-delay: 1.9s; }
// The ProductPanel treatment: the subject centred on a borderless soft-grey
// panel at the catalogue cards' 32px radius (one step in, 26px, since it sits
// inside the sheet's own 32px corner).
.iSheet__panel {
height: 150px;
display: grid;
place-items: center;
border-radius: 26px;
background: @hairlineSoft;
}
.iSheet__art {
position: relative;
z-index: 1;
width: 128px;
height: 128px;
width: 112px;
height: 112px;
> img,
> svg,
@@ -383,18 +300,19 @@ function onTouchEnd() {
}
// Every element sets its own font-size: style.less's `*` rule pins each one
// to 14px, so nothing inherits.
// to 14px, so nothing inherits. Sizes are the detail page's: 16px for the
// heading (its section titles), 12.5px / line-height 2 for body copy.
.iSheet__title {
margin: 20px 16px 0;
font-size: 16.5px;
font-weight: 800;
margin: 18px 8px 0;
font-size: 16px;
font-weight: 700;
line-height: 1.75;
color: @ink;
}
.iSheet__desc {
margin: 6px 20px 0;
font-size: 13px;
margin: 6px 12px 0;
font-size: 12.5px;
font-weight: 400;
line-height: 2;
color: @inkMuted;
@@ -402,9 +320,9 @@ function onTouchEnd() {
.iSheet__actions {
display: flex;
flex-direction: column;
gap: 6px;
margin: 22px 4px 0;
align-items: center;
gap: 10px;
margin-top: 20px;
}
// Scoped under the card on purpose: Tailwind's preflight resets
@@ -413,64 +331,44 @@ function onTouchEnd() {
// imported before main.ts loads tailwind.css — so a bare `.iSheet__btn--primary`
// loses on source order and the button renders white-on-white.
.iSheet__card .iSheet__btn {
position: relative;
height: 52px;
border: 0;
border-radius: 18px;
font-size: 14.5px;
font-weight: 700;
height: 48px;
border-radius: 999px;
font-family: inherit;
font-size: 13.5px;
font-weight: 800;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
transition: transform 0.18s ease, background-color 0.18s ease;
&:active {
transform: scale(0.97);
}
&--primary {
color: #fff;
background: linear-gradient(180deg, var(--t-hi) 0%, var(--t) 100%);
box-shadow:
0 12px 24px -12px rgba(var(--t-rgb), 0.75),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
// A single sheen across the button once the sheet has settled.
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.28), transparent);
transform: translateX(-130%) skewX(-18deg);
animation: iSheet-sheen 0.9s ease-in-out 0.15s both;
}
transform: scale(0.98);
}
// The detail page's outlined pill.
&--ghost {
color: @inkMuted;
background: transparent;
flex: 0 0 auto;
min-width: 108px;
padding: 0 22px;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
&:active {
background: @hairlineSoft;
}
}
}
@keyframes iSheet-breathe {
50% { transform: scale(1.06); }
}
@keyframes iSheet-twinkle {
0%, 100% { transform: scale(0.4) rotate(0deg); opacity: 0.2; }
50% { transform: scale(1) rotate(45deg); opacity: 1; }
}
@keyframes iSheet-sheen {
from { transform: translateX(-130%) skewX(-18deg); }
to { transform: translateX(300%) skewX(-18deg); }
// The detail page's primary pill.
&--primary {
flex: 1;
min-width: 0;
padding: 0 18px;
border: 0;
color: var(--t-on);
background: var(--t);
box-shadow: 0 14px 30px -18px rgba(var(--t-rgb), 0.9);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -478,7 +376,6 @@ function onTouchEnd() {
.iSheet-slide-leave-active {
transition-duration: 0.01s;
}
.iSheet__card *,
.iSheet__card *::after {
animation: none !important;
@@ -74,18 +74,18 @@ const indicatorOffset = computed(
// brightens whatever scrolls beneath it. A light spot sits top-left, a
// faint wine tint pools bottom-right, and the drop shadow lifts the pane.
background:
radial-gradient(120% 140% at 0% 0%, fade(@canvas, 55%) 0%, fade(@canvas, 0%) 55%),
radial-gradient(90% 120% at 100% 100%, fade(@wine, 7%) 0%, fade(@wine, 0%) 60%),
linear-gradient(160deg, fade(@canvas, 42%) 0%, fade(@canvas, 18%) 100%);
radial-gradient(120% 140% at 0% 0%, alpha(@canvas, 55%) 0%, alpha(@canvas, 0%) 55%),
radial-gradient(90% 120% at 100% 100%, alpha(@wine, 7%) 0%, alpha(@wine, 0%) 60%),
linear-gradient(160deg, alpha(@canvas, 42%) 0%, alpha(@canvas, 18%) 100%);
-webkit-backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
border: 0;
box-shadow:
inset 0 1px 1px fade(@canvas, 90%),
inset 0 -8px 16px -10px fade(@canvas, 60%),
inset 0 0 0 0.5px fade(@canvas, 35%),
inset 0 1px 1px alpha(@canvas, 90%),
inset 0 -8px 16px -10px alpha(@canvas, 60%),
inset 0 0 0 0.5px alpha(@canvas, 35%),
0 18px 40px -14px rgba(20, 22, 26, 0.32),
0 6px 14px -8px fade(@wine, 22%),
0 6px 14px -8px alpha(@wine, 22%),
0 1px 3px rgba(20, 22, 26, 0.06);
// Curved specular gloss across the top, like light on a bent pane.
@@ -95,7 +95,7 @@ const indicatorOffset = computed(
inset: 1px 8% auto 8%;
height: 55%;
border-radius: 20px 20px 50% 50% / 20px 20px 100% 100%;
background: linear-gradient(180deg, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 100%);
background: linear-gradient(180deg, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 100%);
pointer-events: none;
}
@@ -109,10 +109,10 @@ const indicatorOffset = computed(
border-radius: inherit;
background: linear-gradient(
145deg,
fade(@canvas, 100%) 0%,
fade(@canvas, 35%) 35%,
fade(@canvas, 10%) 65%,
fade(@canvas, 70%) 100%
alpha(@canvas, 100%) 0%,
alpha(@canvas, 35%) 35%,
alpha(@canvas, 10%) 65%,
alpha(@canvas, 70%) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
@@ -126,7 +126,7 @@ const indicatorOffset = computed(
// Browsers without backdrop-filter get a mostly opaque fill so it stays legible.
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.tabbar__inner {
background: fade(@canvas, 94%);
background: alpha(@canvas, 94%);
}
}
@@ -60,7 +60,7 @@ function onBack() {
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
@@ -29,7 +29,7 @@
<span
v-if="item.duration > 0"
class="toast__progress"
class="toast__progress keep-motion"
:style="{ animationDuration: `${item.duration}ms` }"
@animationend="emit('close')"
/>
@@ -132,14 +132,14 @@ function onUp() {
// Same glass recipe as the bottom nav, so the two read as one system.
background:
radial-gradient(120% 160% at 0% 0%, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 55%),
radial-gradient(80% 140% at 100% 100%, fade(@ink, 4%) 0%, fade(@ink, 0%) 60%),
linear-gradient(160deg, fade(@canvas, 72%) 0%, fade(@canvas, 50%) 100%);
radial-gradient(120% 160% at 0% 0%, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 55%),
radial-gradient(80% 140% at 100% 100%, alpha(@ink, 4%) 0%, alpha(@ink, 0%) 60%),
linear-gradient(160deg, alpha(@canvas, 72%) 0%, alpha(@canvas, 50%) 100%);
-webkit-backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
box-shadow:
inset 0 1px 1px fade(@canvas, 90%),
inset 0 0 0 0.5px fade(@canvas, 40%),
inset 0 1px 1px alpha(@canvas, 90%),
inset 0 0 0 0.5px alpha(@canvas, 40%),
0 18px 40px -16px rgba(20, 22, 26, 0.35),
0 1px 3px rgba(20, 22, 26, 0.06);
@@ -152,10 +152,10 @@ function onUp() {
border-radius: inherit;
background: linear-gradient(
145deg,
fade(@canvas, 100%) 0%,
fade(@canvas, 30%) 40%,
fade(@canvas, 10%) 70%,
fade(@canvas, 70%) 100%
alpha(@canvas, 100%) 0%,
alpha(@canvas, 30%) 40%,
alpha(@canvas, 10%) 70%,
alpha(@canvas, 70%) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
@@ -189,7 +189,7 @@ function onUp() {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.toast {
background: fade(@canvas, 96%);
background: alpha(@canvas, 96%);
}
}
@@ -0,0 +1,63 @@
<!--
The visual half of an on/off switch: a track and a knob. Deliberately not a
control on its own — the row that holds it is the <button role="switch">,
so the whole row is one tap target and screen readers announce one switch
(see views/user/AppearanceView.vue).
STRUCTURE IS SHARED, LOOK IS NOT: identical markup and script in user-app
and user-app-v2; only the "Look" block differs, per app.
-->
<template>
<span class="tsw" :class="{ 'is-on': on }" aria-hidden="true">
<span class="tsw__knob"></span>
</span>
</template>
<script setup lang="ts">
defineProps<{ on: boolean }>()
</script>
<style scoped lang="less">
/* ── Mechanics (same in both apps) ──────────────────────────── */
.tsw {
position: relative;
flex: 0 0 auto;
display: inline-block;
width: 46px;
height: 28px;
border-radius: 999px;
transition: background-color 0.22s ease;
}
.tsw__knob {
position: absolute;
top: 3px;
// RTL page: "off" sits on the right (start), "on" slides to the left (end).
inset-inline-start: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
box-shadow: 0 2px 6px -1px rgba(20, 22, 26, 0.35);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1), background-color 0.22s ease;
}
.tsw.is-on .tsw__knob {
transform: translateX(-18px);
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// The app's chosen state is a filled @ink (chips, CTAs): the track fills @ink
// and the knob takes @onInk, so it stays a contrasting pair in both themes.
.tsw {
background: @hairline;
}
.tsw__knob {
background: #fff;
}
.tsw.is-on {
background: @ink;
}
.tsw.is-on .tsw__knob {
background: @onInk;
}
</style>
@@ -128,11 +128,11 @@ async function onPick(event: Event) {
}
.is-done .siu__box {
border-bottom-color: #16a34a;
border-bottom-color: @green;
}
.is-error .siu__box {
border-bottom-color: #dc2626;
border-bottom-color: @red;
}
.siu__preview {
@@ -167,12 +167,12 @@ async function onPick(event: Event) {
.siu__badge {
flex: 0 0 auto;
display: flex;
color: #16a34a;
color: @green;
}
.siu__error {
margin: 7px 2px 0;
font-size: 12px;
color: #dc2626;
color: @red;
}
</style>