Files
tehran_taj/apps/user-app/src/views/user/AppearanceView.vue
T
s.esmaeili f441ca8289 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.
2026-10-04 16:18:56 +03:30

341 lines
8.8 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
تنظیمات ظاهری — the colour theme and the "fast & light" motion switch.
Reached from the dashboard; open to signed-out users too (these are device
preferences, not account data). State lives in stores/appearanceStore.ts,
which applies it to <html> immediately — nothing to save or confirm.
The theme previews are drawn with FIXED colours, not theme tokens: each one
has to show what that theme looks like, whichever theme is active now.
STRUCTURE IS SHARED, LOOK IS NOT: template and script are identical in
user-app and user-app-v2; only the "Look" block differs, per app.
-->
<template>
<div class="ap">
<PageBar title="تنظیمات ظاهری" back-to="userPage::hasSubRoute" />
<container class="ap__body">
<!-- ── Theme ───────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 id="ap-theme" class="ap__title">پوسته برنامه</h2>
<p class="ap__sub">ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما</p>
</header>
<div class="ap__themes" role="radiogroup" aria-labelledby="ap-theme">
<button
v-for="opt in THEMES"
:key="opt.value"
type="button"
role="radio"
class="ap__theme"
:class="{ 'is-on': appearance.theme === opt.value }"
:aria-checked="appearance.theme === opt.value"
@click="appearance.setTheme(opt.value)"
>
<span class="ap__preview" :class="`ap__preview--${opt.value}`" aria-hidden="true">
<span class="ap__pvBar"></span>
<span class="ap__pvCard"></span>
<span class="ap__pvLine"></span>
<span class="ap__pvLine ap__pvLine--short"></span>
<span class="ap__pvBtn"></span>
</span>
<span class="ap__themeLabel">
<Icon :icon="opt.icon" size="15" />
{{ opt.label }}
</span>
<span class="ap__check" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="18" />
</span>
</button>
</div>
<p v-if="appearance.theme === 'system'" class="ap__note">
الان {{ appearance.resolvedTheme === 'dark' ? 'تیره' : 'روشن' }} است، مطابق تنظیمات گوشی.
</p>
</section>
<!-- ── Motion ──────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 class="ap__title">انیمیشن‌ها</h2>
<p class="ap__sub">برای سرعت بیشتر روی گوشی‌های ضعیف‌تر</p>
</header>
<!-- The whole row is the switch: one tap target, one announcement. -->
<button
type="button"
role="switch"
class="ap__row"
:aria-checked="appearance.reduceMotion"
@click="appearance.setReduceMotion(!appearance.reduceMotion)"
>
<span class="ap__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:flash" size="18" />
</span>
<span class="ap__rowText">
<b>حالت سبک و سریع</b>
<small>
حرکت‌های سنگین، افکت‌های شیشه‌ای و اسلاید خودکار خاموش می‌شوند؛ بازخورد کوتاه لمس‌ها و جابه‌جایی‌های سریع
باقی می‌مانند تا برنامه خشک و بی‌روح نشود.
</small>
</span>
<ToggleSwitch :on="appearance.reduceMotion" />
</button>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import ToggleSwitch from '../../components/ui/ToggleSwitch.vue'
import { useAppearanceStore, type ThemeChoice } from '../../stores/appearanceStore'
const appearance = useAppearanceStore()
const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
{ value: 'dark', label: 'تیره', icon: 'vuesax-linear:moon' },
{ value: 'system', label: 'مطابق سیستم', icon: 'vuesax-linear:monitor-mobbile' },
]
</script>
<style scoped lang="less">
/* ── Structure (same in both apps) ──────────────────────────── */
.ap {
min-height: 100vh;
}
.ap__body {
padding-top: 76px;
padding-bottom: 90px;
display: flex;
flex-direction: column;
gap: 26px;
}
.ap__title,
.ap__sub,
.ap__note {
margin: 0;
}
.ap__themes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.ap__theme {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.ap__theme:active {
transform: scale(0.97);
}
.ap__themeLabel {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.ap__check {
position: absolute;
top: 8px;
inset-inline-end: 8px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.ap__theme.is-on .ap__check {
opacity: 1;
transform: none;
}
// Mini screen, fixed colours on purpose (see the header).
.ap__preview {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 6%;
padding: 10% 12%;
--pv-bg: #ffffff;
--pv-bar: #f4f5f6;
--pv-card: #ecedef;
--pv-line: #dadce0;
--pv-btn: #14161a;
background: var(--pv-bg);
}
.ap__preview--dark {
--pv-bg: #16171a;
--pv-bar: #1e2024;
--pv-card: #2b2e34;
--pv-line: #3a3e45;
--pv-btn: #f1f2f4;
}
// System: the light screen with its lower-left half in dark.
.ap__preview--system::after {
content: '';
position: absolute;
inset: 0;
background: #16171a;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.94;
}
.ap__pvBar {
height: 9%;
border-radius: 4px;
background: var(--pv-bar);
}
.ap__pvCard {
flex: 1;
border-radius: 7px;
background: var(--pv-card);
}
.ap__pvLine {
height: 6%;
border-radius: 3px;
background: var(--pv-line);
}
.ap__pvLine--short {
width: 60%;
}
.ap__pvBtn {
height: 11%;
border-radius: 999px;
background: var(--pv-btn);
}
.ap__row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease;
}
.ap__row:active {
transform: scale(0.985);
}
.ap__rowIcon {
flex: 0 0 auto;
display: grid;
place-items: center;
}
.ap__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
/* ── Look: user-app ─────────────────────────────────────────── */
// White hairline-bordered cards at the 16px radius on the soft-grey ground,
// chosen = a brand-green border with a green check (the app's chips and
// variant picker); the motion row is a card with the dashboard tiles' grey
// icon tile.
.ap {
background: @canvasSoft;
}
.ap__head {
margin-bottom: 13px;
}
.ap__title {
font-size: 15px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
}
.ap__sub {
margin-top: 5px;
font-size: 11.5px;
color: @inkFaint;
}
.ap__note {
margin-top: 10px;
font-size: 11.5px;
color: @inkMuted;
}
.ap__theme {
padding: 8px 8px 11px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
color: @inkMuted;
}
.ap__theme.is-on {
color: @ink;
border-color: @primary;
box-shadow: 0 0 0 1px @primary;
}
.ap__preview {
border-radius: 11px;
box-shadow: 0 0 0 1px rgba(20, 22, 26, 0.07);
}
.ap__themeLabel {
font-size: 12px;
font-weight: 700;
}
.ap__check {
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
background: @primary;
}
.ap__row {
padding: 14px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.ap__rowIcon {
width: 38px;
height: 38px;
border-radius: 12px;
color: @inkSoft;
background: @hairlineSoft;
}
.ap__rowText b {
font-size: 13px;
font-weight: 800;
color: @ink;
}
.ap__rowText small {
font-size: 11px;
line-height: 1.9;
color: @inkFaint;
}
</style>