@nsnanocat/preference-panes 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,3 @@
1
- /**
2
- * 解析已经取得的 pathname,避免重复构造 URL。
3
- * Parse an existing pathname without constructing another URL.
4
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
5
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
6
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
7
- */
8
-
9
1
  /**
10
2
  * 校验原始路径片段,不进行 URL 编码转换。
11
3
  * Validate raw path segments without URL encoding conversion.
@@ -116,7 +108,7 @@ function pageInputs(url, headers = {}) {
116
108
  const module = match[1];
117
109
  const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
118
110
  const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
119
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
111
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
120
112
  if (!json.trim()) throw new TypeError("JSON resource URL is required");
121
113
  return { url: url.href, module, json, css };
122
114
  }
@@ -181,7 +173,7 @@ function errorView(error, retry) {
181
173
  return view;
182
174
  }
183
175
 
184
- var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
176
+ var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
185
177
 
186
178
  /**
187
179
  * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
@@ -322,31 +314,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
322
314
  */
323
315
  const sessions = new Map();
324
316
  /**
325
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
326
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
327
- * @param {string} path 相对请求路径 / Relative request path.
328
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
329
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
317
+ * form 发送完整存储键;读取 404 交给调用方处理。
318
+ * Send a complete storage key as form data; callers handle missing reads.
319
+ * @param {string} path 完整 @root.path / Complete @root.path.
320
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
321
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
330
322
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
331
323
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
332
324
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
333
325
  */
