@nsnanocat/preference-panes 0.7.2 → 0.8.1

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.
@@ -1,3 +1,119 @@
1
+ /**
2
+ * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
3
+ * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
4
+ */
5
+ class ActionMenu {
6
+ #button;
7
+ #popup;
8
+ #backdrop;
9
+ #select;
10
+ #document;
11
+ #key = event => {
12
+ if (event.key === "Escape" && !this.#popup.hidden) {
13
+ event.preventDefault();
14
+ this.close();
15
+ this.#button.focus();
16
+ }
17
+ };
18
+
19
+ /**
20
+ * 创建菜单,操作逻辑由调用方提供。
21
+ * Create a menu whose actions are handled by the caller.
22
+ * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
23
+ */
24
+ constructor(select) {
25
+ this.#document = document;
26
+ this.#select = select;
27
+ this.element = document.createElement("span");
28
+ const root = this.element.attachShadow({ mode: "open" });
29
+ root.innerHTML = `<style>
30
+ :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
31
+ :host([hidden]),[hidden]{display:none!important}
32
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
33
+ button:disabled{opacity:.4;cursor:default}
34
+ button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
35
+ #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
36
+ svg{display:block;width:24px;height:24px;fill:currentColor}
37
+ #backdrop{position:fixed;inset:0;z-index:1}
38
+ #items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
39
+ #items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
40
+ #items button:hover{background:#8882}
41
+ #items button[data-danger]{color:#e45656}
42
+ </style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
43
+ this.#button = root.querySelector("#trigger");
44
+ this.#popup = root.querySelector("#items");
45
+ this.#backdrop = root.querySelector("#backdrop");
46
+ this.#button.onclick = () => {
47
+ const open = this.#popup.hidden;
48
+ this.#popup.hidden = this.#backdrop.hidden = !open;
49
+ this.#button.setAttribute("aria-expanded", String(open));
50
+ if (open) this.#popup.firstElementChild.focus();
51
+ };
52
+ this.#backdrop.onclick = () => this.close();
53
+ this.#popup.onkeydown = event => {
54
+ if (event.key === "Tab") {
55
+ this.close();
56
+ return;
57
+ }
58
+ const items = [...this.#popup.children];
59
+ const index = items.indexOf(root.activeElement);
60
+ const offsets = { ArrowDown: 1, ArrowUp: -1 };
61
+ if (event.key in offsets) {
62
+ event.preventDefault();
63
+ items[(index + offsets[event.key] + items.length) % items.length].focus();
64
+ }
65
+ };
66
+ document.addEventListener("keydown", this.#key);
67
+ this.update([]);
68
+ }
69
+
70
+ /**
71
+ * 同步可用操作和忙碌状态,不重建菜单触发按钮。
72
+ * Update actions and busy state without replacing the trigger button.
73
+ * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
74
+ * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
75
+ * @returns {void} 无返回值 / No return value.
76
+ */
77
+ update(items, disabled = false) {
78
+ this.close();
79
+ this.#button.disabled = disabled || items.length === 0;
80
+ this.#popup.replaceChildren(
81
+ ...items.map(item => {
82
+ const button = this.#document.createElement("button");
83
+ button.type = "button";
84
+ button.setAttribute("role", "menuitem");
85
+ button.textContent = item.label;
86
+ button.toggleAttribute("data-danger", Boolean(item.destructive));
87
+ button.onclick = () => {
88
+ this.close();
89
+ this.#select(item.id);
90
+ };
91
+ return button;
92
+ }),
93
+ );
94
+ }
95
+
96
+ /**
97
+ * 关闭菜单。
98
+ * Close the menu.
99
+ * @returns {void} 无返回值 / No return value.
100
+ */
101
+ close() {
102
+ this.#popup.hidden = this.#backdrop.hidden = true;
103
+ this.#button.setAttribute("aria-expanded", "false");
104
+ }
105
+
106
+ /**
107
+ * 移除监听器与节点。
108
+ * Remove listeners and elements.
109
+ * @returns {void} 无返回值 / No return value.
110
+ */
111
+ destroy() {
112
+ this.#document.removeEventListener("keydown", this.#key);
113
+ this.element.remove();
114
+ }
115
+ }
116
+
1
117
  /**
2
118
  * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
3
119
  * Resolve module resource locations: headers override query parameters and module conventions.
@@ -11,7 +127,7 @@ function pageInputs(url, headers = {}) {
11
127
  const module = match[1];
12
128
  const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
13
129
  const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
14
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
130
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
15
131
  if (!json.trim()) throw new TypeError("JSON resource URL is required");
16
132
  return { url: url.href, module, json, css };
17
133
  }
@@ -27,7 +143,7 @@ class ModuleFrame extends EventTarget {
27
143
  #abort = () => this.destroy();
28
144
  #state;
29
145
  #change = event => {
30
- this.#state = event.detail;
146
+ this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
31
147
  this.dispatchEvent(new Event("change"));
32
148
  };
33
149
 
@@ -46,7 +162,7 @@ class ModuleFrame extends EventTarget {
46
162
  this.element.title = `${inputs.module} 设置`;
47
163
  this.element.dataset.preferencePanes = JSON.stringify(inputs);
48
164
  this.element.addEventListener("preferencepanes:change", this.#change);
49
- this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
165
+ this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
50
166
  options.signal?.addEventListener("abort", this.#abort, { once: true });
51
167
  }
52
168
 
@@ -86,6 +202,17 @@ class ModuleFrame extends EventTarget {
86
202
  if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
87
203
  }
88
204
 
205
+ /**
206
+ * 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
207
+ * Dispatch a menu action without host access to internal DOM or the storage client.
208
+ * @param {string} id 当前可用操作 / Available action identifier.
209
+ * @returns {void} 无返回值 / No return value.
210
+ */
211
+ perform(id) {
212
+ if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
213
+ this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
214
+ }
215
+
89
216
  /**
90
217
  * 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
91
218
  * Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
@@ -98,6 +225,93 @@ class ModuleFrame extends EventTarget {
98
225
  }
99
226
  }
100
227
 
228
+ /**
229
+ * 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
230
+ * Fixed module status row, probing installation and business version with HEAD only.
231
+ */
232
+ class ModuleStatus extends EventTarget {
233
+ #element;
234
+ #controller;
235
+ #state = { status: "checking", version: null };
236
+
237
+ /**
238
+ * 绑定调用方提供的状态行。
239
+ * Bind a caller-owned status row.
240
+ * @param {HTMLElement} element 状态文字容器 / Status text container.
241
+ */
242
+ constructor(element) {
243
+ super();
244
+ this.#element = element;
245
+ this.#render("checking");
246
+ }
247
+
248
+ /**
249
+ * 当前安装状态与业务版本。
250
+ * Current installation state and business version.
251
+ */
252
+ get state() {
253
+ return { ...this.#state };
254
+ }
255
+
256
+ /**
257
+ * 每次进入重新探测,取消旧请求并忽略其迟到结果。
258
+ * Reprobe on entry, cancelling old requests and ignoring late results.
259
+ * @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
260
+ * @returns {Promise<void>} 探测完成 / Probe completion.
261
+ */
262
+ async check(url) {
263
+ this.#controller?.abort();
264
+ const controller = (this.#controller = new AbortController());
265
+ this.#render("checking");
266
+ const timer = setTimeout(() => controller.abort(), 3500);
267
+ try {
268
+ const response = await fetch(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
269
+ if (controller !== this.#controller) return;
270
+ const version = response.headers.get("X-PreferencePanes-Version")?.trim() || null;
271
+ this.#render(response.status === 200 ? "installed" : "missing", version);
272
+ } catch {
273
+ if (controller === this.#controller) this.#render("missing");
274
+ } finally {
275
+ clearTimeout(timer);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * 更新状态标签,缺少版本时不伪造版本号。
281
+ * Render the label without inventing a missing version.
282
+ * @param {"checking" | "installed" | "missing"} status 状态 / State.
283
+ * @param {string | null} [version] 业务版本 / Business version.
284
+ * @returns {void} 无返回值 / No return value.
285
+ */
286
+ #render(status, version = null) {
287
+ this.#state = { status, version: status === "installed" ? version : null };
288
+ switch (status) {
289
+ case "checking":
290
+ this.#element.textContent = "检测中";
291
+ break;
292
+ case "installed":
293
+ this.#element.textContent = version ?? "版本未知";
294
+ break;
295
+ case "missing":
296
+ this.#element.textContent = "未安装";
297
+ break;
298
+ }
299
+ this.#element.dataset.state = status;
300
+ this.#element.title = this.#element.textContent;
301
+ this.dispatchEvent(new Event("change"));
302
+ }
303
+
304
+ /**
305
+ * 释放尚未完成的探测。
306
+ * Release pending probes.
307
+ * @returns {void} 无返回值 / No return value.
308
+ */
309
+ destroy() {
310
+ this.#controller?.abort();
311
+ this.#controller = undefined;
312
+ }
313
+ }
314
+
101
315
  /**
102
316
  * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
103
317
  * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
@@ -257,4 +471,4 @@ class Navigation extends EventTarget {
257
471
  }
258
472
  }
259
473
 
260
- export { ModuleFrame, Navigation };
474
+ export { ActionMenu, ModuleFrame, ModuleStatus, Navigation };
@@ -1,12 +1,4 @@
1
- var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
2
-
3
- /**
4
- * 解析已经取得的 pathname,避免重复构造 URL。
5
- * Parse an existing pathname without constructing another URL.
6
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
7
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
8
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
9
- */
1
+ var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
10
2
 
11
3
  /**
12
4
  * 校验原始路径片段,不进行 URL 编码转换。
@@ -165,6 +157,122 @@ function errorView(error, retry) {
165
157
  return view;
166
158
  }
167
159
 
160
+ /**
161
+ * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
162
+ * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
163
+ */
164
+ class ActionMenu {
165
+ #button;
166
+ #popup;
167
+ #backdrop;
168
+ #select;
169
+ #document;
170
+ #key = event => {
171
+ if (event.key === "Escape" && !this.#popup.hidden) {
172
+ event.preventDefault();
173
+ this.close();
174
+ this.#button.focus();
175
+ }
176
+ };
177
+
178
+ /**
179
+ * 创建菜单,操作逻辑由调用方提供。
180
+ * Create a menu whose actions are handled by the caller.
181
+ * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
182
+ */
183
+ constructor(select) {
184
+ this.#document = document;
185
+ this.#select = select;
186
+ this.element = document.createElement("span");
187
+ const root = this.element.attachShadow({ mode: "open" });
188
+ root.innerHTML = `<style>
189
+ :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
190
+ :host([hidden]),[hidden]{display:none!important}
191
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
192
+ button:disabled{opacity:.4;cursor:default}
193
+ button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
194
+ #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
195
+ svg{display:block;width:24px;height:24px;fill:currentColor}
196
+ #backdrop{position:fixed;inset:0;z-index:1}
197
+ #items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
198
+ #items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
199
+ #items button:hover{background:#8882}
200
+ #items button[data-danger]{color:#e45656}
201
+ </style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
202
+ this.#button = root.querySelector("#trigger");
203
+ this.#popup = root.querySelector("#items");
204
+ this.#backdrop = root.querySelector("#backdrop");
205
+ this.#button.onclick = () => {
206
+ const open = this.#popup.hidden;
207
+ this.#popup.hidden = this.#backdrop.hidden = !open;
208
+ this.#button.setAttribute("aria-expanded", String(open));
209
+ if (open) this.#popup.firstElementChild.focus();
210
+ };
211
+ this.#backdrop.onclick = () => this.close();
212
+ this.#popup.onkeydown = event => {
213
+ if (event.key === "Tab") {
214
+ this.close();
215
+ return;
216
+ }
217
+ const items = [...this.#popup.children];
218
+ const index = items.indexOf(root.activeElement);
219
+ const offsets = { ArrowDown: 1, ArrowUp: -1 };
220
+ if (event.key in offsets) {
221
+ event.preventDefault();
222
+ items[(index + offsets[event.key] + items.length) % items.length].focus();
223
+ }
224
+ };
225
+ document.addEventListener("keydown", this.#key);
226
+ this.update([]);
227
+ }
228
+
229
+ /**
230
+ * 同步可用操作和忙碌状态,不重建菜单触发按钮。
231
+ * Update actions and busy state without replacing the trigger button.
232
+ * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
233
+ * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
234
+ * @returns {void} 无返回值 / No return value.
235
+ */
236
+ update(items, disabled = false) {
237
+ this.close();
238
+ this.#button.disabled = disabled || items.length === 0;
239
+ this.#popup.replaceChildren(
240
+ ...items.map(item => {
241
+ const button = this.#document.createElement("button");
242
+ button.type = "button";
243
+ button.setAttribute("role", "menuitem");
244
+ button.textContent = item.label;
245
+ button.toggleAttribute("data-danger", Boolean(item.destructive));
246
+ button.onclick = () => {
247
+ this.close();
248
+ this.#select(item.id);
249
+ };
250
+ return button;
251
+ }),
252
+ );
253
+ }
254
+
255
+ /**
256
+ * 关闭菜单。
257
+ * Close the menu.
258
+ * @returns {void} 无返回值 / No return value.
259
+ */
260
+ close() {
261
+ this.#popup.hidden = this.#backdrop.hidden = true;
262
+ this.#button.setAttribute("aria-expanded", "false");
263
+ }
264
+
265
+ /**
266
+ * 移除监听器与节点。
267
+ * Remove listeners and elements.
268
+ * @returns {void} 无返回值 / No return value.
269
+ */
270
+ destroy() {
271
+ this.#document.removeEventListener("keydown", this.#key);
272
+ this.element.remove();
273
+ }
274
+ }
275
+
168
276
  /**
169
277
  * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
170
278
  * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
@@ -304,31 +412,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
304
412
  */
