@mzzsfy/dsh-settings-nav-icons 0.2.0 → 0.3.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 CHANGED
@@ -6,21 +6,34 @@ DeepSeek Harness 纯前端插件:设置弹窗左侧导航与 dsh-market 插件
6
6
 
7
7
  | 场景 | 行为 |
8
8
  |---|---|
9
- | 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(声明 → 内置映射 → 关键词 → 哈希) |
9
+ | 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(用户覆盖 → 声明 → 内置映射 → 关键词 → 哈希) |
10
10
  | 分区当前非齿轮(官方原生图标 / 第三方插件供给的图标) | 不干预,映射或声明命中也不例外 |
11
+ | 右键导航分区 | 打开图标编辑浮层:保存/清除用户覆盖(localStorage 持久),非法值行内提示 |
11
12
  | 左侧导航分区溢出(弹窗高度不足) | 列表区域滚动,标题固定;官方无滚动机制,溢出被弹窗裁剪致底部分区不可达 |
12
13
  | 市场卡片头像槽(有名称锚) | 按插件名取图替换作者头像/字母色块 |
13
- | 语言切换导致 label 变化 | 已改写的分区按新 label 重新取图改写 |
14
+ | 语言切换导致 label 变化 | 已改写的分区按新 label 重新取图改写;双语分区覆盖/声明按等价组跨语言命中 |
14
15
  | 面板关闭后重新打开 / 会话切换 | 新渲染的齿轮自动再改写 |
15
16
  | 声明变更(注册新值/撤销) | 受影响分区 svg 内容就地重写,头像槽重新取图 |
16
17
  | 插件卸载 | 观察器随 `ctx.effect` 销毁,重载页面即恢复官方默认 |
17
18
 
18
19
  改写采用**内容制**:直接替换官方齿轮 svg 的内部内容,不新建节点、不动属性——节点、class、DOM 位置全部保持官方原样,其他插件针对官方图标的 CSS(如按 `> svg:first-child` 隐藏、伪元素 mask 叠加)照常作用于改写结果,不会出现双图标。
19
20
 
21
+ ## 用户自定义覆盖
22
+
23
+ 右键点击设置弹窗左侧导航任意分区,弹出图标编辑浮层:
24
+
25
+ - 输入内置 glyph 名(见下表)或完整 16×16 `<svg>` 字符串,「保存」即时生效并写入 localStorage(`__navicUserIcons`),刷新页面后仍然生效
26
+ - 「清除」删除该分区覆盖,回到默认取图管线;Esc 或点击浮层外部关闭
27
+ - 浮层展示当前取图来源(用户覆盖 / 插件声明 / 内置映射 / 自动推导)与当前图标预览
28
+ - 覆盖值与声明走同一安全门(未知 glyph 拒绝、svg 过闸),存储值被篡改时该键跳过,不直通注入
29
+ - 官方双语分区(通用设置 / General)的覆盖与声明按等价组生效:写在任一语言键,两种语言下都命中;保存/清除/重写均按组内全键触达,两侧同步
30
+
31
+ 插件卸载后浮层与样式随生命周期销毁;localStorage 中的覆盖数据不影响官方 UI,重装即恢复。
32
+
20
33
  ## 取图优先级
21
34
 
22
35
  ```
23
- 外部声明(register) → 内置映射(ICONS) → 名称关键词(NAME_RULES) → 稳定哈希备用池(FALLBACK)
36
+ 用户覆盖(localStorage) → 外部声明(register) → 内置映射(ICONS) → 名称关键词(NAME_RULES) → 稳定哈希备用池(FALLBACK)
24
37
  ```
25
38
 
26
39
  - **外部声明**:其他插件运行时注册自己的 label/插件名 → 图标,优先级最高(见下节)。
@@ -40,7 +53,7 @@ window.__navicIcons.register({ '消息通知': 'bell' })
40
53
  ;(window.__navicIconQueue ??= []).push({ '消息通知': 'bell' })
41
54
  ```
42
55
 
43
- - 键:分区显示文本(设置导航)或插件名(dsh-market 卡片)。两域共用一张表,同名时以先命中者生效,键请取不易与官方分区撞名的插件名。
56
+ - 键:分区显示文本(设置导航)或插件名(dsh-market 卡片)。两域共用一张表,同名时以先命中者生效,键请取不易与官方分区撞名的插件名。官方双语分区按等价组解析:任一语言 label 作键,两种语言下均命中(消除语言切换的声明键漂移)。
44
57
  - 值:内置 glyph 名(`tune/theme/bot/market/cube/mcp/shield/cards/plan/bell/wrench/archive/spark/layers/tag/grid/git/search/term/chart/code/doc/db/flow/globe/lock/image/zap`)或完整 16×16 `<svg>` 字符串。svg 字符串过安全门:完整开标签(大小写不敏感,拒绝 `<svgx` 残串)且单根闭合(首个 `</svg>` 后不得再有内容,堵尾缀活动 HTML);不带 `on*` 事件属性(`\b` 前界堵斜杠分隔绕过)、不带 `<script>`/`<style>`(内联样式全文档生效且 @import 可外联)/`<foreignObject>`/SMIL 动画(`<animate>`/`<set>` 等)载体;不带 `href`/`xlink:href` 及 `attributeName="href"` 注入(16×16 静态图标无合法引用/动画场景,外联请求一并封死);不带 `javascript:`(纵深);长度 ≤4096 字符;glyph 名查表经 `typeof` 收口,原型链成员不可能被注入。
45
58
  - 声明值经归一化后写入注册表;同值重复注册幂等短路;非法值(未知 glyph/被安全门拒绝/非字符串)撤销该键声明,该分区回到内置映射或关键词/哈希默认管线,已改写的 nav svg 内容就地重写。
46
59
  - 声明持久化在 `window.__navicIconDeclarations`:本插件 client 半区热重载会重跑工厂而生产者不重发注册,持久层让重装实例恢复声明,页面刷新随 window 释放。
@@ -87,7 +100,7 @@ node scripts/dev-link.mjs dsh-settings-nav-icons
87
100
 
88
101
  前提:profile `package.json` 的 dependencies 有本包 semver 行、`dsh.profile.bundles` 有本包名(未发布包 registry 拉取会失败,junction 覆盖 node_modules 物理目录后启动只走 realpath)。junction 会被 `pnpm install` / `dsh plugin add` 抹掉,之后重跑本脚本即可。
89
102
 
90
- 重启 dsh 后设置面板即生效;无设置项。
103
+ 重启 dsh 后设置面板即生效;设置项即「用户自定义覆盖」的右键浮层,无独立设置页。
91
104
 
92
105
  ## 升级 / 卸载
93
106
 
@@ -106,7 +119,7 @@ pnpm --dir packages/dsh-settings-nav-icons test
106
119
  node --test packages/dsh-settings-nav-icons/test/*.test.mjs
107
120
  ```
108
121
 
109
- 覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / svg 记账幂等与 label 重写 / trim 与空 label / 无 svg 降级 / 内容改写与不建节点 / 0.1.x 残留清理 / 声明安全门 / 撤销回退取图链 / 关键词边界 / 头像槽 img+div / 导航滚动样式注入与卸载)、映射表契约、双实现全量同源 parity、client.js 注册 id 守卫、编排层契约(注册校验 / 就地重写 / 队列三态 / 生命周期 / 异常隔离 / 持久层恢复)、四生产者样板契约。
122
+ 覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / svg 记账幂等与 label 重写 / trim 与空 label / 无 svg 降级 / 内容改写与不建节点 / 0.1.x 残留清理 / 声明安全门 / 撤销回退取图链 / 关键词边界 / 头像槽 img+div / 导航滚动样式注入与卸载)、映射表契约、双实现全量同源 parity、client.js 注册 id 守卫、编排层契约(注册校验 / 就地重写 / 队列三态 / 生命周期 / 异常隔离 / 持久层恢复)、用户覆盖(localStorage 恢复安全门 / 右键浮层保存与非法行内提示 / 清除回默认管线 / 等价组双语触达 / 浮层开闭)、别名等价查询(lookupWithAlias / aliasKeysOf / iconSourceOf)、四生产者样板契约。
110
123
 
