@mzzsfy/dsh-settings-nav-icons 0.1.5 → 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,20 +6,34 @@ DeepSeek Harness 纯前端插件:设置弹窗左侧导航与 dsh-market 插件
6
6
 
7
7
  | 场景 | 行为 |
8
8
  |---|---|
9
- | 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(声明 → 内置映射 → 关键词 → 哈希) |
9
+ | 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(用户覆盖 → 声明 → 内置映射 → 关键词 → 哈希) |
10
10
  | 分区当前非齿轮(官方原生图标 / 第三方插件供给的图标) | 不干预,映射或声明命中也不例外 |
11
+ | 右键导航分区 | 打开图标编辑浮层:保存/清除用户覆盖(localStorage 持久),非法值行内提示 |
12
+ | 左侧导航分区溢出(弹窗高度不足) | 列表区域滚动,标题固定;官方无滚动机制,溢出被弹窗裁剪致底部分区不可达 |
11
13
  | 市场卡片头像槽(有名称锚) | 按插件名取图替换作者头像/字母色块 |
12
- | 语言切换导致 label 变化 | 已改写的分区按新 label 重新取图改写 |
14
+ | 语言切换导致 label 变化 | 已改写的分区按新 label 重新取图改写;双语分区覆盖/声明按等价组跨语言命中 |
13
15
  | 面板关闭后重新打开 / 会话切换 | 新渲染的齿轮自动再改写 |
14
16
  | 声明变更(注册新值/撤销) | 受影响分区 svg 内容就地重写,头像槽重新取图 |
15
17
  | 插件卸载 | 观察器随 `ctx.effect` 销毁,重载页面即恢复官方默认 |
16
18
 
17
19
  改写采用**内容制**:直接替换官方齿轮 svg 的内部内容,不新建节点、不动属性——节点、class、DOM 位置全部保持官方原样,其他插件针对官方图标的 CSS(如按 `> svg:first-child` 隐藏、伪元素 mask 叠加)照常作用于改写结果,不会出现双图标。
18
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
+
19
33
  ## 取图优先级
20
34
 
21
35
  ```
22
- 外部声明(register) → 内置映射(ICONS) → 名称关键词(NAME_RULES) → 稳定哈希备用池(FALLBACK)
36
+ 用户覆盖(localStorage) → 外部声明(register) → 内置映射(ICONS) → 名称关键词(NAME_RULES) → 稳定哈希备用池(FALLBACK)
23
37
  ```
24
38
 
25
39
  - **外部声明**:其他插件运行时注册自己的 label/插件名 → 图标,优先级最高(见下节)。
@@ -39,7 +53,7 @@ window.__navicIcons.register({ '消息通知': 'bell' })
39
53
  ;(window.__navicIconQueue ??= []).push({ '消息通知': 'bell' })
40
54
  ```
41
55
 
42
- - 键:分区显示文本(设置导航)或插件名(dsh-market 卡片)。两域共用一张表,同名时以先命中者生效,键请取不易与官方分区撞名的插件名。
56
+ - 键:分区显示文本(设置导航)或插件名(dsh-market 卡片)。两域共用一张表,同名时以先命中者生效,键请取不易与官方分区撞名的插件名。官方双语分区按等价组解析:任一语言 label 作键,两种语言下均命中(消除语言切换的声明键漂移)。
43
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` 收口,原型链成员不可能被注入。
44
58
  - 声明值经归一化后写入注册表;同值重复注册幂等短路;非法值(未知 glyph/被安全门拒绝/非字符串)撤销该键声明,该分区回到内置映射或关键词/哈希默认管线,已改写的 nav svg 内容就地重写。
45
59
  - 声明持久化在 `window.__navicIconDeclarations`:本插件 client 半区热重载会重跑工厂而生产者不重发注册,持久层让重装实例恢复声明,页面刷新随 window 释放。
@@ -64,11 +78,12 @@ window.__navicIcons.register({ '消息通知': 'bell' })
64
78
 
65
79
  ## 实现要点
66
80
 
