dsh-soul 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/README_EN.md +12 -7
- package/assets/icon.svg +21 -0
- package/client/index.mjs +296 -48
- package/index.mjs +159 -24
- package/lib/config.mjs +15 -3
- package/lib/personas.mjs +172 -0
- package/package.json +7 -4
- package/scripts/verify-config.mjs +438 -0
- package/scripts/verify-nav-icon.mjs +451 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// scripts/verify-nav-icon.mjs — 设置导航图标离线回归(npm run verify:nav-icon)
|
|
2
|
+
//
|
|
3
|
+
// 背景:设置面板导航项的图标由壳层 ui-settings-general 的 navIcon(id) 硬编码决定
|
|
4
|
+
// ——只认 account / models / agent-presets / plugins / archived-sessions,其余 id
|
|
5
|
+
// **一律回退成设置齿轮**,且 settings.section 的选项只有 id / order / label,插件
|
|
6
|
+
// 无法声明自己的图标。dsh-soul 因此在客户端做 DOM 替换,把齿轮改画成与插件图标
|
|
7
|
+
// (assets/icon.svg)同一套几何的「灵魂火花 + 光轨」。
|
|
8
|
+
//
|
|
9
|
+
// 与插件图标**有意不同**的两点,也正是本回归要守住的地方:
|
|
10
|
+
// 1) 单色:插件图标是彩色渐变,导航栏里的一排图标却全是 currentColor 线描。
|
|
11
|
+
// 导航图标跟随邻居用 currentColor(不得出现 defs / 渐变 / 写死色值)。
|
|
12
|
+
// 2) 线宽:宿主机图标是 16 网格 + 1.3 线宽;我们的窗口是 28.4 单位显示成 16px,
|
|
13
|
+
// 因此取 2.3(显示后 ≈ 1.3px)。照搬 icon.svg 的 2.8 会比邻居粗一圈。
|
|
14
|
+
//
|
|
15
|
+
// 另外这个替换还有两处容易悄悄坏掉:
|
|
16
|
+
// 1) 宿主 <svg> 上挂着 `fill="none"` 与 `stroke-width`(medium 为 1.3),这两条会
|
|
17
|
+
// 向下继承;我们的 path 若不写全自身样式,图形会被宿主的属性吃掉(火花被
|
|
18
|
+
// fill:none 清空、或光轨线宽缩成 1.3)。
|
|
19
|
+
// 2) 客户端读不到文件系统,几何只能内联一份 ⇒ 与 assets/icon.svg 有漂移风险。
|
|
20
|
+
//
|
|
21
|
+
// 做法:不需要跑 DSH。把 client/index.mjs 里那段纯 DOM 绘图代码原样抽出,注入一个
|
|
22
|
+
// 静态页,在一个**复刻的导航按钮**(含宿主生成的齿轮 svg)上执行,然后逐项断言:
|
|
23
|
+
//
|
|
24
|
+
// N0 替换前按钮里确实是宿主画的齿轮(证明实验对象正确)
|
|
25
|
+
// N1 复用宿主 <svg> 节点本身(同节点),class / width / height / aria-hidden 不变
|
|
26
|
+
// N2 显示窗口与 icon.svg 同心、不放大,且图形占比(含描边)与宿主图标同量级
|
|
27
|
+
// (0.85–0.98:宿主图标实测 0.89,太松显得轻、太大顶格子)
|
|
28
|
+
// N3 内容是两条 path(光轨 + 火花),且**没有** defs / 渐变
|
|
29
|
+
// N4 两条 d 与 assets/icon.svg **逐字一致**(单一数据源),linecap 亦一致
|
|
30
|
+
// N5 单色:两条 path 的颜色都是 currentColor,且**计算值等于按钮的文字色**
|
|
31
|
+
// ——这是「与兄弟栏目同色」的直接证据;火花纯填充、光轨只描边
|
|
32
|
+
// N6 继承被覆盖:光轨 computed stroke-width = 2.3px(不是宿主的 1.3px),
|
|
33
|
+
// 换算到 16px 显示后 ≈ 1.3px,与邻居等粗
|
|
34
|
+
// N7 布局未被撑坏:仍是 16×16,且包围盒非空;宿主哪天不再给宽高时补默认尺寸
|
|
35
|
+
// (不然 <svg> 会退回 300×150 的默认尺寸)
|
|
36
|
+
//
|
|
37
|
+
// 判断力自检:同一份审计函数对 6 个**故意破坏**的变体(viewBox 退回 16 网格 /
|
|
38
|
+
// 光轨不写线宽 / 火花不关描边 / 路径换成别的图形 / 光轨改回渐变引用 / 火花写死品牌色)
|
|
39
|
+
// 必须各报出问题。少了这一步,写出的很可能是永不失败的检查。
|
|
40
|
+
//
|
|
41
|
+
// 用法:
|
|
42
|
+
// node scripts/verify-nav-icon.mjs [--chrome <可执行文件>] [--keep]
|
|
43
|
+
// node scripts/verify-nav-icon.mjs --emit <页面.html> # 只产出页面(分步验证)
|
|
44
|
+
// node scripts/verify-nav-icon.mjs --dump <dumpdom.html> # 解析已有 dump-dom 输出
|
|
45
|
+
// 环境变量 CHROME_PATH 亦可指定浏览器;找不到浏览器时优雅跳过(退出码 0)。
|
|
46
|
+
//
|
|
47
|
+
// 抽取是「按标记切片 + 断言片段自洽」:client/index.mjs 一旦改结构导致切片失效,
|
|
48
|
+
// 本脚本直接报错退出,而不是悄悄退化成永不失败的检查。
|
|
49
|
+
|
|
50
|
+
import fs from 'node:fs'
|
|
51
|
+
import os from 'node:os'
|
|
52
|
+
import path from 'node:path'
|
|
53
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
54
|
+
import { spawnSync } from 'node:child_process'
|
|
55
|
+
|
|
56
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
57
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
58
|
+
const CLIENT_FILE = path.join(ROOT, 'client', 'index.mjs')
|
|
59
|
+
const ICON_FILE = path.join(ROOT, 'assets', 'icon.svg')
|
|
60
|
+
|
|
61
|
+
const SLICE_START = 'const NAV_ICON_VIEW_BOX'
|
|
62
|
+
const SLICE_END = 'const registerSettingsNavIcon = () => {'
|
|
63
|
+
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const opts = { chrome: null, keep: false, emit: null, dump: null }
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const arg = argv[i]
|
|
68
|
+
if (arg === '--chrome') opts.chrome = argv[++i]
|
|
69
|
+
else if (arg === '--keep') opts.keep = true
|
|
70
|
+
else if (arg === '--emit') opts.emit = argv[++i]
|
|
71
|
+
else if (arg === '--dump') opts.dump = argv[++i]
|
|
72
|
+
}
|
|
73
|
+
return opts
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findBrowser(explicit) {
|
|
77
|
+
const candidates = []
|
|
78
|
+
if (explicit) candidates.push(explicit)
|
|
79
|
+
if (process.env.CHROME_PATH) candidates.push(process.env.CHROME_PATH)
|
|
80
|
+
if (process.platform === 'win32') {
|
|
81
|
+
candidates.push(
|
|
82
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
83
|
+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
84
|
+
path.join(process.env.LOCALAPPDATA || '', 'Google/Chrome/Application/chrome.exe'),
|
|
85
|
+
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
86
|
+
'C:/Program Files/Microsoft/Edge/Application/msedge.exe'
|
|
87
|
+
)
|
|
88
|
+
} else if (process.platform === 'darwin') {
|
|
89
|
+
candidates.push(
|
|
90
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
91
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
|
92
|
+
)
|
|
93
|
+
} else {
|
|
94
|
+
candidates.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge')
|
|
95
|
+
}
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (!candidate) continue
|
|
98
|
+
try {
|
|
99
|
+
if (fs.existsSync(candidate)) return candidate
|
|
100
|
+
} catch {
|
|
101
|
+
/* 忽略不可访问的候选路径 */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 从 client/index.mjs 抽出绘图段(含常量、svgNode / paintSoulNavIcon)。 */
|
|
108
|
+
function extractPainter() {
|
|
109
|
+
const source = fs.readFileSync(CLIENT_FILE, 'utf8')
|
|
110
|
+
const start = source.indexOf(SLICE_START)
|
|
111
|
+
const end = source.indexOf(SLICE_END)
|
|
112
|
+
if (start < 0 || end < 0 || end <= start) {
|
|
113
|
+
throw new Error(`抽取失败:client/index.mjs 中找不到切片标记(${SLICE_START} / ${SLICE_END})`)
|
|
114
|
+
}
|
|
115
|
+
const slice = source.slice(start, end)
|
|
116
|
+
for (const marker of ['paintSoulNavIcon', 'svgNode', 'replaceChildren', 'NAV_ICON_SPARK_D', 'NAV_ICON_STROKE_WIDTH', 'currentColor']) {
|
|
117
|
+
if (!slice.includes(marker)) throw new Error(`抽取的绘图段缺少标记:${marker}(抽取范围可能已失效)`)
|
|
118
|
+
}
|
|
119
|
+
return slice
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 解析 assets/icon.svg,得到导航图标必须与之保持一致的那几个量。 */
|
|
123
|
+
function parseIconFile() {
|
|
124
|
+
const svg = fs.readFileSync(ICON_FILE, 'utf8')
|
|
125
|
+
const viewBox = (svg.match(/viewBox="([^"]+)"/) || [])[1]
|
|
126
|
+
if (!viewBox) throw new Error('assets/icon.svg 缺少 viewBox')
|
|
127
|
+
|
|
128
|
+
const paths = []
|
|
129
|
+
const pathRe = /<path\s+d="([^"]+)"([^>]*)\/>/g
|
|
130
|
+
let pathMatch
|
|
131
|
+
while ((pathMatch = pathRe.exec(svg))) {
|
|
132
|
+
const [, d, attrs] = pathMatch
|
|
133
|
+
paths.push({
|
|
134
|
+
d,
|
|
135
|
+
stroke: (attrs.match(/stroke="([^"]+)"/) || [])[1] || null,
|
|
136
|
+
strokeWidth: (attrs.match(/stroke-width="([^"]+)"/) || [])[1] || null,
|
|
137
|
+
linecap: (attrs.match(/stroke-linecap="([^"]+)"/) || [])[1] || null,
|
|
138
|
+
fill: (attrs.match(/fill="([^"]+)"/) || [])[1] || null
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
// 插件图标是彩色渐变版(光轨描边、火花填充),据此认出这两条 path;
|
|
142
|
+
// 导航图标沿用它们的 d,但颜色与线宽走的是另一套(见文件头)。
|
|
143
|
+
const trail = paths.find((item) => item.stroke && item.stroke.startsWith('url('))
|
|
144
|
+
const spark = paths.find((item) => item.fill && item.fill.startsWith('url('))
|
|
145
|
+
if (!trail || !spark) throw new Error('assets/icon.svg 缺少光轨(描边)或火花(填充)路径')
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
viewBox,
|
|
149
|
+
trail: { d: trail.d, linecap: trail.linecap },
|
|
150
|
+
spark: { d: spark.d }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildPage(painter, expected) {
|
|
155
|
+
return `<!doctype html>
|
|
156
|
+
<html lang="zh">
|
|
157
|
+
<head>
|
|
158
|
+
<meta charset="utf-8">
|
|
159
|
+
<title>verify-nav-icon</title>
|
|
160
|
+
<style>
|
|
161
|
+
body { margin: 0; padding: 12px; font: 14px/22px system-ui, sans-serif; color: #1a1a1a; background: #fff; }
|
|
162
|
+
/* 复刻宿主 SettingsRoot 的导航单元格:图标 flex:none,label 占满剩余宽度 */
|
|
163
|
+
.navCell { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border: 0; background: transparent; font: inherit; cursor: pointer; }
|
|
164
|
+
.navIcon { flex: none; }
|
|
165
|
+
.navLabel { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
166
|
+
#samples { display: flex; gap: 24px; margin-top: 12px; }
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<button type="button" class="navCell" id="cell">
|
|
171
|
+
<svg width="16" height="16" class="navIcon" viewBox="0 0 16 16" fill="none"
|
|
172
|
+
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke-width="1.3">
|
|
173
|
+
<path d="M8 1.6A6.4 6.4 0 1 0 8 14.4A6.4 6.4 0 1 0 8 1.6Z" stroke="currentColor" stroke-miterlimit="10"/>
|
|
174
|
+
</svg>
|
|
175
|
+
<span class="navLabel">个性化 / Personalization</span>
|
|
176
|
+
</button>
|
|
177
|
+
<div id="samples"></div>
|
|
178
|
+
<pre id="nav-icon-result" hidden></pre>
|
|
179
|
+
<script>
|
|
180
|
+
// —— 自 client/index.mjs 原样抽取的绘图段(常量 + svgNode / paintSoulNavIcon)
|
|
181
|
+
${painter}
|
|
182
|
+
|
|
183
|
+
const EXPECTED = ${JSON.stringify(expected)}
|
|
184
|
+
const issues = []
|
|
185
|
+
const record = (name, ok, detail) => issues.push({ name, ok: !!ok, detail: detail === undefined ? '' : String(detail) })
|
|
186
|
+
|
|
187
|
+
// icon.svg 所在的坐标系:导航图标与它共用同一套几何,但**显示窗口可以不同**
|
|
188
|
+
// (图形在 36 网格里只占 26.8 单位,直接拿来当 16px 导航图标会显得又小又轻)。
|
|
189
|
+
// 所以这里校验的是「同心 + 窗口不放大 + 视觉重量合理」,不是 viewBox 字面相等。
|
|
190
|
+
const ICON_BOX = EXPECTED.viewBox.split(/[\\s,]+/).map(Number)
|
|
191
|
+
const ICON_CENTER = { x: ICON_BOX[0] + ICON_BOX[2] / 2, y: ICON_BOX[1] + ICON_BOX[3] / 2 }
|
|
192
|
+
|
|
193
|
+
// 宿主机图标(ui-settings-general 的 navIcon 产物):16 网格 + 1.3 线宽。
|
|
194
|
+
// 导航图标要在这两点上与邻居看齐——单色、同粗。
|
|
195
|
+
const HOST_GRID = 16
|
|
196
|
+
const HOST_STROKE_WIDTH = 1.3
|
|
197
|
+
|
|
198
|
+
// 审计一份绘制结果:返回问题清单(空数组 = 合格)
|
|
199
|
+
function auditIcon(svg, tag) {
|
|
200
|
+
const out = []
|
|
201
|
+
const push = (item) => out.push(tag + ': ' + item)
|
|
202
|
+
|
|
203
|
+
const raw = svg.getAttribute('viewBox') || ''
|
|
204
|
+
const box = raw.split(/[\\s,]+/).map(Number)
|
|
205
|
+
if (box.length !== 4 || box.some((value) => !isFinite(value))) return out.concat(tag + ': viewBox 不可解析:' + raw)
|
|
206
|
+
const cx = box[0] + box[2] / 2
|
|
207
|
+
const cy = box[1] + box[3] / 2
|
|
208
|
+
if (Math.abs(cx - ICON_CENTER.x) > 0.01 || Math.abs(cy - ICON_CENTER.y) > 0.01) {
|
|
209
|
+
push('显示窗口未与 icon.svg 同心:中心 (' + cx + ',' + cy + '),期望 (' + ICON_CENTER.x + ',' + ICON_CENTER.y + ')')
|
|
210
|
+
}
|
|
211
|
+
if (box[2] > ICON_BOX[2] + 0.01 || box[3] > ICON_BOX[3] + 0.01) {
|
|
212
|
+
push('显示窗口比 icon.svg 的坐标系还大:' + box[2] + '×' + box[3])
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const kids = Array.prototype.slice.call(svg.children)
|
|
216
|
+
if (kids.length !== 2) return out.concat('子元素数量=' + kids.length + '(应为光轨 + 火花两条 path)')
|
|
217
|
+
const trail = kids[0]
|
|
218
|
+
const spark = kids[1]
|
|
219
|
+
if (trail.tagName.toLowerCase() !== 'path' || spark.tagName.toLowerCase() !== 'path') {
|
|
220
|
+
push('子元素不是两条 <path>:' + trail.tagName + ' / ' + spark.tagName)
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 单色:插件图标那套 defs / 渐变不该出现在导航图标里
|
|
225
|
+
if (svg.querySelector('defs, linearGradient, radialGradient, stop')) {
|
|
226
|
+
push('含 defs / 渐变,导航图标应为单色 currentColor')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (trail.getAttribute('d') !== EXPECTED.trail.d) push('光轨路径与 icon.svg 不一致')
|
|
230
|
+
if (spark.getAttribute('d') !== EXPECTED.spark.d) push('火花路径与 icon.svg 不一致')
|
|
231
|
+
|
|
232
|
+
if (trail.getAttribute('fill') !== 'none') push('光轨 fill=' + trail.getAttribute('fill'))
|
|
233
|
+
if (trail.getAttribute('stroke') !== 'currentColor') {
|
|
234
|
+
push('光轨 stroke=' + trail.getAttribute('stroke') + '(应为 currentColor,跟随邻居)')
|
|
235
|
+
}
|
|
236
|
+
if (trail.getAttribute('stroke-width') !== String(NAV_ICON_STROKE_WIDTH)) {
|
|
237
|
+
push('光轨 stroke-width 属性=' + trail.getAttribute('stroke-width'))
|
|
238
|
+
}
|
|
239
|
+
if (trail.getAttribute('stroke-linecap') !== EXPECTED.trail.linecap) {
|
|
240
|
+
push('光轨 linecap=' + trail.getAttribute('stroke-linecap'))
|
|
241
|
+
}
|
|
242
|
+
if (spark.getAttribute('stroke') !== 'none') push('火花 stroke 属性=' + spark.getAttribute('stroke'))
|
|
243
|
+
if (spark.getAttribute('fill') !== 'currentColor') {
|
|
244
|
+
push('火花 fill=' + spark.getAttribute('fill') + '(应为 currentColor,跟随邻居)')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 宿主 svg 上的 fill="none" / stroke-width="1.3" 会向下继承:读计算值确认已被覆盖
|
|
248
|
+
const trailStyle = getComputedStyle(trail)
|
|
249
|
+
const wantStroke = Number(NAV_ICON_STROKE_WIDTH)
|
|
250
|
+
if (Math.abs(parseFloat(trailStyle.strokeWidth) - wantStroke) > 0.01) {
|
|
251
|
+
push('光轨计算线宽=' + trailStyle.strokeWidth + '(继承未被覆盖)')
|
|
252
|
+
}
|
|
253
|
+
if (String(trailStyle.fill).indexOf('none') !== 0) push('光轨计算 fill=' + trailStyle.fill)
|
|
254
|
+
|
|
255
|
+
// 「与兄弟栏目同色」的直接证据:currentColor 解析出来必须等于按钮的文字色
|
|
256
|
+
const textColor = getComputedStyle(svg.closest('button') || svg).color
|
|
257
|
+
if (String(trailStyle.stroke) !== String(textColor)) {
|
|
258
|
+
push('光轨颜色 ' + trailStyle.stroke + ' ≠ 按钮文字色 ' + textColor)
|
|
259
|
+
}
|
|
260
|
+
if (String(getComputedStyle(spark).fill) !== String(textColor)) {
|
|
261
|
+
push('火花颜色 ' + getComputedStyle(spark).fill + ' ≠ 按钮文字色 ' + textColor)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 「与兄弟栏目同粗」:计算线宽是用户单位,乘上「16px ÷ 窗口边长」才是屏幕像素
|
|
265
|
+
const displayStroke = parseFloat(trailStyle.strokeWidth) * (HOST_GRID / box[2])
|
|
266
|
+
if (Math.abs(displayStroke - HOST_STROKE_WIDTH) > 0.15) {
|
|
267
|
+
push('显示线宽=' + displayStroke.toFixed(2) + 'px(邻居 ' + HOST_STROKE_WIDTH + 'px)')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const cell = document.getElementById('cell')
|
|
274
|
+
const icon = cell.querySelector('svg')
|
|
275
|
+
|
|
276
|
+
// N0:替换前是宿主画的齿轮(一个 path、stroke 为 currentColor)
|
|
277
|
+
record('替换前:宿主齿轮就位', icon && icon.children.length === 1 && icon.children[0].getAttribute('stroke') === 'currentColor')
|
|
278
|
+
|
|
279
|
+
const before = icon
|
|
280
|
+
paintSoulNavIcon(icon)
|
|
281
|
+
|
|
282
|
+
record('替换后:仍是同一个 <svg> 节点', icon === before && cell.querySelector('svg') === before)
|
|
283
|
+
record('替换后:宿主属性保留(class / 尺寸 / aria)',
|
|
284
|
+
icon.getAttribute('class') === 'navIcon' &&
|
|
285
|
+
icon.getAttribute('width') === '16' && icon.getAttribute('height') === '16' &&
|
|
286
|
+
icon.getAttribute('aria-hidden') === 'true')
|
|
287
|
+
record('替换后:宿主继承属性仍在(fill=none / stroke-width=1.3,用于验证覆盖)',
|
|
288
|
+
icon.getAttribute('fill') === 'none' && icon.getAttribute('stroke-width') === '1.3')
|
|
289
|
+
|
|
290
|
+
const main = auditIcon(icon, '主版本')
|
|
291
|
+
record('主版本:几何与样式全部合格(0 问题)', main.length === 0, main.join(' | '))
|
|
292
|
+
|
|
293
|
+
const box = icon.getBBox()
|
|
294
|
+
record('主版本:绘制包围盒非空', box.width > 0 && box.height > 0, box.width.toFixed(2) + '×' + box.height.toFixed(2))
|
|
295
|
+
|
|
296
|
+
// 宿主哪天不再给 width/height(改由 CSS 控制尺寸)时,必须补上,否则 svg 会退回
|
|
297
|
+
// 300×150 的默认尺寸把导航栏撑坏
|
|
298
|
+
const bare = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
299
|
+
bare.setAttribute('fill', 'none')
|
|
300
|
+
bare.setAttribute('stroke-width', '1.3')
|
|
301
|
+
document.getElementById('samples').appendChild(bare)
|
|
302
|
+
paintSoulNavIcon(bare)
|
|
303
|
+
record('宿主未给宽高时补默认尺寸', bare.getAttribute('width') === '16' && bare.getAttribute('height') === '16',
|
|
304
|
+
bare.getAttribute('width') + '×' + bare.getAttribute('height'))
|
|
305
|
+
|
|
306
|
+
// 视觉重量:图形(含描边)在显示窗口里占多大。宿主图标实测几乎满格——16px 下墨迹
|
|
307
|
+
// 跨度 14.25px(占 89%),这个区间就是照它定的:太松会显得又小又轻,太紧会顶到格子边。
|
|
308
|
+
const vb = (icon.getAttribute('viewBox') || '').split(/[\\s,]+/).map(Number)
|
|
309
|
+
const weight = (Math.max(box.width, box.height) + Number(NAV_ICON_STROKE_WIDTH)) / vb[2]
|
|
310
|
+
record('主版本:图形占比与宿主图标同量级(0.85–0.98)', weight >= 0.85 && weight <= 0.98, weight.toFixed(3))
|
|
311
|
+
|
|
312
|
+
const rect = icon.getBoundingClientRect()
|
|
313
|
+
record('主版本:仍是 16×16(布局未被撑坏)',
|
|
314
|
+
Math.abs(rect.width - 16) < 0.5 && Math.abs(rect.height - 16) < 0.5,
|
|
315
|
+
rect.width.toFixed(2) + '×' + rect.height.toFixed(2))
|
|
316
|
+
|
|
317
|
+
// 颜色确实跟随容器文字色(而不是写死了某个灰):换个文字色再画一遍,两处都应跟着变
|
|
318
|
+
const themed = document.createElement('button')
|
|
319
|
+
themed.setAttribute('type', 'button')
|
|
320
|
+
themed.className = 'navCell'
|
|
321
|
+
themed.style.color = 'rgb(230, 60, 90)'
|
|
322
|
+
const themedIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
323
|
+
themedIcon.setAttribute('viewBox', '0 0 16 16')
|
|
324
|
+
themedIcon.setAttribute('fill', 'none')
|
|
325
|
+
themedIcon.setAttribute('stroke-width', '1.3')
|
|
326
|
+
themed.appendChild(themedIcon)
|
|
327
|
+
document.getElementById('samples').appendChild(themed)
|
|
328
|
+
paintSoulNavIcon(themedIcon)
|
|
329
|
+
record('颜色跟随容器文字色(没写死色值)',
|
|
330
|
+
String(getComputedStyle(themedIcon.children[0]).stroke) === 'rgb(230, 60, 90)' &&
|
|
331
|
+
String(getComputedStyle(themedIcon.children[1]).fill) === 'rgb(230, 60, 90)',
|
|
332
|
+
getComputedStyle(themedIcon.children[0]).stroke + ' / ' + getComputedStyle(themedIcon.children[1]).fill)
|
|
333
|
+
|
|
334
|
+
// —— 判断力自检:只改坏一个条件,审计必须报出问题
|
|
335
|
+
const samples = document.getElementById('samples')
|
|
336
|
+
function variant(name, mutate) {
|
|
337
|
+
const clone = icon.cloneNode(true)
|
|
338
|
+
samples.appendChild(clone)
|
|
339
|
+
mutate(clone)
|
|
340
|
+
return { name, found: auditIcon(clone, name) }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const variants = [
|
|
344
|
+
variant('变体:viewBox 退回宿主 16 网格', (node) => node.setAttribute('viewBox', '0 0 16 16')),
|
|
345
|
+
variant('变体:光轨不写线宽(继承 1.3)', (node) => node.children[0].removeAttribute('stroke-width')),
|
|
346
|
+
variant('变体:火花不关描边', (node) => node.children[1].removeAttribute('stroke')),
|
|
347
|
+
variant('变体:路径换成别的图形', (node) => node.children[0].setAttribute('d', 'M2 2L30 30')),
|
|
348
|
+
variant('变体:光轨改回渐变引用', (node) => node.children[0].setAttribute('stroke', 'url(#soulTrail)')),
|
|
349
|
+
variant('变体:火花写死品牌色', (node) => node.children[1].setAttribute('fill', '#679EFE'))
|
|
350
|
+
]
|
|
351
|
+
for (const item of variants) {
|
|
352
|
+
record(item.name + ' → 必须被判不合格', item.found.length > 0, item.found.join(' | ') || '(未报错,判断力不足)')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const failed = issues.filter((item) => !item.ok)
|
|
356
|
+
document.getElementById('nav-icon-result').textContent = JSON.stringify({
|
|
357
|
+
total: issues.length,
|
|
358
|
+
failed: failed.length,
|
|
359
|
+
items: issues
|
|
360
|
+
})
|
|
361
|
+
</script>
|
|
362
|
+
</body>
|
|
363
|
+
</html>
|
|
364
|
+
`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function decodeEntities(text) {
|
|
368
|
+
return text
|
|
369
|
+
.replace(/</g, '<')
|
|
370
|
+
.replace(/>/g, '>')
|
|
371
|
+
.replace(/"/g, '"')
|
|
372
|
+
.replace(/'/g, "'")
|
|
373
|
+
.replace(/&/g, '&')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function parseDump(dumpHtml) {
|
|
377
|
+
const match = dumpHtml.match(/<pre id="nav-icon-result"[^>]*>([\s\S]*?)<\/pre>/)
|
|
378
|
+
if (!match) {
|
|
379
|
+
throw new Error('dump-dom 输出里找不到结果节点(页面脚本可能抛错,看看浏览器控制台)')
|
|
380
|
+
}
|
|
381
|
+
const raw = decodeEntities(match[1]).trim()
|
|
382
|
+
if (!raw) throw new Error('结果节点是空的(页面脚本可能中途抛错)')
|
|
383
|
+
return JSON.parse(raw)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function report(result) {
|
|
387
|
+
for (const item of result.items) {
|
|
388
|
+
if (!item.ok) console.log(` ✗ ${item.name}${item.detail ? ' — ' + item.detail : ''}`)
|
|
389
|
+
}
|
|
390
|
+
if (result.failed === 0) {
|
|
391
|
+
console.log(`\n全部通过:${result.total} 项检查`)
|
|
392
|
+
return 0
|
|
393
|
+
}
|
|
394
|
+
console.log(`\n结果:${result.total - result.failed} / ${result.total} 项通过,${result.failed} 项失败`)
|
|
395
|
+
return 1
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function main() {
|
|
399
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
400
|
+
const painter = extractPainter()
|
|
401
|
+
const expected = parseIconFile()
|
|
402
|
+
|
|
403
|
+
if (opts.dump) {
|
|
404
|
+
return report(parseDump(fs.readFileSync(opts.dump, 'utf8')))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const page = buildPage(painter, expected)
|
|
408
|
+
|
|
409
|
+
if (opts.emit) {
|
|
410
|
+
fs.writeFileSync(opts.emit, page)
|
|
411
|
+
console.log(`已产出页面:${opts.emit}`)
|
|
412
|
+
console.log('下一步:用无头浏览器 --dump-dom 打开它,再执行 --dump <输出文件>')
|
|
413
|
+
return 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const browser = findBrowser(opts.chrome)
|
|
417
|
+
if (!browser) {
|
|
418
|
+
console.log('未找到可用的 Chrome / Edge,跳过导航图标回归。')
|
|
419
|
+
console.log('可指定浏览器:node scripts/verify-nav-icon.mjs --chrome <可执行文件>')
|
|
420
|
+
return 0
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-soul-nav-icon-'))
|
|
424
|
+
const pagePath = path.join(tmpDir, 'page.html')
|
|
425
|
+
fs.writeFileSync(pagePath, page)
|
|
426
|
+
|
|
427
|
+
const result = spawnSync(browser, [
|
|
428
|
+
'--headless=new',
|
|
429
|
+
'--disable-gpu',
|
|
430
|
+
'--no-sandbox',
|
|
431
|
+
`--user-data-dir=${path.join(tmpDir, 'profile')}`,
|
|
432
|
+
'--virtual-time-budget=5000',
|
|
433
|
+
'--dump-dom',
|
|
434
|
+
pathToFileURL(pagePath).href
|
|
435
|
+
], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 120000 })
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
if (result.error || result.status !== 0 || !result.stdout) {
|
|
439
|
+
const reason = result.error ? result.error.code || result.error.message : `退出码 ${result.status}`
|
|
440
|
+
console.log(`无头浏览器未能运行(${reason})。`)
|
|
441
|
+
console.log('可改用两段式:--emit 产出页面 → 手动跑浏览器 --dump-dom → --dump 解析')
|
|
442
|
+
return 0
|
|
443
|
+
}
|
|
444
|
+
return report(parseDump(result.stdout))
|
|
445
|
+
} finally {
|
|
446
|
+
if (!opts.keep) fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
447
|
+
else console.log(`保留临时目录:${tmpDir}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
process.exit(main())
|