/*** @ui-arch 视觉规则表:硬编码视觉字面量 → 设计令牌的唯一映射来源 * * 为什么单独成模块:迁移脚本(scripts/visual-migrate.mjs)与审计门禁 * (packages/tools/cli/lib/visual-audit.mjs)必须用同一张表,否则「迁移完还报漂移」 * 或「门禁放过迁移漏掉的写法」这类派生语义不一致必然出现(CODING.md §5)。 * 改规则只改这里;新增一类硬编码 = 加一条 KIND_META + 一个 detect 分支 + migrateDeclaration 分支。 */ /*** 皮肤包样式表相对仓库根的路径(迁移与审计共同的目标文件) ***/ export const UI_CSS_REL = 'packages/skin/ui/ui.css'; /*** 作用:五类硬编码的键名、中文名与判定口径,审计输出与报告都按这个顺序 ***/ export const KIND_META = [ { key: 'font-size-literal', label: '字号字面值', hint: 'font-size 写死 px/em,应改用 --ua-font-size-*', }, { key: 'duration-literal', label: '动效时长字面值', hint: 'transition/animation 写死时长,应改用 --ua-duration-*', }, { key: 'shadow-literal', label: '阴影字面值', hint: 'box-shadow 里出现 px 长度,应整条改用 --ua-shadow-1..4', }, { key: 'radius-literal', label: '圆角字面值', hint: 'border-radius 写死 px,应改用 --ua-radius-*', }, { key: 'focus-ring-variant', label: '焦点环变体', hint: '自绘 0 0 0 Npx 色环,应改用 --ua-focus-ring / --ua-focus-ring-inset', }, { key: 'space-literal', label: '内距字面值', hint: 'padding/gap/margin 写死 px,应改用 --ua-space-*', }, ]; /*** 作用:需要扫描的 CSS 属性白名单(其余属性本轮不动;用白名单顺带避开伪类选择器误判) ***/ const SCAN_PROPS = new Set([ 'font-size', 'transition', 'animation', 'transition-duration', 'animation-duration', 'box-shadow', 'border-radius', 'outline', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'gap', 'row-gap', 'column-gap', ]); /*** 作用:内距类属性集合(迁移与统计共用同一判定) ***/ const SPACE_PROPS = new Set([ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'gap', 'row-gap', 'column-gap', ]); /*** 作用:内距 px 字面值 → 令牌(半步档位 05/15/25/35 与既有 1..4 组成完整梯子;1px 是边框语义不入表) ***/ const SPACE_TOKENS = new Map([ ['2px', 'var(--ua-space-05)'], ['4px', 'var(--ua-space-1)'], ['6px', 'var(--ua-space-15)'], ['8px', 'var(--ua-space-2)'], ['10px', 'var(--ua-space-25)'], ['12px', 'var(--ua-space-3)'], ['14px', 'var(--ua-space-35)'], ['16px', 'var(--ua-space-4)'], ]); /*** 作用:字号字面值 → 令牌(10/11 归并到 xs,避免 11px 单独成档) ***/ const FONT_SIZE_TOKENS = new Map([ ['10px', 'var(--ua-font-size-xs)'], ['11px', 'var(--ua-font-size-xs)'], ['12px', 'var(--ua-font-size-sm)'], ['13px', 'var(--ua-font-size-md)'], ['14px', 'var(--ua-font-size-lg)'], ['16px', 'var(--ua-font-size-xl)'], ['18px', 'var(--ua-font-size-2xl)'], ]); /*** 作用:圆角字面值 → 令牌(1px 细线、50% 圆形、999px 胶囊是语义形状,不属漂移,保留) ***/ const RADIUS_TOKENS = new Map([ ['4px', 'var(--ua-radius-sm)'], ['10px', 'var(--ua-radius-md)'], ]); /*** 作用:圆角里视为语义化、不参与漂移统计的取值 ***/ const RADIUS_LEGIT = new Set(['0', '1px', '50%', '999px', 'inherit']); /*** 作用:纯黑投影的(位移:模糊:透明度)→ elevation 档位;表外的主题色投影留待后续按语义人工归并 ***/ const ELEVATION_BY_SHAPE = new Map([ ['8:24:0.12', '2'], ['8:24:0.14', '2'], ['10:28:0.16', '3'], ['12:28:0.18', '4'], ['16:32:0.24', '4'], ['10:40:0.2', '4'], ['0:24:0.18', '2'], ['0:40:0.22', '4'], ]); /*** 作用:动效时长三档的秒数上限(fast 取 0.15s,因为既有 111 处都是 0.15s,把离群值收进来不改变观感) ***/ const DURATION_STEPS = [ { max: 0.17, token: 'var(--ua-duration-fast)' }, { max: 0.23, token: 'var(--ua-duration-base)' }, { max: 0.2501, token: 'var(--ua-duration-slow)' }, ]; /*** 作用:压缩空白,便于用稳定字符串比对(不改语义) ***/ export function squash(value) { return String(value).replace(/\s+/g, ' ').trim(); } /*** 作用:把整段 CSS 的注释替换成等长空格,保持偏移与行号可对齐(迁移时按偏移回写原文) ***/ export function blankComments(src) { let out = ''; let i = 0; while (i < src.length) { if (src[i] === '/' && src[i + 1] === '*') { const close = src.indexOf('*/', i + 2); const stop = close === -1 ? src.length : close + 2; // 未闭合时一路吞到文件尾 for (let k = i; k < stop; k++) out += src[k] === '\n' ? '\n' : ' '; i = stop; } else { out += src[i]; i++; } } return out; } /*** 作用:逐条产出「属性 + 值」及其在原文中的偏移;只认白名单属性且必须以分号收尾(避开伪类选择器) ***/ export function eachDeclaration(code) { const re = /(padding-top|padding-right|padding-bottom|padding-left|padding|margin-top|margin-right|margin-bottom|margin-left|margin|row-gap|column-gap|gap|transition-duration|animation-duration|transition|animation|font-size|box-shadow|border-radius|outline)\s*:\s*([^;{}]*);/gi; const list = []; for (const m of code.matchAll(re)) { const prop = m[1].toLowerCase(); if (!SCAN_PROPS.has(prop)) continue; // 白名单外的属性不进入迁移与统计 const valueEnd = m.index + m[0].length - 1; // 指向结尾分号 list.push({ prop, value: m[2], valueStart: valueEnd - m[2].length, valueEnd }); } return list; } /*** 作用:拆出尾部的 !important,迁移只看核心值、尾部原样保留 ***/ function splitImportant(raw) { const m = String(raw).match(/^(.*?)(\s*!important\s*)$/i); return m ? { core: m[1], tail: m[2] } : { core: raw, tail: '' }; } /*** 作用:按顶层逗号切分(跳过括号内的逗号,cubic-bezier 的入参不会被切开) ***/ function splitTopLevel(value) { const parts = []; let depth = 0; let start = 0; for (let i = 0; i < value.length; i++) { const ch = value[i]; if (ch === '(') depth++; else if (ch === ')') depth = Math.max(0, depth - 1); else if (ch === ',' && depth === 0) { parts.push(value.slice(start, i)); start = i + 1; } } parts.push(value.slice(start)); return parts; } /*** 作用:单个时长字面值 → 档位令牌;超出三档上限的返回 null(如 0.3s / 1.6s 的加载动画节奏,保留原值) ***/ function durationToken(text) { const m = String(text).match(/^(\d*\.?\d+)(ms|s)$/i); if (!m) return null; const sec = m[2].toLowerCase() === 'ms' ? Number(m[1]) / 1000 : Number(m[1]); for (const step of DURATION_STEPS) if (sec <= step.max) return step.token; return null; } /*** 作用:判断一段动效值里是否已写了缓动(含令牌形式;写了就保留,不覆盖作者意图) ***/ function hasTiming(value) { if (/var\(--ua-ease/.test(value)) return true; if (/(cubic-bezier|steps|linear)\(/.test(value)) return true; // 函数式缓动:参数里的逗号不是分段符 return /(^|[\s,])(step-start|step-end|ease-in-out|ease-in|ease-out|ease|linear)(?=$|[\s,])/.test(value); } /*** 作用:迁移单个动效声明值(逐段替换时长,缺缓动时补 --ua-ease);返回 null 表示无需改动 ***/ function migrateMotion(prop, core) { const shorthand = prop === 'transition' || prop === 'animation'; // 只有简写能补缓动,-duration 属性补了是非法的 const parts = splitTopLevel(core); let hits = 0; const next = parts.map((segment) => { let replaced = 0; let out = segment.replace(/(\d*\.?\d+)(ms|s)\b/gi, (token) => { const mapped = durationToken(token); if (!mapped) return token; // 表外时长保留,交给后续视觉条目单独评估 replaced++; hits++; return mapped; }); if (shorthand && replaced && !hasTiming(out)) out = `${out} var(--ua-ease)`; // 只补缺失的缓动,写过的保 return out; }); if (!hits) return null; return { kind: 'duration-literal', value: next.join(',') }; } /*** 作用:迁移单条 box-shadow:焦点环收敛成两个令牌,纯黑投影按 位移/模糊/透明度 归入 elevation 四档 ***/ function migrateShadow(core) { const value = squash(core); // 焦点环:2/3px 的强调色描边环;5px 4% 这类是呼吸光晕,动效依赖两个不同状态,不并入令牌 const ring = value.match(/^(inset )?0 0 0 ([23])px color-mix\(in srgb, var\(--ua-color-accent\) (\d+)%, transparent\)$/); if (ring && Number(ring[3]) >= 10) { return { kind: 'focus-ring-variant', value: ring[1] ? 'var(--ua-focus-ring-inset)' : 'var(--ua-focus-ring)' }; } const offset = value.match(/^0 (\d+)px (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 带位移的投影 const glow = value.match(/^0 0 (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 无位移的发光投影 const hit = offset ? ELEVATION_BY_SHAPE.get(`${offset[1]}:${offset[2]}:${offset[3]}`) : glow ? ELEVATION_BY_SHAPE.get(`0:${glow[1]}:${glow[2]}`) : undefined; if (!hit) return null; // 主题色投影 / 状态色环 / 遮罩色块保留,由视觉条目逐条人工归并 return { kind: 'shadow-literal', value: `var(--ua-shadow-${hit})` }; } /*** 作用:迁移单条内距声明:逐个 px 记号查 SPACE_TOKENS 替换(负值与 calc 内不算,返回 null 表示无需改动) ***/ function migrateSpace(core) { let hits = 0; const next = String(core).replace(/(^|[\s(,])(-?\d+(?:\.\d+)?)px\b/gi, (full, lead, num) => { if (String(num).startsWith('-')) return full; // 负外距是偏移语义,不入令牌 const mapped = SPACE_TOKENS.get(`${num}px`); if (!mapped) return full; // 表外取值(3px / 5px / 22px 等)留给后续条目评估 hits++; return `${lead}${mapped}`; }); if (!hits) return null; return { kind: 'space-literal', value: next }; } /*** 作用:判定单条声明是否需要迁移,返回 { kind, value } 或 null(幂等:已是令牌就返回 null) ***/ export function migrateDeclaration(prop, rawValue) { const { core, tail } = splitImportant(rawValue); let result = null; if (prop === 'font-size') { const token = FONT_SIZE_TOKENS.get(squash(core)); if (token) result = { kind: 'font-size-literal', value: token }; } else if (prop === 'border-radius') { const token = RADIUS_TOKENS.get(squash(core)); if (token) result = { kind: 'radius-literal', value: token }; } else if (prop === 'transition' || prop === 'animation' || prop === 'transition-duration' || prop === 'animation-duration') { result = migrateMotion(prop, core); } else if (prop === 'box-shadow') { result = migrateShadow(core); } else if (SPACE_PROPS.has(prop)) { result = migrateSpace(core); } if (!result) return null; return { kind: result.kind, value: result.value + tail }; } /*** 作用:统计单条声明在一个类别下的命中数(0 表示该类别下不算漂移);口径与迁移规则严格对应 ***/ function detect(prop, rawValue, key) { const { core } = splitImportant(rawValue); const value = squash(core); if (key === 'font-size-literal') { if (prop !== 'font-size') return 0; if (/var\(/.test(value)) return 0; // 已走令牌(含 calc(var(...) * 系数))不算漂移 return /\d+(px|em|rem)\b/.test(value) ? 1 : 0; } if (key === 'duration-literal') { if (!['transition', 'animation', 'transition-duration', 'animation-duration'].includes(prop)) return 0; const stripped = value.replace(/var\(--ua-duration-[\w-]+\)/g, ' '); // 已是令牌的时长先剔除 return (stripped.match(/\b\d*\.?\d+(ms|s)\b/gi) ?? []).length; } if (key === 'shadow-literal') { if (prop !== 'box-shadow') return 0; if (value === 'none') return 0; if (/^0 0 0 (1[0-9]{2,}|[2-9][0-9]{2,})px /.test(value)) return 0; // 巨型扩散是遮罩色块(0 0 0 9999px),不是投影 return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0; } if (key === 'radius-literal') { if (prop !== 'border-radius') return 0; if (/^var\(/.test(value)) return 0; if (RADIUS_LEGIT.has(value)) return 0; return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0; } if (key === 'focus-ring-variant') { // 口径:只统计 2-8px 的 box-shadow 自绘色环。outline 焦点描边是仓库既有的正统兜底写法 // (:focus-visible 全局规则),算进去会让新组件照抄现有写法就被门禁拦住; // 1px 内框是边框语义(backlog 里另有边框条目),与页面底色断开的环是几何技巧,都不入此类别。 if (prop !== 'box-shadow') return 0; if (value === 'var(--ua-focus-ring)' || value === 'var(--ua-focus-ring-inset)') return 0; // 已统一,不算变体 if (!/^(inset )?0 0 0 [2-8]px \S/.test(value)) return 0; return /var\(--ua-color-(accent|warning|danger)/.test(value) ? 1 : 0; } if (key === 'space-literal') { if (!SPACE_PROPS.has(prop)) return 0; // 口径:数非负、非 1px 的 px 记号(1px 是边框/分隔线语义,负外距是偏移语义,都不算漂移) const tokens = String(value).match(/(^|[\s(,])(\d+(?:\.\d+)?)px\b/gi) ?? []; return tokens.filter((t) => !/(^|[\s(,])1px$/i.test(t)).length; } return 0; } /*** 作用:扫描整段 CSS,返回五类硬编码的计数与命中行号(审计命令的唯一统计口径) ***/ export function scanSource(src) { const code = blankComments(src); const lineAt = (offset) => { let line = 1; for (let i = 0; i < offset && i < code.length; i++) if (code[i] === '\n') line++; return line; }; const counts = {}; const hits = {}; for (const meta of KIND_META) { counts[meta.key] = 0; hits[meta.key] = []; } for (const decl of eachDeclaration(code)) { for (const meta of KIND_META) { const n = detect(decl.prop, decl.value, meta.key); if (!n) continue; counts[meta.key] += n; hits[meta.key].push({ line: lineAt(decl.valueStart), prop: decl.prop, value: squash(decl.value) }); } } return { counts, hits }; } /*** 作用:迁移整段 CSS,返回改写后的文本与每类替换处数(幂等:无字面量时 text 与入参完全一致) ***/ export function migrateSource(src) { const code = blankComments(src); const counts = {}; for (const meta of KIND_META) counts[meta.key] = 0; let out = ''; let cursor = 0; for (const decl of eachDeclaration(code)) { const replaced = migrateDeclaration(decl.prop, src.slice(decl.valueStart, decl.valueEnd)); if (!replaced) continue; out += src.slice(cursor, decl.valueStart) + replaced.value; cursor = decl.valueEnd; counts[replaced.kind] += 1; } out += src.slice(cursor); return { text: out, counts }; } /*** 作用:把五类计数拍平成「类别: 数量」数组,供审计报告与基线文件共用同一顺序 ***/ export function countRows(counts) { return KIND_META.map((meta) => ({ ...meta, count: counts[meta.key] ?? 0 })); }