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.
155 lines
6.0 KiB
TypeScript
155 lines
6.0 KiB
TypeScript
// 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}%)`)
|
|
})
|
|
},
|
|
}
|