diff --git a/.ua/backlog.json b/.ua/backlog.json index 4808e39..32833ce 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3041,6 +3041,15 @@ "motivation": "表格列多时用户只想看关心的几列,目前只能靠调用方自己改 columns 重渲染;且 dataTable.ts 已顶到 199 行上限,受控桥接与工具栏构件该分出去。", "acceptance": "① 开 columnToggle 后工具栏出现「列」按钮,弹层勾选即时增删列(表头/单元格/colspan/骨架行/空状态/导出全部跟随);② 至少保留一列可见,最后一列的勾选框置灰;③ 传 hiddenColumns 信号可受控并写回;④ 样式只消费 --ua-* 令牌,npm run audit:visual 计数不涨;⑤ 受控页码与选中桥接抽到 tableBridge.ts,dataTable.ts 仍 ≤200 行;⑥ npm run check 全绿。", "notes": "列显隐落地:新 tableColumns.ts(触发器 + 勾选弹层,末列锁死)+ dataTable 派生 shown()/span() 统一供表头/单元格/骨架/空状态/colgroup 使用;受控页码与选中桥接抽到 tableBridge.ts(dataTable.ts 199→186 行);导出改惰性取列跟随可见列;ui.css 加 .ua-col-list/.ua-col-item(纯令牌,audit:visual 仍 0 处)" + }, + { + "id": "g295", + "domain": "visual", + "status": "open", + "title": "表面阶梯审计:新增 ua audit:surface 并修正悬停底看见不了", + "motivation": "对比度门禁只管「文字 vs 底色」,管不到「底色 vs 底色」;实测表面→二阶表面亮度比只有 1.008(暗色 1.006),即悬停底与表面几乎同色,6 处 surface-2 与新建的列面板悬停都白改。", + "acceptance": "① ua audit:surface 按四套主题逐档验阶梯(页面底→表面→悬停底→按下底 ≥1.04,凹陷底 ≥1.02),不达标退出码 1,负向自检确认会红;② surface-2/3 改为从 surface 派生并拉开档距;③ audit:surface 进 check 链、package.json 脚本、usage 串与 doctor 的 check 链检查;④ 列面板悬停改用 surface-2(原先撞 .ua-pop 同底色);⑤ audit:contrast 与 audit:visual 仍全绿。", + "notes": "" } ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d39d4db..d3b9ba3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ 1. **包管理器必须用 pnpm**:包间依赖用 `workspace:*` 协议,npm 不支持。安装依赖:`pnpm install`。 2. **构建工具**:TypeScript 5(`tsc`)。若环境未全局安装,可使用托管工作区的副本:`C:/Users/shawn/.workbuddy/binaries/node/workspace/node_modules/typescript`(本仓库历史即如此构建)。 3. **Node**:>= 18,ESM(`"type": "module"`)。 -4. **环境自检 + 验证基线**:先跑 `node packages/tools/cli/bin/ua.js doctor`(秒级 11 项检查:node 版本 / 依赖安装 / git 钩子 / git 身份 / .ua 元数据可解析 / usage 串 ↔ dispatch 一致 / check 链覆盖全部门禁步骤 / CLI 清单声明的命令都有实现,任一失败退出码 1),通过后再跑 `npm run check`(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion)确认基线全绿,再开始改代码。 +4. **环境自检 + 验证基线**:先跑 `node packages/tools/cli/bin/ua.js doctor`(秒级 11 项检查:node 版本 / 依赖安装 / git 钩子 / git 身份 / .ua 元数据可解析 / usage 串 ↔ dispatch 一致 / check 链覆盖全部门禁步骤 / CLI 清单声明的命令都有实现,任一失败退出码 1),通过后再跑 `npm run check`(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion → audit:surface)确认基线全绿,再开始改代码。 ## 编码规范(强制) @@ -27,7 +27,7 @@ - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html - `apps/docs`:文档站,纯产物(ua docs 生成) -**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast + audit:motion,提交前必过。其中 `smoke` 是三段断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 31 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写)+ `@ui-arch/ui` 8 条(皮肤层纯逻辑:行级差异的剪前后缀 / LCS 对齐 / 忽略空白 / 超限退化,跑的是 dist/diffKit.js);改内核语义或纯逻辑前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。③ 旋转 / 缩放这类第三类空间动效(折叠箭头、按压缩放)是否有「只留颜色过渡」的收窄兜底——空间变换即时到位、状态仍看得见(一条通配 `transition-property` 收窄兜住全站 35 处)。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、steps 至少 4 条(少于 4 条说明这组件还没想清楚边界)、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。 +**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast + audit:motion + audit:surface,提交前必过。其中 `smoke` 是三段断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 31 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写)+ `@ui-arch/ui` 8 条(皮肤层纯逻辑:行级差异的剪前后缀 / LCS 对齐 / 忽略空白 / 超限退化,跑的是 dist/diffKit.js);改内核语义或纯逻辑前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。③ 旋转 / 缩放这类第三类空间动效(折叠箭头、按压缩放)是否有「只留颜色过渡」的收窄兜底——空间变换即时到位、状态仍看得见(一条通配 `transition-property` 收窄兜住全站 35 处)。`ua audit:surface` 补的是「底色 vs 底色」这条盲区:把表面阶梯(页面底 → 表面 → 悬停底 → 按下底,外加凹陷底)逐档按亮度比验,相邻档低于 1.04(凹陷档 1.02)即退出码 1。对比度门禁只管文字读不读得清,管不到「悬停有没有反馈」——实测过表面 → 二阶表面只有 1.008,悬停改了等于没改;`--json` 出逐档明细,`--list` 只列阶梯表。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、steps 至少 4 条(少于 4 条说明这组件还没想清楚边界)、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。 `ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast / layout / jsonViewer / diffView / codeBlock / signaturePad / heatmap / sparkline 十三段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥39 条(下限由段数 × 3 推导,加抽查位不必手改)且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。 diff --git a/README.md b/README.md index e8c5287..6ba873a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ```bash pnpm install # 包间依赖用 workspace:*,npm 不支持 -npm run check # 门禁一条龙:build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion +npm run check # 门禁一条龙:build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion → audit:surface npm run demo # 打开演示:apps/demo/dist/demo.html npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移) diff --git a/llms-full.txt b/llms-full.txt index 1f5eff8..3fba37c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2707,8 +2707,8 @@ node packages/tools/cli/bin/ua.js [--json|--m - --ua-color-bg: #ffffff(暗色 #101216) - --ua-color-surface: #f6f7f9(暗色 #1a1c22) -- --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 4%, var(--ua-color-bg))(暗色 color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))) -- --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 8%, var(--ua-color-bg))(暗色 color-mix(in srgb, var(--ua-color-text) 10%, var(--ua-color-bg))) +- --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-surface))(暗色 color-mix(in srgb, var(--ua-color-text) 7%, var(--ua-color-surface))) +- --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 14%, var(--ua-color-surface))(暗色 color-mix(in srgb, var(--ua-color-text) 17%, var(--ua-color-surface))) - --ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))(暗色 color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg))) - --ua-color-text: #1c1e21(暗色 #e7e9ec) - --ua-color-text-muted: #676d78(暗色 #888780) diff --git a/package.json b/package.json index a5ec8d4..8486a3b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "style": "node packages/tools/cli/bin/ua.js style", "index": "node packages/tools/cli/bin/ua.js index", "docs": "node packages/tools/cli/bin/ua.js index && node packages/tools/cli/bin/ua.js docs", - "check": "npm run build && npm run demo && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify && npm run lint-iterations && npm run audit:visual && npm run audit:contrast && npm run audit:motion", + "check": "npm run build && npm run demo && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify && npm run lint-iterations && npm run audit:visual && npm run audit:contrast && npm run audit:motion && npm run audit:surface", "ua": "node packages/tools/cli/bin/ua.js", "verify": "node packages/tools/cli/bin/ua.js verify", "lint-iterations": "node packages/tools/cli/bin/ua.js lint-iterations", @@ -22,7 +22,8 @@ "changelog:check": "node packages/tools/cli/bin/ua.js changelog --check", "sync-counts": "node packages/tools/cli/bin/ua.js sync-counts", "audit:contrast": "node packages/tools/cli/bin/ua.js audit:contrast", - "audit:motion": "node packages/tools/cli/bin/ua.js audit:motion" + "audit:motion": "node packages/tools/cli/bin/ua.js audit:motion", + "audit:surface": "node packages/tools/cli/bin/ua.js audit:surface" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/packages/design/tokens/tokens.css b/packages/design/tokens/tokens.css index 8a57aba..9134da2 100644 --- a/packages/design/tokens/tokens.css +++ b/packages/design/tokens/tokens.css @@ -6,8 +6,8 @@ /* ---- 颜色:语义色,暗色主题逐个覆盖 ---- */ --ua-color-bg: #ffffff; /* 页面底色:弹层、卡片之外的最底层 */ --ua-color-surface: #f6f7f9; /* 表面色:卡片 / 表头 / 次级容器 */ - --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 4%, var(--ua-color-bg)); /* 表面二阶:悬停底、次级容器 */ - --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 8%, var(--ua-color-bg)); /* 表面三阶:按下底、最深容器 */ + --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-surface)); /* 表面二阶:悬停底、次级容器——从「表面」而非页底派生,卡片 / 弹层之上的悬停才看得出变化 */ + --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 14%, var(--ua-color-surface)); /* 表面三阶:按下底、最深容器——与二阶拉开一档,按下比悬停更实 */ --ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg)); /* 凹陷区:输入框 / 代码块底 */ --ua-color-text: #1c1e21; /* 主文本色:正文与标题共用 */ --ua-color-text-muted: #676d78; /* 辅助文本色:说明、占位、次要信息(比原值略深:凹陷底上原值仅 4.39,过不了 AA 4.5) */ @@ -145,8 +145,8 @@ [data-theme='dark'] { --ua-color-bg: #101216; /* 暗色页面底色 */ --ua-color-surface: #1a1c22; /* 暗色表面色 */ - --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg)); /* 暗色表面二阶:悬停底、次级容器 */ - --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 10%, var(--ua-color-bg)); /* 暗色表面三阶:按下底、最深容器 */ + --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 7%, var(--ua-color-surface)); /* 暗色表面二阶:悬停底、次级容器(与亮色同结构,暗底上要多混两档才看得见) */ + --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 17%, var(--ua-color-surface)); /* 暗色表面三阶:按下底、最深容器 */ --ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg)); /* 暗色凹陷区:输入框 / 代码块底 */ --ua-color-text: #e7e9ec; /* 暗色主文本色 */ --ua-color-text-muted: #888780; /* 暗色辅助文本色 */ diff --git a/packages/design/tokens/tokens.json b/packages/design/tokens/tokens.json index c51d406..f12ae83 100644 --- a/packages/design/tokens/tokens.json +++ b/packages/design/tokens/tokens.json @@ -54,12 +54,12 @@ "dark": "#d9a94f" }, "surface2": { - "light": "color-mix(in srgb, var(--ua-color-text) 4%, var(--ua-color-bg))", - "dark": "color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))" + "light": "color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-surface))", + "dark": "color-mix(in srgb, var(--ua-color-text) 7%, var(--ua-color-surface))" }, "surface3": { - "light": "color-mix(in srgb, var(--ua-color-text) 8%, var(--ua-color-bg))", - "dark": "color-mix(in srgb, var(--ua-color-text) 10%, var(--ua-color-bg))" + "light": "color-mix(in srgb, var(--ua-color-text) 14%, var(--ua-color-surface))", + "dark": "color-mix(in srgb, var(--ua-color-text) 17%, var(--ua-color-surface))" }, "surfaceInset": { "light": "color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))", diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 93ef058..dcae8d5 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -272,7 +272,7 @@ body.ua-col-resizing { cursor: pointer; } .ua-col-item:hover { - background: var(--ua-color-bg); + background: var(--ua-color-surface-2); /* 悬停底:弹层本身就是页面底色,再用页面底等于没反馈 */ } .ua-col-item .ua-checkbox:disabled { cursor: not-allowed; diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js index ad58947..266761c 100644 --- a/packages/tools/cli/bin/ua.js +++ b/packages/tools/cli/bin/ua.js @@ -8,6 +8,7 @@ import { visualAudit } from '../lib/visual-audit.mjs'; import { auditContrast, formatReport, PAIRS } from '../lib/contrast-audit.mjs'; import { shellPalette } from '../lib/shell-palette.mjs'; import { auditMotion, formatMotionReport } from '../lib/motion-audit.mjs'; +import { auditSurface, formatSurfaceReport, LADDER } from '../lib/surface-audit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -1727,6 +1728,28 @@ function cmdAuditMotion(args) { process.exit(failing === 0 ? 0 : 1); } +/*** 表面阶梯审计:底色对底色(悬停 / 按下 / 凹陷)差得看不出就是没做(--json 出明细、--list 只看阶梯表) ***/ +function cmdAuditSurface(args) { + if (args.includes('--list')) { + for (const s of LADDER) console.log(`${s.label}:--ua-color-${s.from} → --ua-color-${s.to},亮度比阈值 ${s.min}`); + return; + } + let result; + try { + result = auditSurface(ROOT); + } catch (err) { + console.error(`audit:surface 失败:${err.message}`); // 求值不了就红:静默跳过等于默许阶梯糊在一起 + process.exit(1); + } + if (args.includes('--json')) { + const steps = result.rows.map((r) => ({ ...r, ratio: Number(r.ratio.toFixed(3)) })); + console.log(JSON.stringify({ ok: result.failures.length === 0, total: steps.length, failing: result.failures.length, steps }, null, 2)); + } else { + console.log(formatSurfaceReport(result)); + } + process.exit(result.failures.length === 0 ? 0 : 1); +} + /*** 迭代留痕校验:.ua/iterations.log 每行 JSON 字段与格式必须符合 ITERATION.md 规范 ***/ function cmdLintIterations() { const file = path.join(INDEX_DIR, 'iterations.log'); @@ -1824,7 +1847,7 @@ function cmdDoctor() { detail: [missingInUsage.length ? `usage 缺 ${missingInUsage.join('/')}` : '', missingInCode.length ? `实现缺 ${missingInCode.join('/')}` : ''].filter(Boolean).join(';'), }); // 门禁链自检:package.json 的 check 必须跑齐所有门禁步骤(新增门禁忘了接进链等于没加) - const needSteps = ['build', 'demo', 'style', 'typecheck', 'smoke', 'index', 'verify', 'lint-iterations', 'audit:visual', 'audit:contrast', 'audit:motion']; + const needSteps = ['build', 'demo', 'style', 'typecheck', 'smoke', 'index', 'verify', 'lint-iterations', 'audit:visual', 'audit:contrast', 'audit:motion', 'audit:surface']; const chain = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).scripts?.check ?? ''); const lacking = needSteps.filter((step) => !chain.includes(`npm run ${step}`)); checks.push({ name: 'check 链覆盖全部门禁步骤', pass: !lacking.length, detail: lacking.length ? `缺 ${lacking.join(' / ')}` : '' }); @@ -1903,10 +1926,13 @@ switch (cmd) { case 'audit:motion': cmdAuditMotion(args); break; + case 'audit:surface': + cmdAuditSurface(args); + break; case 'mcp': cmdMcp(); break; default: - console.log('usage: ua |info [--json|--md]|recipes [编号] [--json]|docs|tokens [--json|--unused]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|audit:motion [--json]|changelog|mcp>'); + console.log('usage: ua |info [--json|--md]|recipes [编号] [--json]|docs|tokens [--json|--unused]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|audit:motion [--json]|audit:surface [--json|--list]|changelog|mcp>'); process.exit(cmd ? 1 : 0); } diff --git a/packages/tools/cli/lib/contrast-audit.mjs b/packages/tools/cli/lib/contrast-audit.mjs index 118a016..9f060d8 100644 --- a/packages/tools/cli/lib/contrast-audit.mjs +++ b/packages/tools/cli/lib/contrast-audit.mjs @@ -83,10 +83,9 @@ function parseColor(text) { return { r: ch(parts[0]), g: ch(parts[1]), b: ch(parts[2]), a: parts[3] === undefined ? 1 : parseFloat(parts[3]) }; } -/*** 作用:求表达式的值:递归替换 var(--x),支持 alpha 变量与 color-mix 单层混色 - * 只覆盖令牌文件里真实用到的语法(in srgb + 两个颜色 + 一个百分比),不做通用 CSS 求值器—— - * 遇到认不出的表达式直接抛错,宁可红也不静默算错对比度。 ***/ -function resolve(expr, vars, depth = 0) { +// 只覆盖令牌文件里真实用到的语法(in srgb + 两个颜色 + 一个百分比),不做通用 CSS 求值器——遇到认不出的表达式直接抛错,宁可红也不静默算错 +/*** 作用:求表达式的值:递归替换 var(--x),支持 alpha 变量与 color-mix 单层混色 ***/ +export function resolve(expr, vars, depth = 0) { if (depth > 12) throw new Error(`令牌引用层级过深:${expr}`); const text = expr.trim(); const color = parseColor(text); @@ -167,7 +166,7 @@ function luminance(c) { } /*** 作用:两色对比度(1~21),前景先合成到底色上再算;半透明底由调用方压到页面底色上 ***/ -function ratio(fg, bg) { +export function ratio(fg, bg) { const solid = bg; const f = over(fg, solid); const l1 = Math.max(luminance(f), luminance(solid)); diff --git a/packages/tools/cli/lib/surface-audit.mjs b/packages/tools/cli/lib/surface-audit.mjs new file mode 100644 index 0000000..36980e9 --- /dev/null +++ b/packages/tools/cli/lib/surface-audit.mjs @@ -0,0 +1,47 @@ +import { loadThemes, resolve, ratio } from './contrast-audit.mjs'; + +/*** 表面阶梯审计:同一条阶梯上相邻两档必须有肉眼可辨的亮度差 + * —— 对比度门禁只管「文字 vs 底色」,管不到「底色 vs 底色」;而悬停底 / 按下底 / 凹陷底 + * 全是底色对底色,差 1% 就等于用户看不出状态变化(历史实测表面→二阶表面仅 1.008)。 ***/ + +export const LADDER = [ + { label: '页面底 → 表面', from: 'bg', to: 'surface', min: 1.04 }, // 卡片与页底:靠边框也能分,阈值最低 + { label: '表面 → 悬停底', from: 'surface', to: 'surface-2', min: 1.04 }, // 悬停必须有反馈,低一档就白改 + { label: '悬停底 → 按下底', from: 'surface-2', to: 'surface-3', min: 1.04 }, // 按下要比悬停更实,两档不能糊在一起 + { label: '表面 → 凹陷底', from: 'surface', to: 'surface-inset', min: 1.02 }, // 凹陷区自带边框与内阴影,可放宽一档 +]; + +/*** 作用:按四套主题(亮 / 暗 × 常规 / 高对比)逐档算亮度比,返回明细与未达标项 ***/ +export function auditSurface(root) { + const themes = loadThemes(root); + const rows = []; + for (const [theme, vars] of Object.entries(themes)) { + for (const step of LADDER) { + const from = resolve(vars.get(`--ua-color-${step.from}`), vars); + const to = resolve(vars.get(`--ua-color-${step.to}`), vars); + const value = ratio(to, from); + rows.push({ theme, label: step.label, from: step.from, to: step.to, min: step.min, ratio: value, bad: value < step.min }); + } + } + return { rows, failures: rows.filter((r) => r.bad) }; +} + +/*** 作用:按终端显示宽度补空格(中文占两格,padEnd 只按字符数补,表头会歪) ***/ +const pad = (text, width) => { + const wide = [...text].reduce((n, ch) => n + (ch.codePointAt(0) > 0x2e80 ? 2 : 1), 0); + return text + ' '.repeat(Math.max(0, width - wide)); +}; + +/*** 作用:把审计结果渲染成对齐的文本表(--json 之外的人读视图) ***/ +export function formatSurfaceReport({ rows, failures }) { + const themes = [...new Set(rows.map((r) => r.theme))]; + const lines = ['表面阶梯亮度比(1.00 = 完全看不出差别)', `${pad('阶梯', 22)}${themes.map((t) => t.padStart(14)).join('')} 阈值 结论`]; + for (const step of LADDER) { + const cells = themes.map((t) => rows.find((r) => r.theme === t && r.label === step.label).ratio.toFixed(3).padStart(14)); + const bad = rows.filter((r) => r.label === step.label && r.bad).map((r) => r.theme); + lines.push(`${pad(step.label, 22)}${cells.join('')}${String(step.min).padStart(6)} ${bad.length ? `FAIL(${bad.join('+')})` : 'OK'}`); + } + lines.push(''); + lines.push(failures.length === 0 ? '表面阶梯全部达标' : `不达标 ${failures.length} 处:${failures.map((f) => `${f.theme}/${f.label}`).join(' / ')}`); + return lines.join('\n'); +}