diff --git a/.ua/index.json b/.ua/index.json index db6b629..63d501b 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T08:29:05.311Z", + "generatedAt": "2026-10-26T08:48:09.549Z", "repoRoot": ".", "packages": [ { @@ -3663,6 +3663,7 @@ "ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1", "ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)", "ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1", + "ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里五类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数", "ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe" ], "keyTypes": [ @@ -3670,7 +3671,7 @@ "MCP tool 入参 schema" ] }, - "usage": "node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify", + "usage": "node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|audit:visual|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual", "conventions": [ "零依赖:只用 node 标准库,Node >= 18", "清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生", @@ -3679,11 +3680,12 @@ "docs": { "intro": "工程层工具包(private,不发布)。所有 Agent 与人都通过它读取组件库:清单驱动索引、文档、MCP 三件套,外加 style / verify 两道门禁。", "steps": [ - "装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify)", + "装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual)", "查组件:ua list 看全量,ua search <关键词> 按 id/标题/参数名/参数说明/步骤模糊找(「处理中」这类说明里的词也能搜到,命中非 id 标题时会标出命中字段;拼错会回相近 id),ua info <组件id> 看 props/步骤/示例(ua info <包名> 看单包 api 与约定,加 --json 出原始清单)", "查配方:ua recipes 列全量(编号 / 标题 / 用到的组件),ua recipes <编号> 看骨架代码与要点(--json 给脚本用;MCP 里对应 list_recipes / get_recipe)", - "查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码", + "查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码。令牌分十组:颜色 / 圆角 / 间距 / 字号 / 字重行高 / 边框 / 阴影 / 动效 / 焦点环 / 控件高度 / 层级,弹层阴影用 --ua-shadow-1..4,过渡用 --ua-duration-* + --ua-ease,焦点环用 --ua-focus-ring(_inset)", "改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料)", + "改完样式跑 npm run audit:visual:五类硬编码计数不得高于 .ua/visual-baseline.json(棘轮只减不增);字面量可用 npm run visual:migrate 幂等改写成令牌", "在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe" ], "components": [] diff --git a/llms-full.txt b/llms-full.txt index 033331e..5bcdc0e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T08:29:05.311Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T08:48:09.549Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -40,12 +40,13 @@ ### @ui-arch/cli -1. 装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify) +1. 装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual) 2. 查组件:ua list 看全量,ua search <关键词> 按 id/标题/参数名/参数说明/步骤模糊找(「处理中」这类说明里的词也能搜到,命中非 id 标题时会标出命中字段;拼错会回相近 id),ua info <组件id> 看 props/步骤/示例(ua info <包名> 看单包 api 与约定,加 --json 出原始清单) 3. 查配方:ua recipes 列全量(编号 / 标题 / 用到的组件),ua recipes <编号> 看骨架代码与要点(--json 给脚本用;MCP 里对应 list_recipes / get_recipe) -4. 查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码 +4. 查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码。令牌分十组:颜色 / 圆角 / 间距 / 字号 / 字重行高 / 边框 / 阴影 / 动效 / 焦点环 / 控件高度 / 层级,弹层阴影用 --ua-shadow-1..4,过渡用 --ua-duration-* + --ua-ease,焦点环用 --ua-focus-ring(_inset) 5. 改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料) -6. 在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe +6. 改完样式跑 npm run audit:visual:五类硬编码计数不得高于 .ua/visual-baseline.json(棘轮只减不增);字面量可用 npm run visual:migrate 幂等改写成令牌 +7. 在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe ## @ui-arch/ui(skin-dom)v0.1.0 @@ -2087,12 +2088,13 @@ API 速览: - ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1 - ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在) - ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1 +- ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里五类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数 - ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe 一行用法: ``` -node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify +node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|audit:visual|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual ``` 约定: @@ -2123,9 +2125,37 @@ node packages/tools/cli/bin/ua.js |docs|tokens - --ua-space-3: 12px - --ua-space-4: 16px - --ua-space-table-cell: var(--ua-space-2) var(--ua-space-3) +- --ua-font-size-xs: 11px - --ua-font-size-sm: 12px - --ua-font-size-md: 13px - --ua-font-size-lg: 14px +- --ua-font-size-xl: 16px +- --ua-font-size-2xl: 18px +- --ua-weight-normal: 400 +- --ua-weight-medium: 500 +- --ua-weight-semibold: 600 +- --ua-leading-tight: 1.35 +- --ua-leading-normal: 1.55 +- --ua-border-subtle: color-mix(in srgb, var(--ua-color-border) 60%, transparent) +- --ua-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06)(暗色 0 1px 2px rgba(0, 0, 0, 0.3)) +- --ua-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.1)(暗色 0 4px 12px rgba(0, 0, 0, 0.4)) +- --ua-shadow-3: 0 10px 28px rgba(0, 0, 0, 0.12)(暗色 0 10px 28px rgba(0, 0, 0, 0.45)) +- --ua-shadow-4: 0 18px 44px rgba(0, 0, 0, 0.14)(暗色 0 18px 44px rgba(0, 0, 0, 0.5)) +- --ua-duration-fast: 0.15s +- --ua-duration-base: 0.2s +- --ua-duration-slow: 0.25s +- --ua-ease: cubic-bezier(0.2, 0.8, 0.2, 1) +- --ua-ease-out: cubic-bezier(0.16, 1, 0.3, 1) +- --ua-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent) +- --ua-focus-ring-inset: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent) +- --ua-control-h-sm: 28px +- --ua-control-h-md: 34px +- --ua-control-h-lg: 40px +- --ua-z-dropdown: 100 +- --ua-z-sticky: 200 +- --ua-z-overlay: 300 +- --ua-z-modal: 400 +- --ua-z-toast: 500 皮肤包只允许消费这些变量:写死 #hex 会被 verify 拦截,拼错变量名会被令牌引用校验拦截。 diff --git a/llms.txt b/llms.txt index b9f7bfa..49233ac 100644 --- a/llms.txt +++ b/llms.txt @@ -134,8 +134,9 @@ - ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1 - ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在) - ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1 + - ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里五类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数 - ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe -- 用法: `node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify` +- 用法: `node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|audit:visual|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual` - 约定: 零依赖:只用 node 标准库,Node >= 18 - 约定: 清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生 - 约定: 门禁只增不改:新增校验必须能被人手复现,报错要指出文件与字段 diff --git a/package.json b/package.json index e69e0fd..abf2b06 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "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", + "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", "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", + "audit:visual": "node packages/tools/cli/bin/ua.js audit:visual", + "visual:migrate": "node scripts/visual-migrate.mjs", "changelog": "node packages/tools/cli/bin/ua.js changelog", "changelog:check": "node packages/tools/cli/bin/ua.js changelog --check", "sync-counts": "node packages/tools/cli/bin/ua.js sync-counts" diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index f485dce..3c465a7 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -14,7 +14,7 @@ color: var(--ua-color-accent-contrast); font-size: var(--ua-font-size-md); cursor: pointer; - transition: filter 0.15s, transform 0.12s, box-shadow 0.15s; + transition: filter var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease), box-shadow var(--ua-duration-fast) var(--ua-ease); } .ua-btn:hover { filter: brightness(1.08); @@ -45,12 +45,12 @@ background: var(--ua-color-bg); color: var(--ua-color-text); font-size: var(--ua-font-size-md); - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--ua-duration-fast) var(--ua-ease), box-shadow var(--ua-duration-fast) var(--ua-ease); } .ua-input:focus-visible { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-card { padding: var(--ua-space-4); @@ -87,7 +87,7 @@ border: none; } .ua-card-hoverable { - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: transform var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-card-hoverable:hover { transform: translateY(-2px); @@ -118,7 +118,7 @@ .ua-table th.ua-sortable { cursor: pointer; user-select: none; - transition: color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease); } .ua-table th.ua-sortable:hover { color: var(--ua-color-text); @@ -146,7 +146,7 @@ left: 4px; width: 1px; background: transparent; - transition: background 0.15s; + transition: background var(--ua-duration-fast) var(--ua-ease); } .ua-col-grip:hover::after, .ua-col-grip:focus-visible::after { @@ -163,7 +163,7 @@ body.ua-col-resizing { } /* 斑马纹 + 悬停高亮 + 选中行(低视觉比重:淡色底 + 弱化文字) */ .ua-table tbody tr { - transition: background 0.12s; + transition: background var(--ua-duration-fast) var(--ua-ease); } .ua-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--ua-color-border) 18%, transparent); @@ -240,7 +240,7 @@ body.ua-col-resizing { width: 15px; height: 15px; cursor: pointer; - transition: transform 0.12s; + transition: transform var(--ua-duration-fast) var(--ua-ease); } .ua-checkbox:active, .ua-radio:active { @@ -269,10 +269,10 @@ body.ua-col-resizing { display: inline-block; width: 36px; height: 20px; - border-radius: 10px; + border-radius: var(--ua-radius-md); background: var(--ua-color-border); position: relative; - transition: background 0.2s; + transition: background var(--ua-duration-base) var(--ua-ease); } .ua-switch-thumb { position: absolute; @@ -282,7 +282,7 @@ body.ua-col-resizing { height: 16px; border-radius: 50%; background: var(--ua-color-bg); - transition: left 0.2s; + transition: left var(--ua-duration-base) var(--ua-ease); } .ua-switch-input:checked + .ua-switch-track { background: var(--ua-color-accent); @@ -331,7 +331,7 @@ body.ua-col-resizing { color: var(--ua-color-text); font-size: var(--ua-font-size-md); cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--ua-duration-fast) var(--ua-ease), box-shadow var(--ua-duration-fast) var(--ua-ease); } .ua-select-trigger:hover { border-color: color-mix(in srgb, var(--ua-color-accent) 55%, var(--ua-color-border)); @@ -339,7 +339,7 @@ body.ua-col-resizing { .ua-select-trigger:focus-visible { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-select-value { overflow: hidden; @@ -351,9 +351,9 @@ body.ua-col-resizing { opacity: 0.7; } .ua-select-arrow { - font-size: 10px; + font-size: var(--ua-font-size-xs); color: var(--ua-color-text-muted); - transition: transform 0.2s; + transition: transform var(--ua-duration-base) var(--ua-ease); } .ua-select.ua-open .ua-select-arrow { transform: rotate(180deg); @@ -370,11 +370,11 @@ body.ua-col-resizing { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + box-shadow: var(--ua-shadow-2); opacity: 0; transform: translateY(-4px); visibility: hidden; - transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; + transition: opacity var(--ua-duration-fast) ease, transform var(--ua-duration-fast) ease, visibility var(--ua-duration-fast) var(--ua-ease); } .ua-select-pop.is-flip { top: auto; /* 空间不足翻到触发器上方 */ @@ -399,7 +399,7 @@ body.ua-col-resizing { } .ua-select-search:focus { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 22%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-select-blank { padding: 8px 10px; @@ -432,7 +432,7 @@ body.ua-col-resizing { color: var(--ua-color-text); font-size: var(--ua-font-size-md); cursor: pointer; - transition: background 0.12s; + transition: background var(--ua-duration-fast) var(--ua-ease); } .ua-option:hover { background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); @@ -456,9 +456,9 @@ body.ua-col-resizing { gap: 6px; } .ua-menu-caret { - font-size: 10px; + font-size: var(--ua-font-size-xs); opacity: 0.7; - transition: transform 0.2s; + transition: transform var(--ua-duration-base) var(--ua-ease); } .ua-menu.ua-open .ua-menu-caret { transform: rotate(180deg); @@ -476,11 +476,11 @@ body.ua-col-resizing { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + box-shadow: var(--ua-shadow-2); opacity: 0; transform: translateY(-4px); visibility: hidden; - transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; + transition: opacity var(--ua-duration-fast) ease, transform var(--ua-duration-fast) ease, visibility var(--ua-duration-fast) var(--ua-ease); } .ua-menu-pop.is-end { left: auto; @@ -510,7 +510,7 @@ body.ua-col-resizing { color: var(--ua-color-text); font-size: var(--ua-font-size-md); cursor: pointer; - transition: background 0.12s; + transition: background var(--ua-duration-fast) var(--ua-ease); } .ua-menu-item:hover:not(:disabled) { background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); @@ -571,7 +571,7 @@ body.ua-col-resizing { font-size: inherit; text-decoration: none; cursor: pointer; - transition: opacity 0.15s; + transition: opacity var(--ua-duration-fast) var(--ua-ease); } .ua-crumb-link:hover { opacity: 0.75; @@ -580,7 +580,7 @@ body.ua-col-resizing { .ua-crumb-link:focus-visible { outline: none; border-radius: var(--ua-radius-sm); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-crumb-current { color: var(--ua-color-text-muted); @@ -605,10 +605,10 @@ body.ua-col-resizing { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-bg); - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16); + box-shadow: var(--ua-shadow-3); opacity: 0; visibility: hidden; - transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s; + transition: opacity var(--ua-duration-fast) ease, transform var(--ua-duration-fast) ease, visibility var(--ua-duration-fast) var(--ua-ease); } .ua-pop-top { bottom: calc(100% + 8px); @@ -660,23 +660,23 @@ body.ua-col-resizing { font-size: var(--ua-font-size-lg); text-align: left; cursor: pointer; - transition: background 0.15s; + transition: background var(--ua-duration-fast) var(--ua-ease); } .ua-acc-head:hover:not(:disabled) { background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent); } .ua-acc-head:focus-visible { outline: none; - box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 45%, transparent); + box-shadow: var(--ua-focus-ring-inset); } .ua-acc-head:disabled { opacity: 0.45; cursor: not-allowed; } .ua-acc-caret { - font-size: 10px; + font-size: var(--ua-font-size-xs); color: var(--ua-color-text-muted); - transition: transform 0.22s ease; + transition: transform var(--ua-duration-base) ease; } .ua-acc-item.ua-open .ua-acc-caret { transform: rotate(90deg); @@ -685,7 +685,7 @@ body.ua-col-resizing { .ua-acc-body { display: grid; grid-template-rows: 0fr; - transition: grid-template-rows 0.24s ease; + transition: grid-template-rows var(--ua-duration-slow) ease; } .ua-acc-item.ua-open .ua-acc-body { grid-template-rows: 1fr; @@ -744,7 +744,7 @@ body.ua-col-resizing { color: var(--ua-color-text); font-size: var(--ua-font-size-sm); line-height: 1.6; - transition: border-color 0.15s, background 0.15s; + transition: border-color var(--ua-duration-fast) var(--ua-ease), background var(--ua-duration-fast) var(--ua-ease); } .ua-tag-success { border-color: color-mix(in srgb, var(--ua-color-success) 55%, transparent); @@ -767,10 +767,10 @@ body.ua-col-resizing { border: none; background: none; color: var(--ua-color-text-muted); - font-size: 12px; + font-size: var(--ua-font-size-sm); line-height: 1; cursor: pointer; - transition: color 0.15s, transform 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease); } .ua-tag-close:hover { color: var(--ua-color-text); @@ -783,7 +783,7 @@ body.ua-col-resizing { color: var(--ua-color-text-muted); background: transparent; cursor: pointer; - transition: transform 0.15s, border-color 0.15s, background 0.15s, color 0.15s; + transition: transform var(--ua-duration-fast) var(--ua-ease), border-color var(--ua-duration-fast) var(--ua-ease), background var(--ua-duration-fast) var(--ua-ease), color var(--ua-duration-fast) var(--ua-ease); } .ua-tag.is-checkable:hover { transform: translateY(-1px); @@ -823,11 +823,11 @@ body.ua-col-resizing { background: color-mix(in srgb, var(--ua-color-border) 70%, transparent); accent-color: var(--ua-color-accent); cursor: pointer; - transition: opacity 0.15s; + transition: opacity var(--ua-duration-fast) var(--ua-ease); } .ua-slider-input:focus-visible { outline: none; - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 20%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-slider-input:disabled { opacity: 0.45; @@ -860,7 +860,7 @@ body.ua-col-resizing { height: 4px; border-radius: var(--ua-radius-sm); background: var(--ua-color-accent); - transition: left 0.15s, width 0.15s; + transition: left var(--ua-duration-fast) var(--ua-ease), width var(--ua-duration-fast) var(--ua-ease); } .ua-slider-range .ua-slider-input { position: absolute; @@ -993,7 +993,7 @@ body.ua-col-resizing { font: inherit; font-size: var(--ua-font-size-sm); flex-shrink: 0; - transition: border-color 0.15s, background 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s; + transition: border-color var(--ua-duration-fast) var(--ua-ease), background var(--ua-duration-fast) var(--ua-ease), color var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease), box-shadow var(--ua-duration-fast) var(--ua-ease); } button.ua-step-dot { padding: 0; /* 可回跳步骤:重置原生按钮样式 */ @@ -1013,7 +1013,7 @@ button.ua-step-dot:hover { border-color: var(--ua-color-accent); background: var(--ua-color-bg); color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 15%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-step-text { display: flex; @@ -1053,7 +1053,7 @@ button.ua-step-dot:hover { font-size: 22px; line-height: 1; cursor: pointer; - transition: color 0.15s, transform 0.12s; + transition: color var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease); } .ua-rate-star:hover { transform: scale(1.15); /* 悬停微放大 */ @@ -1074,7 +1074,7 @@ button.ua-step-dot:hover { .ua-rate-star:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 25%, transparent); - border-radius: 4px; + border-radius: var(--ua-radius-sm); } /* —— 空状态 —— */ @@ -1193,11 +1193,11 @@ button.ua-step-dot:hover { gap: var(--ua-space-2); width: 100%; padding: 8px 12px; - transition: opacity 0.2s ease, transform 0.2s ease; + transition: opacity var(--ua-duration-base) ease, transform var(--ua-duration-base) ease; border-radius: var(--ua-radius-md); border: 1px solid; font-size: var(--ua-font-size-md); - animation: ua-slide-in 0.25s ease-out; + animation: ua-slide-in var(--ua-duration-slow) ease-out; } @keyframes ua-slide-in { from { @@ -1215,7 +1215,7 @@ button.ua-step-dot:hover { margin-top: 1px; border: 1px solid currentColor; border-radius: 50%; - font-size: 11px; + font-size: var(--ua-font-size-xs); line-height: 1; opacity: 0.85; } @@ -1239,10 +1239,10 @@ button.ua-step-dot:hover { border: none; background: none; color: var(--ua-color-text-muted); - font-size: 14px; + font-size: var(--ua-font-size-lg); line-height: 1.4; cursor: pointer; - transition: color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease); } .ua-alert-close:hover { color: var(--ua-color-text); @@ -1320,7 +1320,7 @@ button.ua-step-dot:hover { font-size: var(--ua-font-size-md); cursor: pointer; font-variant-numeric: tabular-nums; - transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease, transform var(--ua-duration-fast) ease; } .ua-page-btn:hover:not(:disabled) { border-color: var(--ua-color-accent); @@ -1329,7 +1329,7 @@ button.ua-step-dot:hover { .ua-page-btn:focus-visible { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-page-btn:disabled { opacity: 0.45; @@ -1410,7 +1410,7 @@ button.ua-step-dot:hover { font-size: var(--ua-font-size-md); opacity: 0; transform: translateX(12px); - transition: opacity 0.22s ease-out, transform 0.22s ease-out; + transition: opacity var(--ua-duration-base) ease-out, transform var(--ua-duration-base) ease-out; } .ua-toast.ua-open { opacity: 1; @@ -1425,10 +1425,10 @@ button.ua-step-dot:hover { border: none; background: none; color: var(--ua-color-text-muted); - font-size: 14px; + font-size: var(--ua-font-size-lg); line-height: 1; cursor: pointer; - transition: color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease); } .ua-toast-close:hover { color: var(--ua-color-text); @@ -1561,7 +1561,7 @@ button.ua-step-dot:hover { font-size: var(--ua-font-size-md); white-space: nowrap; cursor: pointer; - transition: color 0.15s, background 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), background var(--ua-duration-fast) var(--ua-ease); } .ua-tab:hover { color: var(--ua-color-text); @@ -1600,7 +1600,7 @@ button.ua-step-dot:hover { line-height: 1; cursor: pointer; opacity: 0; - transition: opacity 0.15s, background-color 0.15s, color 0.15s; + transition: opacity var(--ua-duration-fast) var(--ua-ease), background-color var(--ua-duration-fast) var(--ua-ease), color var(--ua-duration-fast) var(--ua-ease); } .ua-tab-item:hover .ua-tab-close, .ua-tab-item:focus-within .ua-tab-close { @@ -1628,7 +1628,7 @@ button.ua-step-dot:hover { height: 2px; border-radius: 1px; background: var(--ua-color-accent); - transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: left var(--ua-duration-slow) cubic-bezier(0.4, 0, 0.2, 1), width var(--ua-duration-slow) cubic-bezier(0.4, 0, 0.2, 1); } /* —— 表单 —— */ @@ -1682,7 +1682,7 @@ button.ua-step-dot:hover { .ua-form-error { color: var(--ua-color-danger); font-size: var(--ua-font-size-sm); - animation: ua-slide-in 0.2s ease-out; + animation: ua-slide-in var(--ua-duration-base) ease-out; } .ua-form-error:empty { display: none; @@ -1711,7 +1711,7 @@ button.ua-step-dot:hover { background: rgba(0, 0, 0, 0.36); opacity: 0; visibility: hidden; - transition: opacity 0.22s ease, visibility 0.22s; + transition: opacity var(--ua-duration-base) ease, visibility var(--ua-duration-base) var(--ua-ease); } .ua-drawer-overlay.ua-open { opacity: 1; @@ -1726,8 +1726,8 @@ button.ua-step-dot:hover { max-width: 92vw; background: var(--ua-color-surface); border-left: 1px solid var(--ua-color-border); - box-shadow: 0 0 40px rgba(0, 0, 0, 0.22); - transition: transform 0.24s ease; + box-shadow: var(--ua-shadow-4); + transition: transform var(--ua-duration-slow) ease; } .ua-drawer-right { right: 0; @@ -1782,7 +1782,7 @@ button.ua-step-dot:hover { border: none; background: none; color: var(--ua-color-text-muted); - font-size: 18px; + font-size: var(--ua-font-size-2xl); line-height: 1; cursor: pointer; } @@ -1813,15 +1813,15 @@ button.ua-step-dot:hover { } .ua-modal-overlay.ua-open { display: flex; - animation: ua-fade 0.2s ease-out; + animation: ua-fade var(--ua-duration-base) ease-out; } .ua-modal { width: min(420px, 90vw); border-radius: var(--ua-radius-lg); background: var(--ua-color-surface); border: 1px solid var(--ua-color-border); - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); - animation: ua-pop-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); + box-shadow: var(--ua-shadow-4); + animation: ua-pop-in var(--ua-duration-base) cubic-bezier(0.2, 0.9, 0.3, 1.2); } /* 宽度档位:md 为基准,sm / lg 覆盖 */ .ua-modal-sm { @@ -1869,7 +1869,7 @@ button.ua-step-dot:hover { border: none; background: none; color: var(--ua-color-text-muted); - font-size: 18px; + font-size: var(--ua-font-size-2xl); cursor: pointer; line-height: 1; } @@ -1879,7 +1879,7 @@ button.ua-step-dot:hover { .ua-textarea:focus-visible { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } /* —— 悬浮提示 —— */ @@ -1899,7 +1899,7 @@ button.ua-step-dot:hover { line-height: 1.5; pointer-events: none; opacity: 0; - transition: opacity 0.16s ease-out, transform 0.16s ease-out; + transition: opacity var(--ua-duration-fast) ease-out, transform var(--ua-duration-fast) ease-out; } .ua-tip-top { left: 50%; @@ -2008,12 +2008,12 @@ button.ua-step-dot:hover { max-width: 100%; border-radius: var(--ua-radius-md); cursor: zoom-in; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: transform var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-image:hover, .ua-image:focus-visible { transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + box-shadow: var(--ua-shadow-2); outline: none; } .ua-image-fallback { @@ -2037,13 +2037,13 @@ button.ua-step-dot:hover { justify-content: center; background: rgba(0, 0, 0, 0.6); /* 遮罩透明度叠加,非实体色 */ cursor: zoom-out; - animation: ua-image-in 0.18s ease; + animation: ua-image-in var(--ua-duration-base) ease; } .ua-image-full { max-width: 90vw; max-height: 90vh; border-radius: var(--ua-radius-md); - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16); + box-shadow: var(--ua-shadow-3); } @keyframes ua-image-in { from { opacity: 0; transform: scale(0.96); } @@ -2064,11 +2064,11 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-bg); - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--ua-duration-fast) var(--ua-ease), box-shadow var(--ua-duration-fast) var(--ua-ease); } .ua-input-group:focus-within { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-input-group .ua-input, .ua-input-group .ua-textarea { @@ -2144,7 +2144,7 @@ button.ua-step-dot:hover { .ua-tl-item { position: relative; padding: 0 0 var(--ua-space-3) 22px; - animation: ua-tl-in 0.18s ease-out both; + animation: ua-tl-in var(--ua-duration-base) ease-out both; } .ua-tl-item:last-child { padding-bottom: 0; @@ -2199,8 +2199,8 @@ button.ua-step-dot:hover { height: 11px; border-radius: 50%; background: var(--ua-color-text-muted); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 10%, transparent); - transition: transform 0.15s; + box-shadow: var(--ua-focus-ring); + transition: transform var(--ua-duration-fast) var(--ua-ease); } .ua-tl-right .ua-tl-dot { left: auto; @@ -2251,7 +2251,7 @@ button.ua-step-dot:hover { to { opacity: 1; transform: translateY(0); } } @keyframes ua-tl-pulse { - 0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); } + 0%, 100% { box-shadow: var(--ua-focus-ring); } 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 4%, transparent); } } @media (prefers-reduced-motion: reduce) { @@ -2280,7 +2280,7 @@ button.ua-step-dot:hover { border-radius: 999px; background: var(--ua-color-bg); box-shadow: 0 1px 4px color-mix(in srgb, var(--ua-color-text) 12%, transparent); - transition: transform 0.18s ease, width 0.18s ease; + transition: transform var(--ua-duration-base) ease, width var(--ua-duration-base) ease; pointer-events: none; } .ua-seg-btn { @@ -2293,7 +2293,7 @@ button.ua-step-dot:hover { color: var(--ua-color-text-muted); font-size: var(--ua-font-size-md); cursor: pointer; - transition: color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease); white-space: nowrap; } .ua-seg-btn:hover:not(:disabled) { @@ -2488,7 +2488,7 @@ button.ua-step-dot:hover { align-items: center; gap: var(--ua-space-2); padding: var(--ua-space-2) var(--ua-space-3); - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; } .ua-list-item + .ua-list-item { border-top: 1px solid var(--ua-color-border); @@ -2538,7 +2538,7 @@ button.ua-step-dot:hover { outline: none; } .ua-carousel:focus-visible { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 45%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-carousel-track { display: flex; @@ -2563,7 +2563,7 @@ button.ua-step-dot:hover { line-height: 1; cursor: pointer; opacity: 0; - transition: opacity 0.15s ease; + transition: opacity var(--ua-duration-fast) ease; } .ua-carousel-prev { left: var(--ua-space-2); } .ua-carousel-next { right: var(--ua-space-2); } @@ -2587,7 +2587,7 @@ button.ua-step-dot:hover { border-radius: 50%; background: var(--ua-color-border); cursor: pointer; - transition: width 0.2s ease, background 0.2s ease; + transition: width var(--ua-duration-base) ease, background var(--ua-duration-base) ease; } .ua-carousel-dot-active { width: 16px; @@ -2615,13 +2615,13 @@ button.ua-step-dot:hover { color: var(--ua-color-text); cursor: pointer; outline: none; - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; } .ua-tree-row:hover { background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); } .ua-tree-row:focus-visible { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-tree-selected { background: color-mix(in srgb, var(--ua-color-accent) 12%, transparent); /* 选中低视觉比重 */ @@ -2632,7 +2632,7 @@ button.ua-step-dot:hover { justify-content: center; width: 14px; color: var(--ua-color-text-muted); - transition: transform 0.15s ease; + transition: transform var(--ua-duration-fast) ease; } .ua-tree-caret-open { transform: rotate(90deg); @@ -2662,7 +2662,7 @@ button.ua-step-dot:hover { .ua-tree-search:focus-visible { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } @media (prefers-reduced-motion: reduce) { .ua-tree-row, @@ -2674,7 +2674,7 @@ button.ua-step-dot:hover { /* —— 文件选择 —— */ .ua-upload-tip { margin: 0; - font-size: 12px; + font-size: var(--ua-font-size-sm); color: var(--ua-color-danger); } .ua-upload { @@ -2697,7 +2697,7 @@ button.ua-step-dot:hover { color: var(--ua-color-text-muted); cursor: pointer; outline: none; - transition: border-color 0.15s ease, background 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, background var(--ua-duration-fast) ease; } .ua-upload-zone:hover, .ua-upload-over { @@ -2705,7 +2705,7 @@ button.ua-step-dot:hover { background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent); } .ua-upload-zone:focus-visible { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-upload-disabled { cursor: not-allowed; @@ -2790,11 +2790,11 @@ button.ua-step-dot:hover { border-left: 3px solid var(--ua-color-accent); border-radius: var(--ua-radius-md); background: var(--ua-color-bg); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: var(--ua-shadow-2); pointer-events: auto; opacity: 0; transform: translateY(8px); - transition: opacity 0.2s ease, transform 0.2s ease; + transition: opacity var(--ua-duration-base) ease, transform var(--ua-duration-base) ease; } .ua-notice-open { opacity: 1; @@ -2931,11 +2931,11 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); outline: none; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-transfer-search:focus { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-transfer-list { display: flex; @@ -2951,7 +2951,7 @@ button.ua-step-dot:hover { border-radius: var(--ua-radius-sm); font-size: var(--ua-font-size-sm); color: var(--ua-color-text); - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; } .ua-transfer-item:hover { background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); @@ -3019,7 +3019,7 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-cascader-trigger:hover { border-color: var(--ua-color-accent); @@ -3030,15 +3030,15 @@ button.ua-step-dot:hover { } .ua-cascader.ua-open .ua-cascader-trigger { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-cascader-value.is-empty { color: var(--ua-color-text-muted); } .ua-cascader-caret { - font-size: 10px; + font-size: var(--ua-font-size-xs); color: var(--ua-color-text-muted); - transition: transform 0.15s ease; + transition: transform var(--ua-duration-fast) ease; } .ua-cascader.ua-open .ua-cascader-caret { transform: rotate(180deg); @@ -3051,12 +3051,12 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + box-shadow: var(--ua-shadow-4); overflow: hidden; } .ua-cascader-pop.ua-open { display: block; - animation: ua-cascader-in 0.15s ease both; + animation: ua-cascader-in var(--ua-duration-fast) ease both; } .ua-cascader-pop.is-flip { margin-top: 0; @@ -3094,7 +3094,7 @@ button.ua-step-dot:hover { border-radius: var(--ua-radius-sm); text-align: left; cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; + transition: background var(--ua-duration-fast) ease, color var(--ua-duration-fast) ease; } .ua-cascader-opt:hover:not(:disabled), .ua-cascader-opt.is-active { @@ -3114,7 +3114,7 @@ button.ua-step-dot:hover { } .ua-cascader-arrow { color: var(--ua-color-text-muted); - font-size: 10px; + font-size: var(--ua-font-size-xs); } @keyframes ua-cascader-in { from { opacity: 0; transform: translateY(-4px); } @@ -3150,7 +3150,7 @@ button.ua-step-dot:hover { flex: 0 0 6px; background: var(--ua-color-border); cursor: col-resize; - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; touch-action: none; } .ua-splitter-vertical > .ua-splitter-bar { @@ -3191,11 +3191,11 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); outline: none; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-autocomplete-input:focus { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-autocomplete-input:disabled { opacity: 0.5; @@ -3211,12 +3211,12 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + box-shadow: var(--ua-shadow-4); overflow: hidden; } .ua-autocomplete-pop.ua-open { display: block; - animation: ua-ac-in 0.15s ease both; + animation: ua-ac-in var(--ua-duration-fast) ease both; } .ua-autocomplete-pop.is-flip { margin-top: 0; @@ -3239,7 +3239,7 @@ button.ua-step-dot:hover { font-size: var(--ua-font-size-sm); color: var(--ua-color-text); cursor: pointer; - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; } .ua-autocomplete-opt.is-active, .ua-autocomplete-opt:hover { @@ -3282,7 +3282,7 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-datepicker-trigger:hover { border-color: var(--ua-color-accent); @@ -3293,13 +3293,13 @@ button.ua-step-dot:hover { } .ua-datepicker.ua-open .ua-datepicker-trigger { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-datepicker-value.is-empty { color: var(--ua-color-text-muted); } .ua-datepicker-caret { - font-size: 11px; + font-size: var(--ua-font-size-xs); color: var(--ua-color-text-muted); } .ua-datepicker-pop { @@ -3312,11 +3312,11 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + box-shadow: var(--ua-shadow-4); } .ua-datepicker-pop.ua-open { display: block; - animation: ua-dp-in 0.15s ease both; + animation: ua-dp-in var(--ua-duration-fast) ease both; } .ua-datepicker-pop.is-flip { margin-top: 0; @@ -3337,14 +3337,14 @@ button.ua-step-dot:hover { .ua-datepicker-nav { width: 24px; height: 24px; - font-size: 14px; + font-size: var(--ua-font-size-lg); line-height: 1; color: var(--ua-color-text); background: transparent; border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: border-color 0.15s ease, color 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, color var(--ua-duration-fast) ease; } .ua-datepicker-nav:hover { color: var(--ua-color-accent); @@ -3374,7 +3374,7 @@ button.ua-step-dot:hover { border: none; border-radius: var(--ua-radius-sm); cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; + transition: background var(--ua-duration-fast) ease, color var(--ua-duration-fast) ease; } .ua-datepicker-day:hover:not(:disabled) { background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent); @@ -3424,7 +3424,7 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: color 0.15s, border-color 0.15s, background-color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), border-color var(--ua-duration-fast) var(--ua-ease), background-color var(--ua-duration-fast) var(--ua-ease); } .ua-datepicker-preset:hover { color: var(--ua-color-accent); @@ -3461,7 +3461,7 @@ button.ua-step-dot:hover { opacity: 0; transform: translateY(8px) scale(0.9); pointer-events: none; - transition: opacity 0.2s ease, transform 0.2s ease; + transition: opacity var(--ua-duration-base) ease, transform var(--ua-duration-base) ease; box-shadow: 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 35%, transparent); } .ua-backtop.is-visible { @@ -3510,7 +3510,7 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-colorpicker-trigger:hover { border-color: var(--ua-color-accent); @@ -3521,7 +3521,7 @@ button.ua-step-dot:hover { } .ua-colorpicker.ua-open .ua-colorpicker-trigger { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-colorpicker-swatch { flex: 0 0 auto; @@ -3542,7 +3542,7 @@ button.ua-step-dot:hover { color: var(--ua-color-text-muted); } .ua-colorpicker-caret { - font-size: 10px; + font-size: var(--ua-font-size-xs); color: var(--ua-color-text-muted); } .ua-colorpicker-pop { @@ -3555,11 +3555,11 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + box-shadow: var(--ua-shadow-4); } .ua-colorpicker-pop.ua-open { display: block; - animation: ua-cp-in 0.15s ease both; + animation: ua-cp-in var(--ua-duration-fast) ease both; } .ua-colorpicker-pop.is-flip { margin-top: 0; @@ -3576,7 +3576,7 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: transform var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-colorpicker-cell:hover { transform: translateY(-1px); @@ -3636,7 +3636,7 @@ button.ua-step-dot:hover { position: absolute; border-radius: var(--ua-radius-md); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45); - transition: all 0.2s ease; + transition: all var(--ua-duration-base) ease; } .ua-tour-card { position: absolute; @@ -3646,8 +3646,8 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.24); - animation: ua-tour-in 0.18s ease both; + box-shadow: var(--ua-shadow-4); + animation: ua-tour-in var(--ua-duration-base) ease both; } .ua-tour-head { display: flex; @@ -3662,7 +3662,7 @@ button.ua-step-dot:hover { } .ua-tour-close { padding: 0 4px; - font-size: 14px; + font-size: var(--ua-font-size-lg); line-height: 1; color: var(--ua-color-text-muted); background: transparent; @@ -3752,11 +3752,11 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-sm); cursor: text; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-multiselect.ua-open .ua-multiselect-box { border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-multiselect-tags { display: contents; @@ -3789,7 +3789,7 @@ button.ua-step-dot:hover { } .ua-multiselect-x { padding: 0; - font-size: 12px; + font-size: var(--ua-font-size-sm); line-height: 1; color: var(--ua-color-text-muted); background: transparent; @@ -3819,12 +3819,12 @@ button.ua-step-dot:hover { border: 1px solid var(--ua-color-border); border-radius: var(--ua-radius-md); background: var(--ua-color-surface); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + box-shadow: var(--ua-shadow-4); overflow: hidden; } .ua-multiselect-pop.ua-open { display: block; - animation: ua-ms-in 0.15s ease both; + animation: ua-ms-in var(--ua-duration-fast) ease both; } .ua-multiselect-pop.is-flip { margin-top: 0; @@ -3854,7 +3854,7 @@ button.ua-step-dot:hover { color: var(--ua-color-text); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; } .ua-multiselect-opt.is-active, .ua-multiselect-opt:hover { @@ -3870,7 +3870,7 @@ button.ua-step-dot:hover { } .ua-multiselect-check { width: 12px; - font-size: 11px; + font-size: var(--ua-font-size-xs); color: var(--ua-color-accent); } .ua-multiselect-empty { @@ -3937,7 +3937,7 @@ button.ua-step-dot:hover { color: var(--ua-color-text-muted); font-size: var(--ua-font-size-md); text-decoration: none; - transition: color 0.15s, background-color 0.15s, transform 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), background-color var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease); } .ua-anchor-link:hover { color: var(--ua-color-text); @@ -4026,7 +4026,7 @@ button.ua-step-dot:hover { line-height: 1; cursor: pointer; transform: translateY(-50%); - transition: color 0.15s, background-color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), background-color var(--ua-duration-fast) var(--ua-ease); } .ua-select-clear:hover { color: var(--ua-color-text); @@ -4049,7 +4049,7 @@ button.ua-step-dot:hover { font-size: inherit; cursor: pointer; border-radius: var(--ua-radius-sm); - transition: color 0.15s, background-color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), background-color var(--ua-duration-fast) var(--ua-ease); } .ua-crumb-fold:hover { color: var(--ua-color-accent); @@ -4072,7 +4072,7 @@ button.ua-step-dot:hover { font-size: var(--ua-font-size-md); font-weight: 500; cursor: pointer; - transition: color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease); } .ua-toast-action:hover { text-decoration: underline; @@ -4111,7 +4111,7 @@ button.ua-step-dot:hover { background: none; color: var(--ua-color-text-muted); cursor: pointer; - transition: color 0.15s, background-color 0.15s; + transition: color var(--ua-duration-fast) var(--ua-ease), background-color var(--ua-duration-fast) var(--ua-ease); } .ua-calendar-nav:hover { color: var(--ua-color-accent); @@ -4145,7 +4145,7 @@ button.ua-step-dot:hover { color: var(--ua-color-text); font-size: var(--ua-font-size-md); cursor: pointer; - transition: background-color 0.15s, color 0.15s, transform 0.15s; + transition: background-color var(--ua-duration-fast) var(--ua-ease), color var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease); } .ua-calendar-day:hover { background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); @@ -4166,7 +4166,7 @@ button.ua-step-dot:hover { } .ua-calendar-mark { color: var(--ua-color-text-muted); - font-size: 10px; + font-size: var(--ua-font-size-xs); line-height: 1; } @media (prefers-reduced-motion: reduce) { @@ -4185,7 +4185,7 @@ button.ua-step-dot:hover { } .ua-drawer-overlay.is-plain .ua-drawer { pointer-events: auto; - box-shadow: 0 0 24px rgba(0, 0, 0, 0.18); + box-shadow: var(--ua-shadow-2); } /* —— 排版文本:层级靠字号与字重区分,语义色与装饰位只换颜色,不引布局 —— */ @@ -4252,7 +4252,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } padding: 0 2px; border-radius: var(--ua-radius-sm); cursor: pointer; - transition: color 0.15s ease, background 0.15s ease; + transition: color var(--ua-duration-fast) ease, background var(--ua-duration-fast) ease; } .ua-type-copy:hover { color: var(--ua-color-accent); @@ -4328,7 +4328,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } font-size: var(--ua-font-size-md); cursor: pointer; box-shadow: 0 6px 20px color-mix(in srgb, var(--ua-color-text) 16%, transparent); - transition: transform 0.15s ease, box-shadow 0.15s ease, color 0.15s ease, border-color 0.15s ease; + transition: transform var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease, color var(--ua-duration-fast) ease, border-color var(--ua-duration-fast) ease; } .ua-fab.has-label { padding: 0 var(--ua-space-3); @@ -4366,7 +4366,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } border-radius: 999px; background: var(--ua-color-danger); color: var(--ua-color-accent-contrast); - font-size: 10px; + font-size: var(--ua-font-size-xs); line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--ua-color-bg); /* 与页面底色断开,避免和按钮边缘糊在一起 */ @@ -4399,12 +4399,12 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } font-size: var(--ua-font-size-md); line-height: 1.6; resize: vertical; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-mentions-input:focus { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-mentions-pop { position: absolute; @@ -4423,7 +4423,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } } .ua-mentions.is-open .ua-mentions-pop { display: block; - animation: ua-mentions-in 0.15s ease; + animation: ua-mentions-in var(--ua-duration-fast) ease; } .ua-mentions-item { display: flex; @@ -4432,7 +4432,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } padding: var(--ua-space-2) var(--ua-space-2); border-radius: var(--ua-radius-sm); cursor: pointer; - transition: background 0.15s ease; + transition: background var(--ua-duration-fast) ease; } .ua-mentions-item.is-active { background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent); /* 选中态低视觉比重 */ @@ -4479,7 +4479,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } font-size: var(--ua-font-size-md); text-align: left; cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color var(--ua-duration-fast) ease, box-shadow var(--ua-duration-fast) ease; } .ua-treeselect-trigger:hover { border-color: color-mix(in srgb, var(--ua-color-accent) 55%, var(--ua-color-border)); @@ -4488,7 +4488,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } .ua-treeselect.is-open .ua-treeselect-trigger { outline: none; border-color: var(--ua-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 20%, transparent); + box-shadow: var(--ua-focus-ring); } .ua-treeselect-trigger:disabled { cursor: not-allowed; @@ -4499,8 +4499,8 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } } .ua-treeselect-arrow { color: var(--ua-color-text-muted); - font-size: 10px; - transition: transform 0.15s ease; + font-size: var(--ua-font-size-xs); + transition: transform var(--ua-duration-fast) ease; } .ua-treeselect.is-open .ua-treeselect-arrow { transform: rotate(180deg); @@ -4522,7 +4522,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } } .ua-treeselect.is-open .ua-treeselect-pop { display: block; - animation: ua-treeselect-in 0.15s ease; + animation: ua-treeselect-in var(--ua-duration-fast) ease; } @keyframes ua-treeselect-in { from { opacity: 0; transform: translateY(-4px); } @@ -4547,7 +4547,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } text-decoration: none; text-underline-offset: 2px; text-decoration-thickness: 1px; - transition: color 0.15s ease, text-decoration-color 0.15s ease; + transition: color var(--ua-duration-fast) ease, text-decoration-color var(--ua-duration-fast) ease; cursor: pointer; } .ua-link:hover { @@ -4715,7 +4715,7 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } .ua-progress-ring.is-danger .ua-progress-ring-arc { stroke: var(--ua-color-danger); } .ua-progress-ring-text { fill: var(--ua-color-text); - font-size: 14px; + font-size: var(--ua-font-size-lg); font-variant-numeric: tabular-nums; } @media (prefers-reduced-motion: reduce) { diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js index 1131fa5..2e7cc9d 100644 --- a/packages/tools/cli/bin/ua.js +++ b/packages/tools/cli/bin/ua.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +import { visualAudit } from '../lib/visual-audit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -1437,10 +1438,13 @@ switch (cmd) { case 'lint-iterations': cmdLintIterations(); break; + case 'audit:visual': + visualAudit(args, ROOT); + break; case 'mcp': cmdMcp(); break; default: - console.log('usage: ua |info |docs|tokens [--json]|style|sync-counts|verify|commit-lint|lint-iterations|changelog|mcp>'); + console.log('usage: ua |info |docs|tokens [--json]|style|sync-counts|verify|commit-lint|lint-iterations|audit:visual [--json|--baseline]|changelog|mcp>'); process.exit(cmd ? 1 : 0); } diff --git a/packages/tools/cli/lib/visual-audit.mjs b/packages/tools/cli/lib/visual-audit.mjs new file mode 100644 index 0000000..5a42cdf --- /dev/null +++ b/packages/tools/cli/lib/visual-audit.mjs @@ -0,0 +1,130 @@ +/*** @ui-arch 视觉漂移审计:统计皮肤包里残留的硬编码视觉字面量,并按基线做棘轮门禁(只减不增) + * + * 为什么放在 lib 而不是塞进 bin/ua.js:ua.js 已经承载包索引、文档、MCP、六道门禁, + * 再加一套统计逻辑会让入口文件继续膨胀;这里只留「读取 → 统计 → 比对 → 报错」四步, + * 规则表本身在 visual-rules.mjs(与迁移脚本共用同一张表,保证判定口径一致)。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { UI_CSS_REL, KIND_META, countRows, scanSource } from './visual-rules.mjs'; + +/*** 作用:基线文件相对仓库根的路径(入库,供 CI 与本地共用) ***/ +const BASELINE_REL = '.ua/visual-baseline.json'; + +/*** 作用:读仓库文件,统一换行便于行号稳定 ***/ +function readText(file) { + return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); +} + +/*** 作用:取当前提交短哈希,写入基线便于回溯「哪次提交把数字定在这」 ***/ +function shortCommit(root) { + const res = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }); + return res.status === 0 ? String(res.stdout).trim() : 'unknown'; +} + +/*** 作用:统计单行文本里某个类别的命中数(diff 归因时逐行套同一套规则,口径与整文件扫描一致) ***/ +function countInLine(line, key) { + return scanSource(line).counts[key] ?? 0; +} + +/*** 作用:按 git 历史倒序找最可能引入该类硬编码的提交(只查样式表最近 40 次改动) ***/ +function suspectCommit(root, key) { + const log = spawnSync('git', ['log', '-n', '40', '--pretty=%h', '--', UI_CSS_REL], { cwd: root, encoding: 'utf8' }); + if (log.status !== 0) return null; // 非 git 环境(打包分发)跳过归因 + for (const hash of String(log.stdout).split('\n').map((s) => s.trim()).filter(Boolean)) { + const show = spawnSync('git', ['show', '--format=', '--unified=0', hash, '--', UI_CSS_REL], { cwd: root, encoding: 'utf8' }); + if (show.status !== 0) continue; + const added = String(show.stdout).split('\n').filter((line) => line.startsWith('+') && !line.startsWith('+++')); + const hit = added.reduce((sum, line) => sum + countInLine(line.slice(1), key), 0); + if (hit) return { hash, hit }; + } + return null; +} + +/*** 作用:打印每类的计数与前 5 个行号,返回总计数(人读模式) ***/ +function printReport(root, result) { + for (const row of countRows(result.counts)) { + const lines = (result.hits[row.key] ?? []).slice(0, 5).map((hit) => hit.line); + const where = lines.length ? ` 例如行 ${lines.join('/')}` : ''; + console.log(`${row.label}(${row.key}):${row.count} 处${where}`); + } + const total = countRows(result.counts).reduce((sum, row) => sum + row.count, 0); + console.log(`硬编码视觉字面量合计 ${total} 处(${UI_CSS_REL})`); + return total; +} + +/*** 作用:把当前计数写入基线文件(含提交与时间,入库),供后续棘轮比对 ***/ +function writeBaseline(root, result) { + const file = path.join(root, BASELINE_REL); + const total = KIND_META.reduce((sum, meta) => sum + (result.counts[meta.key] ?? 0), 0); + const data = { + $schema: '视觉漂移基线:五类硬编码视觉字面量的允许上限,任一类超出即 ua audit:visual 退出码 1;迁移后用 --baseline 重写', + file: UI_CSS_REL, + commit: shortCommit(root), + generatedAt: new Date().toISOString(), + lines: readText(path.join(root, UI_CSS_REL)).split('\n').length, + total, + counts: Object.fromEntries(KIND_META.map((meta) => [meta.key, result.counts[meta.key] ?? 0])), + }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + console.log(`已写入基线 ${BASELINE_REL}(合计 ${total} 处,commit ${data.commit})`); +} + +/*** 作用:读基线;缺失或损坏时返回 null(门禁降级为「只报告不拦截」并提示生成基线) ***/ +function readBaseline(root) { + const file = path.join(root, BASELINE_REL); + if (!fs.existsSync(file)) return null; + try { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + return data && typeof data.counts === 'object' ? data : null; + } catch { + return null; + } +} + +/*** 作用:入口:ua audit:visual [--json] [--baseline];超基线退出码 1,否则打印通过 ***/ +export function visualAudit(args, root) { + const cssFile = path.join(root, UI_CSS_REL); + if (!fs.existsSync(cssFile)) { + console.error(`audit:visual: 找不到 ${UI_CSS_REL}`); + process.exit(1); + } + const result = scanSource(readText(cssFile)); + const total = KIND_META.reduce((sum, meta) => sum + (result.counts[meta.key] ?? 0), 0); + + if (args.includes('--baseline')) { + writeBaseline(root, result); + printReport(root, result); + return 0; + } + if (args.includes('--json')) { + const baseline = readBaseline(root); + console.log(JSON.stringify({ file: UI_CSS_REL, total, counts: result.counts, baseline: baseline?.counts ?? null }, null, 2)); + return 0; + } + + const baseline = readBaseline(root); + printReport(root, result); + if (!baseline) { + console.log(`audit:visual: 尚无基线(${BASELINE_REL}),先跑 npm run audit:visual -- --baseline 定基线`); + return 0; + } + const grown = KIND_META.filter((meta) => (result.counts[meta.key] ?? 0) > (baseline.counts[meta.key] ?? 0)); + const baseTotal = baseline.total ?? KIND_META.reduce((sum, meta) => sum + (baseline.counts[meta.key] ?? 0), 0); + if (!grown.length) { + console.log(`视觉基线通过(当前 ${total} 处,基线 ${baseTotal} 处)`); + return 0; + } + for (const meta of grown) { + const now = result.counts[meta.key] ?? 0; + const was = baseline.counts[meta.key] ?? 0; + const sus = suspectCommit(root, meta.key); + const who = sus ? `疑似由提交 ${sus.hash} 引入(该提交新增 ${sus.hit} 处)` : '未能定位引入提交(可能来自未提交改动)'; + console.error(`audit:visual: ${meta.label}(${meta.key})涨了 ${now - was} 处(基线 ${was} → 当前 ${now}),${meta.hint};${who}`); + } + console.error(`视觉基线失败:${grown.length} 类超过基线。禁止新增硬编码视觉字面量,改用 --ua-* 令牌`); + process.exit(1); + return 1; +} diff --git a/packages/tools/cli/lib/visual-rules.mjs b/packages/tools/cli/lib/visual-rules.mjs new file mode 100644 index 0000000..f456953 --- /dev/null +++ b/packages/tools/cli/lib/visual-rules.mjs @@ -0,0 +1,311 @@ +/*** @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', + }, +]; + +/*** 作用:需要扫描的 CSS 属性白名单(其余属性本轮不动;用白名单顺带避开伪类选择器误判) ***/ +const SCAN_PROPS = new Set([ + 'font-size', + 'transition', + 'animation', + 'transition-duration', + 'animation-duration', + 'box-shadow', + 'border-radius', + 'outline', +]); + +/*** 作用:字号字面值 → 令牌(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 = /(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})` }; +} + +/*** 作用:判定单条声明是否需要迁移,返回 { 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); + } + 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; + } + 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 })); +} diff --git a/packages/tools/cli/ua.manifest.json b/packages/tools/cli/ua.manifest.json index 3323a26..cc4145f 100644 --- a/packages/tools/cli/ua.manifest.json +++ b/packages/tools/cli/ua.manifest.json @@ -19,6 +19,7 @@ "ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1", "ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)", "ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1", + "ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里五类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数", "ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe" ], "keyTypes": [ @@ -26,7 +27,7 @@ "MCP tool 入参 schema" ] }, - "usage": "node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify", + "usage": "node packages/tools/cli/bin/ua.js |docs|tokens|style|verify|commit-lint|audit:visual|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual", "conventions": [ "零依赖:只用 node 标准库,Node >= 18", "清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生", @@ -35,11 +36,12 @@ "docs": { "intro": "工程层工具包(private,不发布)。所有 Agent 与人都通过它读取组件库:清单驱动索引、文档、MCP 三件套,外加 style / verify 两道门禁。", "steps": [ - "装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify)", + "装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual)", "查组件:ua list 看全量,ua search <关键词> 按 id/标题/参数名/参数说明/步骤模糊找(「处理中」这类说明里的词也能搜到,命中非 id 标题时会标出命中字段;拼错会回相近 id),ua info <组件id> 看 props/步骤/示例(ua info <包名> 看单包 api 与约定,加 --json 出原始清单)", "查配方:ua recipes 列全量(编号 / 标题 / 用到的组件),ua recipes <编号> 看骨架代码与要点(--json 给脚本用;MCP 里对应 list_recipes / get_recipe)", - "查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码", + "查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码。令牌分十组:颜色 / 圆角 / 间距 / 字号 / 字重行高 / 边框 / 阴影 / 动效 / 焦点环 / 控件高度 / 层级,弹层阴影用 --ua-shadow-1..4,过渡用 --ua-duration-* + --ua-ease,焦点环用 --ua-focus-ring(_inset)", "改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料)", + "改完样式跑 npm run audit:visual:五类硬编码计数不得高于 .ua/visual-baseline.json(棘轮只减不增);字面量可用 npm run visual:migrate 幂等改写成令牌", "在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe" ], "components": [] diff --git a/scripts/visual-migrate.mjs b/scripts/visual-migrate.mjs new file mode 100644 index 0000000..6a63d00 --- /dev/null +++ b/scripts/visual-migrate.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +/*** 作用:皮肤包视觉字面量 → 设计令牌的幂等迁移,并灌入视觉欠账条目(零新依赖,可重复运行) */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { UI_CSS_REL, migrateSource, countRows } from '../packages/tools/cli/lib/visual-rules.mjs'; + +// 仓库根:本脚本固定放在 /scripts/ 下,向上取一层即可 +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +// 样式表绝对路径 +const CSS_FILE = path.join(ROOT, UI_CSS_REL); +// 迭代待办文件绝对路径 +const BACKLOG_FILE = path.join(ROOT, '.ua', 'backlog.json'); + +/*** 作用:把内容写盘,仅在与现有内容不同才写;返回是否真的写了(幂等的落盘口径) ***/ +function writeIfChanged(file, content) { + const exists = fs.existsSync(file); + const old = exists ? fs.readFileSync(file, 'utf8') : ''; + if (old === content) return false; + fs.writeFileSync(file, content, 'utf8'); + return true; +} + +/*** 作用:迁移 ui.css 字面量到令牌,打印每类替换处数,并在无变化时明确说「无需改动」 ***/ +function migrateCss() { + const before = fs.readFileSync(CSS_FILE, 'utf8'); + const { text, counts } = migrateSource(before); + const rows = countRows(counts); + const total = rows.reduce((sum, row) => sum + row.count, 0); + for (const row of rows) console.log(`${row.label}(${row.key})替换 ${row.count} 处`); + console.log(`合计 ${total} 处`); + if (!writeIfChanged(CSS_FILE, text)) { + console.log(`无需改动:${UI_CSS_REL} 已是令牌化状态`); + return 0; + } + console.log(`已写回 ${UI_CSS_REL}`); + return 0; +} + +/*** 作用:按标题判重后追加视觉欠账条目(domain=visual,状态 open),id 取当前最大 g 编号往后排 ***/ +function seedBacklog() { + const data = JSON.parse(fs.readFileSync(BACKLOG_FILE, 'utf8')); + const items = data.items ?? []; + const seeds = visualSeeds(); + const have = new Set(items.map((item) => item.title)); + let maxId = 0; // 现存最大 g 编号:动态取,避免与并发迭代实例抢号 + for (const item of items) { + const m = /^g(\d+)$/.exec(item.id ?? ''); + if (m) maxId = Math.max(maxId, Number(m[1])); + } + let added = 0; + for (const seed of seeds) { + if (have.has(seed.title)) continue; // 幂等:同标题已存在就不再插 + maxId += 1; + items.push({ id: `g${maxId}`, ...seed, status: 'open', notes: '', domain: 'visual' }); + added += 1; + } + writeIfChanged(BACKLOG_FILE, `${JSON.stringify({ ...data, items }, null, 2)}\n`); + console.log(added ? `已追加 ${added} 条视觉欠账(g${maxId - added + 1}…g${maxId})` : `无需改动:${seeds.length} 条视觉欠账均已存在`); + return 0; +} + +/*** 作用:给缺 domain 字段的存量条目按标题批量推断回填(不动 id / status / 其它字段与顺序) ***/ +function backfillDomain() { + const data = JSON.parse(fs.readFileSync(BACKLOG_FILE, 'utf8')); + const items = data.items ?? []; + let filled = 0; + const tally = {}; // 各域回填条数,便于核对推断比例 + for (const item of items) { + if (item.domain) continue; + const domain = inferDomain(item.title ?? ''); + item.domain = domain; + tally[domain] = (tally[domain] ?? 0) + 1; + filled += 1; + } + writeIfChanged(BACKLOG_FILE, `${JSON.stringify({ ...data, items }, null, 2)}\n`); + if (!filled) { + console.log('无需改动:所有条目已有 domain'); + return 0; + } + const detail = Object.entries(tally).map(([k, v]) => `${k} ${v}`).join(' / '); + console.log(`已回填 ${filled} 条 domain(${detail})`); + return 0; +} + +/*** 作用:按标题关键词推断域:含「新增」判 new,含工具链关键词判 tooling,其余一律 tune ***/ +function inferDomain(title) { + if (title.includes('新增')) return 'new'; + const tooling = ['门禁', 'CLI', 'MCP', '文档', '配方', '校验']; + if (tooling.some((kw) => title.includes(kw))) return 'tooling'; + return 'tune'; +} + +/*** 作用:八条首批视觉欠账的定义(验收标准都写成可枚举或可机器验证的口径) ***/ +function visualSeeds() { + return [ + { + title: '弹层阴影语义化:剩余字面阴影归入 elevation 四档', + motivation: + '阴影已收敛出四档令牌,但仍有主题色投影(color-mix text/accent)与状态色环以字面值散落在弹层、气泡、选中态里,同名层级在不同组件用不同数值,观感不齐且改不动。', + acceptance: + 'ui.css 中 box-shadow 的 px 字面值全部归入 --ua-shadow-1..4 或语义色环(无主题色投影字面值);`ua audit:visual --json` 的 shadow-literal 计数相对 .ua/visual-baseline.json 至少减少 8 处并写入新基线;在 packages/skin/ui/ua.manifest.json 的 docs.intro 写明「下拉=shadow-2 / 浮层=shadow-3 / 弹窗=shadow-4 / 通知=shadow-4」对照表;npm run check 全绿。', + }, + { + title: '内距令牌化:padding / gap 字面值降到基线以下', + motivation: + '间距只有 4/8/12/16 四档令牌,实际却大量写 6px/10px 这类中间值,同一类容器内距不统一,改密度只能全仓替换。', + acceptance: + 'packages/design/tokens/tokens.css 补齐内距档位(新增 6px/10px 两档或把它们归并到既有档并在注释说明),tokens.json 同步;visual-rules 增加 space-literal 类(padding/gap/margin 的 px 字面值),迁移脚本同步规则并幂等;该类计数写入新基线且比首次统计少 30 处以上;npm run check 全绿。', + }, + { + title: '表单控件高度统一接入 control-h 三档', + motivation: + '控件高度令牌 --ua-control-h-sm/md/lg 已就位,但 button/textField/select/inputNumber 仍各自写死高度,同一行并排时高度不一致,也挡住「略放大提高可点面积」的落地。', + acceptance: + 'ui.css 中 .ua-btn / .ua-input / .ua-select / .ua-input-number 的高度声明全部引用 var(--ua-control-h-*);visual-rules 增加 control-h-literal 类且计数为 0;apps/demo/main.ts 中存在一行并排的三档高度演示(sec 锚点内真实调用四个组件);npm run check 全绿。', + }, + { + title: '暗色主题阴影与焦点环适配', + motivation: + '暗色块只覆盖颜色,弹层阴影沿用亮色主题的低透明度黑投影,暗底上几乎看不出层级;焦点环在暗色下对比也不足。', + acceptance: + '[data-theme=\'dark\'] 覆盖 --ua-shadow-1..4(更高黑度)与 --ua-focus-ring 两个槽位;ua verify 增加一条校验:暗色块缺 shadow / focus 覆盖即报错;ua tokens 输出的暗色列能看到这 6 个令牌的暗色值;npm run check 全绿。', + }, + { + title: '字重与行高统一到三档令牌', + motivation: + '标题、正文、辅助文字的字重与行高全靠各组件手写,同一层级的标题在不同弹窗里字重不同,多行文本行距忽松忽紧。', + acceptance: + 'visual-rules 增加 weight-literal 与 leading-literal 两类(font-weight / line-height 的字面值),迁移脚本把字面值改为 --ua-weight-* / --ua-leading-*;两类计数归零并写入新基线;标题/正文/辅助三档在注释里写明归属;npm run check 全绿。', + }, + { + title: '层级令牌落地:z-index 字面值归零', + motivation: + '--ua-z-dropdown/sticky/overlay/modal/toast 五档已定义但无人使用,实际仍是各组件写 100/1000/9999,弹层互相盖住的问题只能靠试数值解决。', + acceptance: + 'visual-rules 增加 z-literal 类;迁移脚本把 ui.css 中全部 z-index 字面值替换为 var(--ua-z-*);该类计数为 0 并写入新基线;ua tokens 能看到五档层级;npm run check 全绿。', + }, + { + title: '边框细节统一到 subtle 与基础色', + motivation: + '分割线、输入框、卡片边框混用基础边框色与更深的黑边,1px 线在不同组件上粗细观感不一,密集表格里分割线过重。', + acceptance: + '分割线改用 --ua-border-subtle、输入框与卡片边框用 --ua-color-border,ui.css 中不再出现硬编码黑边(rgba(0,0,0,…) 作为边框值);visual-rules 增加 border-literal 类且计数为 0;表格与输入框在 demo 中肉眼无粗细跳变;npm run check 全绿。', + }, + { + title: '视觉回归抽查清单:六个代表组件的可勾选验收项', + motivation: + '视觉类改动只能靠人眼抽查,每次凭记忆看一遍,漏看是常态;把六个高频组件的观感要点固化成清单,后续视觉条目可直接复用。', + acceptance: + '在 ITERATION.md 或 packages/skin/ui/ua.manifest.json 增加《视觉回归抽查清单》,button / textField / select / modal / dataTable / toast 六个组件各 ≥3 条可勾选要点(合计 ≥18 条);清单条目必须覆盖边框、阴影、过渡、焦点环、暗色五项;ua verify 校验清单存在且 18 条要点齐全;npm run check 全绿。', + }, + ]; +} + +/*** 作用:命令行入口:默认迁移样式表,--seed 灌视觉欠账,--backfill-domain 回填域字段 ***/ +function main(argv) { + const flags = new Set(argv); + if (flags.has('--help') || flags.has('-h')) { + console.log('用法: node scripts/visual-migrate.mjs [--seed] [--backfill-domain]'); + console.log(' 默认 把 ui.css 的视觉字面量迁移成 --ua-* 令牌(幂等,无变化不写盘)'); + console.log(' --seed 按标题判重追加 8 条 domain=visual 的欠账条目(幂等)'); + console.log(' --backfill-domain 给缺 domain 的存量条目按标题推断回填(幂等)'); + return 0; + } + try { + if (flags.has('--seed')) seedBacklog(); + if (flags.has('--backfill-domain')) backfillDomain(); + if (!flags.has('--seed') && !flags.has('--backfill-domain')) migrateCss(); + } catch (err) { + console.error(`visual-migrate 失败:${err.message}`); + return 1; + } + return 0; +} + +process.exit(main(process.argv.slice(2)));