111
124
  ## License
112
125
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-settings-nav-icons",
3
3
  "description": "设置弹窗导航分区与插件市场卡片图标替换:按分区显示文本把回退齿轮换成专属图形,市场卡片头像槽换成插件图标,DOM 观察实现,卸载后重载页面恢复官方图标",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -12,6 +12,266 @@ window.__ModuleLoader__.load({
12
12
  const SELECTOR_LABEL = '.VOzbGW_navLabel'
13
13
  const ATTR_MARK = 'data-navic'
14
14
 
15
+ // 恢复路径内容刷新:页面刷新后 DOM 全新无此需求;HMR 热重载复用旧 DOM,
16
+ // 记账与 label 相同会被 replacePass 幂等跳过,恢复的声明/覆盖必须就地重算。
17
+ function refreshMarkedContent() {
18
+ const keys = []
19
+ for (const el of document.querySelectorAll('[' + ATTR_MARK + ']')) {
20
+ const key = el.dataset.navic
21
+ if (key !== undefined && key !== '' && key !== '1' && keys.indexOf(key) < 0) keys.push(key)
22
+ }
23
+ if (keys.length === 0) return
24
+ rewriteTouched(keys)
25
+ }
26
+
27
+ // ---- 用户逐分区覆盖:localStorage 持久层 + 右键浮层 UI ----
28
+
29
+ // 覆盖持久化跨会话存活(localStorage),与声明持久层(window 属性,页面级)
30
+ // 的差异:声明服务于热重载恢复,覆盖服务于用户长期自定义。恢复走同一
31
+ // resolveIcon 安全门收口,被篡改的存储不直通注入。
32
+ const USER_STORE_KEY = '__navicUserIcons'
33
+
34
+ function restoreUserOverrides() {
35
+ let raw
36
+ try {
37
+ raw = window.localStorage.getItem(USER_STORE_KEY)
38
+ } catch (error) {
39
+ console.warn('[nav-icons] 用户覆盖存储不可读,已跳过', error)
40
+ return
41
+ }
42
+ if (typeof raw !== 'string' || raw === '') return
43
+ let saved
44
+ try {
45
+ saved = JSON.parse(raw)
46
+ } catch (error) {
47
+ console.warn('[nav-icons] 用户覆盖存储解析失败,已跳过', error)
48
+ return
49
+ }
50
+ if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) return
51
+ for (const key of Object.keys(saved)) {
52
+ if (key === '__proto__' || key === '1' || key.length > KEY_MAX_CHARS) continue
53
+ try {
54
+ if (resolveIcon(saved[key]) !== undefined) USER_OVERRIDES[key] = saved[key]
55
+ } catch (error) {
56
+ console.warn('[nav-icons] 用户覆盖键 ' + key + ' 恢复失败,已跳过', error)
57
+ }
58
+ }
59
+ }
60
+
61
+ function persistUserOverrides() {
62
+ try {
63
+ window.localStorage.setItem(USER_STORE_KEY, JSON.stringify(Object.assign({}, USER_OVERRIDES)))
64
+ } catch (error) {
65
+ console.warn('[nav-icons] 用户覆盖存储不可写', error)
66
+ }
67
+ }
68
+
69
+ // 覆盖写入/清除:值 undefined 视为清除(等价组全键删除);非法值返回 false
70
+ // 交由浮层行内提示,不落库不重贴。变更后按等价组全键触达重写。
71
+ function setUserOverride(key, value) {
72
+ if (!active) return false
73
+ if (key === '' || key === '__proto__' || key === '1' || key.length > KEY_MAX_CHARS) return false
74
+ if (value === undefined || value === null) {
75
+ let cleared = false
76
+ for (const k of aliasKeysOf(key)) {
77
+ if (USER_OVERRIDES[k] !== undefined) {
78
+ delete USER_OVERRIDES[k]
79
+ cleared = true
80
+ }
81
+ }
82
+ if (cleared) {
83
+ persistUserOverrides()
84
+ rewriteTouched(aliasKeysOf(key))
85
+ schedule()
86
+ }
87
+ return true
88
+ }
89
+ let resolved
90
+ try {
91
+ resolved = resolveIcon(value)
92
+ } catch (error) {
93
+ return false
94
+ }
95
+ if (resolved === undefined) return false
96
+ let changed = false
97
+ for (const k of aliasKeysOf(key)) {
98
+ if (k !== key && USER_OVERRIDES[k] !== undefined) {
99
+ delete USER_OVERRIDES[k]
100
+ changed = true
101
+ }
102
+ }
103
+ // 存原始输入(与声明表同语义,glyph 名回填浮层可读),查询侧过闸展开
104
+ if (USER_OVERRIDES[key] !== value) {
105
+ USER_OVERRIDES[key] = value
106
+ changed = true
107
+ }
108
+ if (changed) {
109
+ persistUserOverrides()
110
+ rewriteTouched(aliasKeysOf(key))
111
+ schedule()
112
+ }
113
+ return true
114
+ }
115
+
116
+ // 覆盖浮层:右键 nav 单元格打开,单例常驻 body,随插件 stop 移除。
117
+ // 类名全仓唯一前缀 sni-(dsh-settings-nav-icons 缩写),样式随启动注入。
118
+ const OVERLAY_CLASS = 'sni-ov'
119
+ const OVERLAY_CSS =
120
+ '.' + OVERLAY_CLASS + '{position:fixed;z-index:2147483647;width:264px;padding:10px;' +
121
+ 'border-radius:8px;color-scheme:light dark;background:light-dark(#ffffff,#1f1f1f);' +
122
+ 'color:light-dark(#111111,#eeeeee);box-shadow:0 4px 16px rgba(0,0,0,.28);' +
123
+ 'font:12px/1.5 system-ui,sans-serif}' +
124
+ '.' + OVERLAY_CLASS + '__title{font-weight:600;margin-bottom:2px;word-break:break-all}' +
125
+ '.' + OVERLAY_CLASS + '__src{opacity:.65;margin-bottom:6px}' +
126
+ '.' + OVERLAY_CLASS + '__input{width:100%;box-sizing:border-box;margin-bottom:4px;' +
127
+ 'padding:4px 6px;border:1px solid rgba(128,128,128,.5);border-radius:6px;' +
128
+ 'background:transparent;color:inherit;font:inherit}' +
129
+ '.' + OVERLAY_CLASS + '__input:focus-visible{outline:2px solid #4d6bfe;outline-offset:-1px}' +
130
+ '.' + OVERLAY_CLASS + '__bad{display:none;color:#d5484c;margin-bottom:4px}' +
131
+ '.' + OVERLAY_CLASS + '__bad--on{display:block}' +
132
+ '.' + OVERLAY_CLASS + '__cur{display:flex;align-items:center;gap:6px;margin-bottom:8px;' +
133
+ 'opacity:.9}' +
134
+ '.' + OVERLAY_CLASS + '__row{display:flex;gap:6px;justify-content:flex-end}' +
135
+ '.' + OVERLAY_CLASS + '__btn{padding:4px 12px;border:1px solid rgba(128,128,128,.5);' +
136
+ 'border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer}' +
137
+ '.' + OVERLAY_CLASS + '__btn:hover{border-color:#4d6bfe}' +
138
+ '.' + OVERLAY_CLASS + '__btn:focus-visible{outline:2px solid #4d6bfe;outline-offset:1px}' +
139
+ '.' + OVERLAY_CLASS + '__btn--primary{background:#4d6bfe;border-color:#4d6bfe;color:#ffffff}'
140
+
141
+ const SOURCE_LABELS = {
142
+ user: '用户覆盖',
143
+ declared: '插件声明',
144
+ builtin: '内置映射',
145
+ derived: '自动推导',
146
+ }
147
+
148
+ let overlayStyle = null
149
+ let overlay = null
150
+ let overlayCell = null
151
+
152
+ function removeOverlay() {
153
+ if (overlay === null) return
154
+ overlay.remove()
155
+ overlay = null
156
+ overlayCell = null
157
+ if (slot !== null) slot.overlayEl = null
158
+ }
159
+
160
+ // 就地取当前覆盖原文作输入框回填:等价组任一侧键命中都回填。
161
+ function overlayInputValue(label) {
162
+ const hit = lookupWithAlias(USER_OVERRIDES, label)
163
+ return hit === undefined ? '' : hit
164
+ }
165
+
166
+ function openOverlay(cell) {
167
+ const labelNode = cell.querySelector(SELECTOR_LABEL)
168
+ if (labelNode === null) return
169
+ const label = (labelNode.textContent || '').trim()
170
+ if (label === '') return
171
+ removeOverlay()
172
+ overlayCell = cell
173
+ overlay = document.createElement('div')
174
+ overlay.className = OVERLAY_CLASS
175
+ const title = document.createElement('div')
176
+ title.className = OVERLAY_CLASS + '__title'
177
+ title.textContent = label
178
+ const src = document.createElement('div')
179
+ src.className = OVERLAY_CLASS + '__src'
180
+ src.textContent = '当前来源:' + (SOURCE_LABELS[iconSourceOf(label)] || SOURCE_LABELS.derived)
181
+ const cur = document.createElement('span')
182
+ cur.className = OVERLAY_CLASS + '__cur'
183
+ cur.innerHTML = resolveForLabel(label)
184
+ cur.title = '当前图标'
185
+ const input = document.createElement('input')
186
+ input.className = OVERLAY_CLASS + '__input'
187
+ input.placeholder = 'glyph 名或完整 <svg>(留空不清除,用清除按钮)'
188
+ input.value = overlayInputValue(label)
189
+ const bad = document.createElement('div')
190
+ bad.className = OVERLAY_CLASS + '__bad'
191
+ bad.textContent = '非法值:须为内置 glyph 名或过安全门的 16×16 <svg>'
192
+ const row = document.createElement('div')
193
+ row.className = OVERLAY_CLASS + '__row'
194
+ const clearBtn = document.createElement('button')
195
+ clearBtn.className = OVERLAY_CLASS + '__btn'
196
+ clearBtn.textContent = '清除'
197
+ const saveBtn = document.createElement('button')
198
+ saveBtn.className = OVERLAY_CLASS + '__btn ' + OVERLAY_CLASS + '__btn--primary'
199
+ saveBtn.textContent = '保存'
200
+ row.appendChild(clearBtn)
201
+ row.appendChild(saveBtn)
202
+ overlay.appendChild(title)
203
+ overlay.appendChild(src)
204
+ overlay.appendChild(cur)
205
+ overlay.appendChild(input)
206
+ overlay.appendChild(bad)
207
+ overlay.appendChild(row)
208
+ document.body.appendChild(overlay)
209
+ if (slot !== null) slot.overlayEl = overlay
210
+ input.focus()
211
+ saveBtn.addEventListener('click', function () {
212
+ if (setUserOverride(label, input.value)) removeOverlay()
213
+ else bad.classList.add(OVERLAY_CLASS + '__bad--on')
214
+ })
215
+ clearBtn.addEventListener('click', function () {
216
+ setUserOverride(label, undefined)
217
+ removeOverlay()
218
+ })
219
+ }
220
+
221
+ // 文档级监听:右键开浮层、浮层外点击/Escape 关闭;start 挂载,stop 卸载。
222
+ let contextHandler = null
223
+ let pointerHandler = null
224
+ let keyHandler = null
225
+
226
+ function installOverlayListeners() {
227
+ contextHandler = function (event) {
228
+ const target = event.target
229
+ const cell = target != null && typeof target.closest === 'function'
230
+ ? target.closest(SELECTOR_CELL)
231
+ : null
232
+ if (cell === null) return
233
+ event.preventDefault()
234
+ openOverlay(cell)
235
+ }
236
+ pointerHandler = function (event) {
237
+ if (overlay === null) return
238
+ const target = event.target
239
+ if (target != null && typeof target.closest === 'function'
240
+ && target.closest('.' + OVERLAY_CLASS) !== null) return
241
+ removeOverlay()
242
+ }
243
+ keyHandler = function (event) {
244
+ if (overlay === null) return
245
+ if (event.key === 'Escape') removeOverlay()
246
+ }
247
+ document.addEventListener('contextmenu', contextHandler)
248
+ document.addEventListener('pointerdown', pointerHandler)
249
+ document.addEventListener('keydown', keyHandler)
250
+ }
251
+
252
+ function uninstallOverlayListeners() {
253
+ if (contextHandler !== null) document.removeEventListener('contextmenu', contextHandler)
254
+ if (pointerHandler !== null) document.removeEventListener('pointerdown', pointerHandler)
255
+ if (keyHandler !== null) document.removeEventListener('keydown', keyHandler)
256
+ contextHandler = null
257
+ pointerHandler = null
258
+ keyHandler = null
259
+ }
260
+
261
+ function installOverlayStyle() {
262
+ overlayStyle = document.createElement('style')
263
+ overlayStyle.textContent = OVERLAY_CSS
264
+ document.head.appendChild(overlayStyle)
265
+ if (slot !== null) slot.overlayStyle = overlayStyle
266
+ }
267
+
268
+ function uninstallOverlayStyle() {
269
+ if (overlayStyle === null) return
270
+ overlayStyle.remove()
271
+ overlayStyle = null
272
+ if (slot !== null) slot.overlayStyle = null
273
+ }
274
+
15
275
  // ---- 设置导航滚动补偿:官方 navList 高度随内容撑开,溢出被 panel hidden
16
276
  // 裁剪且无滚动机制,分区一多底部即不可达。样式表形态一次注入,对弹窗重开
17
277
  // 等任意 React 重渲染持续生效;类名锚定与上方单元格同策略(哈希前缀字面量)。
@@ -189,10 +449,59 @@ window.__ModuleLoader__.load({
189
449
  ['zap|flash|fast|quick|boost|speed|turbo', ZAP],
190
450
  ].map(function (pair) { return { re: new RegExp('(^|[^a-z])(' + pair[0] + ')([^a-z]|$)'), icon: pair[1] } })
191
451
 
192
- // 插件名/分区 label 两级取图:外部声明 → 关键词主题图 → 稳定哈希备用池。
452
+ // 用户逐分区覆盖注册表:取图链最前端,值经 resolveIcon 归一化,持久化由
453
+ // 编排层落 localStorage(纯逻辑层不触碰存储)。
454
+ const USER_OVERRIDES = Object.create(null)
455
+
456
+ // 官方双语分区等价组:组内任一语言 label 作声明/覆盖键,组内其他语言取图时
457
+ // 同样命中(消除语言切换下声明键漂移)。仅收官方现有展示形态,不发明未知 id。
458
+ const ALIAS_GROUPS = [
459
+ ['通用设置', 'General'],
460
+ ]
461
+
462
+ // 表查询的别名等价扩展:先按原键直查,再沿等价组用同组成员试查。
463
+ // 表为 Object.create(null) 帧装,继承成员不可达。
464
+ function lookupWithAlias(table, label) {
465
+ const direct = table[label]
466
+ if (direct !== undefined) return direct
467
+ for (const group of ALIAS_GROUPS) {
468
+ if (group.indexOf(label) < 0) continue
469
+ for (const member of group) {
470
+ if (member === label) continue
471
+ const hit = table[member]
472
+ if (hit !== undefined) return hit
473
+ }
474
+ }
475
+ return undefined
476
+ }
477
+
478
+ // 键的等价组展开:自身 + 所属组内其他成员;不入组的键只返回自身。
479
+ // 覆盖写入/清除与就地重写按该范围触达,保证双语分区两侧状态一致。
480
+ function aliasKeysOf(label) {
481
+ const keys = [label]
482
+ for (const group of ALIAS_GROUPS) {
483
+ if (group.indexOf(label) < 0) continue
484
+ for (const member of group) {
485
+ if (member !== label && keys.indexOf(member) < 0) keys.push(member)
486
+ }
487
+ }
488
+ return keys
489
+ }
490
+
491
+ // 插件名/分区 label 两级取图:用户覆盖 → 外部声明 → 关键词主题图 → 稳定哈希备用池。
492
+ // 表值为原始声明(glyph 名或 svg),命中后过 resolveIcon 归一化展开;归一化失败
493
+ // (声明值被撤销/存储被篡改)视为该层未命中,继续下一层。
193
494
  function themedIcon(name) {
194
- const declared = resolveIcon(DECLARED_ICONS[name])
195
- if (declared !== undefined) return declared
495
+ const overridden = lookupWithAlias(USER_OVERRIDES, name)
496
+ if (overridden !== undefined) {
497
+ const resolved = resolveIcon(overridden)
498
+ if (resolved !== undefined) return resolved
499
+ }
500
+ const declared = lookupWithAlias(DECLARED_ICONS, name)
501
+ if (declared !== undefined) {
502
+ const resolved = resolveIcon(declared)
503
+ if (resolved !== undefined) return resolved
504
+ }
196
505
  const n = name.toLowerCase()
197
506
  for (const rule of NAME_RULES) {
198
507
  if (rule.re.test(n)) return rule.icon
@@ -200,16 +509,33 @@ window.__ModuleLoader__.load({
200
509
  return FALLBACK[poolIndexOf(name)]
201
510
  }
202
511
 
203
- // 分区取图全链:声明 → 内置映射 → 关键词/哈希兜底。decide 与声明变更就地
204
- // 重写共用;撤销内置分区声明时回退必须含内置映射,否则卡死在哈希图。
512
+ // 分区取图全链:用户覆盖 → 声明(含别名) → 内置映射 → 关键词/哈希兜底。decide
513
+ // 与声明/覆盖变更就地重写共用;撤销时回退必须含内置映射,否则卡死在哈希图。
205
514
  function resolveForLabel(label) {
206
- const declared = resolveIcon(DECLARED_ICONS[label])
207
- if (declared !== undefined) return declared
515
+ const overridden = lookupWithAlias(USER_OVERRIDES, label)
516
+ if (overridden !== undefined) {
517
+ const resolved = resolveIcon(overridden)
518
+ if (resolved !== undefined) return resolved
519
+ }
520
+ const declared = lookupWithAlias(DECLARED_ICONS, label)
521
+ if (declared !== undefined) {
522
+ const resolved = resolveIcon(declared)
523
+ if (resolved !== undefined) return resolved
524
+ }
208
525
  const mapped = ICONS[label]
209
526
  if (mapped !== undefined) return mapped
210
527
  return themedIcon(label)
211
528
  }
212
529
 
530
+ // 当前 label 的取图来源:用户覆盖 / 插件声明 / 内置映射 / 关键词与哈希推导。
531
+ // 与 resolveForLabel 同序遍历,供覆盖浮层展示当前生效层。
532
+ function iconSourceOf(label) {
533
+ if (lookupWithAlias(USER_OVERRIDES, label) !== undefined) return 'user'
534
+ if (lookupWithAlias(DECLARED_ICONS, label) !== undefined) return 'declared'
535
+ if (ICONS[label] !== undefined) return 'builtin'
536
+ return 'derived'
537
+ }
538
+
213
539
  // 市场卡片头像槽决策:img(作者头像)与 div(字母色块)同构处理——原子节点隐藏,
214
540
  // 插件图标注入跟随。记账值 = 插件名,换名重贴由该比较驱动。
215
541
  function decideAvatar(av, name) {
@@ -306,7 +632,6 @@ window.__ModuleLoader__.load({
306
632
  }
307
633
 
308
634
  /* LOGIC-END */
309
-
310
635
  // ---- 市场卡片 DOM 锚点(css-modules 哈希前缀随版本变,按后缀匹配) ----
311
636
 
312
637
  const SELECTOR_AV = '[class$="_av"]'
@@ -451,18 +776,29 @@ window.__ModuleLoader__.load({
451
776
  }
452
777
  if (touched.length === 0) return
453
778
  persistDeclarations()
454
- // 声明变更就地生效:nav svg(记账为分区/插件名)直接重写内容且记账保留——
455
- // 改写后的 svg 非齿轮,清账会让 isGear 判定失配卡死;头像槽(非 svg 节点)
456
- // 清账后走 decideAvatar 重贴。'1' 为注入纯标记,不命中声明键。
779
+ rewriteTouched(touched)
780
+ schedule()
781
+ }
782
+
783
+ // 键命中元素就地重写:nav svg(记账为分区/插件名)直接重写内容且记账保留——
784
+ // 改写后的 svg 非齿轮,清账会让 isGear 判定失配卡死;头像槽(非 svg 节点)
785
+ // 清账后走 decideAvatar 重贴。'1' 为注入纯标记,不命中声明/覆盖键。
786
+ // 触达键先按等价组展开:声明/覆盖写在哪一侧语言键,双语分区两侧都立即生效。
787
+ function rewriteTouched(touched) {
788
+ const expanded = []
789
+ for (const key of touched) {
790
+ for (const k of aliasKeysOf(key)) {
791
+ if (expanded.indexOf(k) < 0) expanded.push(k)
792
+ }
793
+ }
457
794
  for (const el of document.querySelectorAll('[' + ATTR_MARK + ']')) {
458
- if (touched.indexOf(el.dataset.navic) < 0) continue
795
+ if (expanded.indexOf(el.dataset.navic) < 0) continue
459
796
  if (el.tagName === 'svg') {
460
797
  el.innerHTML = svgInner(resolveForLabel(el.dataset.navic))
461
798
  } else {
462
799
  delete el.dataset.navic
463
800
  }
464
801
  }
465
- schedule()
466
802
  }
467
803
 
468
804
  // 滚动样式元素:start 注入 head,stop 移除,引用即状态源,幂等无守卫分支;
@@ -509,23 +845,32 @@ window.__ModuleLoader__.load({
509
845
  if (previous !== undefined) {
510
846
  if (previous.observer !== null) previous.observer.disconnect()
511
847
  if (previous.rafId !== 0) cancelAnimationFrame(previous.rafId)
512
- // 宽松判空:槽是跨代码版本通道,旧版槽无 scrollStyle 字段(undefined)
848
+ // 宽松判空:槽是跨代码版本通道,旧版槽缺新字段(undefined)
513
849
  if (previous.scrollStyle != null) previous.scrollStyle.remove()
850
+ if (previous.overlayStyle != null) previous.overlayStyle.remove()
851
+ if (previous.overlayEl != null) previous.overlayEl.remove()
514
852
  }
515
- slot = { observer: null, rafId: 0, scrollStyle: null }
853
+ slot = { observer: null, rafId: 0, scrollStyle: null, overlayStyle: null, overlayEl: null }
516
854
  window[SLOT_KEY] = slot
517
- // 可抛步骤(声明恢复/队列排水/样式注入)先于观察器挂载,失败不产生孤儿扫描器
855
+ // 可抛步骤(覆盖/声明恢复、队列排水、样式注入)先于观察器挂载,失败不产生孤儿扫描器
856
+ installOverlayStyle()
518
857
  installScrollStyle()
858
+ restoreUserOverrides()
519
859
  restoreDeclarations()
520
860
  drainQueue()
521
861
  observer = new MutationObserver(onMutations)
522
862
  observer.observe(document.body, { childList: true, subtree: true, characterData: true })
523
863
  slot.observer = observer
864
+ installOverlayListeners()
865
+ refreshMarkedContent()
524
866
  replacePass()
525
867
  }
526
868
 
527
869
  function stop() {
528
870
  active = false
871
+ uninstallOverlayListeners()
872
+ removeOverlay()
873
+ uninstallOverlayStyle()
529
874
  uninstallScrollStyle()
530
875
  if (observer !== null) {
531
876
  observer.disconnect()
package/src/logic.mjs CHANGED
@@ -195,10 +195,20 @@ export const NAME_RULES = [
195
195
  ['zap|flash|fast|quick|boost|speed|turbo', ZAP],
196
196
  ].map(function (pair) { return { re: new RegExp('(^|[^a-z])(' + pair[0] + ')([^a-z]|$)'), icon: pair[1] } })
197
197
 
198
- // 插件名/分区 label 两级取图:外部声明 → 关键词主题图 → 稳定哈希备用池。
198
+ // 插件名/分区 label 两级取图:用户覆盖 → 外部声明 → 关键词主题图 → 稳定哈希备用池。
199
+ // 表值为原始声明(glyph 名或 svg),命中后过 resolveIcon 归一化展开;归一化失败
200
+ // (声明值被撤销/存储被篡改)视为该层未命中,继续下一层。
199
201
  export function themedIcon(name) {
200
- const declared = resolveIcon(DECLARED_ICONS[name])
201
- if (declared !== undefined) return declared
202
+ const overridden = lookupWithAlias(USER_OVERRIDES, name)
203
+ if (overridden !== undefined) {
204
+ const resolved = resolveIcon(overridden)
205
+ if (resolved !== undefined) return resolved
206
+ }
207
+ const declared = lookupWithAlias(DECLARED_ICONS, name)
208
+ if (declared !== undefined) {
209
+ const resolved = resolveIcon(declared)
210
+ if (resolved !== undefined) return resolved
211
+ }
202
212
  const n = name.toLowerCase()
203
213
  for (const rule of NAME_RULES) {
204
214
  if (rule.re.test(n)) return rule.icon
@@ -206,16 +216,33 @@ export function themedIcon(name) {
206
216
  return FALLBACK[poolIndexOf(name)]
207
217
  }
208
218
 
209
- // 分区取图全链:声明 → 内置映射 → 关键词/哈希兜底。decide 与声明变更就地
210
- // 重写共用;撤销内置分区声明时回退必须含内置映射,否则卡死在哈希图。
219
+ // 分区取图全链:用户覆盖 → 声明(含别名) → 内置映射 → 关键词/哈希兜底。decide
220
+ // 与声明/覆盖变更就地重写共用;撤销时回退必须含内置映射,否则卡死在哈希图。
211
221
  export function resolveForLabel(label) {
212
- const declared = resolveIcon(DECLARED_ICONS[label])
213
- if (declared !== undefined) return declared
222
+ const overridden = lookupWithAlias(USER_OVERRIDES, label)
223
+ if (overridden !== undefined) {
224
+ const resolved = resolveIcon(overridden)
225
+ if (resolved !== undefined) return resolved
226
+ }
227
+ const declared = lookupWithAlias(DECLARED_ICONS, label)
228
+ if (declared !== undefined) {
229
+ const resolved = resolveIcon(declared)
230
+ if (resolved !== undefined) return resolved
231
+ }
214
232
  const mapped = ICONS[label]
215
233
  if (mapped !== undefined) return mapped
216
234
  return themedIcon(label)
217
235
  }
218
236
 
237
+ // 当前 label 的取图来源:用户覆盖 / 插件声明 / 内置映射 / 关键词与哈希推导。
238
+ // 与 resolveForLabel 同序遍历,供覆盖浮层展示当前生效层。
239
+ export function iconSourceOf(label) {
240
+ if (lookupWithAlias(USER_OVERRIDES, label) !== undefined) return 'user'
241
+ if (lookupWithAlias(DECLARED_ICONS, label) !== undefined) return 'declared'
242
+ if (ICONS[label] !== undefined) return 'builtin'
243
+ return 'derived'
244
+ }
245
+
219
246
  // 市场卡片头像槽决策:img(作者头像)与 div(字母色块)同构处理——原子节点隐藏,
220
247
  // 插件图标注入跟随。记账值 = 插件名,换名重贴由该比较驱动。
221
248
  export function decideAvatar(av, name) {
@@ -255,6 +282,45 @@ export const ICONS = {
255
282
  // 继承成员;污染面收敛在 window.__navicIcons 单一命名空间。
256
283
  export const DECLARED_ICONS = Object.create(null)
257
284
 
285
+ // 用户逐分区覆盖注册表:取图链最前端,值经 resolveIcon 归一化,持久化由
286
+ // 编排层落 localStorage(纯逻辑层不触碰存储)。
287
+ export const USER_OVERRIDES = Object.create(null)
288
+
289
+ // 官方双语分区等价组:组内任一语言 label 作声明/覆盖键,组内其他语言取图时
290
+ // 同样命中(消除语言切换下声明键漂移)。仅收官方现有展示形态,不发明未知 id。
291
+ export const ALIAS_GROUPS = [
292
+ ['通用设置', 'General'],
293
+ ]
294
+
295
+ // 表查询的别名等价扩展:先按原键直查,再沿等价组用同组成员试查。
296
+ // 表为 Object.create(null) 帧装,继承成员不可达。
297
+ export function lookupWithAlias(table, label) {
298
+ const direct = table[label]
299
+ if (direct !== undefined) return direct
300
+ for (const group of ALIAS_GROUPS) {
301
+ if (group.indexOf(label) < 0) continue
302
+ for (const member of group) {
303
+ if (member === label) continue
304
+ const hit = table[member]
305
+ if (hit !== undefined) return hit
306
+ }
307
+ }
308
+ return undefined
309
+ }
310
+
311
+ // 键的等价组展开:自身 + 所属组内其他成员;不入组的键只返回自身。
312
+ // 覆盖写入/清除与就地重写按该范围触达,保证双语分区两侧状态一致。
313
+ export function aliasKeysOf(label) {
314
+ const keys = [label]
315
+ for (const group of ALIAS_GROUPS) {
316
+ if (group.indexOf(label) < 0) continue
317
+ for (const member of group) {
318
+ if (member !== label && keys.indexOf(member) < 0) keys.push(member)
319
+ }
320
+ }
321
+ return keys
322
+ }
323
+
258
324
  // 单元格改写判定(内容改写制):不新建 svg、不动属性,直接把官方齿轮 svg 的
259
325
  // 内部内容改写为本插件图标;记账迁到官方 svg 自身(dataset.navic = label),
260
326
  // 节点与 class/位置等属性保持官方原样,第三方对官方图标的 CSS/选择器处理
@@ -29,10 +29,21 @@ function fakeRaf() {
29
29
  }
30
30
  }
31
31
 
32
+ // 浮层桩内递归找节点:按钮嵌在 row 子层
33
+ function overlayFind(root, pred) {
34
+ for (const node of root.children) {
35
+ if (pred(node)) return node
36
+ const hit = overlayFind(node, pred)
37
+ if (hit) return hit
38
+ }
39
+ return null
40
+ }
41
+
32
42
  // 官方 svg 桩:gear=true 模拟官方齿轮路径,否则为无路径原生图形;内容改写制下
33
43
  // applyDecision 直接写 innerHTML(桩以 innerHTML 属性承接)与 dataset.navic
34
44
  function makeOfficialSvg({ gear = true } = {}) {
35
45
  const svg = {
46
+ tagName: 'svg',
36
47
  dataset: { navic: '' },
37
48
  style: { display: '' },
38
49
  innerHTML: '',
@@ -69,6 +80,7 @@ function makeCell(label, { gear = true, navic = '' } = {}) {
69
80
  cell.official = official
70
81
  cell.svgs.push(official)
71
82
  cell.matches = (sel) => sel !== SELECTOR_AV
83
+ cell.closest = (sel) => (sel === SELECTOR_CELL ? cell : null)
72
84
  cell.querySelector = (sel) => (sel === SELECTOR_LABEL ? { textContent: label } : null)
73
85
  cell.querySelectorAll = (sel) => {
74
86
  if (sel === 'svg') return cell.svgs
@@ -121,11 +133,29 @@ function fakeDocument() {
121
133
  appendChild(el) { doc.head.children.push(el); doc.events.push('style') },
122
134
  },
123
135
  createdStyles: [],
136
+ listeners: {},
124
137
  createElement(tag) {
125
138
  const el = {
126
139
  tag,
127
140
  textContent: '',
141
+ children: [],
142
+ className: '',
143
+ value: '',
144
+ placeholder: '',
145
+ innerHTML: '',
146
+ appendChild(child) { el.children.push(child) },
147
+ focus() { el.focused = true },
148
+ addEventListener(type, fn) { el.handlers[type] = fn },
149
+ handlers: {},
150
+ classList: {
151
+ add(cls) { el.classes.add(cls) },
152
+ remove(cls) { el.classes.delete(cls) },
153
+ },
154
+ classes: new Set(),
128
155
  remove() {
156
+ const list = doc.bodyChildren
157
+ const at = list.indexOf(el)
158
+ if (at >= 0) list.splice(at, 1)
129
159
  doc.createdStyles.splice(doc.createdStyles.indexOf(el), 1)
130
160
  doc.head.children.splice(doc.head.children.indexOf(el), 1)
131
161
  },
@@ -133,6 +163,18 @@ function fakeDocument() {
133
163
  if (tag === 'style') doc.createdStyles.push(el)
134
164
  return el
135
165
  },
166
+ bodyChildren: [],
167
+ addEventListener(type, fn) {
168
+ ;(doc.listeners[type] ??= []).push(fn)
169
+ },
170
+ removeEventListener(type, fn) {
171
+ const list = doc.listeners[type] ?? []
172
+ const at = list.indexOf(fn)
173
+ if (at >= 0) list.splice(at, 1)
174
+ },
175
+ dispatch(type, event) {
176
+ for (const fn of [...(doc.listeners[type] ?? [])]) fn(event)
177
+ },
136
178
  querySelectorAll(sel) {
137
179
  if (sel === SELECTOR_ALL) return [...doc.cells, ...doc.avatars]
138
180
  if (sel === SELECTOR_CELL) return doc.cells
@@ -141,6 +183,7 @@ function fakeDocument() {
141
183
  return []
142
184
  },
143
185
  }
186
+ doc.body.appendChild = (el) => { doc.bodyChildren.push(el) }
144
187
  return doc
145
188
  }
146
189
 
@@ -164,6 +207,11 @@ function loadClient() {
164
207
  __ModuleLoader__: { load(mod) { win.__loaded = mod } },
165
208
  requestAnimationFrame: raf.requestAnimationFrame,
166
209
  cancelAnimationFrame: raf.cancelAnimationFrame,
210
+ localStorage: {
211
+ map: new Map(),
212
+ getItem(k) { return win.localStorage.map.has(k) ? win.localStorage.map.get(k) : null },
213
+ setItem(k, v) { win.localStorage.map.set(k, String(v)) },
214
+ },
167
215
  }
168
216
  const doc = fakeDocument()
169
217
  new Function('window', 'document', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'console', src)(
@@ -194,21 +242,22 @@ test('boot 观察:body 目标与三开关齐全,卸载后断开', () => {
194
242
  assert.equal(observed.length, 0, '卸载后 disconnect')
195
243
  })
196
244
 
197
- test('导航滚动样式:启动注入 head,卸载移除,重装恢复,先于观察器挂载', () => {
245
+ test('启动样式:覆盖浮层样式与滚动样式注入 head,卸载移除,重装恢复,先于观察器挂载', () => {
198
246
  const { win, doc, observed } = loadClient()
199
247
  assert.equal(doc.createdStyles.length, 0, '启动前无注入')
200
248
  const unload = boot(win.__loaded, doc)
201
249
  try {
202
- assert.equal(doc.createdStyles.length, 1, '恰一次注入')
203
- assert.equal(doc.createdStyles[0].textContent, NAVLIST_SCROLL_CSS, '滚动规则与实现同源')
204
- assert.equal(doc.head.children.length, 1, '样式挂载在 head')
205
- assert.deepEqual(doc.events, ['style', 'observe'], '样式注入先于观察器挂载')
250
+ assert.equal(doc.createdStyles.length, 2, '恰两次注入(浮层+滚动)')
251
+ assert.ok(doc.createdStyles.some((el) => el.textContent.includes('sni-ov')), '浮层样式随启动注入')
252
+ assert.ok(doc.createdStyles.some((el) => el.textContent === NAVLIST_SCROLL_CSS), '滚动规则与实现同源')
253
+ assert.equal(doc.head.children.length, 2, '样式挂载在 head')
254
+ assert.deepEqual(doc.events, ['style', 'style', 'observe'], '样式注入先于观察器挂载')
206
255
  unload()
207
256
  assert.equal(doc.createdStyles.length, 0, '卸载后移除')
208
257
  assert.equal(doc.head.children.length, 0, 'head 无残留')
209
258
  const unload2 = boot(win.__loaded, doc)
210
259
  try {
211
- assert.equal(doc.createdStyles.length, 1, '重装恢复注入')
260
+ assert.equal(doc.createdStyles.length, 2, '重装恢复注入')
212
261
  } finally {
213
262
  unload2()
214
263
  }
@@ -218,16 +267,18 @@ test('导航滚动样式:启动注入 head,卸载移除,重装恢复,先于观
218
267
  }
219
268
  })
220
269
 
221
- test('导航滚动样式代际:HMR 不卸载重评估由新实例代拆,旧形槽跨版本容忍', () => {
270
+ test('启动样式代际:HMR 不卸载重评估由新实例代拆,旧形槽跨版本容忍', () => {
222
271
  // HMR 路径:旧实例未 stop 直接二次 boot(新实例读槽代拆上一代样式)
223
272
  const { win, doc } = loadClient()
224
273
  const unload1 = boot(win.__loaded, doc)
225
- const oldStyle = doc.createdStyles[0]
274
+ const oldStyles = [...doc.createdStyles]
226
275
  const unload2 = boot(win.__loaded, doc)
227
276
  try {
228
- assert.equal(doc.createdStyles.length, 1, '代拆后恰一份样式')
229
- assert.equal(doc.head.children.length, 1, 'head 无旧代残留')
230
- assert.notEqual(doc.createdStyles[0], oldStyle, '存活者为新实例注入的新元素')
277
+ assert.equal(doc.createdStyles.length, 2, '代拆后恰两份样式')
278
+ assert.equal(doc.head.children.length, 2, 'head 无旧代残留')
279
+ for (const old of oldStyles) {
280
+ assert.ok(!doc.createdStyles.includes(old), '旧代样式被替换')
281
+ }
231
282
  unload2()
232
283
  assert.equal(doc.createdStyles.length, 0, '新实例卸载后干净')
233
284
  } finally {
@@ -235,12 +286,12 @@ test('导航滚动样式代际:HMR 不卸载重评估由新实例代拆,旧形
235
286
  unload2()
236
287
  }
237
288
 
238
- // 升级路径:上一代是旧版本槽(无 scrollStyle 字段),代拆不得抛错
289
+ // 升级路径:上一代是旧版本槽(无 scrollStyle/overlayStyle 字段),代拆不得抛错
239
290
  const { win: win2, doc: doc2 } = loadClient()
240
291
  win2[Symbol.for('@mzzsfy/dsh-settings-nav-icons')] = { observer: null, rafId: 0 }
241
292
  const unload3 = boot(win2.__loaded, doc2)
242
293
  try {
243
- assert.equal(doc2.createdStyles.length, 1, '旧形槽不阻塞注入')
294
+ assert.equal(doc2.createdStyles.length, 2, '旧形槽不阻塞注入')
244
295
  } finally {
245
296
  unload3()
246
297
  }
@@ -558,6 +609,137 @@ test('语言切换端到端:characterData 域内变更经回调触发重贴', ()
558
609
  }
559
610
  })
560
611
 
612
+ // 用户覆盖:恢复走安全门、写入幂等/非法拒绝、等价组全键触达、localStorage 持久往返
613
+ test('用户覆盖:启动从 localStorage 恢复,非法键值跳过', () => {
614
+ const { win, doc } = loadClient()
615
+ doc.cells.push(makeCell('插件市场'))
616
+ win.localStorage.map.set('__navicUserIcons', JSON.stringify({
617
+ '插件市场': 'bell',
618
+ '坏 glyph': 'no-such-glyph',
619
+ '活动 html': '<svg><script>alert(1)</script></svg>',
620
+ '1': 'bell',
621
+ __proto__: 'bell',
622
+ '超长键': 'x'.repeat(4097),
623
+ }))
624
+ const unload = boot(win.__loaded, doc)
625
+ try {
626
+ const market = doc.cells[0]
627
+ assert.equal(market.official.dataset.navic, '插件市场')
628
+ assert.ok(market.official.innerHTML.includes('M8 2.2'), '合法覆盖生效(bell 内容)')
629
+ assert.ok(!doc.marked.some((el) => el.dataset.navic === '坏 glyph'), '未知 glyph 不入表')
630
+ assert.ok(!doc.marked.some((el) => el.dataset.navic === '活动 html'), '安全门拒绝直通')
631
+ } finally {
632
+ unload()
633
+ }
634
+ })
635
+
636
+ test('用户覆盖:右键浮层保存/非法行内提示/清除,持久化往返,卸载后重装恢复', () => {
637
+ const { win, doc, raf } = loadClient()
638
+ doc.cells.push(makeCell('插件市场'))
639
+ const unload = boot(win.__loaded, doc)
640
+ try {
641
+ const cell = doc.cells[0]
642
+ doc.marked.push(cell.official)
643
+ // 右键打开浮层:preventDefault + 单例 + 输入聚焦
644
+ const evt = { target: cell, preventDefault() { evt.prevented = true } }
645
+ doc.dispatch('contextmenu', evt)
646
+ assert.ok(evt.prevented, '右键默认菜单被阻止')
647
+ const overlay = doc.bodyChildren[doc.bodyChildren.length - 1]
648
+ assert.ok(overlay.className.includes('sni-ov'), '浮层挂 body')
649
+ const input = overlayFind(overlay, (n) => n.tag === 'input')
650
+ const save = overlayFind(overlay, (n) => n.tag === 'button' && n.textContent === '保存')
651
+ const clear = overlayFind(overlay, (n) => n.tag === 'button' && n.textContent === '清除')
652
+ assert.ok(input && save && clear, '浮层含输入与双按钮')
653
+ assert.equal(input.focused, true, '打开即聚焦')
654
+ // 非法值:行内提示,不落库
655
+ input.value = 'no-such-glyph'
656
+ save.handlers.click()
657
+ const bad = overlayFind(overlay, (n) => (n.classes ?? new Set()).size > 0)
658
+ assert.ok(bad && [...bad.classes].some((c) => c.includes('__bad--on')), '非法值行内提示')
659
+ assert.equal(win.localStorage.map.get('__navicUserIcons'), undefined, '非法值不持久化')
660
+ // 合法保存:落库 + 单元格就地重写 + 浮层关闭
661
+ input.value = 'bell'
662
+ save.handlers.click()
663
+ assert.ok(!doc.bodyChildren.includes(overlay), '保存成功关闭浮层')
664
+ assert.ok(cell.official.innerHTML.includes('M8 2.2'), '覆盖内容生效')
665
+ const saved = JSON.parse(win.localStorage.map.get('__navicUserIcons'))
666
+ assert.ok(saved['插件市场'], '覆盖持久化')
667
+ // 再开浮层:回填当前覆盖值;清除:回默认管线 + 删键
668
+ doc.dispatch('contextmenu', { target: cell, preventDefault() {} })
669
+ const overlay2 = doc.bodyChildren[doc.bodyChildren.length - 1]
670
+ const input2 = overlayFind(overlay2, (n) => n.tag === 'input')
671
+ const clear2 = overlayFind(overlay2, (n) => n.tag === 'button' && n.textContent === '清除')
672
+ assert.equal(input2.value, 'bell', '回填当前覆盖原文(glyph 名)')
673
+ clear2.handlers.click()
674
+ assert.equal(win.localStorage.map.get('__navicUserIcons'), '{}', '清除后持久化为空表')
675
+ assert.ok(!cell.official.innerHTML.includes('M8 2.2'), '清除后回默认管线(店面图标)')
676
+ assert.ok(doc.bodyChildren.indexOf(overlay2) < 0, '清除关闭浮层')
677
+ // 浮层外点击与 Escape 关闭
678
+ doc.dispatch('contextmenu', { target: cell, preventDefault() {} })
679
+ const overlay3 = doc.bodyChildren[doc.bodyChildren.length - 1]
680
+ doc.dispatch('pointerdown', { target: cell })
681
+ assert.ok(!doc.bodyChildren.includes(overlay3), '外部点击关闭')
682
+ doc.dispatch('contextmenu', { target: cell, preventDefault() {} })
683
+ const overlay4 = doc.bodyChildren[doc.bodyChildren.length - 1]
684
+ doc.dispatch('keydown', { key: 'Escape' })
685
+ assert.ok(!doc.bodyChildren.includes(overlay4), 'Escape 关闭')
686
+ // 卸载重装:localStorage 覆盖恢复生效
687
+ win.localStorage.map.set('__navicUserIcons', JSON.stringify({ '插件市场': 'bell' }))
688
+ } finally {
689
+ unload()
690
+ }
691
+ const unload2 = boot(win.__loaded, doc)
692
+ try {
693
+ assert.ok(doc.cells[0].official.innerHTML.includes('M8 2.2'), '重装恢复用户覆盖')
694
+ } finally {
695
+ unload2()
696
+ }
697
+ })
698
+
699
+ test('用户覆盖:别名等价键写入/清除,双语分区两侧触达', () => {
700
+ const { win, doc } = loadClient()
701
+ // 双语两格均按齿轮改写链记账:'通用设置' 与 'General' 各持内置映射
702
+ doc.cells.push(makeCell('通用设置'))
703
+ doc.cells.push(makeCell('General'))
704
+ const unload = boot(win.__loaded, doc)
705
+ try {
706
+ const zhCell = doc.cells[0]
707
+ const enCell = doc.cells[1]
708
+ doc.marked.push(zhCell.official, enCell.official)
709
+ doc.dispatch('contextmenu', { target: zhCell, preventDefault() {} })
710
+ const overlay = doc.bodyChildren[doc.bodyChildren.length - 1]
711
+ const input = overlayFind(overlay, (n) => n.tag === 'input')
712
+ const save = overlayFind(overlay, (n) => n.tag === 'button' && n.textContent === '保存')
713
+ input.value = 'bell'
714
+ save.handlers.click()
715
+ assert.equal(JSON.parse(win.localStorage.map.get('__navicUserIcons'))['通用设置'], 'bell', '按打开侧语言键落库')
716
+ assert.ok(zhCell.official.innerHTML.includes('M8 2.2'), '中文侧覆盖生效')
717
+ assert.ok(enCell.official.innerHTML.includes('M8 2.2'), '英文侧等价触达重写')
718
+ // 清除:等价组全键删除,两侧回各语言内置映射
719
+ doc.dispatch('contextmenu', { target: enCell, preventDefault() {} })
720
+ const overlay2 = doc.bodyChildren[doc.bodyChildren.length - 1]
721
+ assert.ok(overlayFind(overlay2, (n) => n.tag === 'input').value === 'bell', '英文侧打开回填中文键覆盖')
722
+ const clear2 = overlayFind(overlay2, (n) => n.tag === 'button' && n.textContent === '清除')
723
+ clear2.handlers.click()
724
+ assert.equal(JSON.parse(win.localStorage.map.get('__navicUserIcons'))['通用设置'], undefined, '清除走等价组全键')
725
+ assert.ok(!zhCell.official.innerHTML.includes('M8 2.2'), '中文侧回内置映射')
726
+ assert.ok(!enCell.official.innerHTML.includes('M8 2.2'), '英文侧回内置映射')
727
+ } finally {
728
+ unload()
729
+ }
730
+ })
731
+
732
+ test('用户覆盖:非 nav 单元格右键不弹浮层', () => {
733
+ const { win, doc } = loadClient()
734
+ const unload = boot(win.__loaded, doc)
735
+ try {
736
+ doc.dispatch('contextmenu', { target: doc.avatars[0], preventDefault() {} })
737
+ assert.equal(doc.bodyChildren.length, 0, '头像槽右键无浮层')
738
+ } finally {
739
+ unload()
740
+ }
741
+ })
742
+
561
743
  // 声明侧契约:四个生产者包的注册样板(先探测入口、数组态入队),改契约须同步五处。
562
744
  // 包脱离 monorepo 布局(发布态)时跳过:无兄弟包源码可读。
563
745
  test('生产者样板契约:四包注册走 __navicIcons/__navicIconQueue', () => {
@@ -26,7 +26,7 @@ function clientLogic() {
26
26
  }
27
27
  const factory = new Function(
28
28
  'ATTR_MARK', 'SELECTOR_LABEL',
29
- section + '; return { ICONS, DECLARED_ICONS, FALLBACK, GLYPHS, GEAR_PATH, SVG_MAX_CHARS, STROKE_ATTRS, NAME_RULES, ATTR_MARK, SELECTOR_LABEL, poolIndexOf, resolveIcon, svgInner, resolveForLabel, themedIcon, decide, applyDecision, decideAvatar, applyAvatar };',
29
+ section + '; return { ICONS, DECLARED_ICONS, USER_OVERRIDES, ALIAS_GROUPS, FALLBACK, GLYPHS, GEAR_PATH, SVG_MAX_CHARS, STROKE_ATTRS, NAME_RULES, ATTR_MARK, SELECTOR_LABEL, poolIndexOf, resolveIcon, svgInner, resolveForLabel, themedIcon, iconSourceOf, lookupWithAlias, aliasKeysOf, decide, applyDecision, decideAvatar, applyAvatar };',
30
30
  )
31
31
  return factory(pick('ATTR_MARK'), pick('SELECTOR_LABEL'))
32
32
  }
@@ -285,6 +285,51 @@ function defineScenarios(prefix, L) {
285
285
  assert.equal(themedIcon('git-notify'), themedIcon('git'), 'git 先于 notify')
286
286
  })
287
287
 
288
+ test(prefix + '别名等价:双语分区任一语言键命中,组外键不受影响', () => {
289
+ const { USER_OVERRIDES, lookupWithAlias, aliasKeysOf } = L
290
+ try {
291
+ USER_OVERRIDES['通用设置'] = 'bell'
292
+ assert.equal(lookupWithAlias(USER_OVERRIDES, 'General'), 'bell', '反向等价命中')
293
+ assert.equal(lookupWithAlias(USER_OVERRIDES, '通用设置'), 'bell', '正向直查')
294
+ assert.equal(aliasKeysOf('General').length, 2, '等价组键展开为双语两侧')
295
+ assert.deepEqual(aliasKeysOf('插件市场'), ['插件市场'], '组外键只返回自身')
296
+ } finally {
297
+ delete USER_OVERRIDES['通用设置']
298
+ }
299
+ })
300
+
301
+ test(prefix + '用户覆盖插到取图链最前,撤销后回声明/内置/推导管线', () => {
302
+ const { USER_OVERRIDES, DECLARED_ICONS, ICONS, resolveForLabel, resolveIcon, iconSourceOf } = L
303
+ try {
304
+ DECLARED_ICONS['插件市场'] = 'git'
305
+ assert.equal(resolveForLabel('插件市场'), resolveIcon('git'), '基线:声明命中')
306
+ assert.equal(iconSourceOf('插件市场'), 'declared')
307
+ USER_OVERRIDES['插件市场'] = 'bell'
308
+ assert.equal(resolveForLabel('插件市场'), resolveIcon('bell'), '覆盖压过声明')
309
+ assert.equal(iconSourceOf('插件市场'), 'user')
310
+ delete USER_OVERRIDES['插件市场']
311
+ assert.equal(resolveForLabel('插件市场'), resolveIcon('git'), '撤销覆盖回声明')
312
+ delete DECLARED_ICONS['插件市场']
313
+ assert.equal(resolveForLabel('插件市场'), ICONS['插件市场'], '再撤声明回内置映射')
314
+ assert.equal(iconSourceOf('插件市场'), 'builtin')
315
+ assert.equal(iconSourceOf('无任何命中的分区'), 'derived', '无命中归推导')
316
+ } finally {
317
+ delete USER_OVERRIDES['插件市场']
318
+ delete DECLARED_ICONS['插件市场']
319
+ }
320
+ })
321
+
322
+ test(prefix + '覆盖的别名等价:覆盖写在任一语言键,双语 label 都命中且来源为 user', () => {
323
+ const { USER_OVERRIDES, resolveForLabel, resolveIcon, iconSourceOf } = L
324
+ try {
325
+ USER_OVERRIDES['General'] = 'search'
326
+ assert.equal(resolveForLabel('通用设置'), resolveIcon('search'), '反向 label 命中覆盖')
327
+ assert.equal(iconSourceOf('通用设置'), 'user')
328
+ } finally {
329
+ delete USER_OVERRIDES['General']
330
+ }
331
+ })
332
+
288
333
  test(prefix + '头像槽:原子节点隐藏记账,换名清旧注入不误删外来兄弟', () => {
289
334
  const { decideAvatar, applyAvatar } = L
290
335
  const removed = []
@@ -351,6 +396,9 @@ test('两份实现常量同源', () => {
351
396
  assert.equal(logic.SELECTOR_LABEL, client.SELECTOR_LABEL, 'label 选择器同源(精确匹配桩防子串容忍)')
352
397
  assert.equal(logic.GEAR_PATH, client.GEAR_PATH)
353
398
  assert.equal(logic.SVG_MAX_CHARS, client.SVG_MAX_CHARS)
399
+ assert.deepEqual(logic.ALIAS_GROUPS, client.ALIAS_GROUPS, '别名组同源')
400
+ assert.equal(Object.keys(logic.USER_OVERRIDES).length, 0, '用户覆盖表初始为空(logic 侧)')
401
+ assert.equal(Object.keys(client.USER_OVERRIDES).length, 0, '用户覆盖表初始为空(client 侧)')
354
402
  })
355
403
 
356
404
  test('两份实现 ICONS/GLYPHS/FALLBACK 全量同源', () => {
@@ -380,7 +428,7 @@ test('LOGIC 段与 logic.mjs 决策函数逐函数源码一致(归一化注释
380
428
  .replace(/\/\*[\s\S]*?\*\//g, '')
381
429
  .replace(/\/\/[^\n]*/g, '')
382
430
  .replace(/\s+/g, '')
383
- for (const name of ['resolveIcon', 'svgInner', 'resolveForLabel', 'themedIcon', 'decide', 'applyDecision', 'decideAvatar', 'applyAvatar', 'poolIndexOf']) {
431
+ for (const name of ['resolveIcon', 'svgInner', 'resolveForLabel', 'themedIcon', 'iconSourceOf', 'lookupWithAlias', 'aliasKeysOf', 'decide', 'applyDecision', 'decideAvatar', 'applyAvatar', 'poolIndexOf']) {
384
432
  assert.equal(
385
433
  normalize(client[name].toString()),
386
434
  normalize(logic[name].toString()),