iter(g295): 新增表面阶梯审计并修正悬停底

This commit is contained in:
HIsheR
2026-10-26 23:35:57 +08:00
parent df40eb0073
commit 3533459e00
11 changed files with 105 additions and 23 deletions
+9
View File
@@ -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": ""
}
]
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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 可查漂移)
+2 -2
View File
@@ -2707,8 +2707,8 @@ node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id> [--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)
+3 -2
View File
@@ -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"
+4 -4
View File
@@ -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; /* 暗色辅助文本色 */
+4 -4
View File
@@ -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))",
+1 -1
View File
@@ -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;
+28 -2
View File
@@ -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 <index|list|search <kw>|info <name> [--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 <index|list|search <kw>|info <name> [--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);
}
+4 -5
View File
@@ -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));
+47
View File
@@ -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');
}