// 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-)` 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', 'wine200', '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 = { page: (light as Record).canvas, onInk: '#FFFFFF', heroSurface: '#7C0035', hairlineStrong: '#DADCE0', photoMat: 'transparent', photoStage: (light as Record).hairlineSoft, } type Palette = Record 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)[t]])), ...SEMANTIC_LIGHT, } const darkPalette: Palette = Object.fromEntries( Object.entries(dark as Record).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 = 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 /** 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}%)`) }) }, }