305
413
  const sessions = new Map();
306
414
  /**
307
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
308
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
309
- * @param {string} path 相对请求路径 / Relative request path.
310
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
311
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
415
+ * form 发送完整存储键;读取 404 交给调用方处理。
416
+ * Send a complete storage key as form data; callers handle missing reads.
417
+ * @param {string} path 完整 @root.path / Complete @root.path.
418
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
419
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
312
420
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
313
421
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
314
422
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
315
423
  */
316
- async function send(path, method, body, signal) {
424
+ async function send(path, action, body, signal) {
317
425
  const controller = new AbortController();
318
426
  const abort = () => controller.abort();
319
427
  if (signal?.aborted) abort();
320
428
  signal?.addEventListener("abort", abort, { once: true });
321
429
  const timer = setTimeout(abort, timeout);
322
430
  try {
323
- const response = await request(path, {
324
- method,
431
+ const response = await request(`/api/${action}`, {
432
+ method: "POST",
325
433
  credentials: "omit",
326
434
  cache: "no-store",
327
435
  signal: controller.signal,
328
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
329
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
436
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
437
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
330
438
  });
331
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
439
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
332
440
  return response;
333
441
  } finally {
334
442
  clearTimeout(timer);
@@ -352,21 +460,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
352
460
  * Serialize single-key mutations and update a still-active session only after success.
353
461
  * @param {string} module 已打开模块 / Open module.
354
462
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
355
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
463
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
356
464
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
357
465
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
358
466
  * @returns {Promise<void>} 操作完成 / Operation completion.
359
467
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
360
468
  */
361
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
469
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
362
470
  const state = sessions.get(module);
363
471
  if (!state?.definition) throw new Error("Open the module first");
364
472
  if (state.saving) throw new Error("A settings write is already in progress");
365
473
  const field = state.definition.fields.find(field => field.key === key);
366
474
  state.saving = true;
367
475
  try {
368
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
369
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
476
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
477
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
370
478
  if (sessions.get(module) === state) {
371
479
  switch (operation) {
372
480
  case "write":
@@ -409,7 +517,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
409
517
  sessions.set(module, state);
410
518
  try {
411
519
  const definition = normalizeBoxJs(catalog.select(module), module);
412
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
520
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
413
521
  let subtree = response.status === 404 ? {} : await response.json();
414
522
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
415
523
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -439,7 +547,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
439
547
  async readCaches(module) {
440
548
  const state = sessions.get(module);
441
549
  if (!state?.definition) throw new Error("Open the module first");
442
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
550
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
443
551
  return response.status === 404 ? undefined : response.json();
444
552
  },
445
553
  /**
@@ -448,14 +556,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
448
556
  * @param {string} module 已打开模块 / Open module.
449
557
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
450
558
  */
451
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
559
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
452
560
  /**
453
561
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
454
562
  * Delete module persistence and reset the page cache using current BoxJS defaults.
455
563
  * @param {string} module 已打开模块 / Open module.
456
564
  * @returns {Promise<void>} 重置完成 / Reset completion.
457
565
  */
458
- reset: module => change(module, module, "DELETE", undefined, "reset"),
566
+ reset: module => change(module, module, "delete", undefined, "reset"),
459
567
  /**
460
568
  * 取消读取并清除会话,不撤销已发送的写入。
461
569
  * Abort reads and clear the session without undoing dispatched writes.
@@ -474,7 +582,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
474
582
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
475
583
  * @returns {Promise<void>} 写入完成 / Write completion.
476
584
  */
477
- set: (module, key, value) => change(module, key, "POST", value),
585
+ set: (module, key, value) => change(module, key, "set", value),
478
586
  /**
479
587
  * 删除单键覆盖值并显示默认值。
480
588
  * Delete one override and display its default value.
@@ -482,7 +590,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
482
590
  * @param {string} key 点分字段路径 / Dotted field path.
483
591
  * @returns {Promise<void>} 删除完成 / Delete completion.
484
592
  */
485
- remove: (module, key) => change(module, key, "DELETE"),
593
+ remove: (module, key) => change(module, key, "delete"),
486
594
  };
487
595
  }
488
596
 
@@ -663,6 +771,15 @@ function mountPanel(root, catalog) {
663
771
  back.setAttribute("aria-label", "返回");
664
772
  back.type = "button";
665
773
  const heading = element("h1", "pp-title", title);
774
+ const handlers = new Map();
775
+ const menuItems = [
776
+ { id: "viewCaches", label: "查看缓存" },
777
+ { id: "clearCaches", label: "清空缓存", destructive: true },
778
+ { id: "reset", label: "重置模块", destructive: true },
779
+ ];
780
+ const menu = new ActionMenu(id => handlers.get(id)());
781
+ const trailing = element("span", "pp-nav-spacer");
782
+ trailing.append(menu.element);
666
783
  const brand = element("div", "pp-brand");
667
784
  const logo = element("span", "pp-brand-icon");
668
785
  logo.setAttribute("aria-hidden", "true");
@@ -673,20 +790,26 @@ function mountPanel(root, catalog) {
673
790
  const toast = element("div", "pp-toast");
674
791
  toast.setAttribute("role", "status");
675
792
  toast.hidden = true;
676
- header.append(back, brand, element("span", "pp-nav-spacer"));
793
+ header.append(back, brand, trailing);
677
794
  shell.append(header, viewport, toast);
678
795
  root.append(shell);
679
796
  // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
680
797
  // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
681
798
  const publishNavigation = () => {
799
+ const actions = handlers.size ? menuItems : [];
800
+ menu.update(actions, saving);
682
801
  const frame = window.frameElement;
683
802
  if (!frame?.dataset.preferencePanes) return;
684
803
  frame.dispatchEvent(
685
804
  new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
686
- detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
805
+ detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
687
806
  }),
688
807
  );
689
808
  };
809
+ const onAction = event => {
810
+ if (!saving && handlers.has(event.detail)) handlers.get(event.detail)();
811
+ };
812
+ window.frameElement?.addEventListener("preferencepanes:action", onAction);
690
813
  let timer,
691
814
  navigation,
692
815
  generation = 0,
@@ -996,17 +1119,13 @@ function mountPanel(root, catalog) {
996
1119
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
997
1120
  groups.get(group).append(row);
998
1121
  }
999
- const maintenance = element("section", "pp-maintenance");
1000
- maintenance.append(element("h2", "pp-title", "模块数据"));
1001
- const actions = element("div", "pp-actions");
1002
- const cacheView = element("button", "", "查看 Caches");
1003
- const cacheClear = element("button", "", "清空 Caches");
1004
- const reset = element("button", "pp-danger", "重置模块");
1122
+ const cachePage = element("section", "pp-cache-page");
1005
1123
  const output = element("pre", "pp-cache");
1006
- output.hidden = true;
1124
+ output.textContent = "暂无缓存";
1007
1125
  output.setAttribute("aria-label", "Caches 内容");
1008
- for (const button of [cacheView, cacheClear, reset]) button.type = "button";
1009
- cacheView.onclick = () => {
1126
+ cachePage.append(output);
1127
+ editors.set("$caches", { node: cachePage, title: "缓存" });
1128
+ handlers.set("viewCaches", () => {
1010
1129
  if (saving) return;
1011
1130
  let value;
1012
1131
  return perform(
@@ -1020,12 +1139,11 @@ function mountPanel(root, catalog) {
1020
1139
  },
1021
1140
  () => {
1022
1141
  output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
1023
- output.hidden = false;
1024
- cacheView.textContent = "刷新 Caches";
1142
+ navigation.open("$caches");
1025
1143
  },
1026
1144
  );
1027
- };
1028
- cacheClear.onclick = () => {
1145
+ });
1146
+ handlers.set("clearCaches", () => {
1029
1147
  if (saving) return;
1030
1148
  if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
1031
1149
  return perform(
@@ -1034,15 +1152,12 @@ function mountPanel(root, catalog) {
1034
1152
  output.textContent = "暂无缓存";
1035
1153
  },
1036
1154
  );
1037
- };
1038
- reset.onclick = () => {
1155
+ });
1156
+ handlers.set("reset", () => {
1039
1157
  if (saving) return;
1040
1158
  if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
1041
1159
  return perform(() => client.reset(active), controls);
1042
- };
1043
- actions.append(cacheView, cacheClear, reset);
1044
- maintenance.append(actions, output);
1045
- view.append(maintenance);
1160
+ });
1046
1161
  navigation?.destroy();
1047
1162
  navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
1048
1163
  navigation.addEventListener("change", updateNavigation);
@@ -1068,6 +1183,8 @@ function mountPanel(root, catalog) {
1068
1183
  */
1069
1184
  destroy() {
1070
1185
  destroyed = true;
1186
+ menu.destroy();
1187
+ window.frameElement?.removeEventListener("preferencepanes:action", onAction);
1071
1188
  navigation?.destroy();
1072
1189
  generation++;
1073
1190
  if (active && !saving) client.leave(active);