/** * ui-tokens.mjs — UI token 审计共享库 * * 以 src/styles/base.css 为唯一 palette / 字阶 / 半径来源, * 同时服务静态审计(ui-token-audit.mjs)与渲染审计(ui-rendered-audit.mjs)。 */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const mobileRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); export const tokenFile = join(mobileRoot, 'src', 'styles', 'base.css'); export function normalizeHex(hex) { let h = hex.toLowerCase(); if (h.length === 4 || h.length === 5) { h = '#' + [...h.slice(1)].map((c) => c + c).join(''); } return h; } export function normalizeRgba(fn) { return fn.toLowerCase().replace(/\s+/g, ''); } /** 渲染侧 computed style 给出的是 rgb()/rgba() 字符串,统一规整;不透明时折叠为 hex */ export function normalizeComputedColor(value) { const v = value.toLowerCase().replace(/\s+/g, ''); const m = v.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)$/); if (!m) return v; const alpha = m[4] === undefined ? 1 : Number(m[4]); if (alpha === 0) return 'transparent'; if (alpha === 1) { return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join(''); } return v; } export function hexToRgb(h) { return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); } export function colorDist(a, b) { const [r1, g1, b1] = hexToRgb(a); const [r2, g2, b2] = hexToRgb(b); return Math.round(Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2)); } export const NEAR_MISS_MAX_DIST = 24; // RGB 欧氏距离 ≤24 视为"近色漂移",疑似 token 笔误 export function loadTokens() { const baseCss = readFileSync(tokenFile, 'utf8'); const palette = new Set(['transparent', 'currentcolor', 'inherit']); for (const m of baseCss.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) palette.add(normalizeHex(m[0])); for (const m of baseCss.matchAll(/rgba?\([^)]*\)/g)) palette.add(normalizeRgba(m[0])); const fontScale = new Set(); for (const m of baseCss.matchAll(/font-size:\s*([\d.]+)px/g)) fontScale.add(Number(m[1])); const radiusTokens = new Set(); for (const m of baseCss.matchAll(/--[\w-]*radius[\w-]*:\s*([\d.]+)px/g)) radiusTokens.add(Number(m[1])); for (const m of baseCss.matchAll(/border-radius:\s*([\d.]+)px/g)) radiusTokens.add(Number(m[1])); const paletteHexes = [...palette].filter((v) => v.startsWith('#') && v.length === 7); function nearestPalette(hex) { let best = null; for (const p of paletteHexes) { const d = colorDist(hex, p); if (!best || d < best.dist) best = { value: p, dist: d }; } return best; } return { palette, paletteHexes, fontScale, radiusTokens, nearestPalette }; }