81
+ - 导航滚动补偿:官方 navList 高度随内容撑开,弹窗(overflow hidden)裁掉溢出且无滚动途径,分区一多底部不可达。插件启动时向 head 注入一条样式规则(navList `flex:1 1 0 + min-height:0 + overflow-y:auto + scrollbar-width:thin`),样式表形态对弹窗关闭重开等任意 React 重渲染持续生效;卸载即移除,零残留。类名锚定与图标改写同策略(`VOzbGW_` 哈希前缀字面量)。
67
82
  - 实现边界:`settings.*` 子槽位的声明权与渲染权均被原版 `dsh-client-ui-settings-general` 条目占用——`renderSlot` 只授予声明了 `children` 的条目(dsh-client-ui-renderer),而 Slot 声明全局唯一、重复声明即 `already declared`,因此任何插件都无法在保留设置页内容的前提下接管 Shell 重绘导航。DOM 观察是唯一不依赖官方契约变更的路径(dream-skin 与 dsh-better-sidebar 各自内置了同原理的一次性 hack)。
68
83
  - 匹配锚点:分区显示文本而非分区 id——id 不进 DOM,文本是唯一稳定可见锚点;CSS Modules 哈希类名(`VOzbGW_*`)取字面量。label 一律 trim 后匹配,空白 label 直接跳过。
69
84
  - 内容改写制:决策只对官方齿轮(齿轮路径前缀 `M14.0861` 识别)生效,`applyDecision` 写官方 svg 的 `innerHTML` 并把记账(`data-navic` = label)落在官方 svg 自身;不新建节点、不改属性、不驱动 style。React 安全性依据:官方 nav 图标是静态子树,重渲染前后 element 引用相等即 bailout,改写内容稳定存活(0.1.x 的 NotFoundError 仅源于 remove 节点本身);升级残留的旧注入节点(`data-navic="1"`)在改写前清除。改写后的 svg 非齿轮且带记账,与"未处理的官方原生非齿轮 svg"靠记账区分——因此声明变更不删记账,由 `registerIcons` 对命中键的 nav svg 以 `resolveForLabel` 全链(声明 → 内置映射 → 关键词 → 哈希)就地重写内容,头像槽(非 svg 节点)仍走清账重贴。
70
85
  - 市场卡片头像槽记账为插件名,IMG/DIV 同构处理:原节点隐藏、注入图标跟随(不触碰 React 受管子树),换名重贴按父容器范围清理旧注入、不误删外来兄弟。
71
- - 生命周期:单个 `MutationObserver` 常驻 `document.body`(`childList` + `subtree` + `characterData`;childList 通道仅元素级变更放行,characterData 通道按目标域精确放行——label 与市场卡片名所在行内的文本改写才唤醒,流式正文等域外文本变更不唤醒),变更去抖到 `requestAnimationFrame` 扫描,单元格与头像槽合并为单次全文档遍历,扫描结束 `takeRecords` 消除自产写入回波;`ctx.effect` 持有,卸载即断开并取消已排定帧,已停止实例的注册入口短路、不驱动 DOM。观察器句柄挂 window 代际槽(HMR 重评估先拆上一代观察器与在途帧),帧句柄与槽同步(跨代取消真实可达)。常驻全文档观察是已知性能取舍:装饰性插件的目标域(设置弹窗/市场卡片)无稳定根锚点,两级观察的回归风险大于收益。
86
+ - 生命周期:单个 `MutationObserver` 常驻 `document.body`(`childList` + `subtree` + `characterData`;childList 通道仅元素级变更放行,characterData 通道按目标域精确放行——label 与市场卡片名所在行内的文本改写才唤醒,流式正文等域外文本变更不唤醒),变更去抖到 `requestAnimationFrame` 扫描,单元格与头像槽合并为单次全文档遍历,扫描结束 `takeRecords` 消除自产写入回波;`ctx.effect` 持有,卸载即断开并取消已排定帧,已停止实例的注册入口短路、不驱动 DOM。观察器句柄、在途帧与滚动样式元素挂 window 代际槽(HMR 重评估先拆上一代三者,滚动样式对旧版槽缺字段宽松判空),帧句柄与槽同步(跨代取消真实可达)。常驻全文档观察是已知性能取舍:装饰性插件的目标域(设置弹窗/市场卡片)无稳定根锚点,两级观察的回归风险大于收益。
72
87
  - 纯逻辑层(映射表 + 改写决策)在 `src/logic.mjs`,`src/client.js` 内嵌同源实现,`node --test` 以同一套场景对两份实现做 parity 验证(全表 deepEqual + 决策函数逐函数源码对比),另有编排层契约测试(注册校验/队列三态与时序/生命周期/头像槽编排/异常隔离)与四生产者样板契约测试。
