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
+109 -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,95 @@ 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;
font-weight: 700;
color: @ink;
}
.dd__chevron {
flex: 0 0 auto;
color: @inkFaint;
transition: transform 0.2s ease;
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);
color: @wine;
}
.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 ─────────────────────────────────────────── */
// A white card with a hairline border and the 16px radius the detail page's
// spec table uses; the border and chevron take the brand green when open.
.dd {
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
transition: border-color 0.2s ease;
}
.dd.is-open {
border-color: alpha(@primary, 28%);
}
.dd__head {
padding: 15px;
}
.dd__title {
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.dd__chevron {
color: @inkFaint;
}
.dd.is-open .dd__chevron {
color: @primary;
}
.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 15px 15px;
}
</style>
+1 -1
View File
@@ -57,7 +57,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;
}
+11 -11
View File
@@ -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,59 @@
<!--
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 ─────────────────────────────────────────── */
// The app's chosen state is brand green (@primary, as on chips and CTAs).
.tsw {
background: @lightGray1;
}
.tsw__knob {
background: #fff;
}
.tsw.is-on {
background: @primary;
}
</style>