334
- async function send(path, method, body, signal) {
326
+ async function send(path, action, body, signal) {
335
327
  const controller = new AbortController();
336
328
  const abort = () => controller.abort();
337
329
  if (signal?.aborted) abort();
338
330
  signal?.addEventListener("abort", abort, { once: true });
339
331
  const timer = setTimeout(abort, timeout);
340
332
  try {
341
- const response = await request(path, {
342
- method,
333
+ const response = await request(`/api/${action}`, {
334
+ method: "POST",
343
335
  credentials: "omit",
344
336
  cache: "no-store",
345
337
  signal: controller.signal,
346
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
347
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
338
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
339
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
348
340
  });
349
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
341
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
350
342
  return response;
351
343
  } finally {
352
344
  clearTimeout(timer);
@@ -370,21 +362,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
370
362
  * Serialize single-key mutations and update a still-active session only after success.
371
363
  * @param {string} module 已打开模块 / Open module.
372
364
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
373
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
365
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
374
366
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
375
367
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
376
368
  * @returns {Promise<void>} 操作完成 / Operation completion.
377
369
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
378
370
  */
379
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
371
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
380
372
  const state = sessions.get(module);
381
373
  if (!state?.definition) throw new Error("Open the module first");
382
374
  if (state.saving) throw new Error("A settings write is already in progress");
383
375
  const field = state.definition.fields.find(field => field.key === key);
384
376
  state.saving = true;
385
377
  try {
386
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
387
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
378
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
379
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
388
380
  if (sessions.get(module) === state) {
389
381
  switch (operation) {
390
382
  case "write":
@@ -427,7 +419,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
427
419
  sessions.set(module, state);
428
420
  try {
429
421
  const definition = normalizeBoxJs(catalog.select(module), module);
430
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
422
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
431
423
  let subtree = response.status === 404 ? {} : await response.json();
432
424
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
433
425
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -457,7 +449,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
457
449
  async readCaches(module) {
458
450
  const state = sessions.get(module);
459
451
  if (!state?.definition) throw new Error("Open the module first");
460
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
452
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
461
453
  return response.status === 404 ? undefined : response.json();
462
454
  },
463
455
  /**
@@ -466,14 +458,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
466
458
  * @param {string} module 已打开模块 / Open module.
467
459
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
468
460
  */
469
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
461
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
470
462
  /**
471
463
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
472
464
  * Delete module persistence and reset the page cache using current BoxJS defaults.
473
465
  * @param {string} module 已打开模块 / Open module.
474
466
  * @returns {Promise<void>} 重置完成 / Reset completion.
475
467
  */
476
- reset: module => change(module, module, "DELETE", undefined, "reset"),
468
+ reset: module => change(module, module, "delete", undefined, "reset"),
477
469
  /**
478
470
  * 取消读取并清除会话,不撤销已发送的写入。
479
471
  * Abort reads and clear the session without undoing dispatched writes.
@@ -492,7 +484,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
492
484
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
493
485
  * @returns {Promise<void>} 写入完成 / Write completion.
494
486
  */
495
- set: (module, key, value) => change(module, key, "POST", value),
487
+ set: (module, key, value) => change(module, key, "set", value),
496
488
  /**
497
489
  * 删除单键覆盖值并显示默认值。
498
490
  * Delete one override and display its default value.
@@ -500,7 +492,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
500
492
  * @param {string} key 点分字段路径 / Dotted field path.
501
493
  * @returns {Promise<void>} 删除完成 / Delete completion.
502
494
  */
503
- remove: (module, key) => change(module, key, "DELETE"),
495
+ remove: (module, key) => change(module, key, "delete"),
504
496
  };
505
497
  }
506
498
 
@@ -694,6 +686,17 @@ function mountPanel(root, catalog) {
694
686
  header.append(back, brand, element("span", "pp-nav-spacer"));
695
687
  shell.append(header, viewport, toast);
696
688
  root.append(shell);
689
+ // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
690
+ // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
691
+ const publishNavigation = () => {
692
+ const frame = window.frameElement;
693
+ if (!frame?.dataset.preferencePanes) return;
694
+ frame.dispatchEvent(
695
+ new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
696
+ detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
697
+ }),
698
+ );
699
+ };
697
700
  let timer,
698
701
  navigation,
699
702
  generation = 0,
@@ -744,6 +747,7 @@ function mountPanel(root, catalog) {
744
747
  active = module;
745
748
  back.disabled = window.history.length <= 1;
746
749
  heading.textContent = module;
750
+ publishNavigation();
747
751
  viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
748
752
  try {
749
753
  await client.open(module);
@@ -751,6 +755,7 @@ function mountPanel(root, catalog) {
751
755
  } catch (error) {
752
756
  if (version !== generation) return;
753
757
  viewport.replaceChildren(errorView(error, () => open(module)));
758
+ publishNavigation();
754
759
  }
755
760
  }
756
761
  /**
@@ -782,6 +787,7 @@ function mountPanel(root, catalog) {
782
787
  const editor = editors.get(navigation.current);
783
788
  heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
784
789
  back.disabled = saving || !navigation.canGoBack;
790
+ publishNavigation();
785
791
  };
786
792
  /**
787
793
  * 串行执行模块操作,保持输入可编辑。
@@ -795,6 +801,7 @@ function mountPanel(root, catalog) {
795
801
  pendingWrites++;
796
802
  saving = true;
797
803
  back.disabled = true;
804
+ publishNavigation();
798
805
  return (queue = queue
799
806
  .then(action)
800
807
  .then(() => {
@@ -810,6 +817,7 @@ function mountPanel(root, catalog) {
810
817
  saving = pendingWrites > 0;
811
818
  if (destroyed && !saving) client.leave(active);
812
819
  back.disabled = saving || !navigation.canGoBack;
820
+ publishNavigation();
813
821
  }));
814
822
  }
815
823
  const metadata = definition.metadata;
@@ -1148,7 +1156,19 @@ async function start() {
1148
1156
  view?.destroy();
1149
1157
  view = undefined;
1150
1158
  const context = document.querySelector('meta[name="preference-panes-inputs"]');
1151
- const inputs = context ? JSON.parse(decodeURIComponent(context.content)) : pageInputs(new URL(location.href));
1159
+ const embedded = window.frameElement?.dataset.preferencePanes;
1160
+ let inputs;
1161
+ switch (true) {
1162
+ case embedded !== undefined:
1163
+ inputs = JSON.parse(embedded);
1164
+ document.documentElement.dataset.preferencePanesEmbedded = "";
1165
+ break;
1166
+ case context !== null:
1167
+ inputs = JSON.parse(decodeURIComponent(context.content));
1168
+ break;
1169
+ default:
1170
+ inputs = pageInputs(new URL(location.href));
1171
+ }
1152
1172
  const resources = [inputs.json, inputs.css].map(source => {
1153
1173
  if (!source) return null;
1154
1174
  const url = new URL(source, inputs.url);
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/app.mjs?v=0.7.1"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.8.0"></script>
13
13
  </body>
14
14
  </html>
@@ -1,3 +1,103 @@
1
+ /**
2
+ * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
3
+ * Resolve module resource locations: headers override query parameters and module conventions.
4
+ * @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
5
+ * @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
6
+ * @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
7
+ */
8
+ function pageInputs(url, headers = {}) {
9
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
10
+ if (!match) throw new TypeError("Open a concrete module URL");
11
+ const module = match[1];
12
+ const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
13
+ const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
14
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
15
+ if (!json.trim()) throw new TypeError("JSON resource URL is required");
16
+ return { url: url.href, module, json, css };
17
+ }
18
+
19
+ /**
20
+ * 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
21
+ * Module document container: preserve HTML verbatim and carry request context on the iframe element.
22
+ */
23
+ class ModuleFrame extends EventTarget {
24
+ #url;
25
+ #options;
26
+ #controller = new AbortController();
27
+ #abort = () => this.destroy();
28
+ #state;
29
+ #change = event => {
30
+ this.#state = event.detail;
31
+ this.dispatchEvent(new Event("change"));
32
+ };
33
+
34
+ /**
35
+ * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
36
+ * Create the iframe and request inputs; callers mount element and then call load.
37
+ * @param {string | URL} url 模块请求地址 / Module request URL.
38
+ * @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
39
+ */
40
+ constructor(url, options = {}) {
41
+ super();
42
+ this.#url = new URL(url, document.baseURI);
43
+ this.#options = { ...options, headers: new Headers(options.headers) };
44
+ const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
45
+ this.element = document.createElement("iframe");
46
+ this.element.title = `${inputs.module} 设置`;
47
+ this.element.dataset.preferencePanes = JSON.stringify(inputs);
48
+ this.element.addEventListener("preferencepanes:change", this.#change);
49
+ this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
50
+ options.signal?.addEventListener("abort", this.#abort, { once: true });
51
+ }
52
+
53
+ /**
54
+ * 当前模块导航状态。
55
+ * Current module navigation state.
56
+ */
57
+ get state() {
58
+ return { ...this.#state };
59
+ }
60
+
61
+ /**
62
+ * 获取原始 HTML;晚到响应在退出后不得重新挂载。
63
+ * Fetch unmodified HTML; a late response must not remount after departure.
64
+ * @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
65
+ */
66
+ async load() {
67
+ if (this.#options.signal?.aborted) this.destroy();
68
+ const timer = setTimeout(() => this.#controller.abort(), 10000);
69
+ try {
70
+ const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
71
+ if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
72
+ const html = await response.text();
73
+ this.#controller.signal.throwIfAborted();
74
+ this.element.srcdoc = html;
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 使用 iframe 的联合历史返回;写入期间不导航。
82
+ * Navigate joint iframe history back, except while a write is pending.
83
+ * @returns {void} 无返回值 / No return value.
84
+ */
85
+ back() {
86
+ if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
87
+ }
88
+
89
+ /**
90
+ * 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
91
+ * Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
92
+ * @returns {void} 无返回值 / No return value.
93
+ */
94
+ destroy() {
95
+ this.#controller.abort();
96
+ this.#options.signal?.removeEventListener("abort", this.#abort);
97
+ this.element.removeEventListener("preferencepanes:change", this.#change);
98
+ }
99
+ }
100
+
1
101
  /**
2
102
  * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
3
103
  * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
@@ -157,4 +257,4 @@ class Navigation extends EventTarget {
157
257
  }
158
258
  }
159
259
 
160
- export { Navigation };
260
+ export { ModuleFrame, Navigation };
@@ -1,12 +1,4 @@
1
- var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
2
-
3
- /**
4
- * 解析已经取得的 pathname,避免重复构造 URL。
5
- * Parse an existing pathname without constructing another URL.
6
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
7
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
8
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
9
- */
1
+ var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
10
2
 
11
3
  /**
12
4
  * 校验原始路径片段,不进行 URL 编码转换。
@@ -304,31 +296,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
304
296
  */
305
297
  const sessions = new Map();
306
298
  /**
307
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
308
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
309
- * @param {string} path 相对请求路径 / Relative request path.
310
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
311
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
299
+ * form 发送完整存储键;读取 404 交给调用方处理。
300
+ * Send a complete storage key as form data; callers handle missing reads.
301
+ * @param {string} path 完整 @root.path / Complete @root.path.
302
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
303
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
312
304
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
313
305
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
314
306
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
315
307
  */
316
- async function send(path, method, body, signal) {
308
+ async function send(path, action, body, signal) {
317
309
  const controller = new AbortController();
318
310
  const abort = () => controller.abort();
319
311
  if (signal?.aborted) abort();
320
312
  signal?.addEventListener("abort", abort, { once: true });
321
313
  const timer = setTimeout(abort, timeout);
322
314
  try {
323
- const response = await request(path, {
324
- method,
315
+ const response = await request(`/api/${action}`, {
316
+ method: "POST",
325
317
  credentials: "omit",
326
318
  cache: "no-store",
327
319
  signal: controller.signal,
328
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
329
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
320
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
321
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
330
322
  });
331
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
323
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
332
324
  return response;
333
325
  } finally {
334
326
  clearTimeout(timer);
@@ -352,21 +344,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
352
344
  * Serialize single-key mutations and update a still-active session only after success.
353
345
  * @param {string} module 已打开模块 / Open module.
354
346
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
355
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
347
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
356
348
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
357
349
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
358
350
  * @returns {Promise<void>} 操作完成 / Operation completion.
359
351
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
360
352
  */
361
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
353
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
362
354
  const state = sessions.get(module);
363
355
  if (!state?.definition) throw new Error("Open the module first");
364
356
  if (state.saving) throw new Error("A settings write is already in progress");
365
357
  const field = state.definition.fields.find(field => field.key === key);
366
358
  state.saving = true;
367
359
  try {
368
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
369
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
360
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
361
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
370
362
  if (sessions.get(module) === state) {
371
363
  switch (operation) {
372
364
  case "write":
@@ -409,7 +401,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
409
401
  sessions.set(module, state);
410
402
  try {
411
403
  const definition = normalizeBoxJs(catalog.select(module), module);
412
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
404
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
413
405
  let subtree = response.status === 404 ? {} : await response.json();
414
406
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
415
407
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -439,7 +431,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
439
431
  async readCaches(module) {
440
432
  const state = sessions.get(module);
441
433
  if (!state?.definition) throw new Error("Open the module first");
442
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
434
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
443
435
  return response.status === 404 ? undefined : response.json();
444
436
  },
445
437
  /**
@@ -448,14 +440,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
448
440
  * @param {string} module 已打开模块 / Open module.
449
441
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
450
442
  */
451
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
443
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
452
444
  /**
453
445
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
454
446
  * Delete module persistence and reset the page cache using current BoxJS defaults.
455
447
  * @param {string} module 已打开模块 / Open module.
456
448
  * @returns {Promise<void>} 重置完成 / Reset completion.
457
449
  */
458
- reset: module => change(module, module, "DELETE", undefined, "reset"),
450
+ reset: module => change(module, module, "delete", undefined, "reset"),
459
451
  /**
460
452
  * 取消读取并清除会话,不撤销已发送的写入。
461
453
  * Abort reads and clear the session without undoing dispatched writes.
@@ -474,7 +466,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
474
466
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
475
467
  * @returns {Promise<void>} 写入完成 / Write completion.
476
468
  */
477
- set: (module, key, value) => change(module, key, "POST", value),
469
+ set: (module, key, value) => change(module, key, "set", value),
478
470
  /**
479
471
  * 删除单键覆盖值并显示默认值。
480
472
  * Delete one override and display its default value.
@@ -482,7 +474,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
482
474
  * @param {string} key 点分字段路径 / Dotted field path.
483
475
  * @returns {Promise<void>} 删除完成 / Delete completion.
484
476
  */
485
- remove: (module, key) => change(module, key, "DELETE"),
477
+ remove: (module, key) => change(module, key, "delete"),
486
478
  };
487
479
  }
488
480
 
@@ -676,6 +668,17 @@ function mountPanel(root, catalog) {
676
668
  header.append(back, brand, element("span", "pp-nav-spacer"));
677
669
  shell.append(header, viewport, toast);
678
670
  root.append(shell);
671
+ // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
672
+ // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
673
+ const publishNavigation = () => {
674
+ const frame = window.frameElement;
675
+ if (!frame?.dataset.preferencePanes) return;
676
+ frame.dispatchEvent(
677
+ new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
678
+ detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
679
+ }),
680
+ );
681
+ };
679
682
  let timer,
680
683
  navigation,
681
684
  generation = 0,
@@ -726,6 +729,7 @@ function mountPanel(root, catalog) {
726
729
  active = module;
727
730
  back.disabled = window.history.length <= 1;
728
731
  heading.textContent = module;
732
+ publishNavigation();
729
733
  viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
730
734
  try {
731
735
  await client.open(module);
@@ -733,6 +737,7 @@ function mountPanel(root, catalog) {
733
737
  } catch (error) {
734
738
  if (version !== generation) return;
735
739
  viewport.replaceChildren(errorView(error, () => open(module)));
740
+ publishNavigation();
736
741
  }
737
742
  }
738
743
  /**
@@ -764,6 +769,7 @@ function mountPanel(root, catalog) {
764
769
  const editor = editors.get(navigation.current);
765
770
  heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
766
771
  back.disabled = saving || !navigation.canGoBack;
772
+ publishNavigation();
767
773
  };
768
774
  /**
769
775
  * 串行执行模块操作,保持输入可编辑。
@@ -777,6 +783,7 @@ function mountPanel(root, catalog) {
777
783
  pendingWrites++;
778
784
  saving = true;
779
785
  back.disabled = true;
786
+ publishNavigation();
780
787
  return (queue = queue
781
788
  .then(action)
782
789
  .then(() => {
@@ -792,6 +799,7 @@ function mountPanel(root, catalog) {
792
799
  saving = pendingWrites > 0;
793
800
  if (destroyed && !saving) client.leave(active);
794
801
  back.disabled = saving || !navigation.canGoBack;
802
+ publishNavigation();
795
803
  }));
796
804
  }
797
805
  const metadata = definition.metadata;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Shared settings API runtime for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",