73
88
 
74
89
  ## 安装
@@ -85,7 +100,7 @@ node scripts/dev-link.mjs dsh-settings-nav-icons
85
100
 
86
101
  前提:profile `package.json` 的 dependencies 有本包 semver 行、`dsh.profile.bundles` 有本包名(未发布包 registry 拉取会失败,junction 覆盖 node_modules 物理目录后启动只走 realpath)。junction 会被 `pnpm install` / `dsh plugin add` 抹掉,之后重跑本脚本即可。
87
102
 
88
- 重启 dsh 后设置面板即生效;无设置项。
103
+ 重启 dsh 后设置面板即生效;设置项即「用户自定义覆盖」的右键浮层,无独立设置页。
89
104
 
90
105
  ## 升级 / 卸载
91
106
 
@@ -104,7 +119,7 @@ pnpm --dir packages/dsh-settings-nav-icons test
104
119
  node --test packages/dsh-settings-nav-icons/test/*.test.mjs
105
120
  ```
106
121
 
107
- 覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / 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)、四生产者样板契约。
108
123
 
109
124
  ## License
110
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.1.5",
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,272 @@ 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
+
275
+ // ---- 设置导航滚动补偿:官方 navList 高度随内容撑开,溢出被 panel hidden
276
+ // 裁剪且无滚动机制,分区一多底部即不可达。样式表形态一次注入,对弹窗重开
277
+ // 等任意 React 重渲染持续生效;类名锚定与上方单元格同策略(哈希前缀字面量)。
278
+
279
+ const NAVLIST_SCROLL_CSS = '.VOzbGW_navList{flex:1 1 0;min-height:0;overflow-y:auto;scrollbar-width:thin}'
280
+
15
281
  /* LOGIC-BEGIN */
16
282
  // 纯逻辑层:图标映射 + 单元格替换决策,与 src/logic.mjs 同源,由 parity 测试保证。
17
283
 
