@nsnanocat/preference-panes 1.2.5 → 1.3.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,152 @@
1
+ /**
2
+ * 共用菜单按钮和底部操作菜单;原生宿主可通过 MORE 调用。
3
+ * Shared menu trigger and bottom action sheet; native hosts may invoke it through MORE.
4
+ */
5
+ class ActionMenu {
6
+ #button;
7
+ #layer;
8
+ #items;
9
+ #select;
10
+ #document;
11
+ #disabled = true;
12
+ #key = event => {
13
+ if (event.key === "Escape" && !this.#layer.hidden) {
14
+ event.preventDefault();
15
+ this.close();
16
+ this.#button.focus();
17
+ }
18
+ };
19
+
20
+ /**
21
+ * 创建菜单,操作逻辑由调用方提供。
22
+ * Create a menu whose actions are handled by the caller.
23
+ * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
24
+ */
25
+ constructor(select) {
26
+ this.#document = document;
27
+ this.#select = select;
28
+ this.element = document.createElement("span");
29
+ const triggerRoot = this.element.attachShadow({ mode: "open" });
30
+ triggerRoot.innerHTML = `<style>
31
+ :host{display:inline-flex;flex:none;width:37px;height:37px;color:inherit}
32
+ :host([hidden]){display:none!important}
33
+ button{box-sizing:border-box;width:37px;height:37px;padding:8px;border-radius:4px;font:inherit;cursor:pointer;border:0;color:inherit;background:none;transition:background-color .3s}
34
+ button:hover{background:var(--pp-field,#8882)}
35
+ button:disabled{opacity:.4;cursor:default}
36
+ button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
37
+ svg{display:block;width:21px;height:21px;fill:none}
38
+ </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 21 21" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.667" d="M3.645 5.225h13.333M3.645 10.225h13.333M3.645 15.225h13.333"/></svg></button>`;
39
+ this.#button = triggerRoot.querySelector("button");
40
+ this.#layer = document.createElement("span");
41
+ const layerRoot = this.#layer.attachShadow({ mode: "open" });
42
+ layerRoot.innerHTML = `<style>
43
+ :host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
44
+ :host([hidden]){display:none!important}
45
+ *,*::before,*::after{box-sizing:border-box}
46
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
47
+ button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
48
+ #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
49
+ #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
50
+ #items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
51
+ #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
52
+ #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
53
+ #items button+button{border-top:1px solid var(--pp-border,#8884)}
54
+ #items button[data-danger]{color:var(--pp-danger,#e45656)}
55
+ #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
56
+ @keyframes pp-fade-in{from{opacity:0}}
57
+ @keyframes pp-sheet-in{from{transform:translateY(100%)}}
58
+ @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
59
+ </style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
60
+ this.#items = layerRoot.querySelector("#items");
61
+ this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
62
+ layerRoot.querySelector("#backdrop").onclick = () => {
63
+ this.close();
64
+ this.#button.focus();
65
+ };
66
+ layerRoot.querySelector("#cancel").onclick = () => {
67
+ this.close();
68
+ this.#button.focus();
69
+ };
70
+ this.#items.onkeydown = event => {
71
+ const items = [...this.#items.children];
72
+ const index = items.indexOf(layerRoot.activeElement);
73
+ const offsets = { ArrowDown: 1, ArrowUp: -1 };
74
+ if (event.key in offsets) {
75
+ event.preventDefault();
76
+ items[(index + offsets[event.key] + items.length) % items.length].focus();
77
+ }
78
+ };
79
+ document.body.append(this.#layer);
80
+ document.addEventListener("keydown", this.#key);
81
+ this.update([]);
82
+ }
83
+
84
+ /**
85
+ * 同步可用操作和忙碌状态,不重建菜单触发按钮。
86
+ * Update actions and busy state without replacing the trigger button.
87
+ * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
88
+ * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
89
+ * @returns {void} 无返回值 / No return value.
90
+ */
91
+ update(items, disabled = false) {
92
+ this.close();
93
+ this.#disabled = disabled || items.length === 0;
94
+ this.#button.disabled = this.#disabled;
95
+ this.#items.replaceChildren(
96
+ ...items.map(item => {
97
+ const button = this.#document.createElement("button");
98
+ button.type = "button";
99
+ button.setAttribute("role", "menuitem");
100
+ button.textContent = item.label;
101
+ button.toggleAttribute("data-danger", Boolean(item.destructive));
102
+ button.onclick = () => {
103
+ this.close();
104
+ this.#select(item.id);
105
+ };
106
+ return button;
107
+ }),
108
+ );
109
+ }
110
+
111
+ /**
112
+ * 打开当前操作菜单。
113
+ * Open the current action sheet.
114
+ * @returns {void} 无返回值 / No return value.
115
+ */
116
+ open() {
117
+ if (this.#disabled) return;
118
+ const style = getComputedStyle(this.element);
119
+ for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
120
+ const value = style.getPropertyValue(property);
121
+ if (value) this.#layer.style.setProperty(property, value);
122
+ }
123
+ this.#layer.hidden = false;
124
+ this.#button.setAttribute("aria-expanded", "true");
125
+ this.#items.firstElementChild.focus();
126
+ }
127
+
128
+ /**
129
+ * 关闭菜单。
130
+ * Close the menu.
131
+ * @returns {void} 无返回值 / No return value.
132
+ */
133
+ close() {
134
+ this.#layer.hidden = true;
135
+ this.#button.setAttribute("aria-expanded", "false");
136
+ }
137
+
138
+ /**
139
+ * 移除监听器与节点。
140
+ * Remove listeners and elements.
141
+ * @returns {void} 无返回值 / No return value.
142
+ */
143
+ destroy() {
144
+ this.#document.removeEventListener("keydown", this.#key);
145
+ this.#layer.remove();
146
+ this.element.remove();
147
+ }
148
+ }
149
+
1
150
  /**
2
151
  * 校验原始路径片段,不进行 URL 编码转换。
3
152
  * Validate raw path segments without URL encoding conversion.
@@ -27,6 +176,7 @@ function normalizeBoxJs(config, module) {
27
176
  if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
28
177
  for (const entry of app.settings) {
29
178
  if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
179
+ entry.id = entry.id.replace(/\[(0|[1-9]\d*)\]$/, ".$1");
30
180
  if (!entry.id.startsWith("@")) {
31
181
  if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
32
182
  continue;
@@ -68,7 +218,7 @@ function normalizeBoxJs(config, module) {
68
218
  };
69
219
  if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
70
220
  if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
71
- if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
221
+ if (Object.prototype.hasOwnProperty.call(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
72
222
  if (
73
223
  typeof field.name !== "string" ||
74
224
  (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
@@ -78,18 +228,42 @@ function normalizeBoxJs(config, module) {
78
228
  )
79
229
  throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
80
230
  if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
81
- if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
82
- if (field.control === "url" && !Object.hasOwn(field, "defaultValue")) throw new TypeError(`URL BoxJS settings require a val: ${entry.id}`);
231
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
232
+ if (field.control === "url" && !Object.prototype.hasOwnProperty.call(field, "defaultValue")) throw new TypeError(`URL BoxJS settings require a val: ${entry.id}`);
83
233
  fields.push(field);
84
234
  }
85
235
  if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
86
- const common = fields[0].key.split(".").slice(0, -1);
236
+ for (const field of fields) {
237
+ const indexed = /^(.*)\.(\d+)$/.exec(field.key);
238
+ if (!indexed) continue;
239
+ const siblings = fields.filter(candidate => candidate.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(candidate.key.slice(indexed[1].length + 1)));
240
+ if (fields.some(candidate => candidate.key.startsWith(`${indexed[1]}.`) && !siblings.includes(candidate))) throw new TypeError(`Indexed settings cannot mix array entries and named children: ${indexed[1]}`);
241
+ if (siblings.some(candidate => !Object.prototype.hasOwnProperty.call(candidate, "defaultValue")) || !siblings.some(candidate => candidate.key === `${indexed[1]}.${siblings.length - 1}`) || siblings.some(candidate => Number(candidate.key.slice(indexed[1].length + 1)) >= siblings.length))
242
+ throw new TypeError(`Indexed settings require contiguous indices and defaults: ${indexed[1]}`);
243
+ }
244
+ const owner = target.owners.size === 1 ? [...target.owners][0] : undefined;
245
+ const cachePath = owner?.cachePath ?? `@${target.storageKey}.${target.module}.Caches`;
246
+ if (typeof cachePath !== "string" || !cachePath.startsWith(`@${target.storageKey}.`)) throw new TypeError("cachePath must use the module storage root");
247
+ const cacheParts = validatePathParts(cachePath.slice(target.storageKey.length + 2).split("."));
248
+ if (cacheParts.length !== 2 || cacheParts[1] !== "Caches") throw new TypeError("cachePath must identify a module Caches subtree");
249
+ if (owner?.resetPaths !== undefined) {
250
+ if (!Array.isArray(owner.resetPaths) || !owner.resetPaths.length) throw new TypeError("resetPaths must be a nonempty array");
251
+ for (const path of owner.resetPaths) {
252
+ if (typeof path !== "string" || !path.startsWith(`@${target.storageKey}.${target.module}.Settings.`)) throw new TypeError("resetPaths must stay in this module's Settings");
253
+ validatePathParts(path.slice(target.storageKey.length + 2).split("."));
254
+ }
255
+ if (fields.some(field => !owner.resetPaths.some(path => `@${target.storageKey}.${field.key}` === path || `@${target.storageKey}.${field.key}`.startsWith(`${path}.`)))) throw new TypeError("resetPaths must cover the page settings");
256
+ }
257
+ const firstIndexed = /^(.*)\.(\d+)$/.exec(fields[0].key);
258
+ const common = (firstIndexed ? firstIndexed[1] : fields[0].key).split(".").slice(0, -1);
87
259
  for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
88
260
  return {
89
261
  module: target.module,
90
262
  storageKey: target.storageKey,
91
263
  fields,
92
264
  settingsPath: common,
265
+ ...(owner?.cachePath === undefined ? {} : { cachePath }),
266
+ ...(owner?.resetPaths === undefined ? {} : { resetPaths: [...owner.resetPaths] }),
93
267
  ...(Object.keys(metadata).length ? { metadata } : {}),
94
268
  };
95
269
  }
@@ -179,9 +353,14 @@ function scalar(value) {
179
353
  * @returns {boolean} 是否可由控件表示 / Whether the control can represent the value.
180
354
  */
181
355
  function validValueShape(field, value) {
182
- if (field.type === "array") {
183
- if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
184
- } else if (typeof value !== field.type || !scalar(value)) return false;
356
+ switch (field.type) {
357
+ case "array":
358
+ if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
359
+ break;
360
+ default:
361
+ if (typeof value !== field.type || !scalar(value)) return false;
362
+ break;
363
+ }
185
364
  if (field.control === "url" && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(?:javascript|data|vbscript):/i.test(value))) return false;
186
365
  return true;
187
366
  }
@@ -211,7 +390,7 @@ function resolveStoredValue(field, stored) {
211
390
  if (value === undefined) return {};
212
391
  if (!validValueShape(field, value)) {
213
392
  return {
214
- ...(Object.hasOwn(field, "defaultValue") ? { value: field.defaultValue } : {}),
393
+ ...(Object.prototype.hasOwnProperty.call(field, "defaultValue") ? { value: field.defaultValue } : {}),
215
394
  warning: { kind: "invalid-value", values: [stored] },
216
395
  };
217
396
  }
@@ -268,18 +447,6 @@ function fieldControl(control) {
268
447
  return control;
269
448
  }
270
449
 
271
- /**
272
- * 元数据地址只允许 HTTP(S) 和相对地址。
273
- * Allow only HTTP(S) and relative metadata addresses.
274
- * @param {string} value 元数据地址 / Metadata address.
275
- * @returns {string} 完整地址 / Absolute address.
276
- */
277
- function resourceURL(value) {
278
- const url = new URL(value, document.baseURI);
279
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
280
- return url.href;
281
- }
282
-
283
450
  /**
284
451
  * 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
285
452
  * Create a shared status view that fills the available content area and may include retry.
@@ -304,182 +471,191 @@ function statusView(message, retry) {
304
471
  }
305
472
 
306
473
  /**
307
- * 请求宿主确认;独立网页使用浏览器对话框。
308
- * Request confirmation from the host, using the browser dialog for standalone pages.
309
- * @param {Window} host 模块窗口 / Module window.
310
- * @param {string} message 确认内容 / Confirmation message.
311
- * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
474
+ * 同一文档内的首页、模块及字段子页导航。
475
+ * Navigate homepage, module and field views within one document.
312
476
  */
313
- function requestConfirmation(host, message) {
314
- return new Promise((resolve, reject) => {
315
- const frame = host.frameElement;
316
- if (frame) {
317
- const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
318
- if (!frame.dispatchEvent(event)) return;
319
- }
320
- resolve(host.confirm(message));
321
- });
322
- }
477
+ class Navigation extends EventTarget {
478
+ #container;
479
+ #home;
480
+ #create;
481
+ #window;
482
+ #key = null;
483
+ #view;
484
+ #retiring;
485
+ #controller;
486
+ #animation;
487
+ #scroll = new Map();
488
+ #onHistory = () => this.#route();
489
+ #onPageShow = event => {
490
+ if (event.persisted) this.#route(true);
491
+ };
323
492
 
324
- /**
325
- * 请求宿主打开 URL;未被接管时保留链接默认导航。
326
- * Request host URL navigation while preserving the link default when unhandled.
327
- * @param {Window} host 模块窗口 / Module window.
328
- * @param {string} url 完整目标地址 / Absolute target URL.
329
- * @returns {boolean} 宿主是否接管导航 / Whether the host accepted navigation ownership.
330
- */
331
- function requestURLNavigation(host, url) {
332
- const frame = host.frameElement;
333
- if (!frame) return false;
334
- const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:open-url", { cancelable: true, detail: { url } });
335
- return !frame.dispatchEvent(event);
336
- }
493
+ /**
494
+ * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
495
+ * Retain the home view and create details on demand; signal cancels async work after departure.
496
+ * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
497
+ * @param {HTMLElement} home 已创建的主页节点 / Existing home view.
498
+ * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
499
+ */
500
+ constructor(container, home, create) {
501
+ super();
502
+ this.#container = container;
503
+ this.#home = home;
504
+ this.#create = create;
505
+ this.#window = container.ownerDocument.defaultView;
506
+ container.replaceChildren(home);
507
+ this.#window.addEventListener("popstate", this.#onHistory);
508
+ this.#window.addEventListener("hashchange", this.#onHistory);
509
+ this.#window.addEventListener("pageshow", this.#onPageShow);
510
+ this.#route();
511
+ }
337
512
 
338
- /**
339
- * 独立页面的三点按钮和底部操作菜单;嵌入页面由宿主提供对应界面。
340
- * Overflow trigger and bottom action sheet for standalone pages; embedded pages use host-provided chrome.
341
- */
342
- class ActionMenu {
343
- #button;
344
- #layer;
345
- #items;
346
- #select;
347
- #document;
348
- #disabled = true;
349
- #key = event => {
350
- if (event.key === "Escape" && !this.#layer.hidden) {
351
- event.preventDefault();
352
- this.close();
353
- this.#button.focus();
354
- }
355
- };
513
+ /**
514
+ * 当前子页键;空字符串表示主页。
515
+ * Current detail key; empty means home.
516
+ */
517
+ get current() {
518
+ return this.#key;
519
+ }
356
520
 
357
521
  /**
358
- * 创建菜单,操作逻辑由调用方提供。
359
- * Create a menu whose actions are handled by the caller.
360
- * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
522
+ * 是否可以返回上一级或先前文档。
523
+ * Whether a parent view or previous document is available.
361
524
  */
362
- constructor(select) {
363
- this.#document = document;
364
- this.#select = select;
365
- this.element = document.createElement("span");
366
- const triggerRoot = this.element.attachShadow({ mode: "open" });
367
- triggerRoot.innerHTML = `<style>
368
- :host{display:inline-flex;width:44px;height:44px;color:inherit}
369
- :host([hidden]){display:none!important}
370
- button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
371
- button:disabled{opacity:.4;cursor:default}
372
- button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
373
- svg{display:block;width:24px;height:24px;fill:currentColor}
374
- </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><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>`;
375
- this.#button = triggerRoot.querySelector("button");
376
- this.#layer = document.createElement("span");
377
- const layerRoot = this.#layer.attachShadow({ mode: "open" });
378
- layerRoot.innerHTML = `<style>
379
- :host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
380
- :host([hidden]){display:none!important}
381
- *,*::before,*::after{box-sizing:border-box}
382
- button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
383
- button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
384
- #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
385
- #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
386
- #items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
387
- #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
388
- #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
389
- #items button+button{border-top:1px solid var(--pp-border,#8884)}
390
- #items button[data-danger]{color:var(--pp-danger,#e45656)}
391
- #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
392
- @keyframes pp-fade-in{from{opacity:0}}
393
- @keyframes pp-sheet-in{from{transform:translateY(100%)}}
394
- @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
395
- </style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
396
- this.#items = layerRoot.querySelector("#items");
397
- this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
398
- layerRoot.querySelector("#backdrop").onclick = () => {
399
- this.close();
400
- this.#button.focus();
401
- };
402
- layerRoot.querySelector("#cancel").onclick = () => {
403
- this.close();
404
- this.#button.focus();
405
- };
406
- this.#items.onkeydown = event => {
407
- const items = [...this.#items.children];
408
- const index = items.indexOf(layerRoot.activeElement);
409
- const offsets = { ArrowDown: 1, ArrowUp: -1 };
410
- if (event.key in offsets) {
411
- event.preventDefault();
412
- items[(index + offsets[event.key] + items.length) % items.length].focus();
413
- }
414
- };
415
- document.body.append(this.#layer);
416
- document.addEventListener("keydown", this.#key);
417
- this.update([]);
525
+ get canGoBack() {
526
+ return Boolean(this.#key) || this.#window.history.length > 1;
418
527
  }
419
528
 
420
529
  /**
421
- * 同步可用操作和忙碌状态,不重建菜单触发按钮。
422
- * Update actions and busy state without replacing the trigger button.
423
- * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
424
- * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
530
+ * 加入模块或字段子页历史,保留路由中的层级分隔符。
531
+ * Push a module or field view while retaining route hierarchy separators.
532
+ * @param {string} key 子页键 / Detail key.
425
533
  * @returns {void} 无返回值 / No return value.
426
534
  */
427
- update(items, disabled = false) {
428
- this.close();
429
- this.#disabled = disabled || items.length === 0;
430
- this.#button.disabled = this.#disabled;
431
- this.#items.replaceChildren(
432
- ...items.map(item => {
433
- const button = this.#document.createElement("button");
434
- button.type = "button";
435
- button.setAttribute("role", "menuitem");
436
- button.textContent = item.label;
437
- button.toggleAttribute("data-danger", Boolean(item.destructive));
438
- button.onclick = () => {
439
- this.close();
440
- this.#select(item.id);
441
- };
442
- return button;
443
- }),
444
- );
535
+ open(key) {
536
+ if (key === this.#key) return;
537
+ const url = new URL(this.#window.location.href);
538
+ url.hash = key.split("/").map(encodeURIComponent).join("/");
539
+ this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
540
+ this.#route();
445
541
  }
446
542
 
447
543
  /**
448
- * 打开当前操作菜单。
449
- * Open the current action sheet.
544
+ * 沿文档历史返回,根页可退回上个文档。
545
+ * Go back through document history, including the previous document from home.
450
546
  * @returns {void} 无返回值 / No return value.
451
547
  */
452
- open() {
453
- if (this.#disabled) return;
454
- const style = getComputedStyle(this.element);
455
- for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
456
- const value = style.getPropertyValue(property);
457
- if (value) this.#layer.style.setProperty(property, value);
458
- }
459
- this.#layer.hidden = false;
460
- this.#button.setAttribute("aria-expanded", "true");
461
- this.#items.firstElementChild.focus();
548
+ back() {
549
+ if (this.canGoBack) this.#window.history.back();
462
550
  }
463
551
 
464
552
  /**
465
- * 关闭菜单。
466
- * Close the menu.
553
+ * 更新当前页的挂载视图,保留路由和滚动位置。
554
+ * Refresh the mounted view while retaining route and scroll position.
467
555
  * @returns {void} 无返回值 / No return value.
468
556
  */
469
- close() {
470
- this.#layer.hidden = true;
471
- this.#button.setAttribute("aria-expanded", "false");
557
+ refresh() {
558
+ this.#route(true);
472
559
  }
473
560
 
474
561
  /**
475
- * 移除监听器与节点。
476
- * Remove listeners and elements.
562
+ * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
563
+ * Resolve the URL and coordinate transitions, cancellation and release after animation.
564
+ * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
565
+ * @returns {void} 无返回值 / No return value.
566
+ */
567
+ #route(reload = false) {
568
+ const url = new URL(this.#window.location.href);
569
+ let key;
570
+ try {
571
+ key = decodeURIComponent(url.hash.slice(1));
572
+ } catch (error) {
573
+ if (!(error instanceof URIError)) throw error;
574
+ key = "";
575
+ }
576
+ if (!reload && key === this.#key) return;
577
+ if (key !== this.#key) this.#scroll.set(this.#key, (this.#view ?? this.#home).scrollTop);
578
+ this.#controller?.abort();
579
+ this.#controller = new AbortController();
580
+ const next = key ? this.#create(key, this.#controller.signal) : undefined;
581
+ if (!next) key = "";
582
+ const history = this.#window.history;
583
+ // 直接打开字段页时补齐首页和模块历史;刷新不重复堆叠。
584
+ // Seed homepage and module history for direct field links without stacking on reload.
585
+ if (url.hash && history.state?.preferencePanesRoute !== key) {
586
+ url.hash = "";
587
+ history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
588
+ if (key) {
589
+ const module = key.split("/")[0];
590
+ if (key.includes("/") && module !== "$link") {
591
+ url.hash = encodeURIComponent(module);
592
+ history.pushState({ ...history.state, preferencePanesRoute: module }, "", url.href);
593
+ }
594
+ url.hash = key.split("/").map(encodeURIComponent).join("/");
595
+ history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
596
+ }
597
+ }
598
+ const previous = this.#view;
599
+ if (previous === next && this.#key === key) {
600
+ if (previous && this.#scroll.has(key)) {
601
+ previous.scrollTop = this.#scroll.get(key);
602
+ this.#scroll.delete(key);
603
+ }
604
+ this.dispatchEvent(new Event("change"));
605
+ return;
606
+ }
607
+ const scroll = this.#scroll.get(key) ?? (key === this.#key ? (previous?.scrollTop ?? 0) : 0);
608
+ const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
609
+ this.#animation?.cancel();
610
+ this.#retiring?.remove();
611
+ this.#retiring = previous;
612
+ if (previous) {
613
+ previous.inert = true;
614
+ }
615
+ this.#key = key;
616
+ this.#view = next;
617
+ this.#home.inert = Boolean(next);
618
+ if (next) {
619
+ next.inert = false;
620
+ this.#container.append(next);
621
+ next.scrollTop = scroll;
622
+ // 加载占位页可能无法滚到原位置;配置与控件就绪后的 refresh 再恢复一次。
623
+ // A loading placeholder may clamp scrolling; restore once more when config and controls refresh.
624
+ if (next.scrollTop === scroll) this.#scroll.delete(key);
625
+ }
626
+ if (!next) {
627
+ this.#home.scrollTop = this.#scroll.get("") ?? 0;
628
+ this.#scroll.delete("");
629
+ }
630
+ const moving = next ?? previous;
631
+ if (moving) {
632
+ const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
633
+ this.#animation = animation;
634
+ animation.onfinish = () => {
635
+ if (this.#animation !== animation) return;
636
+ this.#retiring?.remove();
637
+ this.#retiring = undefined;
638
+ animation.cancel();
639
+ this.#animation = undefined;
640
+ };
641
+ }
642
+ this.dispatchEvent(new Event("change"));
643
+ }
644
+
645
+ /**
646
+ * 释放监听器、加载、动画和节点;调用方可重新创建导航。
647
+ * Release listeners, loads, animations and nodes so callers can recreate navigation.
477
648
  * @returns {void} 无返回值 / No return value.
478
649
  */
479
650
  destroy() {
480
- this.#document.removeEventListener("keydown", this.#key);
481
- this.#layer.remove();
482
- this.element.remove();
651
+ this.#window.removeEventListener("popstate", this.#onHistory);
652
+ this.#window.removeEventListener("hashchange", this.#onHistory);
653
+ this.#window.removeEventListener("pageshow", this.#onPageShow);
654
+ this.#controller?.abort();
655
+ this.#animation?.cancel();
656
+ this.#retiring?.remove();
657
+ this.#view?.remove();
658
+ this.#home.remove();
483
659
  }
484
660
  }
485
661
 
@@ -497,6 +673,7 @@ class PreferencesClient {
497
673
  #values = {};
498
674
  #warnings = {};
499
675
  #saving = false;
676
+ #arrays = new Map();
500
677
 
501
678
  /**
502
679
  * 创建从 BoxJS 定义读取和持久化设置的页面客户端。
@@ -523,13 +700,22 @@ class PreferencesClient {
523
700
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
524
701
  const values = {};
525
702
  const warnings = {};
703
+ this.#arrays.clear();
526
704
  for (const field of this.#definition.fields) {
705
+ const indexed = /^(.*)\.(\d+)$/.exec(field.key);
706
+ if (indexed && !this.#arrays.has(indexed[1])) {
707
+ const storedArray = indexed[1]
708
+ .split(".")
709
+ .slice(this.#definition.settingsPath.length)
710
+ .reduce((parent, part) => Object(parent)[part], subtree);
711
+ this.#arrays.set(indexed[1], Array.isArray(storedArray) ? [...storedArray] : []);
712
+ }
527
713
  const stored = field.key
528
714
  .split(".")
529
715
  .slice(this.#definition.settingsPath.length)
530
716
  .reduce((parent, part) => Object(parent)[part], subtree);
531
717
  const resolved = resolveStoredValue(field, stored);
532
- if (Object.hasOwn(resolved, "value")) values[field.key] = resolved.value;
718
+ if (Object.prototype.hasOwnProperty.call(resolved, "value")) values[field.key] = resolved.value;
533
719
  if (resolved.warning) warnings[field.key] = resolved.warning;
534
720
  }
535
721
  this.#values = values;
@@ -543,7 +729,7 @@ class PreferencesClient {
543
729
  * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
544
730
  */
545
731
  snapshot() {
546
- return structuredClone({ definition: this.#definition, values: this.#values, warnings: this.#warnings });
732
+ return JSON.parse(JSON.stringify({ definition: this.#definition, values: this.#values, warnings: this.#warnings }));
547
733
  }
548
734
 
549
735
  /**
@@ -562,22 +748,23 @@ class PreferencesClient {
562
748
  * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
563
749
  */
564
750
  async readCaches() {
565
- const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
751
+ const response = await this.#send("get", this.#definition.cachePath ?? `@${this.#definition.storageKey}.${this.#module}.Caches`);
566
752
  return response.status === 404 ? undefined : response.json();
567
753
  }
568
754
 
569
755
  /**
570
- * 删除当前模块的 Caches 子树。
571
- * Delete the current module Caches subtree.
756
+ * 删除声明的缓存子树,默认当前模块 Caches。
757
+ * Delete the declared cache subtree, defaulting to the current module Caches.
572
758
  * @returns {Promise<void>} 操作完成 / Operation completion.
573
759
  */
574
760
  clearCaches() {
575
- return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
761
+ const path = this.#definition.cachePath?.slice(this.#definition.storageKey.length + 2) ?? `${this.#module}.Caches`;
762
+ return this.#change("delete", path, undefined, "clearCaches");
576
763
  }
577
764
 
578
765
  /**
579
- * 删除当前模块数据并恢复页面默认值。
580
- * Delete current module data and restore page defaults.
766
+ * 删除声明的设置子树,默认删除当前模块数据,并恢复页面默认值。
767
+ * Delete declared settings subtrees, defaulting to current module data, and restore page defaults.
581
768
  * @returns {Promise<void>} 操作完成 / Operation completion.
582
769
  */
583
770
  reset() {
@@ -585,8 +772,8 @@ class PreferencesClient {
585
772
  }
586
773
 
587
774
  /**
588
- * 终止当前页面仍在进行的请求。
589
- * Abort requests still owned by the current page.
775
+ * 终止当前页面的读取;已开始的持久化写入继续完成。
776
+ * Abort reads owned by the current page while allowing started persistence writes to finish.
590
777
  * @returns {void} 无返回值 / No return value.
591
778
  */
592
779
  leave() {
@@ -625,8 +812,10 @@ class PreferencesClient {
625
812
  async #send(action, path, value) {
626
813
  const controller = new AbortController();
627
814
  const abort = () => controller.abort();
628
- if (this.#session.signal.aborted) abort();
629
- this.#session.signal.addEventListener("abort", abort, { once: true });
815
+ if (action === "get") {
816
+ if (this.#session.signal.aborted) abort();
817
+ this.#session.signal.addEventListener("abort", abort, { once: true });
818
+ }
630
819
  const timer = setTimeout(abort, this.#timeout);
631
820
  try {
632
821
  const response = await this.#request(`/api/${action}`, {
@@ -646,211 +835,71 @@ class PreferencesClient {
646
835
  }
647
836
 
648
837
  /**
649
- * 执行写入动作;成功后只更新当前页面值。
650
- * Execute a mutation and update only the current page values after success.
651
- * @param {"set" | "delete"} action API 动作 / API action.
652
- * @param {string} key 不含存储根的路径 / Path without the storage root.
653
- * @param {unknown} value set 写入值 / Value written by set.
654
- * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
655
- * @returns {Promise<void>} 操作完成 / Operation completion.
656
- */
657
- async #change(action, key, value, operation) {
658
- if (this.#saving) throw new Error("A settings write is already in progress");
659
- this.#saving = true;
660
- try {
661
- let field;
662
- if (operation === "write" || operation === "delete") {
663
- field = this.#definition.fields.find(candidate => candidate.key === key);
664
- if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
665
- }
666
- await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
667
- switch (operation) {
668
- case "write":
669
- this.#values[key] = structuredClone(value);
670
- delete this.#warnings[key];
671
- break;
672
- case "delete": {
673
- delete this.#values[key];
674
- delete this.#warnings[key];
675
- if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
676
- break;
677
- }
678
- case "clearCaches":
679
- break;
680
- case "reset":
681
- this.#warnings = {};
682
- for (const field of this.#definition.fields) {
683
- delete this.#values[field.key];
684
- if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
685
- }
686
- break;
687
- }
688
- this.#notify({ kind: "success", operation, module: this.#module, key });
689
- } catch (error) {
690
- this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
691
- throw error;
692
- } finally {
693
- this.#saving = false;
694
- }
695
- }
696
- }
697
-
698
- /**
699
- * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
700
- * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
701
- */
702
- class Navigation extends EventTarget {
703
- #container;
704
- #home;
705
- #create;
706
- #window;
707
- #key = null;
708
- #view;
709
- #retiring;
710
- #controller;
711
- #animation;
712
- #scroll = new WeakMap();
713
- #onHistory = () => this.#route();
714
- #onPageShow = event => {
715
- if (event.persisted) this.#route(true);
716
- };
717
-
718
- /**
719
- * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
720
- * Retain the home view and create details on demand; signal cancels async work after departure.
721
- * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
722
- * @param {HTMLElement} home 已创建的主页节点 / Existing home view.
723
- * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
724
- */
725
- constructor(container, home, create) {
726
- super();
727
- this.#container = container;
728
- this.#home = home;
729
- this.#create = create;
730
- this.#window = container.ownerDocument.defaultView;
731
- container.replaceChildren(home);
732
- this.#window.addEventListener("popstate", this.#onHistory);
733
- this.#window.addEventListener("hashchange", this.#onHistory);
734
- this.#window.addEventListener("pageshow", this.#onPageShow);
735
- this.#route();
736
- }
737
-
738
- /**
739
- * 当前子页键;空字符串表示主页。
740
- * Current detail key; empty means home.
741
- */
742
- get current() {
743
- return this.#key;
744
- }
745
-
746
- /**
747
- * 是否可以返回上一级或先前文档。
748
- * Whether a parent view or previous document is available.
749
- */
750
- get canGoBack() {
751
- return Boolean(this.#key) || this.#window.history.length > 1;
752
- }
753
-
754
- /**
755
- * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
756
- * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
757
- * @param {string} key 子页键 / Detail key.
758
- * @returns {void} 无返回值 / No return value.
759
- */
760
- open(key) {
761
- if (key === this.#key) return;
762
- const url = new URL(this.#window.location.href);
763
- url.hash = encodeURIComponent(key);
764
- this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
765
- this.#route();
766
- }
767
-
768
- /**
769
- * 沿浏览器联合历史返回,根页可退回宿主或上个文档。
770
- * Go back through joint history, including a host or previous document from home.
771
- * @returns {void} 无返回值 / No return value.
772
- */
773
- back() {
774
- if (this.canGoBack) this.#window.history.back();
775
- }
776
-
777
- /**
778
- * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
779
- * Resolve the URL and coordinate transitions, cancellation and release after animation.
780
- * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
781
- * @returns {void} 无返回值 / No return value.
838
+ * 执行写入动作;成功后只更新当前页面值。
839
+ * Execute a mutation and update only the current page values after success.
840
+ * @param {"set" | "delete"} action API 动作 / API action.
841
+ * @param {string} key 不含存储根的路径 / Path without the storage root.
842
+ * @param {unknown} value set 写入值 / Value written by set.
843
+ * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
844
+ * @returns {Promise<void>} 操作完成 / Operation completion.
782
845
  */
783
- #route(reload = false) {
784
- const url = new URL(this.#window.location.href);
785
- let key;
846
+ async #change(action, key, value, operation) {
847
+ if (this.#saving) throw new Error("A settings write is already in progress");
848
+ this.#saving = true;
786
849
  try {
787
- key = decodeURIComponent(url.hash.slice(1));
788
- } catch (error) {
789
- if (!(error instanceof URIError)) throw error;
790
- key = "";
791
- }
792
- if (!reload && key === this.#key) return;
793
- this.#controller?.abort();
794
- this.#controller = new AbortController();
795
- const next = key ? this.#create(key, this.#controller.signal) : undefined;
796
- if (!next) key = "";
797
- const history = this.#window.history;
798
- // 直接打开子页时建立一次主页历史;刷新不重复堆叠。
799
- // Seed home history once for direct details, without stacking entries on reload.
800
- if (url.hash && history.state?.preferencePanesRoute !== key) {
801
- url.hash = "";
802
- history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
803
- if (key) {
804
- url.hash = encodeURIComponent(key);
805
- history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
850
+ switch (operation) {
851
+ case "write":
852
+ case "delete": {
853
+ const field = this.#definition.fields.find(candidate => candidate.key === key);
854
+ if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
855
+ const indexed = /^(.*)\.(\d+)$/.exec(key);
856
+ if (indexed) {
857
+ const array = [...(this.#arrays.get(indexed[1]) ?? [])];
858
+ for (const sibling of this.#definition.fields) {
859
+ if (sibling.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(sibling.key.slice(indexed[1].length + 1))) {
860
+ const index = Number(sibling.key.slice(indexed[1].length + 1));
861
+ if (array[index] === undefined) array[index] = this.#values[sibling.key] ?? sibling.defaultValue;
862
+ }
863
+ }
864
+ array[Number(indexed[2])] = operation === "delete" ? field.defaultValue : value;
865
+ await this.#send("set", `@${this.#definition.storageKey}.${indexed[1]}`, array);
866
+ this.#arrays.set(indexed[1], array);
867
+ } else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
868
+ switch (operation) {
869
+ case "write":
870
+ this.#values[key] = JSON.parse(JSON.stringify(value));
871
+ delete this.#warnings[key];
872
+ break;
873
+ case "delete":
874
+ delete this.#values[key];
875
+ delete this.#warnings[key];
876
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[key] = JSON.parse(JSON.stringify(field.defaultValue));
877
+ break;
878
+ }
879
+ break;
880
+ }
881
+ case "clearCaches":
882
+ await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
883
+ break;
884
+ case "reset":
885
+ if (this.#definition.resetPaths) {
886
+ for (const path of this.#definition.resetPaths) await this.#send("delete", path);
887
+ } else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
888
+ this.#arrays.clear();
889
+ this.#warnings = {};
890
+ for (const field of this.#definition.fields) {
891
+ delete this.#values[field.key];
892
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
893
+ }
894
+ break;
806
895
  }
896
+ this.#notify({ kind: "success", operation, module: this.#module, key });
897
+ } catch (error) {
898
+ this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
899
+ throw error;
900
+ } finally {
901
+ this.#saving = false;
807
902
  }
808
- const previous = this.#view;
809
- const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
810
- this.#animation?.cancel();
811
- this.#retiring?.remove();
812
- this.#retiring = previous;
813
- if (previous) {
814
- this.#scroll.set(previous, previous.scrollTop);
815
- previous.inert = true;
816
- }
817
- this.#key = key;
818
- this.#view = next;
819
- this.#home.inert = Boolean(next);
820
- if (next) {
821
- next.inert = false;
822
- this.#container.append(next);
823
- next.scrollTop = this.#scroll.get(next) ?? 0;
824
- }
825
- const moving = next ?? previous;
826
- if (moving) {
827
- const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
828
- this.#animation = animation;
829
- animation.onfinish = () => {
830
- if (this.#animation !== animation) return;
831
- this.#retiring?.remove();
832
- this.#retiring = undefined;
833
- animation.cancel();
834
- this.#animation = undefined;
835
- };
836
- }
837
- this.dispatchEvent(new Event("change"));
838
- }
839
-
840
- /**
841
- * 释放监听器、加载、动画和节点;调用方可重新创建导航。
842
- * Release listeners, loads, animations and nodes so callers can recreate navigation.
843
- * @returns {void} 无返回值 / No return value.
844
- */
845
- destroy() {
846
- this.#window.removeEventListener("popstate", this.#onHistory);
847
- this.#window.removeEventListener("hashchange", this.#onHistory);
848
- this.#window.removeEventListener("pageshow", this.#onPageShow);
849
- this.#controller?.abort();
850
- this.#animation?.cancel();
851
- this.#retiring?.remove();
852
- this.#view?.remove();
853
- this.#home.remove();
854
903
  }
855
904
  }
856
905
 
@@ -871,20 +920,24 @@ function warningMessage(warning) {
871
920
  }
872
921
 
873
922
  /**
874
- * 管理模块表单、导航、操作队列和短暂通知。
875
- * Manage the module form, navigation, operation queue, and transient notifications.
923
+ * 管理模块表单、操作队列和设置会话。
924
+ * Manage the module form, operation queue and settings session.
876
925
  */
877
926
  class PreferencesPanel {
878
927
  #release;
928
+ #view;
929
+ #state;
930
+ #perform;
879
931
 
880
932
  /**
881
933
  * 挂载 BoxJS 定义对应的模块表单。
882
934
  * Mount the module form described by a BoxJS definition.
883
935
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
884
936
  * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
937
+ * @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
885
938
  */
886
- constructor(root, definition) {
887
- this.#release = this.#mount(root, definition);
939
+ constructor(root, definition, page) {
940
+ this.#release = this.#mount(root, definition, page);
888
941
  }
889
942
 
890
943
  /**
@@ -892,13 +945,13 @@ class PreferencesPanel {
892
945
  * Build panel DOM, interactions, and session, then return its release operation.
893
946
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
894
947
  * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
948
+ * @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
895
949
  * @returns {() => void} 释放操作 / Release operation.
896
950
  */
897
- #mount(root, definition) {
951
+ #mount(root, definition, page) {
898
952
  const title = definition.metadata?.name ?? definition.module;
899
953
  const document = root.ownerDocument;
900
954
  const window = document.defaultView;
901
- const frame = window.frameElement?.dataset.preferencePanes ? window.frameElement : undefined;
902
955
  const shell = element("div", "pp-panel");
903
956
  shell.dataset.module = definition.module;
904
957
  const handlers = new Map();
@@ -909,50 +962,25 @@ class PreferencesPanel {
909
962
  { id: "reset", label: "重置设置", destructive: true },
910
963
  ];
911
964
  const viewport = element("div", "pp-viewport");
912
- let back, heading, menu;
913
- if (frame) shell.append(viewport);
914
- else {
915
- const header = element("header", "pp-header");
916
- back = element("button", "pp-back", "‹");
917
- back.setAttribute("aria-label", "返回");
918
- back.type = "button";
919
- heading = element("h1", "pp-title", title);
920
- menu = new ActionMenu(id => runAction(id));
921
- const trailing = element("span", "pp-nav-spacer");
922
- trailing.append(menu.element);
923
- header.append(back, heading, trailing);
924
- shell.append(header, viewport);
925
- }
926
- let toast;
965
+ shell.append(viewport);
927
966
  root.append(shell);
928
- // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
929
- // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
930
- const publishNavigation = () => {
931
- const actions = handlers.size ? menuItems : [];
932
- if (frame)
933
- frame.dispatchEvent(
934
- new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
935
- detail: { title: currentTitle, module: definition.module, busy: saving, canGoBack, actions },
936
- }),
937
- );
938
- else {
939
- heading.textContent = currentTitle;
940
- back.disabled = !canGoBack;
941
- menu.update(actions, saving);
942
- }
967
+ const editors = new Map();
968
+ this.#view = key => {
969
+ const editor = editors.get(key);
970
+ editor?.load?.();
971
+ return editor?.node ?? root;
943
972
  };
944
- const onAction = event => {
945
- if (!saving && handlers.has(event.detail)) runAction(event.detail);
973
+ this.#state = key => ({ title: editors.get(key)?.title ?? title, actions: handlers.size ? menuItems : [], busy: saving });
974
+ this.#perform = id => {
975
+ if (!saving && handlers.has(id)) runAction(id);
946
976
  };
947
- frame?.addEventListener("preferencepanes:action", onAction);
948
- let timer,
949
- navigation,
950
- generation = 0,
977
+ const publishNavigation = () => {
978
+ if (!destroyed) page.update();
979
+ };
980
+ let generation = 0,
951
981
  active = null,
952
982
  saving = false,
953
- destroyed = false,
954
- currentTitle = title,
955
- canGoBack = window.history.length > 1;
983
+ destroyed = false;
956
984
  /**
957
985
  * 展示短暂通知,不刷新设置数据。
958
986
  * Display a transient notification without refreshing settings.
@@ -962,43 +990,29 @@ class PreferencesPanel {
962
990
  const notify = event => {
963
991
  if (destroyed) return;
964
992
  let message;
965
- switch (true) {
966
- case event.kind === "error":
993
+ switch (event.kind === "error" ? "error" : event.operation) {
994
+ case "error":
967
995
  message = `操作失败:${event.message}`;
968
996
  break;
969
- case event.operation === "delete":
997
+ case "delete":
970
998
  message = "删除成功";
971
999
  break;
972
- case event.operation === "clearCaches":
1000
+ case "clearCaches":
973
1001
  message = "Caches 已清空";
974
1002
  break;
975
- case event.operation === "reset":
1003
+ case "reset":
976
1004
  message = "设置已重置";
977
1005
  break;
978
1006
  default:
979
1007
  message = "修改成功";
980
1008
  break;
981
1009
  }
982
- // 宿主接管时不创建网页 Toast,也不运行其计时器。
983
- // A host-owned notice creates no web Toast and starts no local timer.
984
- if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
985
- if (!toast) {
986
- toast = element("div", "pp-toast");
987
- toast.setAttribute("role", "status");
988
- shell.append(toast);
989
- }
990
- toast.textContent = message;
991
- toast.dataset.kind = event.kind;
992
- toast.hidden = false;
993
- clearTimeout(timer);
994
- timer = setTimeout(() => {
995
- toast.hidden = true;
996
- }, 2400);
1010
+ page.notice({ kind: event.kind, message });
997
1011
  };
998
1012
  const client = new PreferencesClient({ definition, notify });
999
1013
  /**
1000
- * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
1001
- * Share async error handling between both menus, including host-dialog errors.
1014
+ * 菜单操作统一处理异步错误,包含宿主确认框错误。
1015
+ * Handle asynchronous action errors, including host-dialog errors.
1002
1016
  * @param {string} id 操作标识 / Action identifier.
1003
1017
  * @returns {Promise<void>} 操作已处理 / Action handled.
1004
1018
  */
@@ -1018,8 +1032,6 @@ class PreferencesPanel {
1018
1032
  async function open(module) {
1019
1033
  const version = ++generation;
1020
1034
  active = module;
1021
- currentTitle = module;
1022
- canGoBack = window.history.length > 1;
1023
1035
  publishNavigation();
1024
1036
  viewport.replaceChildren(statusView("读取设置…"));
1025
1037
  try {
@@ -1038,7 +1050,6 @@ class PreferencesPanel {
1038
1050
  */
1039
1051
  function controls() {
1040
1052
  const { definition, values } = client.snapshot();
1041
- currentTitle = definition.metadata?.name || active;
1042
1053
  const view = element("section", "pp-fields");
1043
1054
  /**
1044
1055
  * 挂载后执行的多行高度更新
@@ -1046,23 +1057,12 @@ class PreferencesPanel {
1046
1057
  * @type {Array<() => void>}
1047
1058
  */
1048
1059
  const growingInputs = [];
1049
- const editors = new Map();
1060
+ editors.clear();
1050
1061
  const summaries = [];
1051
1062
  const warningRefreshers = [];
1052
1063
  const groups = new Map();
1053
1064
  let queue = Promise.resolve(),
1054
1065
  pendingWrites = 0;
1055
- /**
1056
- * 导航组件处理页面切换,表单只更新当前标题与返回按钮。
1057
- * Let navigation own transitions; the form only updates the title and back button.
1058
- * @returns {void} 无返回值 / No return value.
1059
- */
1060
- const updateNavigation = () => {
1061
- const editor = editors.get(navigation.current);
1062
- currentTitle = editor?.title ?? definition.metadata?.name ?? active;
1063
- canGoBack = !saving && navigation.canGoBack;
1064
- publishNavigation();
1065
- };
1066
1066
  /**
1067
1067
  * 串行执行模块操作,保持输入可编辑。
1068
1068
  * Serialize module actions while keeping inputs editable.
@@ -1074,7 +1074,6 @@ class PreferencesPanel {
1074
1074
  function perform(action, success, failure = () => {}) {
1075
1075
  pendingWrites++;
1076
1076
  saving = true;
1077
- canGoBack = false;
1078
1077
  publishNavigation();
1079
1078
  queue = queue
1080
1079
  .then(action)
@@ -1089,27 +1088,10 @@ class PreferencesPanel {
1089
1088
  .finally(() => {
1090
1089
  pendingWrites--;
1091
1090
  saving = pendingWrites > 0;
1092
- if (destroyed && !saving) client.leave();
1093
- canGoBack = !saving && navigation.canGoBack;
1094
1091
  publishNavigation();
1095
1092
  });
1096
1093
  return queue;
1097
1094
  }
1098
- const metadata = definition.metadata;
1099
- if (metadata) {
1100
- const info = element("div", "pp-module-info");
1101
- const details = element("div", "pp-module-details");
1102
- for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element("p", "pp-description", description));
1103
- if (metadata.repo) {
1104
- const link = element("a", "pp-module-source", "项目主页");
1105
- link.href = resourceURL(metadata.repo);
1106
- link.target = "_blank";
1107
- link.rel = "noopener noreferrer";
1108
- details.append(link);
1109
- }
1110
- info.append(details);
1111
- view.append(info);
1112
- }
1113
1095
  for (const field of definition.fields) {
1114
1096
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
1115
1097
  const group = match?.[1] ?? "通用";
@@ -1150,8 +1132,8 @@ class PreferencesPanel {
1150
1132
  let write;
1151
1133
  let inputContainer = row;
1152
1134
  let eventName = "change";
1153
- switch (true) {
1154
- case field.control === "url": {
1135
+ switch (field.control === "url" ? "url" : field.options ? (field.type === "array" ? "checkboxes" : "selects") : field.type) {
1136
+ case "url": {
1155
1137
  const link = element("a", "pp-choice-link", "打开");
1156
1138
  link.setAttribute("aria-label", field.name);
1157
1139
  link.rel = "noopener noreferrer";
@@ -1159,7 +1141,8 @@ class PreferencesPanel {
1159
1141
  link.href = value;
1160
1142
  };
1161
1143
  link.addEventListener("click", event => {
1162
- if (requestURLNavigation(window, link.href)) event.preventDefault();
1144
+ event.preventDefault();
1145
+ page.openURL(link.href);
1163
1146
  });
1164
1147
  row.append(link);
1165
1148
  row.addEventListener("click", event => {
@@ -1167,7 +1150,7 @@ class PreferencesPanel {
1167
1150
  });
1168
1151
  break;
1169
1152
  }
1170
- case Boolean(field.options) && field.type !== "array": {
1153
+ case "selects": {
1171
1154
  const select = element("select", "");
1172
1155
  select.setAttribute("aria-label", field.name);
1173
1156
  field.options.forEach((option, index) => {
@@ -1182,13 +1165,13 @@ class PreferencesPanel {
1182
1165
  read = () => field.options[select.selectedIndex]?.key;
1183
1166
  break;
1184
1167
  }
1185
- case field.type === "array" && Boolean(field.options): {
1186
- const page = element("section", "pp-choice-page");
1187
- if (field.description) page.append(element("p", "pp-description", field.description));
1168
+ case "checkboxes": {
1169
+ const editor = element("section", "pp-panel pp-detail pp-choice-page");
1170
+ if (field.description) editor.append(element("p", "pp-description", field.description));
1188
1171
  const choices = element("div", "pp-rows");
1189
- page.append(choices);
1172
+ editor.append(choices);
1190
1173
  inputContainer = choices;
1191
- editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
1174
+ editors.set(field.key, { node: editor, title: match?.[2] ?? field.name });
1192
1175
  const summary = element("span", "pp-summary");
1193
1176
  const link = element("button", "pp-choice-link");
1194
1177
  link.type = "button";
@@ -1205,7 +1188,7 @@ class PreferencesPanel {
1205
1188
  };
1206
1189
  summaries.push(refresh);
1207
1190
  refresh();
1208
- link.onclick = () => navigation.open(field.key);
1191
+ link.onclick = () => page.open(field.key);
1209
1192
  row.addEventListener("click", event => {
1210
1193
  if (!link.contains(event.target)) link.click();
1211
1194
  });
@@ -1226,7 +1209,7 @@ class PreferencesPanel {
1226
1209
  };
1227
1210
  break;
1228
1211
  }
1229
- case field.type === "boolean": {
1212
+ case "boolean": {
1230
1213
  const toggle = element("input", "pp-switch");
1231
1214
  toggle.type = "checkbox";
1232
1215
  toggle.setAttribute("switch", "");
@@ -1264,7 +1247,12 @@ class PreferencesPanel {
1264
1247
  growingInputs.push(grow);
1265
1248
  }
1266
1249
  eventName = "input";
1267
- if (!multiline) input.type = field.type === "number" ? "number" : "text";
1250
+ if (!multiline) {
1251
+ input.type = field.type === "number" ? "number" : "text";
1252
+ input.autocapitalize = "off";
1253
+ input.setAttribute("autocorrect", "off");
1254
+ input.spellcheck = false;
1255
+ }
1268
1256
  write = value => {
1269
1257
  input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
1270
1258
  grow();
@@ -1317,12 +1305,12 @@ class PreferencesPanel {
1317
1305
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
1318
1306
  groups.get(group).append(row);
1319
1307
  }
1320
- const settingsPage = element("section", "pp-settings-page");
1308
+ const settingsPage = element("section", "pp-panel pp-detail pp-settings-page");
1321
1309
  const settingsOutput = element("pre", "pp-cache");
1322
1310
  settingsOutput.setAttribute("aria-label", "Settings 内容");
1323
1311
  settingsPage.append(settingsOutput);
1324
- editors.set("$settings", { node: settingsPage, title: "设置" });
1325
- handlers.set("viewSettings", () => {
1312
+ const loadSettings = () => {
1313
+ editors.get("$settings").load = undefined;
1326
1314
  if (saving) return;
1327
1315
  let value;
1328
1316
  return perform(
@@ -1336,17 +1324,24 @@ class PreferencesPanel {
1336
1324
  },
1337
1325
  () => {
1338
1326
  settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
1339
- navigation.open("$settings");
1327
+ },
1328
+ () => {
1329
+ settingsOutput.textContent = "读取设置失败";
1340
1330
  },
1341
1331
  );
1332
+ };
1333
+ editors.set("$settings", { node: settingsPage, title: "设置", load: loadSettings });
1334
+ handlers.set("viewSettings", () => {
1335
+ loadSettings();
1336
+ page.open("$settings");
1342
1337
  });
1343
- const cachePage = element("section", "pp-cache-page");
1338
+ const cachePage = element("section", "pp-panel pp-detail pp-cache-page");
1344
1339
  const output = element("pre", "pp-cache");
1345
- output.textContent = "暂无缓存";
1340
+ output.textContent = "正在读取缓存…";
1346
1341
  output.setAttribute("aria-label", "Caches 内容");
1347
1342
  cachePage.append(output);
1348
- editors.set("$caches", { node: cachePage, title: "缓存" });
1349
- handlers.set("viewCaches", () => {
1343
+ const loadCaches = () => {
1344
+ editors.get("$caches").load = undefined;
1350
1345
  if (saving) return;
1351
1346
  let value;
1352
1347
  return perform(
@@ -1360,13 +1355,22 @@ class PreferencesPanel {
1360
1355
  },
1361
1356
  () => {
1362
1357
  output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
1363
- navigation.open("$caches");
1358
+ },
1359
+ () => {
1360
+ output.textContent = "读取缓存失败";
1364
1361
  },
1365
1362
  );
1363
+ };
1364
+ editors.set("$caches", { node: cachePage, title: "缓存", load: loadCaches });
1365
+ handlers.set("viewCaches", () => {
1366
+ loadCaches();
1367
+ page.open("$caches");
1366
1368
  });
1367
1369
  handlers.set("clearCaches", async () => {
1368
1370
  if (saving) return;
1369
- if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
1371
+ const shared = definition.cachePath && definition.cachePath !== `@${definition.storageKey}.${active}.Caches`;
1372
+ const message = shared ? `清空共享缓存 ${definition.cachePath}?使用该缓存的其它模块也会受影响。` : `清空 ${active} 的全部 Caches?`;
1373
+ if (!(await page.confirm(message)) || destroyed || saving) return;
1370
1374
  return perform(
1371
1375
  () => client.clearCaches(),
1372
1376
  () => {
@@ -1376,42 +1380,58 @@ class PreferencesPanel {
1376
1380
  });
1377
1381
  handlers.set("reset", async () => {
1378
1382
  if (saving) return;
1379
- if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
1383
+ const message = definition.resetPaths ? `重置 ${definition.metadata?.name ?? active} 的设置?只删除本页面的设置,保留共享存储中的其它设置和缓存。` : `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`;
1384
+ if (!(await page.confirm(message)) || destroyed || saving) return;
1380
1385
  return perform(() => client.reset(), controls);
1381
1386
  });
1382
- navigation?.destroy();
1383
- navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
1384
- navigation.addEventListener("change", updateNavigation);
1387
+ viewport.replaceChildren(view);
1385
1388
  for (const grow of growingInputs) grow();
1386
- updateNavigation();
1389
+ page.refresh();
1387
1390
  }
1388
- /**
1389
- * 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
1390
- * Loaded forms delegate back to navigation; loading views can return to the previous document.
1391
- * @returns {void} 无返回值 / No return value.
1392
- */
1393
- if (back)
1394
- back.onclick = () => {
1395
- if (saving) return;
1396
- if (navigation) navigation.back();
1397
- else window.history.back();
1398
- };
1399
- open(definition.module);
1391
+ queueMicrotask(() => {
1392
+ if (!destroyed) open(definition.module);
1393
+ });
1400
1394
  return () => {
1401
1395
  destroyed = true;
1402
- menu?.destroy();
1403
- frame?.removeEventListener("preferencepanes:action", onAction);
1404
- navigation?.destroy();
1405
1396
  generation++;
1406
- if (active && !saving) client.leave();
1407
- clearTimeout(timer);
1397
+ client.leave();
1408
1398
  shell.remove();
1409
1399
  };
1410
1400
  }
1411
1401
 
1412
1402
  /**
1413
- * 移除监听器、定时器、会话和挂载内容。
1414
- * Remove listeners, timers, session, and mounted content.
1403
+ * 返回当前会话中的字段或操作页,不再读取配置。
1404
+ * Return a field or action view from the current session without fetching configuration.
1405
+ * @param {string} key 子页键 / Detail key.
1406
+ * @returns {HTMLElement} 当前视图 / Current view.
1407
+ */
1408
+ view(key) {
1409
+ return this.#view(key);
1410
+ }
1411
+
1412
+ /**
1413
+ * 提供当前页标题及菜单状态。
1414
+ * Provide the current title and action state.
1415
+ * @param {string} key 子页键 / Detail key.
1416
+ * @returns {object} 页面状态 / Page state.
1417
+ */
1418
+ state(key) {
1419
+ return this.#state(key);
1420
+ }
1421
+
1422
+ /**
1423
+ * 执行共用菜单中的操作。
1424
+ * Perform an action from the shared menu.
1425
+ * @param {string} id 操作标识 / Action identifier.
1426
+ * @returns {void} 无返回值 / No return value.
1427
+ */
1428
+ perform(id) {
1429
+ this.#perform(id);
1430
+ }
1431
+
1432
+ /**
1433
+ * 释放读取与视图;已开始的写入仍沿原队列完成。
1434
+ * Release reads and views while allowing started writes to finish through the existing queue.
1415
1435
  * @returns {void} 无返回值 / No return value.
1416
1436
  */
1417
1437
  destroy() {
@@ -1419,124 +1439,403 @@ class PreferencesPanel {
1419
1439
  }
1420
1440
  }
1421
1441
 
1422
- var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n --pp-warning: #9a6700;\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 display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n --pp-warning: #f0b849;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\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: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\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 flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\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-panel a.pp-choice-link {\n color: var(--pp-accent);\n text-decoration: none;\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-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-field-warning {\n margin-top: 4px;\n color: var(--pp-warning);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\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-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-status {\n position: fixed;\n inset: 0;\n display: grid;\n place-content: center;\n justify-items: center;\n gap: 12px;\n min-width: 0;\n min-height: 0;\n margin: 0;\n padding: 24px;\n color: var(--pp-muted, GrayText);\n text-align: center;\n background: var(--pp-background, Canvas);\n}\n.pp-viewport > .pp-status {\n position: absolute;\n}\n.pp-status-spinner {\n box-sizing: border-box;\n width: 28px;\n height: 28px;\n border: 3px solid color-mix(in srgb, currentColor 25%, transparent);\n border-top-color: var(--pp-accent, AccentColor);\n border-radius: 50%;\n animation: pp-status-spin 0.8s linear infinite;\n}\n.pp-status-message {\n max-width: 100%;\n margin: 0;\n overflow-wrap: anywhere;\n}\n.pp-status-action {\n min-width: 96px;\n min-height: 44px;\n padding: 8px 16px;\n border: 0;\n border-radius: 6px;\n color: var(--pp-text, ButtonText);\n font: inherit;\n cursor: pointer;\n background: var(--pp-surface, ButtonFace);\n}\n@keyframes pp-status-spin {\n to {\n transform: rotate(1turn);\n }\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";
1423
-
1424
- const selector = "style[data-preference-panes-defaults]";
1425
- const stylesheetSelector = "link[data-preference-panes-stylesheet]";
1426
-
1427
1442
  /**
1428
- * 在文档中安装一次默认样式,并标记当前调用方是否拥有该节点。
1429
- * Install default styles once and report whether the current caller owns the node.
1430
- * @param {Document} document 目标文档 / Target document.
1431
- * @returns {{element: HTMLStyleElement, owned: boolean}} 样式节点及所有权 / Style node and ownership.
1443
+ * 浏览器宿主提供网页导航、通知、主题和可视区域避让。
1444
+ * The browser host provides web navigation, notices, theme and viewport clearance.
1445
+ * @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
1446
+ * @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
1432
1447
  */
1433
- function installDefaultStyles(document) {
1434
- const existing = document.head.querySelector(selector);
1435
- if (existing) return { element: existing, owned: false };
1436
- const element = document.createElement("style");
1437
- element.dataset.preferencePanesDefaults = "";
1438
- element.textContent = defaults;
1439
- document.head.insertBefore(element, document.head.querySelector(stylesheetSelector));
1440
- return { element, owned: true };
1448
+ function browserHost({ menu, back }) {
1449
+ const root = document.documentElement;
1450
+ const header = document.createElement("header");
1451
+ header.className = "pp-host-header";
1452
+ const button = document.createElement("button");
1453
+ button.className = "pp-host-back";
1454
+ button.type = "button";
1455
+ button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg>';
1456
+ button.setAttribute("aria-label", "返回");
1457
+ button.onclick = back;
1458
+ const left = document.createElement("span");
1459
+ left.className = "pp-host-left";
1460
+ left.append(button);
1461
+ const title = document.createElement("span");
1462
+ title.className = "pp-host-title";
1463
+ const brand = document.createElement("div");
1464
+ brand.className = "pp-host-brand";
1465
+ const brandLink = document.createElement("a");
1466
+ brandLink.className = "pp-host-brand-link";
1467
+ const logo = document.createElement("span");
1468
+ logo.className = "pp-host-logo";
1469
+ const image = document.createElement("img");
1470
+ image.alt = "logo";
1471
+ logo.append(image);
1472
+ brandLink.append(logo, title);
1473
+ brand.append(brandLink);
1474
+ header.append(left, brand, menu.element);
1475
+ document.body.prepend(header);
1476
+ const toast = document.createElement("div");
1477
+ toast.className = "pp-host-toast";
1478
+ toast.setAttribute("role", "status");
1479
+ toast.hidden = true;
1480
+ document.body.append(toast);
1481
+ let timer;
1482
+ const theme = window.matchMedia("(prefers-color-scheme: dark)");
1483
+ const applyTheme = () => {
1484
+ root.dataset.theme = theme.matches ? "dark" : "light";
1485
+ };
1486
+ const viewport = window.visualViewport;
1487
+ const resize = () => {
1488
+ root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
1489
+ const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
1490
+ root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
1491
+ };
1492
+ const scroll = () => {
1493
+ const pages = document.querySelector("[data-preference-panes-pages]");
1494
+ const current = [...pages.children].find(view => !view.inert);
1495
+ header.classList.toggle("pp-scrolled", (current?.scrollTop ?? 0) >= 100);
1496
+ };
1497
+ document.addEventListener("scroll", scroll, true);
1498
+ theme.addEventListener("change", applyTheme);
1499
+ window.addEventListener("resize", resize);
1500
+ window.addEventListener("pageshow", resize);
1501
+ viewport?.addEventListener("resize", resize);
1502
+ viewport?.addEventListener("scroll", resize);
1503
+ applyTheme();
1504
+ resize();
1505
+ return {
1506
+ update(state) {
1507
+ title.textContent = state.logoText ?? state.title;
1508
+ brandLink.href = state.logoHref ?? "/";
1509
+ logo.hidden = !state.logo;
1510
+ if (state.logo) image.src = state.logo;
1511
+ left.hidden = !state.canGoBack;
1512
+ menu.element.hidden = !state.actions.length;
1513
+ button.disabled = state.busy;
1514
+ scroll();
1515
+ },
1516
+ confirm: message => window.confirm(message),
1517
+ notice(detail) {
1518
+ clearTimeout(timer);
1519
+ toast.textContent = detail.message;
1520
+ toast.hidden = false;
1521
+ timer = setTimeout(() => {
1522
+ toast.hidden = true;
1523
+ }, 2400);
1524
+ },
1525
+ openURL(url, target) {
1526
+ switch (target) {
1527
+ case "_blank":
1528
+ window.open(url, "_blank", "noopener,noreferrer");
1529
+ break;
1530
+ default:
1531
+ window.location.assign(url);
1532
+ break;
1533
+ }
1534
+ },
1535
+ destroy() {
1536
+ clearTimeout(timer);
1537
+ document.removeEventListener("scroll", scroll, true);
1538
+ theme.removeEventListener("change", applyTheme);
1539
+ window.removeEventListener("resize", resize);
1540
+ window.removeEventListener("pageshow", resize);
1541
+ viewport?.removeEventListener("resize", resize);
1542
+ viewport?.removeEventListener("scroll", resize);
1543
+ root.style.removeProperty("--pp-host-height");
1544
+ root.style.removeProperty("--pp-keyboard-height");
1545
+ delete root.dataset.theme;
1546
+ header.remove();
1547
+ toast.remove();
1548
+ },
1549
+ };
1441
1550
  }
1442
1551
 
1443
1552
  /**
1444
- * 管理 BoxJS 规范化、主题同步和面板生命周期。
1445
- * Manage BoxJS normalization, theme synchronization, and panel lifecycle.
1553
+ * 一个文档共享宿主、路由和当前模块会话,字段子页不重新读取配置。
1554
+ * Share one host, router and module session within a document without reloading config for field views.
1446
1555
  */
1447
- class PreferencesView {
1448
- #existing;
1449
- #root;
1450
- #base;
1451
- #ownsBase;
1452
- #previousTitle;
1453
- #previousTheme;
1454
- #systemTheme;
1455
- #previousKeyboard;
1556
+ class PageController {
1557
+ #navigation;
1456
1558
  #host;
1457
- #observer;
1458
- #panel;
1559
+ #menu;
1560
+ #title;
1561
+ #logo;
1562
+ #logoText;
1563
+ #logoHref;
1564
+ #modules;
1565
+ #links;
1566
+ #session;
1567
+ #destroyed = false;
1568
+
1569
+ /**
1570
+ * 接管已初始化的宿主与首页;此接口仅供包内入口组合。
1571
+ * Own an initialized host and homepage; this interface is only used by internal entry points.
1572
+ * @param {HTMLElement} container 页面容器 / Page container.
1573
+ * @param {HTMLElement} home 首页节点 / Homepage node.
1574
+ * @param {object} options 宿主、菜单、品牌及入口 / Host, menu, brand and entries.
1575
+ */
1576
+ constructor(container, home, { host, menu, title, logo, logoText, logoHref, modules, links = [] }) {
1577
+ this.#host = host;
1578
+ this.#menu = menu;
1579
+ this.#title = title;
1580
+ this.#logo = logo;
1581
+ this.#logoText = logoText || (logo ? "" : title);
1582
+ this.#logoHref = logoHref;
1583
+ this.#modules = modules;
1584
+ this.#links = links;
1585
+ this.#navigation = new Navigation(container, home, (key, signal) => this.#view(key, signal));
1586
+ this.#navigation.addEventListener("change", () => this.update());
1587
+ this.update();
1588
+ }
1459
1589
 
1460
1590
  /**
1461
- * 使用原始 BoxJS JSON 挂载设置页。
1462
- * Mount a settings page from raw BoxJS JSON.
1463
- * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1591
+ * 创建模块会话或复用其字段页;外部网站才使用 iframe。
1592
+ * Create a module session or reuse a field view; only external sites use an iframe.
1593
+ * @param {string} key 路由键 / Route key.
1594
+ * @param {AbortSignal} signal 当前外链视图的取消信号 / Cancellation signal for the current external view.
1595
+ * @returns {HTMLElement | undefined} 视图;未知模块返回 undefined / View; undefined for unknown modules.
1464
1596
  */
1465
- constructor(boxjs) {
1466
- const definition = normalizeBoxJs(boxjs);
1467
- const metadata = definition.metadata ?? {};
1468
- const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1469
- if (image) resourceURL(image);
1470
- if (metadata.repo) resourceURL(metadata.repo);
1471
-
1472
- this.#existing = document.querySelector("#preferences");
1473
- this.#root = this.#existing ?? element("main", "");
1474
- if (!this.#existing) {
1475
- this.#root.id = "preferences";
1476
- document.body.append(this.#root);
1597
+ #view(key, signal) {
1598
+ const [module, ...parts] = key.split("/");
1599
+ const view = parts.join("/");
1600
+ if (module !== this.#session?.module) {
1601
+ this.#session?.controller.abort();
1602
+ this.#session?.panel?.destroy();
1603
+ this.#session = undefined;
1604
+ }
1605
+ if (module === "$link") {
1606
+ const link = this.#links[Number(view)];
1607
+ if (!link || link.dataset.target !== "internal") return;
1608
+ const page = element("section", "pp-link-page");
1609
+ const iframe = element("iframe", "");
1610
+ iframe.title = link.querySelector(".pp-home-name").textContent;
1611
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
1612
+ iframe.src = link.href;
1613
+ page.append(iframe);
1614
+ signal.addEventListener("abort", () => iframe.remove(), { once: true });
1615
+ return page;
1616
+ }
1617
+ const entry = this.#modules.get(module);
1618
+ if (!entry) return;
1619
+ if (!this.#session) {
1620
+ const controller = new AbortController();
1621
+ const root = element("section", "pp-module-page");
1622
+ const session = { module, root, controller };
1623
+ this.#session = session;
1624
+ const load = async () => {
1625
+ if (controller.signal.aborted) return;
1626
+ root.replaceChildren(statusView("正在打开设置…"));
1627
+ try {
1628
+ let boxjs = entry.boxjs;
1629
+ if (!boxjs) {
1630
+ const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", signal: controller.signal });
1631
+ if (!response.ok) throw new Error(`配置加载失败:HTTP ${response.status}`);
1632
+ boxjs = await response.json();
1633
+ }
1634
+ if (controller.signal.aborted) return;
1635
+ root.replaceChildren();
1636
+ session.panel = new PreferencesPanel(root, normalizeBoxJs(boxjs), this);
1637
+ this.update();
1638
+ } catch (error) {
1639
+ if (controller.signal.aborted) return;
1640
+ root.replaceChildren(statusView(`加载失败:${error.message}`, load));
1641
+ }
1642
+ };
1643
+ // 已提供 BoxJS 时也等待路由切换完成,再发布表单状态。
1644
+ // Wait for routing to finish before publishing form state, even with supplied BoxJS.
1645
+ queueMicrotask(load);
1477
1646
  }
1478
- const styles = installDefaultStyles(document);
1479
- this.#base = styles.element;
1480
- this.#ownsBase = styles.owned;
1481
- this.#previousTitle = document.title;
1482
- this.#previousTheme = document.documentElement.dataset.theme;
1483
- this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
1484
- this.#previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
1485
- this.#host = window.frameElement?.ownerDocument.documentElement;
1486
- this.#syncAppearance();
1487
- this.#systemTheme.addEventListener("change", this.#syncAppearance);
1488
- if (this.#host) {
1489
- this.#observer = new MutationObserver(this.#syncAppearance);
1490
- this.#observer.observe(this.#host, { attributes: true, attributeFilter: ["data-theme", "style"] });
1647
+ return this.#session.panel?.view(view) ?? this.#session.root;
1648
+ }
1649
+
1650
+ /**
1651
+ * 同步唯一标题栏与菜单;离开模块时终止读取和会话。
1652
+ * Synchronize the only title bar and menu, ending reads and sessions on departure.
1653
+ * @returns {void} 无返回值 / No return value.
1654
+ */
1655
+ update() {
1656
+ if (this.#destroyed || !this.#navigation) return;
1657
+ const [module, ...parts] = this.#navigation.current.split("/");
1658
+ if (!module && this.#session) {
1659
+ this.#session.controller.abort();
1660
+ this.#session.panel?.destroy();
1661
+ this.#session = undefined;
1491
1662
  }
1492
- document.title = metadata.name ?? definition.module;
1663
+ const state = this.#session?.panel?.state(parts.join("/")) ?? {
1664
+ title: module === "$link" ? this.#links[Number(parts[0])].querySelector(".pp-home-name").textContent : (this.#modules.get(module)?.name ?? this.#title),
1665
+ actions: [],
1666
+ busy: false,
1667
+ };
1668
+ document.title = state.title;
1669
+ this.#menu.update(state.actions, state.busy);
1670
+ Promise.resolve(this.#host.update({ ...state, logo: module ? undefined : this.#logo, logoText: module ? undefined : this.#logoText, logoHref: this.#logoHref, canGoBack: this.#navigation.canGoBack })).catch(error => this.notice({ kind: "error", message: error.message }));
1671
+ }
1672
+
1673
+ /**
1674
+ * 控件重建后重新解析当前路由,不增加历史或重新加载模块配置。
1675
+ * Resolve the current route after rebuilding controls without adding history or reloading config.
1676
+ * @returns {void} 无返回值 / No return value.
1677
+ */
1678
+ refresh() {
1679
+ if (!this.#destroyed) this.#navigation.refresh();
1680
+ }
1681
+
1682
+ /**
1683
+ * 打开当前模块的字段或操作子页。
1684
+ * Open a field or action subview of the current module.
1685
+ * @param {string} view 子页键 / Detail key.
1686
+ * @returns {void} 无返回值 / No return value.
1687
+ */
1688
+ open(view) {
1689
+ this.#navigation.open(`${this.#session.module}/${view}`);
1690
+ }
1691
+
1692
+ /**
1693
+ * 执行当前表单操作。
1694
+ * Perform an action on the current form.
1695
+ * @param {string} id 操作标识 / Action identifier.
1696
+ * @returns {void} 无返回值 / No return value.
1697
+ */
1698
+ perform(id) {
1699
+ this.#session?.panel?.perform(id);
1700
+ }
1701
+
1702
+ /**
1703
+ * 请求宿主确认;退出后忽略晚到的选择。
1704
+ * Request host confirmation and ignore a late choice after departure.
1705
+ * @param {string} message 确认文字 / Confirmation message.
1706
+ * @returns {Promise<boolean>} 用户选择 / User choice.
1707
+ */
1708
+ async confirm(message) {
1709
+ const session = this.#session;
1710
+ const confirmed = await this.#host.confirm(message);
1711
+ return !this.#destroyed && session === this.#session && confirmed;
1712
+ }
1713
+
1714
+ /**
1715
+ * 由唯一宿主显示通知。
1716
+ * Display a notice through the only host.
1717
+ * @param {import("./Navigation.mjs").Notice} detail 已格式化通知 / Formatted notice.
1718
+ * @returns {void} 无返回值 / No return value.
1719
+ */
1720
+ notice(detail) {
1721
+ if (!this.#destroyed) this.#host.notice(detail);
1722
+ }
1723
+
1724
+ /**
1725
+ * 由宿主执行标准外链导航。
1726
+ * Delegate standard external navigation to the host.
1727
+ * @param {string} url 完整地址 / Absolute URL.
1728
+ * @param {"_self" | "_blank"} [target] 打开方式 / Opening mode.
1729
+ * @returns {void} 无返回值 / No return value.
1730
+ */
1731
+ openURL(url, target) {
1493
1732
  try {
1494
- this.#root.replaceChildren();
1495
- this.#panel = new PreferencesPanel(this.#root, definition);
1733
+ Promise.resolve(this.#host.openURL(url, target)).catch(error => this.notice({ kind: "error", message: error.message }));
1496
1734
  } catch (error) {
1497
- this.destroy();
1498
- throw error;
1735
+ this.notice({ kind: "error", message: error.message });
1499
1736
  }
1500
1737
  }
1501
1738
 
1502
1739
  /**
1503
- * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
1504
- * Follow generic host appearance without detecting a business App or parsing its UA.
1505
- * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
1740
+ * 共用首页与表单导航。
1741
+ * Navigation shared by the homepage and forms.
1506
1742
  */
1507
- #syncAppearance = () => {
1508
- const theme = this.#host?.dataset.theme ?? this.#previousTheme ?? (this.#systemTheme.matches ? "dark" : "light");
1509
- document.documentElement.dataset.theme = theme;
1510
- if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
1511
- };
1743
+ get navigation() {
1744
+ return this.#navigation;
1745
+ }
1512
1746
 
1513
1747
  /**
1514
- * 释放模块视图、样式与会话,不操作项目入口页。
1515
- * Release the module view, styles, and session without operating a project landing page.
1748
+ * 结束页面能力和会话;表单写入队列自行收尾。
1749
+ * End page capabilities and sessions while forms finish their write queues.
1516
1750
  * @returns {void} 无返回值 / No return value.
1517
1751
  */
1518
1752
  destroy() {
1519
- this.#observer?.disconnect();
1520
- this.#systemTheme.removeEventListener("change", this.#syncAppearance);
1521
- this.#panel?.destroy();
1522
- if (this.#ownsBase) this.#base.remove();
1523
- if (this.#existing) this.#root.replaceChildren();
1524
- else this.#root.remove();
1525
- document.title = this.#previousTitle;
1526
- if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
1527
- else document.documentElement.dataset.theme = this.#previousTheme;
1528
- document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
1753
+ if (this.#destroyed) return;
1754
+ this.#destroyed = true;
1755
+ this.#session?.controller.abort();
1756
+ this.#session?.panel?.destroy();
1757
+ this.#navigation.destroy();
1758
+ this.#host.destroy();
1759
+ this.#menu.destroy();
1529
1760
  }
1530
1761
  }
1531
1762
 
1763
+ var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n:root {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n --pp-warning: #9a6700;\n --pp-host-height: 0px;\n --pp-keyboard-height: 0px;\n}\n.pp-panel {\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 display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n}\n\n:root[data-theme=\"dark\"] {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n --pp-warning: #f0b849;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n min-width: 0;\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n box-sizing: border-box;\n min-width: 0;\n padding: calc(12px + var(--pp-host-height)) max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n scroll-padding-bottom: var(--pp-keyboard-height);\n background: var(--pp-background);\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-panel a.pp-choice-link {\n color: var(--pp-accent);\n text-decoration: none;\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-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n font-size: 16px;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-field-warning {\n margin-top: 4px;\n color: var(--pp-warning);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\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-status {\n position: absolute;\n inset: 0;\n display: grid;\n place-content: center;\n justify-items: center;\n gap: 12px;\n min-width: 0;\n min-height: 0;\n margin: 0;\n padding: 24px;\n color: var(--pp-muted, GrayText);\n text-align: center;\n background: var(--pp-background, Canvas);\n}\n.pp-viewport > .pp-status {\n position: absolute;\n}\n.pp-status-spinner {\n box-sizing: border-box;\n width: 28px;\n height: 28px;\n border: 3px solid var(--pp-border, #8884);\n border-top-color: var(--pp-accent, AccentColor);\n border-radius: 50%;\n animation: pp-status-spin 0.8s linear infinite;\n}\n.pp-status-message {\n max-width: 100%;\n margin: 0;\n overflow-wrap: anywhere;\n}\n.pp-status-action {\n min-width: 96px;\n min-height: 44px;\n padding: 8px 16px;\n border: 0;\n border-radius: 6px;\n color: var(--pp-text, ButtonText);\n font: inherit;\n cursor: pointer;\n background: var(--pp-surface, ButtonFace);\n}\n@keyframes pp-status-spin {\n to {\n transform: rotate(1turn);\n }\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n\n/* 首页沿用表单的分组与说明样式,入口按屏幕宽度自动换行。\n * The landing page reuses form groups and descriptions, with entries wrapping by viewport width. */\n:root.pp-page {\n color-scheme: light dark;\n}\n.pp-page body {\n margin: 0;\n overflow: hidden;\n font:\n 16px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n background: var(--pp-background, Canvas);\n color: var(--pp-text, CanvasText);\n}\n[data-preference-panes-pages] {\n position: relative;\n height: 100vh;\n background: inherit;\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n [data-preference-panes-pages] {\n height: 100dvh;\n }\n}\n.pp-home,\n.pp-module-page,\n.pp-detail,\n.pp-link-page {\n position: absolute;\n inset: 0;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n background: var(--pp-background);\n}\n.pp-module-page,\n.pp-detail,\n.pp-link-page {\n z-index: 1;\n box-shadow: -8px 0 24px #00000014;\n}\n.pp-link-page {\n top: var(--pp-host-height);\n}\n.pp-link-page iframe {\n display: block;\n border: 0;\n width: 100%;\n height: 100%;\n background: inherit;\n}\n.pp-host-header {\n box-sizing: border-box;\n position: fixed;\n inset: 0 0 auto;\n z-index: 2;\n display: flex;\n align-items: center;\n height: calc(64px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) max(20px, env(safe-area-inset-right)) 0 max(20px, env(safe-area-inset-left));\n border-bottom: 1px solid transparent;\n background: transparent;\n -webkit-backdrop-filter: blur(25px);\n backdrop-filter: blur(25px);\n color: var(--pp-text);\n transition:\n background-color 200ms ease-in-out,\n border-color 200ms ease-in-out;\n}\n.pp-host-header.pp-scrolled {\n background: rgba(255, 255, 255, 0.6);\n border-color: var(--pp-border);\n}\n:root[data-theme=\"dark\"] .pp-host-header.pp-scrolled {\n background: rgba(13, 14, 15, 0.6);\n}\n@media (min-width: 1280px) {\n .pp-host-header {\n padding-right: max(20px, calc((100vw - 1588px) / 2));\n padding-left: max(20px, calc((100vw - 1588px) / 2));\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-host-header {\n transition: none;\n }\n}\n.pp-host-brand {\n display: flex;\n align-items: center;\n flex: 1;\n height: 36px;\n min-width: 0;\n margin-right: 8px;\n}\n.pp-host-brand-link {\n display: flex;\n align-items: center;\n height: 100%;\n min-width: 0;\n max-width: 100%;\n color: inherit;\n text-decoration: none;\n transition: opacity 0.3s;\n}\n.pp-host-brand-link:hover {\n opacity: 0.6;\n}\n.pp-host-logo {\n flex: none;\n min-width: 2rem;\n margin-right: 0.25rem;\n}\n.pp-host-logo img {\n display: block;\n height: 1.6rem;\n}\n.pp-host-title {\n min-width: 0;\n text-align: left;\n font-size: 16px;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-host-left {\n display: flex;\n align-items: center;\n justify-content: center;\n flex: none;\n}\n.pp-host-left::after {\n content: \"\";\n display: inline-block;\n width: 1px;\n height: 24px;\n margin: 0 16px;\n background-color: #0000001f;\n}\n:root[data-theme=\"dark\"] .pp-host-left::after {\n background-color: #5454547a;\n}\n.pp-host-left[hidden] {\n display: none;\n}\n.pp-host-back {\n border: 0;\n border-radius: 18px;\n background: none;\n color: inherit;\n width: 36px;\n height: 36px;\n padding: 8px;\n cursor: pointer;\n transition: background-color 0.2s ease-in-out;\n}\n.pp-host-back svg {\n display: block;\n width: 20px;\n height: 20px;\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n}\n.pp-host-back:hover {\n background: #f1f1f1;\n}\n:root[data-theme=\"dark\"] .pp-host-back:hover {\n background: #343a46;\n}\n.pp-host-toast {\n position: fixed;\n z-index: 3;\n bottom: calc(24px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n left: 50%;\n transform: translateX(-50%);\n max-width: calc(100% - 48px);\n padding: 10px 18px;\n border-radius: 12px;\n background: #222e;\n color: #fff;\n font-size: 14px;\n text-align: center;\n}\n.pp-host-toast[hidden] {\n display: none;\n}\n.pp-host-error {\n margin: 24px;\n padding: 16px;\n border: 1px solid var(--pp-danger, #e45656);\n border-radius: 12px;\n font-size: 16px;\n overflow-wrap: anywhere;\n}\n\n.pp-home {\n max-width: 1000px;\n margin: auto;\n padding: var(--pp-host-height) max(16px, env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));\n}\n.pp-home-entries {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-around;\n row-gap: 16px;\n padding: 16px 0;\n background: var(--pp-surface);\n}\n.pp-home-item {\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n align-items: center;\n width: 25%;\n min-width: 0;\n padding: 0 4px;\n border: 0;\n background: none;\n color: var(--pp-text);\n font:\n 12px / 1.5 -apple-system,\n sans-serif;\n cursor: pointer;\n text-decoration: none;\n}\n@media (min-width: 500px) {\n .pp-home-item {\n width: calc(100% / 6);\n }\n}\n@media (min-width: 700px) {\n .pp-home-item {\n width: 12.5%;\n }\n}\n.pp-home-icon {\n box-sizing: border-box;\n width: 56px;\n height: 56px;\n padding: 8px;\n border-radius: 50%;\n background: var(--pp-field);\n font-size: 32px;\n line-height: 40px;\n text-align: center;\n}\n.pp-home-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-home-name {\n margin-top: 8px;\n}\n.pp-home-content {\n min-width: 0;\n width: 100%;\n}\n.pp-home-content > span {\n display: block;\n}\n.pp-home-name,\n.pp-home-item [data-module-status] {\n width: 100%;\n overflow: hidden;\n text-align: center;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-home-item [data-module-status] {\n min-height: 20px;\n}\n.pp-home-item:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.pp-home-entries[data-layout=\"list\"] {\n display: block;\n padding: 0;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item {\n flex-direction: row;\n gap: 12px;\n width: 100%;\n padding: 12px 16px;\n font-size: 15px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item + .pp-home-item {\n border-top: 1px solid var(--pp-border);\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-icon {\n flex: none;\n width: 40px;\n height: 40px;\n padding: 4px;\n font-size: 24px;\n line-height: 32px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-name {\n margin-top: 0;\n text-align: left;\n}\n.pp-home-arrow {\n color: var(--pp-muted);\n font-size: 24px;\n}\n.pp-home-footer {\n margin-top: 16px;\n text-align: center;\n white-space: normal;\n}\n.pp-home-footer p {\n margin: 8px 0 0;\n}\n";
1764
+
1765
+ const selector = "style[data-preference-panes-defaults]";
1766
+ const stylesheetSelector = "link[data-preference-panes-stylesheet]";
1767
+
1768
+ /**
1769
+ * 在文档中安装一次默认样式,并标记当前调用方是否拥有该节点。
1770
+ * Install default styles once and report whether the current caller owns the node.
1771
+ * @param {Document} document 目标文档 / Target document.
1772
+ * @returns {{element: HTMLStyleElement, owned: boolean}} 样式节点及所有权 / Style node and ownership.
1773
+ */
1774
+ function installDefaultStyles(document) {
1775
+ const existing = document.head.querySelector(selector);
1776
+ if (existing) return { element: existing, owned: false };
1777
+ const element = document.createElement("style");
1778
+ element.dataset.preferencePanesDefaults = "";
1779
+ element.textContent = defaults;
1780
+ document.head.insertBefore(element, document.head.querySelector(stylesheetSelector));
1781
+ return { element, owned: true };
1782
+ }
1783
+
1532
1784
  /**
1533
- * 使用原始 BoxJS JSON 挂载设置页。
1534
- * Mount a settings page from raw BoxJS JSON.
1785
+ * 使用原始 BoxJS JSON 挂载设置页,共用内部页面控制器。
1786
+ * Mount a settings page from raw BoxJS JSON through the shared internal page controller.
1535
1787
  * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1536
1788
  * @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
1537
1789
  */
1538
1790
  function mount(boxjs) {
1539
- return new PreferencesView(boxjs);
1791
+ const definition = normalizeBoxJs(boxjs);
1792
+ const title = definition.metadata?.name ?? definition.module;
1793
+ const existing = document.querySelector("#preferences");
1794
+ const root = existing ?? element("main", "");
1795
+ const previousTitle = document.title;
1796
+ const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
1797
+ const previousHeight = document.documentElement.style.getPropertyValue("--pp-host-height");
1798
+ const previousTheme = document.documentElement.dataset.theme;
1799
+ const previousClass = document.documentElement.classList.contains("pp-page");
1800
+ document.documentElement.classList.add("pp-page");
1801
+ root.dataset.preferencePanesPages = "";
1802
+ if (!existing) {
1803
+ root.id = "preferences";
1804
+ document.body.append(root);
1805
+ }
1806
+ const defaults = installDefaultStyles(document);
1807
+ const menu = new ActionMenu(id => page.perform(id));
1808
+ const host = browserHost({ menu, back: () => page.navigation.back() });
1809
+ const page = new PageController(root, element("main", "pp-home"), { host, menu, title, modules: new Map([[definition.module, { name: title, boxjs }]]) });
1810
+ if (!page.navigation.current) page.navigation.open(definition.module);
1811
+ let destroyed = false;
1812
+ const pagehide = event => {
1813
+ if (!event.persisted) destroy();
1814
+ };
1815
+ window.addEventListener("pagehide", pagehide);
1816
+ /**
1817
+ * 释放模块、样式与监听器,并恢复调用方的文档状态。
1818
+ * Release the module, styles and listeners and restore the caller's document state.
1819
+ * @returns {void} 无返回值 / No return value.
1820
+ */
1821
+ function destroy() {
1822
+ if (destroyed) return;
1823
+ destroyed = true;
1824
+ page.destroy();
1825
+ window.removeEventListener("pagehide", pagehide);
1826
+ if (defaults.owned) defaults.element.remove();
1827
+ if (existing) {
1828
+ root.replaceChildren();
1829
+ delete root.dataset.preferencePanesPages;
1830
+ } else root.remove();
1831
+ if (!previousClass) document.documentElement.classList.remove("pp-page");
1832
+ document.title = previousTitle;
1833
+ document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
1834
+ document.documentElement.style.setProperty("--pp-host-height", previousHeight);
1835
+ if (previousTheme === undefined) delete document.documentElement.dataset.theme;
1836
+ else document.documentElement.dataset.theme = previousTheme;
1837
+ }
1838
+ return { destroy };
1540
1839
  }
1541
1840
 
1542
1841
  export { mount };