25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

399 lines
15 KiB

  1. /**
  2. * 暗色模式 codemod(一次性脚本,保留以便后续新增页面时复跑)
  3. *
  4. * 做两件事:
  5. * 1. 扫描 app/ 下所有 Tailwind 任意值里的硬编码颜色(#hex / rgba() / bg-white 家族),
  6. * 生成 app/assets/css/theme.css:亮色变量值 = 原色值(亮色视觉零变化),
  7. * .dark 下按语义分桶给出暗色取值。
  8. * 2. 把源码中的硬编码色值替换为 var(--c-xxxxxx) / var(--sh-n)。
  9. *
  10. * 用法:node scripts/gen-theme.mjs [--dry]
  11. * 只处理 <template> 与 <style> 段,避免动到 <script> 里作为数据传给后端的颜色值。
  12. */
  13. import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs'
  14. import { join, dirname } from 'node:path'
  15. import { fileURLToPath } from 'node:url'
  16. const here = dirname(fileURLToPath(import.meta.url))
  17. const appDir = join(here, '..', 'app')
  18. const cssOut = join(appDir, 'assets', 'css', 'theme.css')
  19. const dry = process.argv.includes('--dry')
  20. /* ---------------- 颜色工具 ---------------- */
  21. function normHex(h) {
  22. let s = h.replace('#', '').toLowerCase()
  23. if (s.length === 3) s = s.split('').map(c => c + c).join('')
  24. if (s.length === 8) s = s.slice(0, 6)
  25. return s
  26. }
  27. function hexToRgb(hex) {
  28. const s = normHex(hex)
  29. return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
  30. }
  31. function rgbToHsl([r, g, b]) {
  32. r /= 255; g /= 255; b /= 255
  33. const max = Math.max(r, g, b), min = Math.min(r, g, b)
  34. const l = (max + min) / 2
  35. let h = 0, s = 0
  36. const d = max - min
  37. if (d) {
  38. s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
  39. if (max === r) h = ((g - b) / d + (g < b ? 6 : 0))
  40. else if (max === g) h = (b - r) / d + 2
  41. else h = (r - g) / d + 4
  42. h *= 60
  43. }
  44. return { h, s, l, chroma: d }
  45. }
  46. function hslToHex(h, s, l) {
  47. h = ((h % 360) + 360) % 360
  48. const c = (1 - Math.abs(2 * l - 1)) * s
  49. const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
  50. const m = l - c / 2
  51. let rgb
  52. if (h < 60) rgb = [c, x, 0]
  53. else if (h < 120) rgb = [x, c, 0]
  54. else if (h < 180) rgb = [0, c, x]
  55. else if (h < 240) rgb = [0, x, c]
  56. else if (h < 300) rgb = [x, 0, c]
  57. else rgb = [c, 0, x]
  58. return '#' + rgb.map(v => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')
  59. }
  60. const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
  61. /**
  62. * 亮色 -> 暗色。
  63. *
  64. * 同一个色值在项目里既可能当文字、当背景、也可能当边框(例如 #172033 既是标题色,
  65. * 也是证件照页选中态的深底;#e8eef7 主要是卡片描边),因此按
  66. * 「用法角色 + 彩度 + 亮度」决定映射方向:
  67. * - text:深色文字提亮,浅灰文字压成暗色下的弱文字;
  68. * - line(border / ring / divide / outline):亮色下的浅描边必须映射成「比面板更亮」
  69. * 的线色,否则暗色下边框会和面板同色、直接看不见;
  70. * - surface:浅色底转深色底;本来就是深色的底(遮罩、深色 hero)原样保留。
  71. * @param {string} hex 亮色取值
  72. * @param {'text'|'line'|'surface'} role 该色值在源码中的主要用法
  73. */
  74. function darkFor(hex, role = 'surface') {
  75. const { h, s, l, chroma } = rgbToHsl(hexToRgb(hex))
  76. const isText = role === 'text'
  77. const isLine = role === 'line'
  78. // 鲜艳的品牌色 / 语义色:保持色相与识别度
  79. if (chroma >= 0.45) {
  80. if (isText && l < 0.62) return hslToHex(h, Math.min(s, 0.95), 0.65)
  81. if (isLine && l >= 0.85) return hslToHex(h, clamp(s, 0.25, 0.55), 0.42) // 彩色描边要看得见
  82. if (!isText && !isLine && l >= 0.85) return hslToHex(h, clamp(s, 0.2, 0.55), 0.30)
  83. return hex
  84. }
  85. // 带色调的浅底(蓝/绿/橙/红/紫的 tint)
  86. if (chroma >= 0.055) {
  87. if (isText) {
  88. return l < 0.45
  89. ? hslToHex(h, Math.min(s, 0.20), 0.88)
  90. : hslToHex(h, Math.min(s, 0.25), 0.62)
  91. }
  92. if (isLine) {
  93. if (l < 0.35) return hex // 本来就是深色描边
  94. if (l >= 0.90) return hslToHex(h, Math.min(s, 0.22), 0.32) // 极浅描边 -> 可见线色
  95. if (l >= 0.70) return hslToHex(h, Math.min(s, 0.28), 0.38)
  96. return hslToHex(h, Math.min(s, 0.32), 0.46)
  97. }
  98. if (l < 0.35) return hex
  99. if (l >= 0.93) return hslToHex(h, Math.min(s, 0.42), 0.16)
  100. if (l >= 0.85) return hslToHex(h, Math.min(s, 0.45), 0.20)
  101. if (l >= 0.70) return hslToHex(h, Math.min(s, 0.45), 0.26)
  102. return hslToHex(h, Math.min(s, 0.40), 0.34)
  103. }
  104. // 中性灰:暗色下统一到一套固定层级,避免同层级颜色互相不一致
  105. if (isText) {
  106. if (l < 0.45) return '#e9eef6' // 标题
  107. if (l < 0.62) return '#aebdd0' // 正文
  108. return '#8494a8' // 弱文字
  109. }
  110. if (isLine) {
  111. if (l < 0.35) return hex
  112. if (l >= 0.90) return '#2f3a49' // 常规描边(比面板亮一档)
  113. if (l >= 0.75) return '#3a4757' // 稍重的描边
  114. return '#485768'
  115. }
  116. if (l < 0.35) return hex // 已是深底(遮罩等),沿用
  117. if (l >= 0.99) return '#171e2a' // 面板
  118. if (l >= 0.955) return '#1b2331' // 次级面板
  119. if (l >= 0.90) return '#202939'
  120. if (l >= 0.80) return '#2a3442'
  121. return '#3a4553'
  122. }
  123. /** 人工校准的关键层级(覆盖算法结果,保证主要面板/文字层级干净) */
  124. const CURATED = {
  125. ffffff: '#171e2a', // 卡片、弹层、页头
  126. fff: '#171e2a',
  127. f3f7fb: '#090d13', // 页面底色(与面板拉开对比,无阴影时靠它区分层次)
  128. f4f7fb: '#090d13',
  129. f7f9fd: '#1d2632', // 次级面板
  130. f1f5fa: '#1d2632',
  131. eef2f7: '#2f3a49', // 也是描边(15 处 border),不能按浅底压暗,否则边线看不见
  132. e8eef7: '#2f3a49', // 主描边
  133. eef7ff: '#152a40', // 品牌浅底(选中/hover)
  134. e0f0ff: '#1b3550',
  135. // 品牌蓝钉死不变:它既是文字色也是按钮底色,提亮会让按钮上的白字掉到 3:1 以下;
  136. // 而 #0b8cff 落在暗色面板上本身就有约 5:1 的对比,不需要动。
  137. '0b8cff': '#0b8cff',
  138. '1f8cff': '#1f8cff',
  139. '3aa9ff': '#3aa9ff',
  140. '0a7ce0': '#0a7ce0',
  141. '172033': '#e9eef6', // 标题(注意:#0f172a / #0b1324 在本项目里是深色底与遮罩,不在此列)
  142. '4b5b70': '#aebdd0', // 正文
  143. '5b6b80': '#a2b2c6',
  144. '66758a': '#97a7bb',
  145. '8a97a8': '#8494a8', // 弱文字
  146. '8a95a6': '#8494a8',
  147. a0aabb: '#7c8b9f',
  148. '9aa5b5': '#7c8b9f',
  149. f04438: '#ff6f63', // 语义-危险
  150. e5484d: '#ff6f63',
  151. '17a65a': '#38c884', // 语义-成功
  152. '159a54': '#38c884',
  153. e85f1c: '#ff9455', // 语义-警示
  154. ff681f: '#ff9455',
  155. '7a5af8': '#a68cff', // 语义-紫
  156. }
  157. /* ---------------- 需要替换的 Tailwind 具名颜色 ---------------- */
  158. // 只映射「作为表面/文字层级」的具名色;text-white、bg-white/低透明度等装饰用法保持原样
  159. const NAMED = {
  160. 'gray-50': 'f9fafb',
  161. 'gray-100': 'f3f4f6',
  162. 'gray-200': 'e5e7eb',
  163. 'slate-100': 'f1f5f9',
  164. 'slate-200': 'e2e8f0',
  165. 'slate-300': 'cbd5e1',
  166. 'slate-400': '94a3b8',
  167. 'slate-500': '64748b',
  168. 'slate-600': '475569',
  169. 'slate-900': '0f172a',
  170. }
  171. /* ---------------- 收集文件 ---------------- */
  172. const files = []
  173. ;(function walk(dir) {
  174. for (const name of readdirSync(dir)) {
  175. const p = join(dir, name)
  176. if (statSync(p).isDirectory()) walk(p)
  177. else if (name.endsWith('.vue')) files.push(p)
  178. }
  179. })(appDir)
  180. const hexUsed = new Set()
  181. const shadowColors = new Map() // var 名 -> 亮色 rgba 值
  182. const roleCount = new Map() // hex -> { text, line, surface }
  183. const TEXT_UTILS = new Set(['text', 'placeholder', 'decoration', 'caret'])
  184. const LINE_UTILS = new Set(['border', 'ring', 'divide', 'outline'])
  185. function utilRole(util) {
  186. if (TEXT_UTILS.has(util)) return 'text'
  187. if (LINE_UTILS.has(util)) return 'line'
  188. return 'surface'
  189. }
  190. function addRole(hex, role) {
  191. if (!roleCount.has(hex)) roleCount.set(hex, { text: 0, line: 0, surface: 0 })
  192. roleCount.get(hex)[role]++
  193. }
  194. function countRoles(src) {
  195. // Tailwind 工具类
  196. for (const m of src.matchAll(/([a-z]+)-\[var\(--c-([0-9a-f]{6})\)\]/g)) {
  197. addRole(m[2], utilRole(m[1]))
  198. }
  199. // <style> 段里的 CSS 声明(富文本样式等)
  200. for (const block of src.matchAll(/<style[\s\S]*?<\/style>/g)) {
  201. for (const m of block[0].matchAll(/(^|[\s;{])color\s*:\s*var\(--c-([0-9a-f]{6})\)/g)) {
  202. addRole(m[2], 'text')
  203. }
  204. for (const m of block[0].matchAll(/(border|outline)[\w-]*\s*:[^;]*var\(--c-([0-9a-f]{6})\)/g)) {
  205. addRole(m[2], 'line')
  206. }
  207. for (const m of block[0].matchAll(/(background|box-shadow|fill|stroke)[\w-]*\s*:[^;]*var\(--c-([0-9a-f]{6})\)/g)) {
  208. addRole(m[2], 'surface')
  209. }
  210. }
  211. }
  212. /** 取用得最多的角色;数量相同时,line 优先于 surface(描边看不见比底色偏差更刺眼) */
  213. function roleOf(hex) {
  214. const rec = roleCount.get(hex)
  215. if (!rec) return 'surface'
  216. if (rec.text > rec.line && rec.text > rec.surface) return 'text'
  217. if (rec.line >= rec.surface && rec.line > 0) return 'line'
  218. return 'surface'
  219. }
  220. /**
  221. * 阴影色 var 名由 rgba 数值直接编码(--sh-r-g-b-a千分位),
  222. * 这样脚本可重复执行:即使源码里已经是 var(--sh-...),也能还原出亮色取值。
  223. */
  224. function shadowVar(raw) {
  225. const nums = raw.match(/[\d.]+/g) || []
  226. const [r, g, b] = nums.slice(0, 3).map(Number)
  227. let a = nums.length > 3 ? Number(nums[3]) : 1
  228. if (raw.includes('%')) a = a / 100
  229. const name = `--sh-${r}-${g}-${b}-${Math.round(a * 1000)}`
  230. shadowColors.set(name, `rgba(${r}, ${g}, ${b}, ${+a.toFixed(3)})`)
  231. return name
  232. }
  233. function transform(src) {
  234. // 已替换过的令牌先登记,保证脚本可重复执行
  235. for (const m of src.matchAll(/var\(--c-([0-9a-f]{6})\)/g)) hexUsed.add(m[1])
  236. for (const m of src.matchAll(/var\(--sh-(\d+)-(\d+)-(\d+)-(\d+)\)/g)) {
  237. shadowColors.set(`--sh-${m[1]}-${m[2]}-${m[3]}-${m[4]}`, `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${+(Number(m[4]) / 1000).toFixed(3)})`)
  238. }
  239. // 方括号里的 hex 一定是 Tailwind 任意值(含 <script> 里的 class 字符串),全文替换。
  240. // 复合任意值也覆盖,例如 shadow-[inset_0_0_0_1px_#edf2f7]、bg-[linear-gradient(...,#edf2f8_25%,...)]。
  241. // 裸写的 '#438edb'(如证件照背景色,会作为数据传给后端)不在方括号里,不会被动到。
  242. // 注意:hex 后面不能用 \b —— 任意值里的空格被写成下划线(#edf2f8_25%),
  243. // 下划线是单词字符,\b 不成立会漏改。这里用「后面不是 hex 字符」来收尾。
  244. let out = src.replace(/\[[^\s"'`\]]*#[0-9a-fA-F]{3,8}[^\s"'`]*?\]/g, token =>
  245. token.replace(/#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g, (_all, hx) => {
  246. const key = normHex(hx)
  247. hexUsed.add(key)
  248. return `var(--c-${key})`
  249. }),
  250. )
  251. // 其余规则(rgba、具名色)跳过 <script> 段,避免误改脚本里作为数据的颜色。
  252. // 注意:不能用 <template>...</template> 去框定范围 —— 页面里有 <template #slot> 嵌套,
  253. // 非贪婪匹配会在第一个 </template> 处收尾,导致后半个模板漏改。
  254. const parts = []
  255. const re = /<script[\s\S]*?<\/script>/g
  256. let last = 0
  257. let m
  258. while ((m = re.exec(out))) {
  259. parts.push(['edit', out.slice(last, m.index)])
  260. parts.push(['keep', m[0]])
  261. last = m.index + m[0].length
  262. }
  263. parts.push(['edit', out.slice(last)])
  264. out = parts.map(([kind, text]) => (kind === 'keep' ? text : editSection(text))).join('')
  265. // <style> 段里的裸 hex(富文本 prose 样式、滚动条等)
  266. out = out.replace(/<style[\s\S]*?<\/style>/g, block =>
  267. block.replace(/#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g, (_all, hx) => {
  268. const key = normHex(hx)
  269. hexUsed.add(key)
  270. return `var(--c-${key})`
  271. }),
  272. )
  273. return out
  274. }
  275. function editSection(text) {
  276. let out = text
  277. // 2) 阴影等复合任意值里的 rgb/rgba
  278. out = out.replace(/rgba?\([^)]*\)/g, (raw) => {
  279. // style 段里的 CSS 变量定义本身不处理
  280. const name = shadowVar(raw.replace(/\s+/g, ' ').trim())
  281. return `var(${name})`
  282. })
  283. // 3) 具名中性色(表面/文字层级)
  284. for (const [named, hx] of Object.entries(NAMED)) {
  285. const re = new RegExp(`\\b(bg|text|border|ring|from|via|to|divide|placeholder)-${named}\\b(?!/)`, 'g')
  286. out = out.replace(re, (_a, util) => {
  287. hexUsed.add(hx)
  288. return `${util}-[var(--c-${hx})]`
  289. })
  290. }
  291. // 4) white:仅表面类用法替换,text-white 与低透明度装饰保持不变
  292. out = out.replace(/\b(bg|from|via|to|divide)-white\b(?!\/)/g, (_a, util) => {
  293. hexUsed.add('ffffff')
  294. return `${util}-[var(--c-ffffff)]`
  295. })
  296. out = out.replace(/\b(border|ring)-white\b(?!\/)/g, (_a, util) => {
  297. hexUsed.add('e8eef7')
  298. return `${util}-[var(--c-e8eef7)]`
  299. })
  300. // bg-white/70 及以上视为面板(半透明毛玻璃面板)
  301. out = out.replace(/\bbg-white\/(\d{2,3})\b/g, (all, a) => {
  302. if (Number(a) < 70) return all
  303. hexUsed.add('ffffff')
  304. return `bg-[var(--c-ffffff)]/${a}`
  305. })
  306. return out
  307. }
  308. /* ---------------- 执行替换 ---------------- */
  309. let changed = 0
  310. for (const f of files) {
  311. const src = readFileSync(f, 'utf8')
  312. const next = transform(src)
  313. countRoles(next)
  314. if (next !== src) {
  315. changed++
  316. if (!dry) writeFileSync(f, next, 'utf8')
  317. }
  318. }
  319. /* ---------------- 生成 theme.css ---------------- */
  320. /**
  321. * 阴影色的暗色取值。
  322. *
  323. * 亮色下这些投影是「浅灰蓝 + 很低透明度」,直接搬到暗色底上等于不存在。
  324. * 处理方式:
  325. * - 品牌色光晕(按钮、选中态)保留色相并略微加强,暗色下依然是发光效果;
  326. * - 中性投影换成近黑并显著提高透明度,才能在深色面板上压出层次。
  327. * 卡片边缘主要靠描边(line 角色)和「面板 / 页面底」的明度差来区分,不指望投影。
  328. */
  329. function darkShadow(raw) {
  330. const nums = raw.match(/[\d.]+/g) || []
  331. const [r, g, b] = nums.slice(0, 3).map(Number)
  332. const a = nums.length > 3 ? Number(nums[3]) : 1
  333. const isBrand = b > 180 && g > 90 && r < 80
  334. if (isBrand) return `rgba(${r}, ${g}, ${b}, ${Math.min(+(a * 1.3).toFixed(3), 0.5)})`
  335. return `rgba(0, 0, 0, ${clamp(+(a * 3.2).toFixed(3), 0.28, 0.62)})`
  336. }
  337. const hexList = [...hexUsed].sort()
  338. const lines = []
  339. lines.push('/**')
  340. lines.push(' * 主题色令牌(由 scripts/gen-theme.mjs 生成,勿手改生成段)')
  341. lines.push(' *')
  342. lines.push(' * :root 为亮色,取值与改造前的硬编码色值完全一致 —— 亮色视觉不变。')
  343. lines.push(' * html.dark 为暗色,按层级(页面底/面板/边框/文字/品牌/语义)分桶取值。')
  344. lines.push(' * 切换由 @nuxtjs/color-mode(Nuxt UI 内置)在 <html> 上加 .dark 类完成。')
  345. lines.push(' */')
  346. lines.push('')
  347. lines.push('/* Tailwind v4 默认 dark: 走 prefers-color-scheme,这里改为跟随 .dark 类 */')
  348. lines.push('@custom-variant dark (&:where(.dark, .dark *));')
  349. lines.push('')
  350. lines.push(':root {')
  351. lines.push(' color-scheme: light;')
  352. const shadowList = [...shadowColors.entries()].sort((a, b) => a[0].localeCompare(b[0]))
  353. for (const h of hexList) lines.push(` --c-${h}: #${h};`)
  354. for (const [name, raw] of shadowList) lines.push(` ${name}: ${raw};`)
  355. lines.push('}')
  356. lines.push('')
  357. lines.push('html.dark {')
  358. lines.push(' color-scheme: dark;')
  359. for (const h of hexList) lines.push(` --c-${h}: ${CURATED[h] || darkFor('#' + h, roleOf(h))};`)
  360. for (const [name, raw] of shadowList) lines.push(` ${name}: ${darkShadow(raw)};`)
  361. lines.push('}')
  362. lines.push('')
  363. if (!dry) writeFileSync(cssOut, lines.join('\n'), 'utf8')
  364. console.log(`files: ${files.length}, changed: ${changed}`)
  365. console.log(`hex tokens: ${hexList.length}, shadow tokens: ${shadowColors.size}`)
  366. console.log(dry ? '(dry run)' : `written: ${cssOut}`)