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:
@@ -0,0 +1,154 @@
|
||||
// Runtime theming (light / dark) for the LESS design tokens.
|
||||
//
|
||||
// The app's colours are LESS variables (@canvas, @ink, @wine, …) injected by
|
||||
// vite.config.ts through `modifyVars`. Before dark mode they compiled straight
|
||||
// to hex, so nothing could change at runtime. Now the THEME tokens below are
|
||||
// injected as `var(--c-<name>)` instead, and their values live in a generated
|
||||
// stylesheet (`virtual:palette.css`, imported first in main.ts):
|
||||
//
|
||||
// :root { --c-canvas: #FFFFFF; --c-canvas-rgb: 255 255 255; … }
|
||||
// html.theme-dark { --c-canvas: #16171A; --c-canvas-rgb: 22 23 26; … }
|
||||
//
|
||||
// Light values come from colors.json (unchanged, still the source of truth and
|
||||
// still what Tailwind reads), dark values from colors.dark.json. A key missing
|
||||
// from colors.dark.json simply keeps its light value in dark mode.
|
||||
//
|
||||
// Not converted, on purpose: `white`, `black`, `secondary`, `danger` and the
|
||||
// antd keys in themeConfig.ts. ant-design-vue's own LESS compiles with the
|
||||
// same modifyVars and runs colour functions (fade/tint/colorPalette) on those,
|
||||
// which a `var()` would break.
|
||||
//
|
||||
// LESS colour functions can't operate on a `var()` either, so app code uses
|
||||
// `alpha(@token, 40%)` instead of `fade(@token, 40%)` — registered below as a
|
||||
// LESS plugin; for a theme token it emits `rgb(var(--c-token-rgb) / 40%)`, for
|
||||
// a real colour it behaves exactly like `fade`.
|
||||
//
|
||||
// This file is kept identical in user-app and user-app-v2; only the JSONs
|
||||
// differ per app.
|
||||
import type { Plugin } from 'vite'
|
||||
import light from './colors.json'
|
||||
import dark from './colors.dark.json'
|
||||
|
||||
/** Tokens that change with the theme. Everything else in colors.json stays a
|
||||
* plain compile-time hex value. */
|
||||
export const THEME_TOKENS = [
|
||||
'canvas',
|
||||
'canvasSoft',
|
||||
'ink',
|
||||
'inkSoft',
|
||||
'inkMuted',
|
||||
'inkFaint',
|
||||
'hairline',
|
||||
'hairlineSoft',
|
||||
'wine',
|
||||
'wineDeep',
|
||||
'wineSoft',
|
||||
'wineWash',
|
||||
'primary',
|
||||
'primaryLighten',
|
||||
'green',
|
||||
'red',
|
||||
'info',
|
||||
'light',
|
||||
'grey',
|
||||
'lightGray',
|
||||
'lightGray1',
|
||||
'lightGray2',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Semantic tokens added for dark mode, for the places where one light-mode
|
||||
* token did two jobs. Light values reproduce the old look exactly.
|
||||
* - onInk: text/icons ON an @ink fill (filled buttons, chosen chips). White in
|
||||
* light; in dark @ink turns light, so this turns dark.
|
||||
* - heroSurface: decorative dark cards (rate strip, balance card…) that stay
|
||||
* dark in both themes, so their white text needs no change.
|
||||
* - hairlineStrong: a step darker than @hairline (was `darken(@hairline, 7%)`).
|
||||
* - photoMat: behind product photos. Transparent in light (no change); in
|
||||
* dark a light mat, so photos shot on white don't sit as white boxes on a
|
||||
* dark card.
|
||||
* - page: the screen's own ground, behind every card and panel. Same white
|
||||
* as @canvas in light; in dark it is the deepest layer, so cards (@canvas)
|
||||
* and panels (@hairlineSoft) stand off it a step at a time.
|
||||
* - photoStage: the detail page's gallery backdrop — @hairlineSoft in light,
|
||||
* the same light mat in dark.
|
||||
*/
|
||||
const SEMANTIC_LIGHT: Record<string, string> = {
|
||||
page: (light as Record<string, string>).canvas,
|
||||
onInk: '#FFFFFF',
|
||||
heroSurface: '#14161A',
|
||||
hairlineStrong: '#DADCE0',
|
||||
photoMat: 'transparent',
|
||||
photoStage: (light as Record<string, string>).hairlineSoft,
|
||||
}
|
||||
|
||||
type Palette = Record<string, string>
|
||||
|
||||
function rgbChannels(hex: string): string | null {
|
||||
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
|
||||
if (!m) return null
|
||||
let h = m[1]
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
const n = parseInt(h, 16)
|
||||
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`
|
||||
}
|
||||
|
||||
function block(selector: string, values: Palette): string {
|
||||
const lines: string[] = []
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
lines.push(` --c-${name}: ${value};`)
|
||||
const rgb = rgbChannels(value)
|
||||
if (rgb) lines.push(` --c-${name}-rgb: ${rgb};`)
|
||||
}
|
||||
return `${selector} {\n${lines.join('\n')}\n}\n`
|
||||
}
|
||||
|
||||
const lightPalette: Palette = {
|
||||
...Object.fromEntries(THEME_TOKENS.map((t) => [t, (light as Record<string, string>)[t]])),
|
||||
...SEMANTIC_LIGHT,
|
||||
}
|
||||
const darkPalette: Palette = Object.fromEntries(
|
||||
Object.entries(dark as Record<string, string>).filter(([k]) => !k.startsWith('//')),
|
||||
)
|
||||
|
||||
export const paletteCss =
|
||||
block(':root', lightPalette) +
|
||||
// `color-scheme` lets native bits (inputs, scrollbars, autofill) follow.
|
||||
block('html.theme-dark', darkPalette).replace('{\n', '{\n color-scheme: dark;\n')
|
||||
|
||||
/** LESS variables for every theme/semantic token: `@canvas` → `var(--c-canvas)`. */
|
||||
export const themeLessVarOverrides: Record<string, string> = Object.fromEntries(
|
||||
Object.keys(lightPalette).map((t) => [t, `var(--c-${t})`]),
|
||||
)
|
||||
|
||||
/** Raw light hex values, for places that need a real colour at build time
|
||||
* (PWA manifest). */
|
||||
export const lightHex = light as Record<string, string>
|
||||
|
||||
/** Serves `virtual:palette.css`. */
|
||||
export function palettePlugin(): Plugin {
|
||||
const id = 'virtual:palette.css'
|
||||
const resolved = '\0' + id
|
||||
return {
|
||||
name: 'gold-bank-palette',
|
||||
resolveId: (source) => (source === id ? resolved : undefined),
|
||||
load: (source) => (source === resolved ? paletteCss : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
/** LESS plugin adding `alpha(color, amount)` — see the file header. */
|
||||
export const lessAlphaPlugin = {
|
||||
install(less: any, _pm: unknown, functions: any) {
|
||||
functions.add('alpha', function (this: unknown, color: any, amount: any) {
|
||||
if (color && color.type === 'Color') {
|
||||
const fade = less.functions.functionRegistry.get('fade')
|
||||
return fade.call(this, color, amount)
|
||||
}
|
||||
const css: string = typeof color?.toCSS === 'function' ? color.toCSS({}) : String(color?.value ?? '')
|
||||
const m = /var\(\s*--c-([A-Za-z0-9]+)\s*\)/.exec(css)
|
||||
if (!m) throw new Error(`alpha(): expected a colour or a theme token, got "${css}"`)
|
||||
const pct = amount.unit && amount.unit.toString() === '%' ? amount.value : amount.value * 100
|
||||
return new less.tree.Anonymous(`rgb(var(--c-${m[1]}-rgb) / ${pct}%)`)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"//": "Dark-mode values for the theme tokens in appearance.ts; missing keys keep their light value. Built as elevation layers so surfaces separate without shadows: page (deepest) < canvas (cards, sheets, bars) < hairlineSoft (panels, chips) < hairline (borders). v2: @primary (navy) is only ever a text accent here, so it lifts to a light navy.",
|
||||
"page": "#15171A",
|
||||
"canvasSoft": "#15171A",
|
||||
"canvas": "#1F2125",
|
||||
"hairlineSoft": "#2A2D32",
|
||||
"hairline": "#3A3D44",
|
||||
"hairlineStrong": "#4B4F57",
|
||||
"ink": "#F2F3F5",
|
||||
"inkSoft": "#E1E3E7",
|
||||
"inkMuted": "#B2B7C0",
|
||||
"inkFaint": "#8F95A0",
|
||||
"wine": "#C9566F",
|
||||
"wineDeep": "#A8435C",
|
||||
"wineSoft": "#DC8197",
|
||||
"wineWash": "#3A222B",
|
||||
"primary": "#A3B6EA",
|
||||
"primaryLighten": "#B8C8F0",
|
||||
"green": "#45C677",
|
||||
"red": "#F26161",
|
||||
"info": "#7C9DE6",
|
||||
"light": "#1F2125",
|
||||
"grey": "#9298A2",
|
||||
"lightGray": "#757A84",
|
||||
"lightGray1": "#4A4E56",
|
||||
"lightGray2": "#3A3D44",
|
||||
"onInk": "#15171A",
|
||||
"heroSurface": "#2C2F36",
|
||||
"photoMat": "#FFFFFF",
|
||||
"photoStage": "#F2F3F5"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// out of scope for a presentation-only port and superseded by this app's own
|
||||
// src/api/client.ts, which already talks to the new NestJS API.)
|
||||
import data from './colors.json'
|
||||
import { themeLessVarOverrides } from './appearance'
|
||||
|
||||
const colors = data
|
||||
const curve = {
|
||||
@@ -40,4 +41,13 @@ const vars = {
|
||||
'transition-duration': '.5s',
|
||||
'transition-delay': '.5s',
|
||||
}
|
||||
export const $themeLessVars = Object.assign(vars, colors)
|
||||
// Theme tokens (see appearance.ts) are overridden last, so @canvas, @ink, …
|
||||
// compile to var(--c-…) and switch with the theme at runtime. The antd keys
|
||||
// above were already resolved to hex from `colors`, so antd still gets real
|
||||
// colours for its colour functions; only its surface backgrounds follow the
|
||||
// theme.
|
||||
export const $themeLessVars = Object.assign(vars, colors, themeLessVarOverrides, {
|
||||
'component-background': 'var(--c-canvas)',
|
||||
'body-background': 'var(--c-page)',
|
||||
'popover-background': 'var(--c-canvas)',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user