@@ -183,10 +449,59 @@ window.__ModuleLoader__.load({
183
449
  ['zap|flash|fast|quick|boost|speed|turbo', ZAP],
184
450
  ].map(function (pair) { return { re: new RegExp('(^|[^a-z])(' + pair[0] + ')([^a-z]|$)'), icon: pair[1] } })
185
451
 
186
- // 插件名/分区 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
+ // (声明值被撤销/存储被篡改)视为该层未命中,继续下一层。
187
494
  function themedIcon(name) {
188
- const declared = resolveIcon(DECLARED_ICONS[name])
189
- 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
+ }
190
505
  const n = name.toLowerCase()
191
506
  for (const rule of NAME_RULES) {
192
507
  if (rule.re.test(n)) return rule.icon
@@ -194,16 +509,33 @@ window.__ModuleLoader__.load({
194
509
  return FALLBACK[poolIndexOf(name)]
195
510
  }
196
511
 
197
- // 分区取图全链:声明 → 内置映射 → 关键词/哈希兜底。decide 与声明变更就地
198
- // 重写共用;撤销内置分区声明时回退必须含内置映射,否则卡死在哈希图。
512
+ // 分区取图全链:用户覆盖 → 声明(含别名) → 内置映射 → 关键词/哈希兜底。decide
513
+ // 与声明/覆盖变更就地重写共用;撤销时回退必须含内置映射,否则卡死在哈希图。
199
514
  function resolveForLabel(label) {
200
- const declared = resolveIcon(DECLARED_ICONS[label])
201
- 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
+ }
202
525
  const mapped = ICONS[label]
203
526
  if (mapped !== undefined) return mapped
204
527
  return themedIcon(label)
205
528
  }
206
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
+
207
539
  // 市场卡片头像槽决策:img(作者头像)与 div(字母色块)同构处理——原子节点隐藏,
208
540
  // 插件图标注入跟随。记账值 = 插件名,换名重贴由该比较驱动。
209
541
  function decideAvatar(av, name) {
@@ -300,7 +632,6 @@ window.__ModuleLoader__.load({
300
632
  }
301
633
 
302
634
  /* LOGIC-END */
303
-
304
635
  // ---- 市场卡片 DOM 锚点(css-modules 哈希前缀随版本变,按后缀匹配) ----
305
636
 
306
637
  const SELECTOR_AV = '[class$="_av"]'
@@ -445,18 +776,47 @@ window.__ModuleLoader__.load({
445
776
  }
446
777
  if (touched.length === 0) return
447
778
  persistDeclarations()
448
- // 声明变更就地生效:nav svg(记账为分区/插件名)直接重写内容且记账保留——
449
- // 改写后的 svg 非齿轮,清账会让 isGear 判定失配卡死;头像槽(非 svg 节点)
450
- // 清账后走 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
+ }
451
794
  for (const el of document.querySelectorAll('[' + ATTR_MARK + ']')) {
452
- if (touched.indexOf(el.dataset.navic) < 0) continue
795
+ if (expanded.indexOf(el.dataset.navic) < 0) continue
453
796
  if (el.tagName === 'svg') {
454
797
  el.innerHTML = svgInner(resolveForLabel(el.dataset.navic))
455
798
  } else {
456
799
  delete el.dataset.navic
457
800
  }
458
801
  }
459
- schedule()
802
+ }
803
+
804
+ // 滚动样式元素:start 注入 head,stop 移除,引用即状态源,幂等无守卫分支;
805
+ // 同挂代际槽, HMR 重评估 stop 未跑时由新实例代拆
806
+ let scrollStyle = null
807
+
808
+ function installScrollStyle() {
809
+ scrollStyle = document.createElement('style')
810
+ scrollStyle.textContent = NAVLIST_SCROLL_CSS
811
+ document.head.appendChild(scrollStyle)
812
+ if (slot !== null) slot.scrollStyle = scrollStyle
813
+ }
814
+
815
+ function uninstallScrollStyle() {
816
+ if (scrollStyle === null) return
817
+ scrollStyle.remove()
818
+ scrollStyle = null
819
+ if (slot !== null) slot.scrollStyle = null
460
820
  }
461
821
 
462
822
  // 队列直通桩属主守卫:stop 仅复位本实例所置桩,乱序清理不覆盖新实例运行态
@@ -485,20 +845,33 @@ window.__ModuleLoader__.load({
485
845
  if (previous !== undefined) {
486
846
  if (previous.observer !== null) previous.observer.disconnect()
487
847
  if (previous.rafId !== 0) cancelAnimationFrame(previous.rafId)
848
+ // 宽松判空:槽是跨代码版本通道,旧版槽缺新字段(undefined)
849
+ if (previous.scrollStyle != null) previous.scrollStyle.remove()
850
+ if (previous.overlayStyle != null) previous.overlayStyle.remove()
851
+ if (previous.overlayEl != null) previous.overlayEl.remove()
488
852
  }
489
- slot = { observer: null, rafId: 0 }
853
+ slot = { observer: null, rafId: 0, scrollStyle: null, overlayStyle: null, overlayEl: null }
490
854
  window[SLOT_KEY] = slot
491
- // 可抛步骤(声明恢复/队列排水)先于观察器挂载,失败不产生孤儿扫描器
855
+ // 可抛步骤(覆盖/声明恢复、队列排水、样式注入)先于观察器挂载,失败不产生孤儿扫描器
856
+ installOverlayStyle()
857
+ installScrollStyle()
858
+ restoreUserOverrides()
492
859
  restoreDeclarations()
493
860
  drainQueue()
494
861
  observer = new MutationObserver(onMutations)
495
862
  observer.observe(document.body, { childList: true, subtree: true, characterData: true })
496
863
  slot.observer = observer
864
+ installOverlayListeners()
865
+ refreshMarkedContent()
497
866
  replacePass()
498
867
  }
499
868
 
500
869
  function stop() {
501
870
  active = false
871
+ uninstallOverlayListeners()
872
+ removeOverlay()
873
+ uninstallOverlayStyle()
874
+ uninstallScrollStyle()
502
875
  if (observer !== null) {
503
876
  observer.disconnect()
504
877
  observer = null
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/选择器处理
@@ -16,6 +16,7 @@ const SELECTOR_AV = '[class$="_av"]'
16
16
  const SELECTOR_ALL = SELECTOR_CELL + ',' + SELECTOR_AV
17
17
  const SELECTOR_MARKED = '[data-navic]'
18
18
  const SELECTOR_LABEL = '.VOzbGW_navLabel'
19
+ const NAVLIST_SCROLL_CSS = '.VOzbGW_navList{flex:1 1 0;min-height:0;overflow-y:auto;scrollbar-width:thin}'
19
20
 
20
21
  function fakeRaf() {
21
22
  const pending = new Map()
@@ -28,10 +29,21 @@ function fakeRaf() {
28
29
  }
29
30
  }
30
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
+
31
42
  // 官方 svg 桩:gear=true 模拟官方齿轮路径,否则为无路径原生图形;内容改写制下
32
43
  // applyDecision 直接写 innerHTML(桩以 innerHTML 属性承接)与 dataset.navic
33
44
  function makeOfficialSvg({ gear = true } = {}) {
34
45
  const svg = {
46
+ tagName: 'svg',
35
47
  dataset: { navic: '' },
36
48
  style: { display: '' },
37
49
  innerHTML: '',
@@ -68,6 +80,7 @@ function makeCell(label, { gear = true, navic = '' } = {}) {
68
80
  cell.official = official
69
81
  cell.svgs.push(official)
70
82
  cell.matches = (sel) => sel !== SELECTOR_AV
83
+ cell.closest = (sel) => (sel === SELECTOR_CELL ? cell : null)
71
84
  cell.querySelector = (sel) => (sel === SELECTOR_LABEL ? { textContent: label } : null)
72
85
  cell.querySelectorAll = (sel) => {
73
86
  if (sel === 'svg') return cell.svgs
@@ -113,6 +126,55 @@ function fakeDocument() {
113
126
  marked: [],
114
127
  cells: [],
115
128
  avatars: [],
129
+ // 生命周期事件序:锁定"样式注入先于观察器挂载"的顺序契约
130
+ events: [],
131
+ head: {
132
+ children: [],
133
+ appendChild(el) { doc.head.children.push(el); doc.events.push('style') },
134
+ },
135
+ createdStyles: [],
136
+ listeners: {},
137
+ createElement(tag) {
138
+ const el = {
139
+ tag,
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(),
155
+ remove() {
156
+ const list = doc.bodyChildren
157
+ const at = list.indexOf(el)
158
+ if (at >= 0) list.splice(at, 1)
159
+ doc.createdStyles.splice(doc.createdStyles.indexOf(el), 1)
160
+ doc.head.children.splice(doc.head.children.indexOf(el), 1)
161
+ },
162
+ }
163
+ if (tag === 'style') doc.createdStyles.push(el)
164
+ return el
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
+ },
116
178
  querySelectorAll(sel) {
117
179
  if (sel === SELECTOR_ALL) return [...doc.cells, ...doc.avatars]
118
180
  if (sel === SELECTOR_CELL) return doc.cells
@@ -121,6 +183,7 @@ function fakeDocument() {
121
183
  return []
122
184
  },
123
185
  }
186
+ doc.body.appendChild = (el) => { doc.bodyChildren.push(el) }
124
187
  return doc
125
188
  }
126
189
 
@@ -136,7 +199,7 @@ function loadClient() {
136
199
  this.cb = cb
137
200
  observers.push(this)
138
201
  }
139
- observe(...args) { observed.push(args) }
202
+ observe(...args) { observed.push(args); if (doc.events) doc.events.push('observe') }
140
203
  disconnect() { observed.length = 0 }
141
204
  takeRecords() { return [] }
142
205
  }
@@ -144,6 +207,11 @@ function loadClient() {
144
207
  __ModuleLoader__: { load(mod) { win.__loaded = mod } },
145
208
  requestAnimationFrame: raf.requestAnimationFrame,
146
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
+ },
147
215
  }
148
216
  const doc = fakeDocument()
149
217
  new Function('window', 'document', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'console', src)(
@@ -174,6 +242,61 @@ test('boot 观察:body 目标与三开关齐全,卸载后断开', () => {
174
242
  assert.equal(observed.length, 0, '卸载后 disconnect')
175
243
  })
176
244
 
245
+ test('启动样式:覆盖浮层样式与滚动样式注入 head,卸载移除,重装恢复,先于观察器挂载', () => {
246
+ const { win, doc, observed } = loadClient()
247
+ assert.equal(doc.createdStyles.length, 0, '启动前无注入')
248
+ const unload = boot(win.__loaded, doc)
249
+ try {
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'], '样式注入先于观察器挂载')
255
+ unload()
256
+ assert.equal(doc.createdStyles.length, 0, '卸载后移除')
257
+ assert.equal(doc.head.children.length, 0, 'head 无残留')
258
+ const unload2 = boot(win.__loaded, doc)
259
+ try {
260
+ assert.equal(doc.createdStyles.length, 2, '重装恢复注入')
261
+ } finally {
262
+ unload2()
263
+ }
264
+ assert.equal(doc.createdStyles.length, 0, '二次卸载移除')
265
+ } finally {
266
+ unload()
267
+ }
268
+ })
269
+
270
+ test('启动样式代际:HMR 不卸载重评估由新实例代拆,旧形槽跨版本容忍', () => {
271
+ // HMR 路径:旧实例未 stop 直接二次 boot(新实例读槽代拆上一代样式)
272
+ const { win, doc } = loadClient()
273
+ const unload1 = boot(win.__loaded, doc)
274
+ const oldStyles = [...doc.createdStyles]
275
+ const unload2 = boot(win.__loaded, doc)
276
+ try {
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
+ }
282
+ unload2()
283
+ assert.equal(doc.createdStyles.length, 0, '新实例卸载后干净')
284
+ } finally {
285
+ unload1()
286
+ unload2()
287
+ }
288
+
289
+ // 升级路径:上一代是旧版本槽(无 scrollStyle/overlayStyle 字段),代拆不得抛错
290
+ const { win: win2, doc: doc2 } = loadClient()
291
+ win2[Symbol.for('@mzzsfy/dsh-settings-nav-icons')] = { observer: null, rafId: 0 }
292
+ const unload3 = boot(win2.__loaded, doc2)
293
+ try {
294
+ assert.equal(doc2.createdStyles.length, 2, '旧形槽不阻塞注入')
295
+ } finally {
296
+ unload3()
297
+ }
298
+ })
299
+
177
300
  test('registerIcons 输入校验:数组与空值拒收', () => {
178
301
  const { win, doc, raf } = loadClient()
179
302
  const unload = boot(win.__loaded, doc)
@@ -486,6 +609,137 @@ test('语言切换端到端:characterData 域内变更经回调触发重贴', ()
486
609
  }
487
610
  })
488
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
+
489
743
  // 声明侧契约:四个生产者包的注册样板(先探测入口、数组态入队),改契约须同步五处。
490
744
  // 包脱离 monorepo 布局(发布态)时跳过:无兄弟包源码可读。
491
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()),