From 6215689ce30c0f828554e3786bda0780998a9654 Mon Sep 17 00:00:00 2001 From: clumsypsc Date: Thu, 1 Oct 2026 14:25:30 +0800 Subject: [PATCH 1/4] =?UTF-8?q?chore(ui):=20=E5=BC=95=E5=85=A5=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=AE=BE=E8=AE=A1=E7=BA=A6=E6=9D=9F=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE=E4=B8=8E=20frontend-craft=20=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把"前端该长什么样"从口头约定变成可执行的检查:三个零依赖脚本 + 一份 stylelint 配置 + 一个可复用的技能包。 ui/scripts/audit-css.mjs(1403 行) 对 .css 与 .vue 的 块参与分析) + * - 文件:直接分析(.vue 同样只取 style 块) + * - 跳过目录:node_modules / dist / build / .git / coverage / vendor + * + * 指标 + * 1. :root 块数量;CSS 变量重复声明(最后一个生效,其余标为死代码) + * 2. 十六进制颜色 distinct + 频次 Top15 + 近重复聚类(RGB 欧氏距离 < 12) + * 3. rgb()/rgba() distinct + 基色(忽略 alpha)distinct + 基色近重复聚类 + * 4. border-radius 取值列表 + distinct + * 5. font-size 取值列表 + distinct + 频次分布(clamp() 整体视作一个值) + * 6. 同一选择器分散在多块(> 1 次)数量 + Top15(提示项,不参与判定) + * 7. token 覆盖率 = var(--x) 次数 /(var 次数 + 硬编码颜色数 + 硬编码间距/字号/圆角 px 数) + * 8. !important 数量 + * 9. 规则块总数 / distinct 选择器数 + * + * 评分(任一 FAIL → 进程退出码 1) + * 彩色 distinct ≤ 5 · border-radius distinct ≤ 4 · font-size distinct ≤ 7 + * 近白背景 distinct ≤ 3 · :root 块 = 1 · token 覆盖率 ≥ 90% + * rgb/rgba 基色 distinct ≤ 3 · 冗余声明(同属性重复声明)= 0 · !important = 0 + * + * 提示项(不参与判定,不影响退出码) + * 同一选择器分散在多块 —— 归零必须合并块,而合并会把声明搬到文件更靠后处, + * 改变它与其它同特异性选择器的先后关系,有渲染风险,因此只提示不判定 + * + * 退出码 + * 0 全部通过 · 1 有未通过项 / 参数或输入错误 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// ───────────────────────────── 常量与阈值 ───────────────────────────── + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', 'coverage', 'vendor']); +const NEAR_DUP_DISTANCE = 12; // RGB 欧氏距离阈值:小于它视为「近重复颜色」 +const NEAR_WHITE_MIN = 245; // 三通道全部 > 245 视为「近白」 +const NEUTRAL_SAT = 0.12; // HSL 饱和度 < 12% 视为中性色 +const NEUTRAL_CHROMA = 6; // 彩度(max-min)≤ 6 视为「彩度接近 0」→ 中性色 +const VALUE_PREVIEW = 12; // 记分卡值列表最多展示几个 +const DETAIL_PREVIEW = 24; // 详情行最多展示几个 +const TOP_N = 15; // Top 列表长度 +const MAX_LISTED_PX = 24; // JSON 里 px 明细最多列几个 + +// 评分阈值(改这里即可调整严格度) +// 注意 duplicateSelectors 与 redundantDeclarations 是两件事: +// redundantDeclarations —— 同一 (上下文, 选择器) 下同一个属性被声明了不止一次。 +// 后一条必然覆盖前一条,删掉前一条不改变任何渲染。这是真正的漂移症状,硬门 = 0。 +// duplicateSelectors(提示,不参与判定)—— 同一选择器分散在多个规则块里,但各块声明的属性互不重叠。 +// 它不是冗余:要归零就得把多个块合并,而合并会把声明搬到文件更靠后的位置, +// 从而改变它与其它同特异性选择器之间的先后关系 —— 那是有渲染风险的,不能当作硬门。 +const LIMITS = { + coloredDistinct: 5, + radiusDistinct: 4, + fontSizeDistinct: 7, + nearWhiteDistinct: 3, + rootBlocks: 1, + tokenCoverage: 90, + rgbBaseDistinct: 3, + redundantDeclarations: 0, + duplicateSelectors: 0, + importantCount: 0, +}; + +// ───────────────────────────── 基础文本工具 ───────────────────────────── + +/** 跳过一段字符串字面量,返回结束引号之后的偏移 */ +function skipString(src, i) { + const n = src.length; + const quote = src[i]; + i++; + while (i < n) { + const c = src[i]; + if (c === '\\') { + i += 2; + continue; + } + if (c === quote) return i + 1; + i++; + } + return i; +} + +/** 跳过一对配对的括号,返回右括号之后的偏移 */ +function skipBalanced(src, i, open, close) { + const n = src.length; + let depth = 0; + while (i < n) { + const c = src[i]; + if (c === '"' || c === "'") { + i = skipString(src, i); + continue; + } + if (c === open) depth++; + else if (c === close) { + depth--; + if (depth === 0) return i + 1; + } + i++; + } + return i; +} + +/** + * 剥离 /* … *\/ 注释,但保持字符串长度不变(注释字符换成空格、换行保留), + * 这样后续所有偏移量都能一对一映射回原文件,行号才不会错位。 + * 注意:只剥离注释、不动声明,所以 :root 里的变量声明不会丢。 + */ +function stripComments(src) { + const out = src.split(''); + const n = src.length; + let i = 0; + while (i < n) { + const c = src[i]; + if (c === '"' || c === "'") { + i = skipString(src, i); + continue; + } + if (c === '/' && src[i + 1] === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? n : end + 2; + for (let k = i; k < stop; k++) { + if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '; + } + i = stop; + continue; + } + i++; + } + return out.join(''); +} + +/** + * .vue:把 diff --git a/.agents/skills/frontend-craft/templates/vue/UiButton.vue b/.agents/skills/frontend-craft/templates/vue/UiButton.vue new file mode 100644 index 0000000..f75c98f --- /dev/null +++ b/.agents/skills/frontend-craft/templates/vue/UiButton.vue @@ -0,0 +1,204 @@ + + + + + + diff --git a/.agents/skills/frontend-craft/templates/vue/UiCard.vue b/.agents/skills/frontend-craft/templates/vue/UiCard.vue new file mode 100644 index 0000000..f8f570a --- /dev/null +++ b/.agents/skills/frontend-craft/templates/vue/UiCard.vue @@ -0,0 +1,78 @@ + + + + + + diff --git a/.agents/skills/frontend-craft/templates/vue/UiEmptyState.vue b/.agents/skills/frontend-craft/templates/vue/UiEmptyState.vue new file mode 100644 index 0000000..626bffe --- /dev/null +++ b/.agents/skills/frontend-craft/templates/vue/UiEmptyState.vue @@ -0,0 +1,89 @@ + + + + + + diff --git a/.agents/skills/frontend-craft/templates/vue/UiInput.vue b/.agents/skills/frontend-craft/templates/vue/UiInput.vue new file mode 100644 index 0000000..328e6b9 --- /dev/null +++ b/.agents/skills/frontend-craft/templates/vue/UiInput.vue @@ -0,0 +1,167 @@ + + + + + + diff --git a/.agents/skills/frontend-craft/templates/vue/UiSelect.vue b/.agents/skills/frontend-craft/templates/vue/UiSelect.vue new file mode 100644 index 0000000..6dd3dc9 --- /dev/null +++ b/.agents/skills/frontend-craft/templates/vue/UiSelect.vue @@ -0,0 +1,157 @@ + + + + + + diff --git a/.gitignore b/.gitignore index 7caa647..3ea7897 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,14 @@ __pycache__/ *.egg-info/ ui/node_modules/ ui/dist/ +ui/shots/ +# 一次性脚本与截图落点:迁移过程中用完即弃,不进仓库。 +# 需要留证的产物放 docs/design/baseline/,不靠 tmp/。 +tmp/ +# 参照物截图(Cursor / Linear / GitHub / Vercel / Notion / Raycast 的产品截图)。 +# 是别人的版权素材、体积 1.8 MB、重跑 capture.mjs 就能再取一份; +# 项目自己的设计证据都在 docs/design/baseline/。 +docs/design/reference/ ui/.tanstack/tmp/ ui/tsconfig.tsbuildinfo ui/.output/ diff --git a/ui/package.json b/ui/package.json index 8bfc307..c72f961 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,14 @@ "build": "vite build", "test": "vitest run", "test:watch": "vitest", - "preview": "vite preview --host 127.0.0.1 --port 3000" + "preview": "vite preview --host 127.0.0.1 --port 3000", + "audit": "node scripts/audit-css.mjs src --ignore tokens.css", + "audit:json": "node scripts/audit-css.mjs src --ignore tokens.css --json", + "shots": "node scripts/capture.mjs http://127.0.0.1:3000 shots --widths 375,768,1440 --name shot", + "lint:css": "stylelint \"src/components/ui/**/*.vue\" \"src/components/layout/**/*.vue\"", + "lint:css:all": "stylelint \"src/**/*.{css,vue}\"", + "check:contrast": "node scripts/check-contrast.mjs", + "verify": "npm run lint:css && npm run audit && npm run check:contrast && npm test && npm run build" }, "dependencies": { "@vitejs/plugin-vue": "^6.0.0", @@ -22,6 +29,9 @@ "devDependencies": { "@vue/test-utils": "^2.5.1", "happy-dom": "^20.14.5", + "postcss-html": "^2.0.0", + "stylelint": "^17.15.0", + "stylelint-config-standard": "^40.0.0", "vitest": "^5.0.2" } } diff --git a/ui/scripts/audit-css.mjs b/ui/scripts/audit-css.mjs new file mode 100644 index 0000000..5d7a3dc --- /dev/null +++ b/ui/scripts/audit-css.mjs @@ -0,0 +1,1403 @@ +#!/usr/bin/env node +/** + * audit-css.mjs —— CSS「设计系统漂移」审计记分卡(零依赖 · Node 18+ · 跨平台) + * + * 用途 + * 扫一个前端项目(或单个 CSS 文件),统计颜色 / 圆角 / 字号 / 变量 / 选择器 / + * !important 的散乱程度,输出一张 ✅ / ❌ 记分卡,判断设计系统是否已经漂移。 + * + * 用法 + * node audit-css.mjs [--json] [--quiet] [--help] + * + * 选项 + * --json 输出机器可读 JSON(含全部原始数据 + pass/fail),无装饰字符 + * --quiet 只输出最后一行总结 + * -h, --help 显示本帮助 + * + * 输入 + * - 目录:递归收集所有 .css,以及所有 .vue(只取 块参与分析) + * - 文件:直接分析(.vue 同样只取 style 块) + * - 跳过目录:node_modules / dist / build / .git / coverage / vendor + * + * 指标 + * 1. :root 块数量;CSS 变量重复声明(最后一个生效,其余标为死代码) + * 2. 十六进制颜色 distinct + 频次 Top15 + 近重复聚类(RGB 欧氏距离 < 12) + * 3. rgb()/rgba() distinct + 基色(忽略 alpha)distinct + 基色近重复聚类 + * 4. border-radius 取值列表 + distinct + * 5. font-size 取值列表 + distinct + 频次分布(clamp() 整体视作一个值) + * 6. 同一选择器分散在多块(> 1 次)数量 + Top15(提示项,不参与判定) + * 7. token 覆盖率 = var(--x) 次数 /(var 次数 + 硬编码颜色数 + 硬编码间距/字号/圆角 px 数) + * 8. !important 数量 + * 9. 规则块总数 / distinct 选择器数 + * + * 评分(任一 FAIL → 进程退出码 1) + * 彩色 distinct ≤ 5 · border-radius distinct ≤ 4 · font-size distinct ≤ 7 + * 近白背景 distinct ≤ 3 · :root 块 = 1 · token 覆盖率 ≥ 90% + * rgb/rgba 基色 distinct ≤ 3 · 冗余声明(同属性重复声明)= 0 · !important = 0 + * + * 提示项(不参与判定,不影响退出码) + * 同一选择器分散在多块 —— 归零必须合并块,而合并会把声明搬到文件更靠后处, + * 改变它与其它同特异性选择器的先后关系,有渲染风险,因此只提示不判定 + * + * 退出码 + * 0 全部通过 · 1 有未通过项 / 参数或输入错误 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// ───────────────────────────── 常量与阈值 ───────────────────────────── + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', 'coverage', 'vendor']); +const NEAR_DUP_DISTANCE = 12; // RGB 欧氏距离阈值:小于它视为「近重复颜色」 +const NEAR_WHITE_MIN = 245; // 三通道全部 > 245 视为「近白」 +const NEUTRAL_SAT = 0.12; // HSL 饱和度 < 12% 视为中性色 +const NEUTRAL_CHROMA = 6; // 彩度(max-min)≤ 6 视为「彩度接近 0」→ 中性色 +const VALUE_PREVIEW = 12; // 记分卡值列表最多展示几个 +const DETAIL_PREVIEW = 24; // 详情行最多展示几个 +const TOP_N = 15; // Top 列表长度 +const MAX_LISTED_PX = 24; // JSON 里 px 明细最多列几个 + +// 评分阈值(改这里即可调整严格度) +// 注意 duplicateSelectors 与 redundantDeclarations 是两件事: +// redundantDeclarations —— 同一 (上下文, 选择器) 下同一个属性被声明了不止一次。 +// 后一条必然覆盖前一条,删掉前一条不改变任何渲染。这是真正的漂移症状,硬门 = 0。 +// duplicateSelectors(提示,不参与判定)—— 同一选择器分散在多个规则块里,但各块声明的属性互不重叠。 +// 它不是冗余:要归零就得把多个块合并,而合并会把声明搬到文件更靠后的位置, +// 从而改变它与其它同特异性选择器之间的先后关系 —— 那是有渲染风险的,不能当作硬门。 +const LIMITS = { + coloredDistinct: 5, + radiusDistinct: 4, + fontSizeDistinct: 7, + nearWhiteDistinct: 3, + rootBlocks: 1, + tokenCoverage: 90, + rgbBaseDistinct: 3, + redundantDeclarations: 0, + duplicateSelectors: 0, + importantCount: 0, +}; + +// ───────────────────────────── 基础文本工具 ───────────────────────────── + +/** 跳过一段字符串字面量,返回结束引号之后的偏移 */ +function skipString(src, i) { + const n = src.length; + const quote = src[i]; + i++; + while (i < n) { + const c = src[i]; + if (c === '\\') { + i += 2; + continue; + } + if (c === quote) return i + 1; + i++; + } + return i; +} + +/** 跳过一对配对的括号,返回右括号之后的偏移 */ +function skipBalanced(src, i, open, close) { + const n = src.length; + let depth = 0; + while (i < n) { + const c = src[i]; + if (c === '"' || c === "'") { + i = skipString(src, i); + continue; + } + if (c === open) depth++; + else if (c === close) { + depth--; + if (depth === 0) return i + 1; + } + i++; + } + return i; +} + +/** + * 剥离 /* … *\/ 注释,但保持字符串长度不变(注释字符换成空格、换行保留), + * 这样后续所有偏移量都能一对一映射回原文件,行号才不会错位。 + * 注意:只剥离注释、不动声明,所以 :root 里的变量声明不会丢。 + */ +function stripComments(src) { + const out = src.split(''); + const n = src.length; + let i = 0; + while (i < n) { + const c = src[i]; + if (c === '"' || c === "'") { + i = skipString(src, i); + continue; + } + if (c === '/' && src[i + 1] === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? n : end + 2; + for (let k = i; k < stop; k++) { + if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '; + } + i = stop; + continue; + } + i++; + } + return out.join(''); +} + +/** + * .vue:把 + + +
+ + + diff --git a/ui/preview/active-tasks.js b/ui/preview/active-tasks.js new file mode 100644 index 0000000..6eb3cfc --- /dev/null +++ b/ui/preview/active-tasks.js @@ -0,0 +1,55 @@ +// ActiveTaskStrip 的 1:1 放大夹具。 +// +// 为什么单独建这一个:这个组件在外层