@nsnanocat/preference-panes 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ PreferencePanes 负责具体模块的设置页、共享导航和本地持久化
6
6
 
7
7
  嵌入的模块页跟随宿主根元素的 `data-theme`(light/dark)和 `--pp-keyboard-height`(CSS 长度),退出时释放观察器。独立页面使用网页自身/系统主题;通用包不再解析 Bilibili 的 User-Agent。
8
8
 
9
- 正式表单仅引用 `src/browser/official-styles.json` 中的官方 Hilo 资源地址,不内嵌、镜像或 Mock App 自带 CSS,也不发布样式下载包。目标环境是能解析这些官方资源的 App WebView,普通公网浏览器不提供资源兜底。原有手写开关、行样式和颜色表已删除;BoxJS 动态生成及修改即保存保持不变。
9
+ 正式表单通过独立 stylesheet 链接引用 `src/browser/official-styles.json` 中的官方 b-style 与主题 CDN,不内嵌、镜像或 Mock 官方 CSS。基础分页布局单独打包,不依赖远程样式加载。设置行由公开 b-style 工具类组成,不使用 Hilo 内置页面的编译作用域。开关采用标准 checkbox 的 switch 属性及 switch 语义,由浏览器呈现原生控件([Safari/iOS 17.4 起显示开关](https://webkit.org/blog/15054/an-html-switch-control/),其他浏览器保留可操作的复选框);单选使用 select,多选保留二级页面。BoxJS 动态生成及修改即保存保持不变。
10
10
 
11
11
  本地验证可运行 `npm run preview -- --override-official`,显式把官方 URL override 到 `test/fixtures/official-styles/`。副本和 SHA-256 只用于测试,不进入 npm 或 Release;不带此参数的预览与生产一样使用官方地址。
12
12
 
@@ -14,7 +14,7 @@ PreferencePanes 负责具体模块的设置页、共享导航和本地持久化
14
14
 
15
15
  宿主也可监听 `notice` 事件,通过 `preventDefault()` 接管 `{kind, message}` 提示;被接管时模块不创建网页 Toast、不启用提示计时器。独立使用的通用面板仍提供默认通知。
16
16
 
17
- 设置页顶部使用官方 VField 外观搜索当前字段的名称、说明、路径和选项标签。搜索只隐藏现有行,不重新生成控件、不追加网络读取;文本框、多行输入和下拉框也共用相同字段结构。
17
+ 设置页顶部使用标准 search 控件搜索当前字段的名称、说明、路径和选项标签。搜索只隐藏现有行,不重新生成控件、不追加网络读取;文本框、多行输入和下拉框共用官方配色、间距和原生输入结构。
18
18
 
19
19
  业务模块安装同一个 `https://github.com/NSNanoCat/PreferencePanes/releases/latest/download/api.js`,不再生成绑定业务配置的读写脚本,也不需要额外安装独立设置插件。该文件由本仓库 Release 工作流发布,自动更新遵循代理工具的缓存周期。
20
20
 
package/dist/api.js CHANGED
@@ -341,7 +341,7 @@
341
341
  });
342
342
  }
343
343
 
344
- var assets = {"page":{"type":"text/html","body":"<!doctype html>\n<html lang=\"zh-CN\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"color-scheme\" content=\"light dark\">\n <title>Module Preferences</title>\n <style>body { margin: 0; }</style>\n </head>\n <body>\n <main id=\"preferences\"></main>\n <script type=\"module\" src=\"/settings/assets/app.mjs?v=0.9.3\"></script>\n </body>\n</html>\n"},"/settings/assets/app.mjs":{"type":"text/javascript","body":"/**\n * 校验原始路径片段,不进行 URL 编码转换。\n * Validate raw path segments without URL encoding conversion.\n * @param {string[]} parts 原始路径片段 / Raw path segments.\n * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.\n * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.\n */\nfunction validatePathParts(parts) {\n if (!parts.every(part => typeof part === \"string\" && /^[a-zA-Z0-9_-]+$/.test(part) && ![\"__proto__\", \"prototype\", \"constructor\"].includes(part))) throw new TypeError(\"Invalid key path\");\n return parts;\n}\n\n/**\n * BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。\n * Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.\n */\nclass BoxJS {\n /**\n * 建立路径索引,不解析控件类型,也不读写持久化存储。\n * Index field paths without interpreting controls or accessing persistence.\n * @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.\n */\n constructor(input) {\n if (!input || typeof input !== \"object\") throw new TypeError(\"Expected BoxJS JSON\");\n this.document = JSON.parse(JSON.stringify(input));\n const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);\n if (!Array.isArray(apps)) throw new TypeError(\"Expected BoxJS apps array\");\n this.modules = new Map();\n for (const app of apps) {\n if (!app || !Array.isArray(app.settings)) throw new TypeError(\"Expected BoxJS settings array\");\n for (const entry of app.settings) {\n if (typeof entry.id !== \"string\") throw new TypeError(\"BoxJS settings require string IDs\");\n if (!entry.id.startsWith(\"@\")) {\n if (Array.isArray(this.document)) throw new TypeError(\"BoxJS settings require @root.path IDs\");\n continue;\n }\n const [storageKey, ...parts] = entry.id.slice(1).split(\".\");\n if (!storageKey || storageKey.startsWith(\"@\") || parts.length < 2) throw new TypeError(\"A BoxJS setting must be below a literal storage root and module\");\n validatePathParts(parts);\n const module = parts[0];\n let target = this.modules.get(module);\n if (!target) {\n target = { module, storageKey, entries: [], owners: new Set() };\n this.modules.set(module, target);\n }\n if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);\n target.entries.push(entry);\n target.owners.add(app);\n }\n }\n this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);\n for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};\n }\n\n /**\n * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。\n * Select a module's native BoxJS while retaining owning-app metadata.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.\n */\n select(module) {\n const target = this.modules.get(module);\n if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);\n if (Array.isArray(this.document)) return target.entries;\n const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));\n return this.document.apps ? { ...this.document, apps } : apps[0];\n }\n\n /**\n * 取得本次导入的唯一模块,避免把模块数据变成项目目录。\n * Get the single imported module without turning module data into a project directory.\n * @returns {object} 唯一模块的目录项 / The single module entry.\n */\n get module() {\n if (this.modules.size !== 1) throw new TypeError(\"Import BoxJS JSON for exactly one module\");\n return this.modules.values().next().value;\n }\n}\n\n/**\n * 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。\n * Retain standard BoxJS presentation data; script is metadata only and never executed.\n * @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.\n * @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.\n */\nfunction metadata(source) {\n const result = {};\n for (const key of [\"id\", \"name\", \"author\", \"repo\", \"script\", \"icon\", \"description\", \"desc\", \"icons\", \"descs\"]) {\n if (source[key] === undefined) continue;\n const multiple = key === \"icons\" || key === \"descs\";\n const values = multiple ? source[key] : [source[key]];\n if (!Array.isArray(values) || values.some(item => typeof item !== \"string\")) throw new TypeError(`Invalid BoxJS app ${key}`);\n result[key] = multiple ? [...values] : source[key];\n }\n return result;\n}\n\n/**\n * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。\n * Resolve module resource locations: headers override query parameters and module conventions.\n * @param {URL} url 已解析的页面请求地址 / Parsed page request URL.\n * @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.\n * @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.\n */\nfunction pageInputs(url, headers = {}) {\n const match = /^\\/settings\\/([a-zA-Z0-9_-]+)\\/?$/.exec(url.pathname);\n if (!match) throw new TypeError(\"Open a concrete module URL\");\n const module = match[1];\n const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));\n const json = values[\"x-preferencepanes-json\"] ?? url.searchParams.get(\"json\") ?? `/configs/${module}`;\n const css = values[\"x-preferencepanes-css\"] ?? url.searchParams.get(\"css\") ?? \"\";\n if (!json.trim()) throw new TypeError(\"JSON resource URL is required\");\n return { url: url.href, module, json, css };\n}\n\n/**\n * 创建元素,所有展示文本通过 textContent 写入。\n * Create elements and assign display text through textContent only.\n * @template {keyof HTMLElementTagNameMap} T\n * @param {T} tag 元素标签 / Element tag.\n * @param {string} className 样式类名 / CSS class.\n * @param {string} [text] 纯文本 / Plain text.\n * @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.\n */\nfunction element(tag, className, text) {\n const node = document.createElement(tag);\n node.className = className;\n // 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。\n // Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.\n if (/\\bform-row(?:\\b|__)/.test(className)) node.setAttribute(\"data-v-b69aa1ea\", \"\");\n if (/\\bform-group(?:\\b|__)/.test(className)) node.setAttribute(\"data-v-e590be47\", \"\");\n if (text !== undefined) node.textContent = text;\n return node;\n}\n\n/**\n * 搜索、选择和文本控件共用官方 VField 的 DOM 结构。\n * Share the official VField DOM structure across search, select and text controls.\n * @param {HTMLElement} control 已创建的原生控件 / Existing native control.\n * @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.\n * @returns {HTMLDivElement} 字段容器 / Field container.\n */\nfunction fieldControl(control, multiline = false) {\n const field = element(\"div\", `v-field pp-editor${multiline ? \" v-field--textarea\" : \"\"}`);\n const body = element(\"div\", \"v-field__body\");\n control.classList.add(\"v-field__control\");\n body.append(control);\n field.append(body);\n return field;\n}\n\n/**\n * 元数据地址只允许 HTTP(S) 和相对地址。\n * Allow only HTTP(S) and relative metadata addresses.\n * @param {string} value 元数据地址 / Metadata address.\n * @returns {string} 完整地址 / Absolute address.\n */\nfunction resourceURL(value) {\n const url = new URL(value, document.baseURI);\n if (![\"http:\", \"https:\"].includes(url.protocol)) throw new TypeError(\"Metadata URLs must use HTTP(S)\");\n return url.href;\n}\n\n/**\n * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。\n * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.\n * @param {import(\"../index.js\").BoxJSMetadata} metadata 展示信息 / Presentation metadata.\n * @param {string} className 样式 / CSS class.\n * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.\n */\nfunction icon(metadata, className) {\n const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];\n if (!source) return null;\n const image = element(\"img\", className);\n image.src = resourceURL(source);\n image.alt = \"\";\n return image;\n}\n\n/**\n * 共享加载失败视图,不创建配置表单或数据读取。\n * Share a load-error view without creating controls or reading settings.\n * @param {Error} error 失败原因 / Failure reason.\n * @param {() => unknown} retry 重试动作 / Retry action.\n * @returns {HTMLElement} 错误视图 / Error view.\n */\nfunction errorView(error, retry) {\n const view = element(\"section\", \"pp-error\");\n const button = element(\"button\", \"\", \"重新读取\");\n button.type = \"button\";\n button.onclick = retry;\n view.append(element(\"p\", \"\", `加载失败:${error.message}`), button);\n return view;\n}\n\n/**\n * 请求宿主确认;独立网页使用浏览器对话框。\n * Request confirmation from the host, using the browser dialog for standalone pages.\n * @param {Window} host 模块窗口 / Module window.\n * @param {string} message 确认内容 / Confirmation message.\n * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.\n */\nfunction requestConfirmation(host, message) {\n return new Promise((resolve, reject) => {\n const frame = host.frameElement;\n if (frame) {\n const event = new frame.ownerDocument.defaultView.CustomEvent(\"preferencepanes:confirm\", { cancelable: true, detail: { message, resolve, reject } });\n if (!frame.dispatchEvent(event)) return;\n }\n resolve(host.confirm(message));\n });\n}\n\nvar defaults = \"@import url(\\\"https://hilo.bilibili.com/h5_common/theme.min.css\\\") layer(preference-panes);\\n@import url(\\\"https://hilo.bilibili.com/h5_common/b-style.min.css\\\") layer(preference-panes);\\n@import url(\\\"https://hilo.bilibili.com/app_settings/assets/messageSettingsLayout-ltzQ1gMi.css\\\") layer(preference-panes);\\n@import url(\\\"https://hilo.bilibili.com/app_settings/assets/message-settings-BD3N1lqQ.css\\\") layer(preference-panes);\\n@layer preference-panes {\\n/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\\n.pp-panel {\\n --pp-text: var(--text1);\\n --pp-background: var(--bg2);\\n --pp-surface: var(--bg1);\\n --pp-border: var(--line_regular);\\n --pp-muted: var(--text3);\\n --pp-accent: var(--brand_pink);\\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 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/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\\n * Keep module icons in content so embedded hosts can use text-only native titles. */\\n.pp-module-logo {\\n display: none;\\n width: 64px;\\n height: 64px;\\n margin: 8px auto 20px;\\n}\\n.pp-module-logo:not(:empty) {\\n display: block;\\n}\\n.pp-module-logo 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:not(.v-toggle) {\\n font: inherit;\\n cursor: pointer;\\n border: 0;\\n background: none;\\n color: inherit;\\n}\\n.pp-panel .pp-back {\\n width: 44px;\\n height: 44px;\\n flex: none;\\n font-size: 34px;\\n line-height: 32px;\\n padding: 0;\\n}\\n.pp-panel button:disabled {\\n opacity: 0.5;\\n cursor: wait;\\n}\\n.pp-viewport {\\n position: relative;\\n height: calc(100vh - 52px - env(safe-area-inset-top));\\n overflow: hidden;\\n}\\n:root[data-preference-panes-embedded] .pp-header {\\n display: none;\\n}\\n:root[data-preference-panes-embedded] .pp-viewport {\\n height: 100vh;\\n}\\n@supports (height: 100dvh) {\\n .pp-viewport {\\n height: calc(100dvh - 52px - env(safe-area-inset-top));\\n }\\n :root[data-preference-panes-embedded] .pp-viewport {\\n height: 100dvh;\\n }\\n}\\n.pp-fields,\\n.pp-choice-page,\\n.pp-cache-page {\\n position: absolute;\\n inset: 0;\\n overflow: auto;\\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + 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-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}\\n.pp-search {\\n width: 100%;\\n margin-bottom: 12px;\\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-panel .v-toggle {\\n border: 0;\\n padding: 0;\\n flex: none;\\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-error button {\\n min-height: 44px;\\n padding: 8px 12px;\\n border-radius: 6px;\\n background: var(--pp-surface);\\n}\\n.pp-cache {\\n white-space: pre-wrap;\\n overflow-wrap: anywhere;\\n}\\n.pp-toast {\\n pointer-events: none;\\n position: fixed;\\n bottom: calc(30px + env(safe-area-inset-bottom));\\n left: 50%;\\n transform: translateX(-50%);\\n max-width: 90vw;\\n padding: 10px 16px;\\n border-radius: 8px;\\n background: #333e;\\n color: white;\\n font-size: 13px;\\n z-index: 20;\\n}\\n.pp-toast[data-kind=\\\"error\\\"] {\\n background: #8d2424;\\n}\\n.pp-panel :focus-visible {\\n outline: 2px solid var(--pp-accent);\\n outline-offset: -2px;\\n}\\n\\n}\";\n\n/**\n * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。\n * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.\n */\nclass ActionMenu {\n #button;\n #popup;\n #backdrop;\n #select;\n #document;\n #key = event => {\n if (event.key === \"Escape\" && !this.#popup.hidden) {\n event.preventDefault();\n this.close();\n this.#button.focus();\n }\n };\n\n /**\n * 创建菜单,操作逻辑由调用方提供。\n * Create a menu whose actions are handled by the caller.\n * @param {(id: string) => void} select 菜单选择回调 / Selection callback.\n */\n constructor(select) {\n this.#document = document;\n this.#select = select;\n this.element = document.createElement(\"span\");\n const root = this.element.attachShadow({ mode: \"open\" });\n root.innerHTML = `<style>\n :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}\n :host([hidden]),[hidden]{display:none!important}\n button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}\n button:disabled{opacity:.4;cursor:default}\n button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}\n #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}\n svg{display:block;width:24px;height:24px;fill:currentColor}\n #backdrop{position:fixed;inset:0;z-index:1}\n #items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}\n #items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}\n #items button:hover{background:#8882}\n #items button[data-danger]{color:#e45656}\n </style><button id=\"trigger\" type=\"button\" aria-label=\"更多操作\" aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"items\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"4\" cy=\"12\" r=\"2\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/><circle cx=\"20\" cy=\"12\" r=\"2\"/></svg></button><button id=\"backdrop\" type=\"button\" tabindex=\"-1\" aria-label=\"关闭菜单\" hidden></button><div id=\"items\" role=\"menu\" hidden></div>`;\n this.#button = root.querySelector(\"#trigger\");\n this.#popup = root.querySelector(\"#items\");\n this.#backdrop = root.querySelector(\"#backdrop\");\n this.#button.onclick = () => {\n const open = this.#popup.hidden;\n this.#popup.hidden = this.#backdrop.hidden = !open;\n this.#button.setAttribute(\"aria-expanded\", String(open));\n if (open) this.#popup.firstElementChild.focus();\n };\n this.#backdrop.onclick = () => this.close();\n this.#popup.onkeydown = event => {\n if (event.key === \"Tab\") {\n this.close();\n return;\n }\n const items = [...this.#popup.children];\n const index = items.indexOf(root.activeElement);\n const offsets = { ArrowDown: 1, ArrowUp: -1 };\n if (event.key in offsets) {\n event.preventDefault();\n items[(index + offsets[event.key] + items.length) % items.length].focus();\n }\n };\n document.addEventListener(\"keydown\", this.#key);\n this.update([]);\n }\n\n /**\n * 同步可用操作和忙碌状态,不重建菜单触发按钮。\n * Update actions and busy state without replacing the trigger button.\n * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.\n * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.\n * @returns {void} 无返回值 / No return value.\n */\n update(items, disabled = false) {\n this.close();\n this.#button.disabled = disabled || items.length === 0;\n this.#popup.replaceChildren(\n ...items.map(item => {\n const button = this.#document.createElement(\"button\");\n button.type = \"button\";\n button.setAttribute(\"role\", \"menuitem\");\n button.textContent = item.label;\n button.toggleAttribute(\"data-danger\", Boolean(item.destructive));\n button.onclick = () => {\n this.close();\n this.#select(item.id);\n };\n return button;\n }),\n );\n }\n\n /**\n * 关闭菜单。\n * Close the menu.\n * @returns {void} 无返回值 / No return value.\n */\n close() {\n this.#popup.hidden = this.#backdrop.hidden = true;\n this.#button.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * 移除监听器与节点。\n * Remove listeners and elements.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n this.#document.removeEventListener(\"keydown\", this.#key);\n this.element.remove();\n }\n}\n\n/**\n * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。\n * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.\n * @param {unknown} config BoxJS JSON / BoxJS document.\n * @param {string} module API 第一段模块名 / First API path segment.\n * @returns {import(\"../index.js\").ModuleDefinition} 存储根和字段 / Storage root and fields.\n * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.\n */\nfunction normalizeBoxJs(config, module) {\n validatePathParts([module]);\n const target = new BoxJS(config).modules.get(module);\n if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const { entries, storageKey, metadata } = target;\n const fields = [];\n for (const entry of entries) {\n const parts = entry.id.slice(1).split(\".\").slice(1);\n const type = { boolean: \"boolean\", checkboxes: \"array\", selects: \"select\", text: \"string\", textarea: \"string\", number: \"number\" }[entry.type];\n if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);\n const field = {\n key: parts.join(\".\"),\n type: type === \"select\" ? typeof entry.val : type,\n\n name: entry.name,\n description: entry.desc ?? \"\",\n control: entry.type,\n ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),\n ...(entry.rows === undefined ? {} : { rows: entry.rows }),\n ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),\n };\n if (type === \"select\" && ![\"string\", \"number\", \"boolean\"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);\n if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));\n if (Object.hasOwn(entry, \"val\")) field.defaultValue = normalizeStoredValue(field, entry.val);\n if (\n typeof field.name !== \"string\" ||\n (field.placeholder !== undefined && typeof field.placeholder !== \"string\") ||\n (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||\n (field.autoGrow !== undefined && typeof field.autoGrow !== \"boolean\") ||\n fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))\n )\n throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);\n 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}`);\n if (Object.hasOwn(field, \"defaultValue\") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);\n fields.push(field);\n }\n if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const common = fields[0].key.split(\".\").slice(0, -1);\n for (const field of fields) while (!field.key.startsWith(`${common.join(\".\")}.`)) common.pop();\n return {\n module,\n storageKey,\n fields,\n settingsPath: common,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n };\n}\n\n/**\n * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。\n * Normalize BoxJS string persistence without changing free-text values.\n * @param {import(\"../index.js\").SettingsField} field 前端字段约束 / Frontend field constraints.\n * @param {unknown} value 存储值 / Stored value.\n * @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.\n */\nfunction normalizeStoredValue(field, value) {\n switch (field.type) {\n case \"boolean\":\n if (value === \"true\" || value === \"false\") return value === \"true\";\n break;\n case \"number\":\n if (typeof value === \"string\" && value.trim() !== \"\") return Number(value);\n break;\n case \"array\":\n if (typeof value === \"string\") value = value === \"\" || value === \"[]\" ? [] : value.split(\",\");\n break;\n }\n if (field.options) {\n const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;\n return field.type === \"array\" && Array.isArray(value) ? value.map(match) : match(value);\n }\n return value;\n}\n\n/**\n * 校验支持的标量范围,包括文本长度与数值有限性。\n * Validate supported scalar bounds, including text length and numeric finiteness.\n * @param {unknown} value 待检查值 / Value to inspect.\n * @returns {boolean} 是否为有效标量 / Whether the scalar is valid.\n */\nfunction scalar(value) {\n switch (typeof value) {\n case \"boolean\":\n return true;\n case \"string\":\n return value.length <= 2048;\n case \"number\":\n return Number.isFinite(value);\n default:\n return false;\n }\n}\n\n/**\n * 检查值类型、数组唯一性及声明的选项,不进行转换。\n * Check value type, array uniqueness and declared choices without coercion.\n * @param {import(\"../index.js\").SettingsField} field 前端归一化字段 / Normalized frontend field.\n * @param {unknown} value 待写入的 JSON 值 / JSON value to write.\n * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.\n */\nfunction validValue(field, value) {\n if (field.type === \"array\") {\n if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;\n } else if (typeof value !== field.type || !scalar(value)) return false;\n return !field.options || (field.type === \"array\" ? value : [value]).every(item => field.options.some(option => option.key === item));\n}\n\n/**\n * 单个模块的临时会话;离开页面后丢弃。\n * Transient module session discarded when leaving the page.\n * @typedef {object} ModuleSession\n * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.\n * @property {import(\"../index.js\").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.\n * @property {import(\"./client.mjs\").ModuleSnapshot[\"values\"]} values 当前显示值 / Current display values.\n * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.\n */\n\n/**\n * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。\n * Create a page-session cache; reload on open and mutate cache only after HTTP 200.\n * @param {import(\"./client.mjs\").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.\n * @returns {import(\"./client.mjs\").PreferencesClient} 通用客户端 / Generic client.\n */\nfunction createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {\n /**\n * 模块会话表\n * Module session map.\n * @type {Map<string, ModuleSession>}\n */\n const sessions = new Map();\n /**\n * 用 form 发送完整存储键;读取 404 交给调用方处理。\n * Send a complete storage key as form data; callers handle missing reads.\n * @param {string} path 完整 @root.path / Complete @root.path.\n * @param {\"get\" | \"set\" | \"delete\"} action 存储操作 / Storage operation.\n * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.\n * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.\n * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.\n * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.\n */\n async function send(path, action, body, signal) {\n const controller = new AbortController();\n const abort = () => controller.abort();\n if (signal?.aborted) abort();\n signal?.addEventListener(\"abort\", abort, { once: true });\n const timer = setTimeout(abort, timeout);\n try {\n const response = await request(`/api/${action}`, {\n method: \"POST\",\n credentials: \"omit\",\n cache: \"no-store\",\n signal: controller.signal,\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams([[path, action === \"set\" ? JSON.stringify(body) : \"\"]]).toString(),\n });\n if (response.status !== 200 && !(action === \"get\" && response.status === 404)) throw new Error(`HTTP ${response.status}`);\n return response;\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", abort);\n }\n }\n /**\n * 获取独立快照,避免调用方修改内部缓存。\n * Return an independent snapshot so callers cannot mutate the cache.\n * @param {string} module 已打开模块 / Open module.\n * @returns {import(\"./client.mjs\").ModuleSnapshot} 会话快照 / Session snapshot.\n * @throws {Error} 模块未完成加载 / Module has not finished loading.\n */\n const snapshot = module => {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n return structuredClone({ definition: state.definition, values: state.values });\n };\n /**\n * 串行修改单键,仅成功后更新仍存活的会话。\n * Serialize single-key mutations and update a still-active session only after success.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 完整点分字段路径 / Complete dotted field path.\n * @param {\"set\" | \"delete\"} action 写入或删除 / Write or delete.\n * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.\n * @param {\"write\" | \"delete\" | \"clearCaches\" | \"reset\"} [operation] 操作类型 / Operation kind.\n * @returns {Promise<void>} 操作完成 / Operation completion.\n * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.\n */\n async function change(module, key, action, value, operation = action === \"set\" ? \"write\" : \"delete\") {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n if (state.saving) throw new Error(\"A settings write is already in progress\");\n const field = state.definition.fields.find(field => field.key === key);\n state.saving = true;\n try {\n if ((operation === \"write\" || operation === \"delete\") && (!field || (action === \"set\" && !validValue(field, value)))) throw new TypeError(\"Invalid setting value\");\n await send(`@${state.definition.storageKey}.${key}`, action, value);\n if (sessions.get(module) === state) {\n switch (operation) {\n case \"write\":\n state.values[key] = structuredClone(value);\n break;\n case \"delete\":\n case \"clearCaches\":\n case \"reset\":\n for (const candidate of state.definition.fields) {\n if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;\n delete state.values[candidate.key];\n if (Object.hasOwn(candidate, \"defaultValue\")) state.values[candidate.key] = structuredClone(candidate.defaultValue);\n }\n break;\n }\n }\n notify({ kind: \"success\", operation, module, key });\n } catch (error) {\n notify({ kind: \"error\", operation, module, key, message: error.message });\n throw error;\n } finally {\n state.saving = false;\n }\n }\n return {\n /**\n * 从已导入的 JSON 创建新会话,只读取一次设置值。\n * Create a session from imported JSON and read stored settings once.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {Promise<import(\"./client.mjs\").ModuleSnapshot>} 新快照 / New snapshot.\n * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.\n */\n async open(module) {\n const binding = catalog.modules.get(module);\n if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const previous = sessions.get(module);\n if (previous?.saving) throw new Error(\"Cannot refresh while saving\");\n previous?.controller.abort();\n const state = { controller: new AbortController(), definition: null, values: {}, saving: false };\n sessions.set(module, state);\n try {\n const definition = normalizeBoxJs(catalog.select(module), module);\n const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(\".\")}`, \"get\", undefined, state.controller.signal);\n let subtree = response.status === 404 ? {} : await response.json();\n if (typeof subtree === \"string\") subtree = JSON.parse(subtree);\n if (!subtree || typeof subtree !== \"object\" || Array.isArray(subtree)) throw new TypeError(\"Expected a settings subtree object\");\n if (sessions.get(module) !== state) throw new Error(\"Module session was replaced\");\n state.definition = definition;\n for (const field of definition.fields) {\n const stored = field.key\n .split(\".\")\n .slice(definition.settingsPath.length)\n .reduce((parent, part) => Object(parent)[part], subtree);\n const value = stored === undefined ? field.defaultValue : stored;\n if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);\n }\n return snapshot(module);\n } catch (error) {\n if (sessions.get(module) === state) sessions.delete(module);\n throw error;\n }\n },\n snapshot,\n /**\n * 按需读取模块 Caches,不自动读取其它设置。\n * Read module Caches on demand without refreshing other settings.\n * @param {string} module 已打开的模块 / Open module.\n * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.\n */\n async readCaches(module) {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n const response = await send(`@${state.definition.storageKey}.${module}.Caches`, \"get\", undefined, state.controller.signal);\n return response.status === 404 ? undefined : response.json();\n },\n /**\n * 删除整个 Caches 节点,成功后不追加 GET。\n * Delete the entire Caches node without a follow-up GET.\n * @param {string} module 已打开模块 / Open module.\n * @returns {Promise<void>} 清理完成 / Cleanup completion.\n */\n clearCaches: module => change(module, `${module}.Caches`, \"delete\", undefined, \"clearCaches\"),\n /**\n * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。\n * Delete module persistence and reset the page cache using current BoxJS defaults.\n * @param {string} module 已打开模块 / Open module.\n * @returns {Promise<void>} 重置完成 / Reset completion.\n */\n reset: module => change(module, module, \"delete\", undefined, \"reset\"),\n /**\n * 取消读取并清除会话,不撤销已发送的写入。\n * Abort reads and clear the session without undoing dispatched writes.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {void} 无返回值 / No return value.\n */\n leave(module) {\n sessions.get(module)?.controller.abort();\n sessions.delete(module);\n },\n /**\n * 写入单键并更新当前会话。\n * Write one key and update the current session.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 点分字段路径 / Dotted field path.\n * @param {import(\"../index.js\").SettingsScalar | import(\"../index.js\").SettingsScalar[]} value 字段值 / Field value.\n * @returns {Promise<void>} 写入完成 / Write completion.\n */\n set: (module, key, value) => change(module, key, \"set\", value),\n /**\n * 删除单键覆盖值并显示默认值。\n * Delete one override and display its default value.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 点分字段路径 / Dotted field path.\n * @returns {Promise<void>} 删除完成 / Delete completion.\n */\n remove: (module, key) => change(module, key, \"delete\"),\n };\n}\n\n/**\n * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。\n * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.\n */\nclass Navigation extends EventTarget {\n #container;\n #home;\n #create;\n #window;\n #key = null;\n #view;\n #retiring;\n #controller;\n #animation;\n #scroll = new WeakMap();\n #onHistory = () => this.#route();\n #onPageShow = event => {\n if (event.persisted) this.#route(true);\n };\n\n /**\n * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。\n * Retain the home view and create details on demand; signal cancels async work after departure.\n * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.\n * @param {HTMLElement} home 已创建的主页节点 / Existing home view.\n * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.\n */\n constructor(container, home, create) {\n super();\n this.#container = container;\n this.#home = home;\n this.#create = create;\n this.#window = container.ownerDocument.defaultView;\n container.replaceChildren(home);\n this.#window.addEventListener(\"popstate\", this.#onHistory);\n this.#window.addEventListener(\"hashchange\", this.#onHistory);\n this.#window.addEventListener(\"pageshow\", this.#onPageShow);\n this.#route();\n }\n\n /**\n * 当前子页键;空字符串表示主页。\n * Current detail key; empty means home.\n */\n get current() {\n return this.#key;\n }\n\n /**\n * 是否可以返回上一级或先前文档。\n * Whether a parent view or previous document is available.\n */\n get canGoBack() {\n return Boolean(this.#key) || this.#window.history.length > 1;\n }\n\n /**\n * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。\n * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.\n * @param {string} key 子页键 / Detail key.\n * @returns {void} 无返回值 / No return value.\n */\n open(key) {\n if (key === this.#key) return;\n const url = new URL(this.#window.location.href);\n url.hash = encodeURIComponent(key);\n this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, \"\", url.href);\n this.#route();\n }\n\n /**\n * 沿浏览器联合历史返回,根页可退回宿主或上个文档。\n * Go back through joint history, including a host or previous document from home.\n * @returns {void} 无返回值 / No return value.\n */\n back() {\n if (this.canGoBack) this.#window.history.back();\n }\n\n /**\n * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。\n * Resolve the URL and coordinate transitions, cancellation and release after animation.\n * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.\n * @returns {void} 无返回值 / No return value.\n */\n #route(reload = false) {\n const url = new URL(this.#window.location.href);\n let key;\n try {\n key = decodeURIComponent(url.hash.slice(1));\n } catch (error) {\n if (!(error instanceof URIError)) throw error;\n key = \"\";\n }\n if (!reload && key === this.#key) return;\n this.#controller?.abort();\n this.#controller = new AbortController();\n const next = key ? this.#create(key, this.#controller.signal) : undefined;\n if (!next) key = \"\";\n const history = this.#window.history;\n // 直接打开子页时建立一次主页历史;刷新不重复堆叠。\n // Seed home history once for direct details, without stacking entries on reload.\n if (url.hash && history.state?.preferencePanesRoute !== key) {\n url.hash = \"\";\n history.replaceState({ ...history.state, preferencePanesRoute: \"\" }, \"\", url.href);\n if (key) {\n url.hash = encodeURIComponent(key);\n history.pushState({ ...history.state, preferencePanesRoute: key }, \"\", url.href);\n }\n }\n const previous = this.#view;\n const position = previous ? this.#window.getComputedStyle(previous).transform : \"none\";\n this.#animation?.cancel();\n this.#retiring?.remove();\n this.#retiring = previous;\n if (previous) {\n this.#scroll.set(previous, previous.scrollTop);\n previous.inert = true;\n }\n this.#key = key;\n this.#view = next;\n this.#home.inert = Boolean(next);\n if (next) {\n next.inert = false;\n this.#container.append(next);\n next.scrollTop = this.#scroll.get(next) ?? 0;\n }\n const moving = next ?? previous;\n if (moving) {\n 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\" });\n this.#animation = animation;\n animation.onfinish = () => {\n if (this.#animation !== animation) return;\n this.#retiring?.remove();\n this.#retiring = undefined;\n animation.cancel();\n this.#animation = undefined;\n };\n }\n this.dispatchEvent(new Event(\"change\"));\n }\n\n /**\n * 释放监听器、加载、动画和节点;调用方可重新创建导航。\n * Release listeners, loads, animations and nodes so callers can recreate navigation.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n this.#window.removeEventListener(\"popstate\", this.#onHistory);\n this.#window.removeEventListener(\"hashchange\", this.#onHistory);\n this.#window.removeEventListener(\"pageshow\", this.#onPageShow);\n this.#controller?.abort();\n this.#animation?.cancel();\n this.#retiring?.remove();\n this.#view?.remove();\n this.#home.remove();\n }\n}\n\n/**\n * 挂载已导入 BoxJS 对应的模块表单和短暂通知。\n * Mount the imported BoxJS module form and transient notifications.\n * @param {HTMLElement} root 包内挂载元素 / Internal mount element.\n * @param {import(\"../BoxJS.mjs\").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.\n * @returns {import(\"./index.js\").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.\n */\nfunction mountPanel(root, catalog) {\n const title = catalog.module.metadata.name ?? catalog.module.module;\n const document = root.ownerDocument;\n const window = document.defaultView;\n const shell = element(\"div\", \"pp-panel\");\n shell.dataset.module = catalog.module.module;\n const header = element(\"header\", \"pp-header\");\n const back = element(\"button\", \"pp-back\", \"‹\");\n back.setAttribute(\"aria-label\", \"返回\");\n back.type = \"button\";\n const heading = element(\"h1\", \"pp-title\", title);\n const handlers = new Map();\n const menuItems = [\n { id: \"viewCaches\", label: \"查看缓存\" },\n { id: \"clearCaches\", label: \"清空缓存\", destructive: true },\n { id: \"reset\", label: \"重置模块\", destructive: true },\n ];\n const menu = new ActionMenu(id => runAction(id));\n const trailing = element(\"span\", \"pp-nav-spacer\");\n trailing.append(menu.element);\n const logo = element(\"span\", \"pp-module-logo\");\n logo.setAttribute(\"aria-hidden\", \"true\");\n const image = icon(catalog.module.metadata, \"\");\n if (image) logo.append(image);\n const viewport = element(\"div\", \"pp-viewport\");\n let toast;\n header.append(back, heading, trailing);\n shell.append(header, viewport);\n root.append(shell);\n // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。\n // Embedded mode publishes navigation state without host reads or mutations of the module DOM.\n const publishNavigation = () => {\n const actions = handlers.size ? menuItems : [];\n menu.update(actions, saving);\n const frame = window.frameElement;\n if (!frame?.dataset.preferencePanes) return;\n frame.dispatchEvent(\n new frame.ownerDocument.defaultView.CustomEvent(\"preferencepanes:change\", {\n detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },\n }),\n );\n };\n const onAction = event => {\n if (!saving && handlers.has(event.detail)) runAction(event.detail);\n };\n window.frameElement?.addEventListener(\"preferencepanes:action\", onAction);\n let timer,\n navigation,\n generation = 0,\n active = null,\n saving = false,\n destroyed = false;\n /**\n * 展示短暂通知,不刷新设置数据。\n * Display a transient notification without refreshing settings.\n * @param {{kind: \"success\" | \"error\", operation?: \"write\" | \"delete\" | \"clearCaches\" | \"reset\", message?: string}} event 操作结果 / Operation result.\n * @returns {void} 无返回值 / No return value.\n */\n const notify = event => {\n if (destroyed) return;\n let message;\n switch (true) {\n case event.kind === \"error\":\n message = `操作失败:${event.message}`;\n break;\n case event.operation === \"delete\":\n message = \"删除成功\";\n break;\n case event.operation === \"clearCaches\":\n message = \"Caches 已清空\";\n break;\n case event.operation === \"reset\":\n message = \"模块已重置\";\n break;\n default:\n message = \"修改成功\";\n break;\n }\n // 宿主接管时不创建网页 Toast,也不运行其计时器。\n // A host-owned notice creates no web Toast and starts no local timer.\n const frame = window.frameElement;\n if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent(\"preferencepanes:notice\", { cancelable: true, detail: { kind: event.kind, message } }))) return;\n if (!toast) {\n toast = element(\"div\", \"pp-toast\");\n toast.setAttribute(\"role\", \"status\");\n shell.append(toast);\n }\n toast.textContent = message;\n toast.dataset.kind = event.kind;\n toast.hidden = false;\n clearTimeout(timer);\n timer = setTimeout(() => {\n toast.hidden = true;\n }, 2400);\n };\n const client = createPreferencesClient({ catalog, notify });\n /**\n * 两种菜单入口共用异步错误处理,包含宿主确认框错误。\n * Share async error handling between both menus, including host-dialog errors.\n * @param {string} id 操作标识 / Action identifier.\n * @returns {Promise<void>} 操作已处理 / Action handled.\n */\n async function runAction(id) {\n try {\n await handlers.get(id)();\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n }\n }\n /**\n * 打开模块并忽略已过期的异步结果。\n * Open a module and ignore stale asynchronous results.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.\n */\n async function open(module) {\n const version = ++generation;\n active = module;\n back.disabled = window.history.length <= 1;\n heading.textContent = module;\n publishNavigation();\n viewport.replaceChildren(element(\"p\", \"pp-loading\", \"读取设置…\"));\n try {\n await client.open(module);\n if (version === generation) controls();\n } catch (error) {\n if (version !== generation) return;\n viewport.replaceChildren(errorView(error, () => open(module)));\n publishNavigation();\n }\n }\n /**\n * 从会话快照创建控件与操作按钮,不重新读取网络配置。\n * Build controls and actions from the session snapshot without fetching config again.\n * @returns {void} 无返回值 / No return value.\n */\n function controls() {\n const { definition, values } = client.snapshot(active);\n heading.textContent = definition.metadata?.name || active;\n const view = element(\"section\", \"pp-fields\");\n view.append(logo);\n const search = element(\"input\", \"\");\n search.type = \"search\";\n search.placeholder = \"搜索设置项\";\n search.setAttribute(\"aria-label\", \"搜索设置\");\n const searchField = fieldControl(search);\n searchField.classList.add(\"pp-search\");\n view.append(searchField);\n const searchRows = [];\n /**\n * 挂载后执行的多行高度更新\n * Textarea sizing callbacks run after mounting.\n * @type {Array<() => void>}\n */\n const growingInputs = [];\n const editors = new Map();\n const summaries = [];\n const groups = new Map();\n let queue = Promise.resolve(),\n pendingWrites = 0;\n /**\n * 导航组件处理页面切换,表单只更新当前标题与返回按钮。\n * Let navigation own transitions; the form only updates the title and back button.\n * @returns {void} 无返回值 / No return value.\n */\n const updateNavigation = () => {\n const editor = editors.get(navigation.current);\n heading.textContent = editor?.title ?? definition.metadata?.name ?? active;\n back.disabled = saving || !navigation.canGoBack;\n publishNavigation();\n };\n /**\n * 串行执行模块操作,保持输入可编辑。\n * Serialize module actions while keeping inputs editable.\n * @param {() => Promise<void>} action 请求或写入 / Request or mutation.\n * @param {() => void} success 成功后的局部更新 / Local update after success.\n * @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.\n * @returns {Promise<void>} 操作完成 / Operation completion.\n */\n function perform(action, success, failure = () => {}) {\n pendingWrites++;\n saving = true;\n back.disabled = true;\n publishNavigation();\n return (queue = queue\n .then(action)\n .then(() => {\n if (!destroyed) success();\n })\n .catch(() => {\n /* 请求层已通知错误。\n * The request layer has already reported the error. */\n if (!destroyed) failure();\n })\n .finally(() => {\n pendingWrites--;\n saving = pendingWrites > 0;\n if (destroyed && !saving) client.leave(active);\n back.disabled = saving || !navigation.canGoBack;\n publishNavigation();\n }));\n }\n const metadata = definition.metadata;\n if (metadata) {\n const info = element(\"div\", \"pp-module-info\");\n const details = element(\"div\", \"pp-module-details\");\n for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element(\"p\", \"pp-description\", description));\n if (metadata.repo) {\n const link = element(\"a\", \"pp-module-source\", \"项目主页\");\n link.href = resourceURL(metadata.repo);\n link.target = \"_blank\";\n link.rel = \"noopener noreferrer\";\n details.append(link);\n }\n info.append(details);\n view.append(info);\n }\n for (const field of definition.fields) {\n const match = /^\\[([^\\]]+)\\]\\s*(.*)$/.exec(field.name);\n const group = match?.[1] ?? \"通用\";\n if (!groups.has(group)) {\n const section = element(\"section\", \"form-group form-group--has-title\");\n const rows = element(\"div\", \"form-group__row\");\n section.append(element(\"h2\", \"form-group__title\", group), rows);\n groups.set(group, rows);\n view.append(section);\n }\n const row = element(\"div\", \"form-row pp-field\");\n const label = element(\"div\", \"form-row__text\");\n label.append(element(\"span\", \"form-row__title\", match?.[2] ?? field.name));\n if (field.description) label.append(element(\"span\", \"form-row__subtitle\", field.description));\n row.append(label);\n const value = values[field.key];\n /**\n * 读取尚未保存的输入\n * Read the unsaved input.\n * @type {() => unknown}\n */\n let read;\n /**\n * 更新当前控件\n * Update the current control.\n * @type {(value: unknown) => void}\n */\n let write;\n let inputContainer = row;\n let eventName = \"change\";\n switch (true) {\n case Boolean(field.options) && field.type !== \"array\": {\n const select = element(\"select\", \"\");\n select.setAttribute(\"aria-label\", field.name);\n field.options.forEach((option, index) => {\n const item = element(\"option\", \"\", option.label);\n item.value = String(index);\n select.append(item);\n });\n write = value => {\n select.selectedIndex = field.options.findIndex(option => option.key === value);\n };\n row.append(fieldControl(select));\n read = () => field.options[select.selectedIndex]?.key;\n break;\n }\n case field.type === \"array\" && Boolean(field.options): {\n const page = element(\"section\", \"pp-choice-page\");\n if (field.description) page.append(element(\"p\", \"pp-description\", field.description));\n const choices = element(\"div\", \"form-group__row\");\n page.append(choices);\n inputContainer = choices;\n editors.set(field.key, { node: page, title: match?.[2] ?? field.name });\n const summary = element(\"span\", \"form-row__value pp-summary\");\n const link = element(\"button\", \"pp-choice-link\");\n link.type = \"button\";\n link.setAttribute(\"aria-label\", field.name);\n link.append(summary, element(\"span\", \"pp-chevron\", \"›\"));\n row.append(link);\n const refresh = () => {\n const value = client.snapshot(active).values[field.key];\n summary.textContent =\n field.options\n .filter(option => Array.isArray(value) && value.includes(option.key))\n .map(option => option.label)\n .join(\"、\") || \"未选择\";\n };\n summaries.push(refresh);\n refresh();\n link.onclick = () => navigation.open(field.key);\n row.addEventListener(\"click\", event => {\n if (!link.contains(event.target)) link.click();\n });\n const inputs = field.options.map(option => {\n const label = element(\"label\", \"form-row pp-choice\", option.label);\n const input = element(\"input\", \"\");\n input.type = \"checkbox\";\n input.setAttribute(\"aria-label\", option.label);\n label.append(input);\n choices.append(label);\n return { input, key: option.key };\n });\n read = () => inputs.filter(option => option.input.checked).map(option => option.key);\n write = value => {\n for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);\n };\n break;\n }\n case field.type === \"boolean\": {\n const toggle = element(\"button\", \"v-toggle v-toggle--small form-row__toggle\");\n toggle.type = \"button\";\n toggle.setAttribute(\"role\", \"switch\");\n toggle.setAttribute(\"aria-label\", field.name);\n toggle.append(element(\"span\", \"v-toggle__circle\"));\n write = value => {\n toggle.setAttribute(\"aria-checked\", String(value === true));\n toggle.classList.toggle(\"v-toggle--closed\", value !== true);\n };\n read = () => toggle.getAttribute(\"aria-checked\") === \"true\";\n toggle.onclick = () => {\n write(!read());\n toggle.dispatchEvent(new window.Event(\"change\", { bubbles: true }));\n };\n row.append(toggle);\n break;\n }\n default: {\n const multiline = field.control === \"textarea\" || field.type === \"array\";\n const input = element(multiline ? \"textarea\" : \"input\", \"\");\n if (multiline) row.classList.add(\"pp-multiline\");\n input.setAttribute(\"aria-label\", field.name);\n if (field.placeholder) input.placeholder = field.placeholder;\n if (multiline && field.rows) input.rows = field.rows;\n /**\n * 在挂载后根据内容调整高度,同时保留基础行数。\n * Size mounted textareas to their contents while retaining baseline rows.\n * @returns {void} 无返回值 / No return value.\n */\n const grow = () => {\n if (!multiline || !field.autoGrow || !input.isConnected) return;\n input.style.height = \"auto\";\n const baseline = input.getBoundingClientRect().height;\n const style = window.getComputedStyle(input);\n const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);\n input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;\n };\n if (multiline && field.autoGrow) {\n input.addEventListener(\"input\", grow);\n growingInputs.push(grow);\n }\n eventName = \"input\";\n if (!multiline) input.type = field.type === \"number\" ? \"number\" : \"text\";\n write = value => {\n input.value = field.type === \"array\" ? JSON.stringify(value ?? []) : (value ?? \"\");\n grow();\n };\n read = () => {\n switch (field.type) {\n case \"array\":\n return JSON.parse(input.value);\n case \"number\":\n return input.value === \"\" ? Number.NaN : Number(input.value);\n default:\n return input.value;\n }\n };\n row.append(fieldControl(input, multiline));\n break;\n }\n }\n write(value);\n let inputVersion = 0;\n inputContainer.addEventListener(eventName, event => {\n if (event.isComposing) return;\n const version = ++inputVersion,\n module = active;\n let value;\n try {\n value = read();\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n return;\n }\n const restore = () => {\n if (version === inputVersion) write(client.snapshot(module).values[field.key]);\n };\n perform(\n () => client.set(module, field.key, value),\n () => {\n for (const refresh of summaries) refresh();\n },\n restore,\n );\n });\n if (eventName === \"input\") inputContainer.addEventListener(\"compositionend\", event => event.target.dispatchEvent(new window.Event(\"input\", { bubbles: true })));\n groups.get(group).append(row);\n searchRows.push({ row, text: [field.name, field.key, field.description, ...(field.options ?? []).map(option => option.label)].join(\" \").toLocaleLowerCase() });\n }\n const empty = element(\"p\", \"pp-description\", \"没有匹配的设置项\");\n empty.hidden = true;\n empty.setAttribute(\"role\", \"status\");\n view.append(empty);\n search.oninput = () => {\n const words = search.value.trim().toLocaleLowerCase().split(/\\s+/);\n for (const { row, text } of searchRows) row.hidden = !words.every(word => text.includes(word));\n for (const rows of groups.values()) rows.parentElement.hidden = [...rows.children].every(row => row.hidden);\n empty.hidden = searchRows.some(({ row }) => !row.hidden);\n };\n const cachePage = element(\"section\", \"pp-cache-page\");\n const output = element(\"pre\", \"pp-cache\");\n output.textContent = \"暂无缓存\";\n output.setAttribute(\"aria-label\", \"Caches 内容\");\n cachePage.append(output);\n editors.set(\"$caches\", { node: cachePage, title: \"缓存\" });\n handlers.set(\"viewCaches\", () => {\n if (saving) return;\n let value;\n return perform(\n async () => {\n try {\n value = await client.readCaches(active);\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n throw error;\n }\n },\n () => {\n output.textContent = value === undefined ? \"暂无缓存\" : JSON.stringify(value, null, 2);\n navigation.open(\"$caches\");\n },\n );\n });\n handlers.set(\"clearCaches\", async () => {\n if (saving) return;\n if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;\n return perform(\n () => client.clearCaches(active),\n () => {\n output.textContent = \"暂无缓存\";\n },\n );\n });\n handlers.set(\"reset\", async () => {\n if (saving) return;\n if (!(await requestConfirmation(window, `重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;\n return perform(() => client.reset(active), controls);\n });\n navigation?.destroy();\n navigation = new Navigation(viewport, view, key => editors.get(key)?.node);\n navigation.addEventListener(\"change\", updateNavigation);\n for (const grow of growingInputs) grow();\n updateNavigation();\n }\n /**\n * 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。\n * Loaded forms delegate back to navigation; loading views can return to the previous document.\n * @returns {void} 无返回值 / No return value.\n */\n back.onclick = () => {\n if (saving) return;\n if (navigation) navigation.back();\n else window.history.back();\n };\n open(catalog.module.module);\n return {\n /**\n * 移除监听器、定时器、会话和挂载内容。\n * Remove listeners, timers, session and mounted content.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n destroyed = true;\n menu.destroy();\n window.frameElement?.removeEventListener(\"preferencepanes:action\", onAction);\n navigation?.destroy();\n generation++;\n if (active && !saving) client.leave(active);\n clearTimeout(timer);\n shell.remove();\n },\n };\n}\n\n/**\n * 挂载模块设置页;内置资源只引用官方地址,CSS 输入仅用于该页。\n * Mount the module page with official resource URLs and optional page-specific CSS.\n * @param {import(\"../index.js\").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.\n * @param {string} [css] 可选 CSS 正文 / Optional CSS text.\n * @returns {import(\"./index.js\").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.\n */\nfunction mount(boxjs, css = \"\") {\n if (typeof css !== \"string\") throw new TypeError(\"CSS must be a string\");\n const catalog = new BoxJS(boxjs);\n const metadata = catalog.module.metadata;\n const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];\n if (image) resourceURL(image);\n if (metadata.repo) resourceURL(metadata.repo);\n const existing = document.querySelector(\"#preferences\");\n const root = existing ?? element(\"main\", \"\");\n if (!existing) {\n root.id = \"preferences\";\n document.body.append(root);\n }\n const base = element(\"style\", \"\"),\n custom = element(\"style\", \"\");\n base.textContent = defaults;\n custom.textContent = css;\n document.head.append(base, custom);\n const previousTitle = document.title;\n const previousTheme = document.documentElement.dataset.theme;\n const previousDark = document.documentElement.classList.contains(\"bili_dark\");\n const systemTheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const previousKeyboard = document.documentElement.style.getPropertyValue(\"--pp-keyboard-height\");\n const host = window.frameElement?.ownerDocument.documentElement;\n /**\n * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。\n * Follow generic host appearance without detecting a business app or parsing its user agent.\n * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.\n */\n const syncAppearance = () => {\n const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? \"dark\" : \"light\");\n document.documentElement.dataset.theme = theme;\n document.documentElement.classList.toggle(\"bili_dark\", theme === \"dark\");\n if (host) document.documentElement.style.setProperty(\"--pp-keyboard-height\", host.style.getPropertyValue(\"--pp-keyboard-height\"));\n };\n let observer;\n syncAppearance();\n systemTheme.addEventListener(\"change\", syncAppearance);\n if (host) {\n observer = new MutationObserver(syncAppearance);\n observer.observe(host, { attributes: true, attributeFilter: [\"data-theme\", \"style\"] });\n }\n document.title = metadata.name ?? catalog.module.module;\n let panel;\n const view = {\n /**\n * 释放模块视图、样式与会话,不操作项目入口页。\n * Release the module view, styles and session without operating a project landing page.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n observer?.disconnect();\n systemTheme.removeEventListener(\"change\", syncAppearance);\n document.documentElement.classList.toggle(\"bili_dark\", previousDark);\n panel?.destroy();\n base.remove();\n custom.remove();\n if (existing) root.replaceChildren();\n else root.remove();\n document.title = previousTitle;\n if (previousTheme === undefined) delete document.documentElement.dataset.theme;\n else document.documentElement.dataset.theme = previousTheme;\n document.documentElement.style.setProperty(\"--pp-keyboard-height\", previousKeyboard);\n },\n };\n try {\n root.replaceChildren();\n panel = mountPanel(root, catalog);\n return view;\n } catch (error) {\n view.destroy();\n throw error;\n }\n}\n\nlet view;\n/**\n * 从 URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。\n * Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.\n * @returns {Promise<void>} 启动完成 / Startup completion.\n */\nasync function start() {\n try {\n view?.destroy();\n view = undefined;\n const context = document.querySelector('meta[name=\"preference-panes-inputs\"]');\n const embedded = window.frameElement?.dataset.preferencePanes;\n let inputs;\n switch (true) {\n case embedded !== undefined:\n inputs = JSON.parse(embedded);\n document.documentElement.dataset.preferencePanesEmbedded = \"\";\n break;\n case context !== null:\n inputs = JSON.parse(decodeURIComponent(context.content));\n break;\n default:\n inputs = pageInputs(new URL(location.href));\n }\n const resources = [inputs.json, inputs.css].map(source => {\n if (!source) return null;\n const url = new URL(source, inputs.url);\n if (![\"http:\", \"https:\"].includes(url.protocol)) throw new TypeError(\"Resources must use HTTP(S) URLs\");\n return url.href;\n });\n const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: \"no-store\", credentials: \"omit\" }) : null)));\n if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);\n const boxjs = await data.json();\n if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error(\"Imported JSON does not match the module URL\");\n view = mount(boxjs, style ? await style.text() : \"\");\n } catch (error) {\n document.querySelector(\"#preferences\").replaceChildren(errorView(error, start));\n }\n}\nstart();\nwindow.addEventListener(\"pageshow\", event => {\n if (event.persisted) start();\n});\n"}};
344
+ var assets = {"page":{"type":"text/html","body":"<!doctype html>\n<html lang=\"zh-CN\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"color-scheme\" content=\"light dark\">\n <title>Module Preferences</title>\n <style>body { margin: 0; }</style>\n </head>\n <body>\n <main id=\"preferences\"></main>\n <script type=\"module\" src=\"/settings/assets/app.mjs?v=0.9.4\"></script>\n </body>\n</html>\n"},"/settings/assets/app.mjs":{"type":"text/javascript","body":"/**\n * 校验原始路径片段,不进行 URL 编码转换。\n * Validate raw path segments without URL encoding conversion.\n * @param {string[]} parts 原始路径片段 / Raw path segments.\n * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.\n * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.\n */\nfunction validatePathParts(parts) {\n if (!parts.every(part => typeof part === \"string\" && /^[a-zA-Z0-9_-]+$/.test(part) && ![\"__proto__\", \"prototype\", \"constructor\"].includes(part))) throw new TypeError(\"Invalid key path\");\n return parts;\n}\n\n/**\n * BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。\n * Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.\n */\nclass BoxJS {\n /**\n * 建立路径索引,不解析控件类型,也不读写持久化存储。\n * Index field paths without interpreting controls or accessing persistence.\n * @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.\n */\n constructor(input) {\n if (!input || typeof input !== \"object\") throw new TypeError(\"Expected BoxJS JSON\");\n this.document = JSON.parse(JSON.stringify(input));\n const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);\n if (!Array.isArray(apps)) throw new TypeError(\"Expected BoxJS apps array\");\n this.modules = new Map();\n for (const app of apps) {\n if (!app || !Array.isArray(app.settings)) throw new TypeError(\"Expected BoxJS settings array\");\n for (const entry of app.settings) {\n if (typeof entry.id !== \"string\") throw new TypeError(\"BoxJS settings require string IDs\");\n if (!entry.id.startsWith(\"@\")) {\n if (Array.isArray(this.document)) throw new TypeError(\"BoxJS settings require @root.path IDs\");\n continue;\n }\n const [storageKey, ...parts] = entry.id.slice(1).split(\".\");\n if (!storageKey || storageKey.startsWith(\"@\") || parts.length < 2) throw new TypeError(\"A BoxJS setting must be below a literal storage root and module\");\n validatePathParts(parts);\n const module = parts[0];\n let target = this.modules.get(module);\n if (!target) {\n target = { module, storageKey, entries: [], owners: new Set() };\n this.modules.set(module, target);\n }\n if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);\n target.entries.push(entry);\n target.owners.add(app);\n }\n }\n this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);\n for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};\n }\n\n /**\n * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。\n * Select a module's native BoxJS while retaining owning-app metadata.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.\n */\n select(module) {\n const target = this.modules.get(module);\n if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);\n if (Array.isArray(this.document)) return target.entries;\n const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));\n return this.document.apps ? { ...this.document, apps } : apps[0];\n }\n\n /**\n * 取得本次导入的唯一模块,避免把模块数据变成项目目录。\n * Get the single imported module without turning module data into a project directory.\n * @returns {object} 唯一模块的目录项 / The single module entry.\n */\n get module() {\n if (this.modules.size !== 1) throw new TypeError(\"Import BoxJS JSON for exactly one module\");\n return this.modules.values().next().value;\n }\n}\n\n/**\n * 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。\n * Retain standard BoxJS presentation data; script is metadata only and never executed.\n * @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.\n * @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.\n */\nfunction metadata(source) {\n const result = {};\n for (const key of [\"id\", \"name\", \"author\", \"repo\", \"script\", \"icon\", \"description\", \"desc\", \"icons\", \"descs\"]) {\n if (source[key] === undefined) continue;\n const multiple = key === \"icons\" || key === \"descs\";\n const values = multiple ? source[key] : [source[key]];\n if (!Array.isArray(values) || values.some(item => typeof item !== \"string\")) throw new TypeError(`Invalid BoxJS app ${key}`);\n result[key] = multiple ? [...values] : source[key];\n }\n return result;\n}\n\n/**\n * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。\n * Resolve module resource locations: headers override query parameters and module conventions.\n * @param {URL} url 已解析的页面请求地址 / Parsed page request URL.\n * @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.\n * @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.\n */\nfunction pageInputs(url, headers = {}) {\n const match = /^\\/settings\\/([a-zA-Z0-9_-]+)\\/?$/.exec(url.pathname);\n if (!match) throw new TypeError(\"Open a concrete module URL\");\n const module = match[1];\n const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));\n const json = values[\"x-preferencepanes-json\"] ?? url.searchParams.get(\"json\") ?? `/configs/${module}`;\n const css = values[\"x-preferencepanes-css\"] ?? url.searchParams.get(\"css\") ?? \"\";\n if (!json.trim()) throw new TypeError(\"JSON resource URL is required\");\n return { url: url.href, module, json, css };\n}\n\n/**\n * 创建元素,所有展示文本通过 textContent 写入。\n * Create elements and assign display text through textContent only.\n * @template {keyof HTMLElementTagNameMap} T\n * @param {T} tag 元素标签 / Element tag.\n * @param {string} className 样式类名 / CSS class.\n * @param {string} [text] 纯文本 / Plain text.\n * @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.\n */\nfunction element(tag, className, text) {\n const node = document.createElement(tag);\n node.className = className;\n if (text !== undefined) node.textContent = text;\n return node;\n}\n\n/**\n * 用官方 b-style 组合行布局,不绑定某个 App 内置页面的编译作用域。\n * Compose rows with official b-style utilities without private app-page compilation scopes.\n * @template {\"div\" | \"label\"} T\n * @param {T} tag 行元素 / Row element.\n * @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.\n */\nfunction settingRow(tag) {\n return element(tag, \"pp-row flex_between pd_md bb_1 bc_line_regular bg_bg1\");\n}\n\n/**\n * 搜索、选择和文本控件共用官方输入配色与间距,交互由标准 HTML 控件负责。\n * Share official colors and spacing while native HTML controls own input interaction.\n * @param {HTMLElement} control 已创建的原生控件 / Existing native control.\n * @returns {HTMLElement} 输入控件 / Input control.\n */\nfunction fieldControl(control) {\n control.classList.add(\"pp-editor\", \"bg_bg3\", \"text1\", \"pd_sm\", \"bd_radius_md\");\n return control;\n}\n\n/**\n * 元数据地址只允许 HTTP(S) 和相对地址。\n * Allow only HTTP(S) and relative metadata addresses.\n * @param {string} value 元数据地址 / Metadata address.\n * @returns {string} 完整地址 / Absolute address.\n */\nfunction resourceURL(value) {\n const url = new URL(value, document.baseURI);\n if (![\"http:\", \"https:\"].includes(url.protocol)) throw new TypeError(\"Metadata URLs must use HTTP(S)\");\n return url.href;\n}\n\n/**\n * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。\n * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.\n * @param {import(\"../index.js\").BoxJSMetadata} metadata 展示信息 / Presentation metadata.\n * @param {string} className 样式 / CSS class.\n * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.\n */\nfunction icon(metadata, className) {\n const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];\n if (!source) return null;\n const image = element(\"img\", className);\n image.src = resourceURL(source);\n image.alt = \"\";\n return image;\n}\n\n/**\n * 共享加载失败视图,不创建配置表单或数据读取。\n * Share a load-error view without creating controls or reading settings.\n * @param {Error} error 失败原因 / Failure reason.\n * @param {() => unknown} retry 重试动作 / Retry action.\n * @returns {HTMLElement} 错误视图 / Error view.\n */\nfunction errorView(error, retry) {\n const view = element(\"section\", \"pp-error\");\n const button = element(\"button\", \"\", \"重新读取\");\n button.type = \"button\";\n button.onclick = retry;\n view.append(element(\"p\", \"\", `加载失败:${error.message}`), button);\n return view;\n}\n\n/**\n * 请求宿主确认;独立网页使用浏览器对话框。\n * Request confirmation from the host, using the browser dialog for standalone pages.\n * @param {Window} host 模块窗口 / Module window.\n * @param {string} message 确认内容 / Confirmation message.\n * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.\n */\nfunction requestConfirmation(host, message) {\n return new Promise((resolve, reject) => {\n const frame = host.frameElement;\n if (frame) {\n const event = new frame.ownerDocument.defaultView.CustomEvent(\"preferencepanes:confirm\", { cancelable: true, detail: { message, resolve, reject } });\n if (!frame.dispatchEvent(event)) return;\n }\n resolve(host.confirm(message));\n });\n}\n\nvar styleURLs = [\"https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/theme.min.css\",\"https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/b-style.min.css\"];\n\nvar defaults = \"/* 官方 b-style 负责行布局与配色;本文件只定义面板容器和控件约束。\\n * Official b-style owns row layout and colors; this file defines panel containers and control constraints. */\\n.pp-panel {\\n --pp-text: var(--text1);\\n --pp-background: var(--bg2);\\n --pp-surface: var(--bg1);\\n --pp-border: var(--line_regular);\\n --pp-muted: var(--text3);\\n --pp-accent: var(--brand_pink);\\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 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/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\\n * Keep module icons in content so embedded hosts can use text-only native titles. */\\n.pp-module-logo {\\n display: none;\\n width: 64px;\\n height: 64px;\\n margin: 8px auto 20px;\\n}\\n.pp-module-logo:not(:empty) {\\n display: block;\\n}\\n.pp-module-logo img {\\n display: block;\\n width: 100%;\\n height: 100%;\\n object-fit: contain;\\n}\\n.pp-nav-spacer {\\n width: 44px;\\n flex: none;\\n}\\n.pp-panel button {\\n font: inherit;\\n cursor: pointer;\\n border: 0;\\n background: none;\\n color: inherit;\\n}\\n.pp-panel .pp-back {\\n width: 44px;\\n height: 44px;\\n flex: none;\\n font-size: 34px;\\n line-height: 32px;\\n padding: 0;\\n}\\n.pp-panel button:disabled {\\n opacity: 0.5;\\n cursor: wait;\\n}\\n.pp-viewport {\\n position: relative;\\n height: calc(100vh - 52px - env(safe-area-inset-top));\\n overflow: hidden;\\n}\\n:root[data-preference-panes-embedded] .pp-header {\\n display: none;\\n}\\n:root[data-preference-panes-embedded] .pp-viewport {\\n height: 100vh;\\n}\\n@supports (height: 100dvh) {\\n .pp-viewport {\\n height: calc(100dvh - 52px - env(safe-area-inset-top));\\n }\\n :root[data-preference-panes-embedded] .pp-viewport {\\n height: 100dvh;\\n }\\n}\\n.pp-fields,\\n.pp-choice-page,\\n.pp-cache-page {\\n position: absolute;\\n inset: 0;\\n overflow: auto;\\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + 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-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 font: inherit;\\n border: 0;\\n}\\n.pp-search {\\n width: 100%;\\n margin-bottom: 12px;\\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}\\n.pp-row {\\n min-height: 48px;\\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-error button {\\n min-height: 44px;\\n padding: 8px 12px;\\n border-radius: 6px;\\n background: var(--pp-surface);\\n}\\n.pp-cache {\\n white-space: pre-wrap;\\n overflow-wrap: anywhere;\\n}\\n.pp-toast {\\n pointer-events: none;\\n position: fixed;\\n bottom: calc(30px + env(safe-area-inset-bottom));\\n left: 50%;\\n transform: translateX(-50%);\\n max-width: 90vw;\\n padding: 10px 16px;\\n border-radius: 8px;\\n background: #333e;\\n color: white;\\n font-size: 13px;\\n z-index: 20;\\n}\\n.pp-toast[data-kind=\\\"error\\\"] {\\n background: #8d2424;\\n}\\n.pp-panel :focus-visible {\\n outline: 2px solid var(--pp-accent);\\n outline-offset: -2px;\\n}\\n\";\n\n/**\n * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。\n * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.\n */\nclass ActionMenu {\n #button;\n #popup;\n #backdrop;\n #select;\n #document;\n #key = event => {\n if (event.key === \"Escape\" && !this.#popup.hidden) {\n event.preventDefault();\n this.close();\n this.#button.focus();\n }\n };\n\n /**\n * 创建菜单,操作逻辑由调用方提供。\n * Create a menu whose actions are handled by the caller.\n * @param {(id: string) => void} select 菜单选择回调 / Selection callback.\n */\n constructor(select) {\n this.#document = document;\n this.#select = select;\n this.element = document.createElement(\"span\");\n const root = this.element.attachShadow({ mode: \"open\" });\n root.innerHTML = `<style>\n :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}\n :host([hidden]),[hidden]{display:none!important}\n button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}\n button:disabled{opacity:.4;cursor:default}\n button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}\n #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}\n svg{display:block;width:24px;height:24px;fill:currentColor}\n #backdrop{position:fixed;inset:0;z-index:1}\n #items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}\n #items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}\n #items button:hover{background:#8882}\n #items button[data-danger]{color:#e45656}\n </style><button id=\"trigger\" type=\"button\" aria-label=\"更多操作\" aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"items\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"4\" cy=\"12\" r=\"2\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/><circle cx=\"20\" cy=\"12\" r=\"2\"/></svg></button><button id=\"backdrop\" type=\"button\" tabindex=\"-1\" aria-label=\"关闭菜单\" hidden></button><div id=\"items\" role=\"menu\" hidden></div>`;\n this.#button = root.querySelector(\"#trigger\");\n this.#popup = root.querySelector(\"#items\");\n this.#backdrop = root.querySelector(\"#backdrop\");\n this.#button.onclick = () => {\n const open = this.#popup.hidden;\n this.#popup.hidden = this.#backdrop.hidden = !open;\n this.#button.setAttribute(\"aria-expanded\", String(open));\n if (open) this.#popup.firstElementChild.focus();\n };\n this.#backdrop.onclick = () => this.close();\n this.#popup.onkeydown = event => {\n if (event.key === \"Tab\") {\n this.close();\n return;\n }\n const items = [...this.#popup.children];\n const index = items.indexOf(root.activeElement);\n const offsets = { ArrowDown: 1, ArrowUp: -1 };\n if (event.key in offsets) {\n event.preventDefault();\n items[(index + offsets[event.key] + items.length) % items.length].focus();\n }\n };\n document.addEventListener(\"keydown\", this.#key);\n this.update([]);\n }\n\n /**\n * 同步可用操作和忙碌状态,不重建菜单触发按钮。\n * Update actions and busy state without replacing the trigger button.\n * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.\n * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.\n * @returns {void} 无返回值 / No return value.\n */\n update(items, disabled = false) {\n this.close();\n this.#button.disabled = disabled || items.length === 0;\n this.#popup.replaceChildren(\n ...items.map(item => {\n const button = this.#document.createElement(\"button\");\n button.type = \"button\";\n button.setAttribute(\"role\", \"menuitem\");\n button.textContent = item.label;\n button.toggleAttribute(\"data-danger\", Boolean(item.destructive));\n button.onclick = () => {\n this.close();\n this.#select(item.id);\n };\n return button;\n }),\n );\n }\n\n /**\n * 关闭菜单。\n * Close the menu.\n * @returns {void} 无返回值 / No return value.\n */\n close() {\n this.#popup.hidden = this.#backdrop.hidden = true;\n this.#button.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * 移除监听器与节点。\n * Remove listeners and elements.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n this.#document.removeEventListener(\"keydown\", this.#key);\n this.element.remove();\n }\n}\n\n/**\n * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。\n * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.\n * @param {unknown} config BoxJS JSON / BoxJS document.\n * @param {string} module API 第一段模块名 / First API path segment.\n * @returns {import(\"../index.js\").ModuleDefinition} 存储根和字段 / Storage root and fields.\n * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.\n */\nfunction normalizeBoxJs(config, module) {\n validatePathParts([module]);\n const target = new BoxJS(config).modules.get(module);\n if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const { entries, storageKey, metadata } = target;\n const fields = [];\n for (const entry of entries) {\n const parts = entry.id.slice(1).split(\".\").slice(1);\n const type = { boolean: \"boolean\", checkboxes: \"array\", selects: \"select\", text: \"string\", textarea: \"string\", number: \"number\" }[entry.type];\n if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);\n const field = {\n key: parts.join(\".\"),\n type: type === \"select\" ? typeof entry.val : type,\n\n name: entry.name,\n description: entry.desc ?? \"\",\n control: entry.type,\n ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),\n ...(entry.rows === undefined ? {} : { rows: entry.rows }),\n ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),\n };\n if (type === \"select\" && ![\"string\", \"number\", \"boolean\"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);\n if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));\n if (Object.hasOwn(entry, \"val\")) field.defaultValue = normalizeStoredValue(field, entry.val);\n if (\n typeof field.name !== \"string\" ||\n (field.placeholder !== undefined && typeof field.placeholder !== \"string\") ||\n (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||\n (field.autoGrow !== undefined && typeof field.autoGrow !== \"boolean\") ||\n fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))\n )\n throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);\n 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}`);\n if (Object.hasOwn(field, \"defaultValue\") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);\n fields.push(field);\n }\n if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const common = fields[0].key.split(\".\").slice(0, -1);\n for (const field of fields) while (!field.key.startsWith(`${common.join(\".\")}.`)) common.pop();\n return {\n module,\n storageKey,\n fields,\n settingsPath: common,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n };\n}\n\n/**\n * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。\n * Normalize BoxJS string persistence without changing free-text values.\n * @param {import(\"../index.js\").SettingsField} field 前端字段约束 / Frontend field constraints.\n * @param {unknown} value 存储值 / Stored value.\n * @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.\n */\nfunction normalizeStoredValue(field, value) {\n switch (field.type) {\n case \"boolean\":\n if (value === \"true\" || value === \"false\") return value === \"true\";\n break;\n case \"number\":\n if (typeof value === \"string\" && value.trim() !== \"\") return Number(value);\n break;\n case \"array\":\n if (typeof value === \"string\") value = value === \"\" || value === \"[]\" ? [] : value.split(\",\");\n break;\n }\n if (field.options) {\n const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;\n return field.type === \"array\" && Array.isArray(value) ? value.map(match) : match(value);\n }\n return value;\n}\n\n/**\n * 校验支持的标量范围,包括文本长度与数值有限性。\n * Validate supported scalar bounds, including text length and numeric finiteness.\n * @param {unknown} value 待检查值 / Value to inspect.\n * @returns {boolean} 是否为有效标量 / Whether the scalar is valid.\n */\nfunction scalar(value) {\n switch (typeof value) {\n case \"boolean\":\n return true;\n case \"string\":\n return value.length <= 2048;\n case \"number\":\n return Number.isFinite(value);\n default:\n return false;\n }\n}\n\n/**\n * 检查值类型、数组唯一性及声明的选项,不进行转换。\n * Check value type, array uniqueness and declared choices without coercion.\n * @param {import(\"../index.js\").SettingsField} field 前端归一化字段 / Normalized frontend field.\n * @param {unknown} value 待写入的 JSON 值 / JSON value to write.\n * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.\n */\nfunction validValue(field, value) {\n if (field.type === \"array\") {\n if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;\n } else if (typeof value !== field.type || !scalar(value)) return false;\n return !field.options || (field.type === \"array\" ? value : [value]).every(item => field.options.some(option => option.key === item));\n}\n\n/**\n * 单个模块的临时会话;离开页面后丢弃。\n * Transient module session discarded when leaving the page.\n * @typedef {object} ModuleSession\n * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.\n * @property {import(\"../index.js\").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.\n * @property {import(\"./client.mjs\").ModuleSnapshot[\"values\"]} values 当前显示值 / Current display values.\n * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.\n */\n\n/**\n * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。\n * Create a page-session cache; reload on open and mutate cache only after HTTP 200.\n * @param {import(\"./client.mjs\").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.\n * @returns {import(\"./client.mjs\").PreferencesClient} 通用客户端 / Generic client.\n */\nfunction createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {\n /**\n * 模块会话表\n * Module session map.\n * @type {Map<string, ModuleSession>}\n */\n const sessions = new Map();\n /**\n * 用 form 发送完整存储键;读取 404 交给调用方处理。\n * Send a complete storage key as form data; callers handle missing reads.\n * @param {string} path 完整 @root.path / Complete @root.path.\n * @param {\"get\" | \"set\" | \"delete\"} action 存储操作 / Storage operation.\n * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.\n * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.\n * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.\n * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.\n */\n async function send(path, action, body, signal) {\n const controller = new AbortController();\n const abort = () => controller.abort();\n if (signal?.aborted) abort();\n signal?.addEventListener(\"abort\", abort, { once: true });\n const timer = setTimeout(abort, timeout);\n try {\n const response = await request(`/api/${action}`, {\n method: \"POST\",\n credentials: \"omit\",\n cache: \"no-store\",\n signal: controller.signal,\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams([[path, action === \"set\" ? JSON.stringify(body) : \"\"]]).toString(),\n });\n if (response.status !== 200 && !(action === \"get\" && response.status === 404)) throw new Error(`HTTP ${response.status}`);\n return response;\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", abort);\n }\n }\n /**\n * 获取独立快照,避免调用方修改内部缓存。\n * Return an independent snapshot so callers cannot mutate the cache.\n * @param {string} module 已打开模块 / Open module.\n * @returns {import(\"./client.mjs\").ModuleSnapshot} 会话快照 / Session snapshot.\n * @throws {Error} 模块未完成加载 / Module has not finished loading.\n */\n const snapshot = module => {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n return structuredClone({ definition: state.definition, values: state.values });\n };\n /**\n * 串行修改单键,仅成功后更新仍存活的会话。\n * Serialize single-key mutations and update a still-active session only after success.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 完整点分字段路径 / Complete dotted field path.\n * @param {\"set\" | \"delete\"} action 写入或删除 / Write or delete.\n * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.\n * @param {\"write\" | \"delete\" | \"clearCaches\" | \"reset\"} [operation] 操作类型 / Operation kind.\n * @returns {Promise<void>} 操作完成 / Operation completion.\n * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.\n */\n async function change(module, key, action, value, operation = action === \"set\" ? \"write\" : \"delete\") {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n if (state.saving) throw new Error(\"A settings write is already in progress\");\n const field = state.definition.fields.find(field => field.key === key);\n state.saving = true;\n try {\n if ((operation === \"write\" || operation === \"delete\") && (!field || (action === \"set\" && !validValue(field, value)))) throw new TypeError(\"Invalid setting value\");\n await send(`@${state.definition.storageKey}.${key}`, action, value);\n if (sessions.get(module) === state) {\n switch (operation) {\n case \"write\":\n state.values[key] = structuredClone(value);\n break;\n case \"delete\":\n case \"clearCaches\":\n case \"reset\":\n for (const candidate of state.definition.fields) {\n if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;\n delete state.values[candidate.key];\n if (Object.hasOwn(candidate, \"defaultValue\")) state.values[candidate.key] = structuredClone(candidate.defaultValue);\n }\n break;\n }\n }\n notify({ kind: \"success\", operation, module, key });\n } catch (error) {\n notify({ kind: \"error\", operation, module, key, message: error.message });\n throw error;\n } finally {\n state.saving = false;\n }\n }\n return {\n /**\n * 从已导入的 JSON 创建新会话,只读取一次设置值。\n * Create a session from imported JSON and read stored settings once.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {Promise<import(\"./client.mjs\").ModuleSnapshot>} 新快照 / New snapshot.\n * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.\n */\n async open(module) {\n const binding = catalog.modules.get(module);\n if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const previous = sessions.get(module);\n if (previous?.saving) throw new Error(\"Cannot refresh while saving\");\n previous?.controller.abort();\n const state = { controller: new AbortController(), definition: null, values: {}, saving: false };\n sessions.set(module, state);\n try {\n const definition = normalizeBoxJs(catalog.select(module), module);\n const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(\".\")}`, \"get\", undefined, state.controller.signal);\n let subtree = response.status === 404 ? {} : await response.json();\n if (typeof subtree === \"string\") subtree = JSON.parse(subtree);\n if (!subtree || typeof subtree !== \"object\" || Array.isArray(subtree)) throw new TypeError(\"Expected a settings subtree object\");\n if (sessions.get(module) !== state) throw new Error(\"Module session was replaced\");\n state.definition = definition;\n for (const field of definition.fields) {\n const stored = field.key\n .split(\".\")\n .slice(definition.settingsPath.length)\n .reduce((parent, part) => Object(parent)[part], subtree);\n const value = stored === undefined ? field.defaultValue : stored;\n if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);\n }\n return snapshot(module);\n } catch (error) {\n if (sessions.get(module) === state) sessions.delete(module);\n throw error;\n }\n },\n snapshot,\n /**\n * 按需读取模块 Caches,不自动读取其它设置。\n * Read module Caches on demand without refreshing other settings.\n * @param {string} module 已打开的模块 / Open module.\n * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.\n */\n async readCaches(module) {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n const response = await send(`@${state.definition.storageKey}.${module}.Caches`, \"get\", undefined, state.controller.signal);\n return response.status === 404 ? undefined : response.json();\n },\n /**\n * 删除整个 Caches 节点,成功后不追加 GET。\n * Delete the entire Caches node without a follow-up GET.\n * @param {string} module 已打开模块 / Open module.\n * @returns {Promise<void>} 清理完成 / Cleanup completion.\n */\n clearCaches: module => change(module, `${module}.Caches`, \"delete\", undefined, \"clearCaches\"),\n /**\n * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。\n * Delete module persistence and reset the page cache using current BoxJS defaults.\n * @param {string} module 已打开模块 / Open module.\n * @returns {Promise<void>} 重置完成 / Reset completion.\n */\n reset: module => change(module, module, \"delete\", undefined, \"reset\"),\n /**\n * 取消读取并清除会话,不撤销已发送的写入。\n * Abort reads and clear the session without undoing dispatched writes.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {void} 无返回值 / No return value.\n */\n leave(module) {\n sessions.get(module)?.controller.abort();\n sessions.delete(module);\n },\n /**\n * 写入单键并更新当前会话。\n * Write one key and update the current session.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 点分字段路径 / Dotted field path.\n * @param {import(\"../index.js\").SettingsScalar | import(\"../index.js\").SettingsScalar[]} value 字段值 / Field value.\n * @returns {Promise<void>} 写入完成 / Write completion.\n */\n set: (module, key, value) => change(module, key, \"set\", value),\n /**\n * 删除单键覆盖值并显示默认值。\n * Delete one override and display its default value.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 点分字段路径 / Dotted field path.\n * @returns {Promise<void>} 删除完成 / Delete completion.\n */\n remove: (module, key) => change(module, key, \"delete\"),\n };\n}\n\n/**\n * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。\n * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.\n */\nclass Navigation extends EventTarget {\n #container;\n #home;\n #create;\n #window;\n #key = null;\n #view;\n #retiring;\n #controller;\n #animation;\n #scroll = new WeakMap();\n #onHistory = () => this.#route();\n #onPageShow = event => {\n if (event.persisted) this.#route(true);\n };\n\n /**\n * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。\n * Retain the home view and create details on demand; signal cancels async work after departure.\n * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.\n * @param {HTMLElement} home 已创建的主页节点 / Existing home view.\n * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.\n */\n constructor(container, home, create) {\n super();\n this.#container = container;\n this.#home = home;\n this.#create = create;\n this.#window = container.ownerDocument.defaultView;\n container.replaceChildren(home);\n this.#window.addEventListener(\"popstate\", this.#onHistory);\n this.#window.addEventListener(\"hashchange\", this.#onHistory);\n this.#window.addEventListener(\"pageshow\", this.#onPageShow);\n this.#route();\n }\n\n /**\n * 当前子页键;空字符串表示主页。\n * Current detail key; empty means home.\n */\n get current() {\n return this.#key;\n }\n\n /**\n * 是否可以返回上一级或先前文档。\n * Whether a parent view or previous document is available.\n */\n get canGoBack() {\n return Boolean(this.#key) || this.#window.history.length > 1;\n }\n\n /**\n * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。\n * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.\n * @param {string} key 子页键 / Detail key.\n * @returns {void} 无返回值 / No return value.\n */\n open(key) {\n if (key === this.#key) return;\n const url = new URL(this.#window.location.href);\n url.hash = encodeURIComponent(key);\n this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, \"\", url.href);\n this.#route();\n }\n\n /**\n * 沿浏览器联合历史返回,根页可退回宿主或上个文档。\n * Go back through joint history, including a host or previous document from home.\n * @returns {void} 无返回值 / No return value.\n */\n back() {\n if (this.canGoBack) this.#window.history.back();\n }\n\n /**\n * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。\n * Resolve the URL and coordinate transitions, cancellation and release after animation.\n * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.\n * @returns {void} 无返回值 / No return value.\n */\n #route(reload = false) {\n const url = new URL(this.#window.location.href);\n let key;\n try {\n key = decodeURIComponent(url.hash.slice(1));\n } catch (error) {\n if (!(error instanceof URIError)) throw error;\n key = \"\";\n }\n if (!reload && key === this.#key) return;\n this.#controller?.abort();\n this.#controller = new AbortController();\n const next = key ? this.#create(key, this.#controller.signal) : undefined;\n if (!next) key = \"\";\n const history = this.#window.history;\n // 直接打开子页时建立一次主页历史;刷新不重复堆叠。\n // Seed home history once for direct details, without stacking entries on reload.\n if (url.hash && history.state?.preferencePanesRoute !== key) {\n url.hash = \"\";\n history.replaceState({ ...history.state, preferencePanesRoute: \"\" }, \"\", url.href);\n if (key) {\n url.hash = encodeURIComponent(key);\n history.pushState({ ...history.state, preferencePanesRoute: key }, \"\", url.href);\n }\n }\n const previous = this.#view;\n const position = previous ? this.#window.getComputedStyle(previous).transform : \"none\";\n this.#animation?.cancel();\n this.#retiring?.remove();\n this.#retiring = previous;\n if (previous) {\n this.#scroll.set(previous, previous.scrollTop);\n previous.inert = true;\n }\n this.#key = key;\n this.#view = next;\n this.#home.inert = Boolean(next);\n if (next) {\n next.inert = false;\n this.#container.append(next);\n next.scrollTop = this.#scroll.get(next) ?? 0;\n }\n const moving = next ?? previous;\n if (moving) {\n 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\" });\n this.#animation = animation;\n animation.onfinish = () => {\n if (this.#animation !== animation) return;\n this.#retiring?.remove();\n this.#retiring = undefined;\n animation.cancel();\n this.#animation = undefined;\n };\n }\n this.dispatchEvent(new Event(\"change\"));\n }\n\n /**\n * 释放监听器、加载、动画和节点;调用方可重新创建导航。\n * Release listeners, loads, animations and nodes so callers can recreate navigation.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n this.#window.removeEventListener(\"popstate\", this.#onHistory);\n this.#window.removeEventListener(\"hashchange\", this.#onHistory);\n this.#window.removeEventListener(\"pageshow\", this.#onPageShow);\n this.#controller?.abort();\n this.#animation?.cancel();\n this.#retiring?.remove();\n this.#view?.remove();\n this.#home.remove();\n }\n}\n\n/**\n * 挂载已导入 BoxJS 对应的模块表单和短暂通知。\n * Mount the imported BoxJS module form and transient notifications.\n * @param {HTMLElement} root 包内挂载元素 / Internal mount element.\n * @param {import(\"../BoxJS.mjs\").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.\n * @returns {import(\"./index.js\").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.\n */\nfunction mountPanel(root, catalog) {\n const title = catalog.module.metadata.name ?? catalog.module.module;\n const document = root.ownerDocument;\n const window = document.defaultView;\n const shell = element(\"div\", \"pp-panel\");\n shell.dataset.module = catalog.module.module;\n const header = element(\"header\", \"pp-header\");\n const back = element(\"button\", \"pp-back\", \"‹\");\n back.setAttribute(\"aria-label\", \"返回\");\n back.type = \"button\";\n const heading = element(\"h1\", \"pp-title\", title);\n const handlers = new Map();\n const menuItems = [\n { id: \"viewCaches\", label: \"查看缓存\" },\n { id: \"clearCaches\", label: \"清空缓存\", destructive: true },\n { id: \"reset\", label: \"重置模块\", destructive: true },\n ];\n const menu = new ActionMenu(id => runAction(id));\n const trailing = element(\"span\", \"pp-nav-spacer\");\n trailing.append(menu.element);\n const logo = element(\"span\", \"pp-module-logo\");\n logo.setAttribute(\"aria-hidden\", \"true\");\n const image = icon(catalog.module.metadata, \"\");\n if (image) logo.append(image);\n const viewport = element(\"div\", \"pp-viewport\");\n let toast;\n header.append(back, heading, trailing);\n shell.append(header, viewport);\n root.append(shell);\n // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。\n // Embedded mode publishes navigation state without host reads or mutations of the module DOM.\n const publishNavigation = () => {\n const actions = handlers.size ? menuItems : [];\n menu.update(actions, saving);\n const frame = window.frameElement;\n if (!frame?.dataset.preferencePanes) return;\n frame.dispatchEvent(\n new frame.ownerDocument.defaultView.CustomEvent(\"preferencepanes:change\", {\n detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },\n }),\n );\n };\n const onAction = event => {\n if (!saving && handlers.has(event.detail)) runAction(event.detail);\n };\n window.frameElement?.addEventListener(\"preferencepanes:action\", onAction);\n let timer,\n navigation,\n generation = 0,\n active = null,\n saving = false,\n destroyed = false;\n /**\n * 展示短暂通知,不刷新设置数据。\n * Display a transient notification without refreshing settings.\n * @param {{kind: \"success\" | \"error\", operation?: \"write\" | \"delete\" | \"clearCaches\" | \"reset\", message?: string}} event 操作结果 / Operation result.\n * @returns {void} 无返回值 / No return value.\n */\n const notify = event => {\n if (destroyed) return;\n let message;\n switch (true) {\n case event.kind === \"error\":\n message = `操作失败:${event.message}`;\n break;\n case event.operation === \"delete\":\n message = \"删除成功\";\n break;\n case event.operation === \"clearCaches\":\n message = \"Caches 已清空\";\n break;\n case event.operation === \"reset\":\n message = \"模块已重置\";\n break;\n default:\n message = \"修改成功\";\n break;\n }\n // 宿主接管时不创建网页 Toast,也不运行其计时器。\n // A host-owned notice creates no web Toast and starts no local timer.\n const frame = window.frameElement;\n if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent(\"preferencepanes:notice\", { cancelable: true, detail: { kind: event.kind, message } }))) return;\n if (!toast) {\n toast = element(\"div\", \"pp-toast\");\n toast.setAttribute(\"role\", \"status\");\n shell.append(toast);\n }\n toast.textContent = message;\n toast.dataset.kind = event.kind;\n toast.hidden = false;\n clearTimeout(timer);\n timer = setTimeout(() => {\n toast.hidden = true;\n }, 2400);\n };\n const client = createPreferencesClient({ catalog, notify });\n /**\n * 两种菜单入口共用异步错误处理,包含宿主确认框错误。\n * Share async error handling between both menus, including host-dialog errors.\n * @param {string} id 操作标识 / Action identifier.\n * @returns {Promise<void>} 操作已处理 / Action handled.\n */\n async function runAction(id) {\n try {\n await handlers.get(id)();\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n }\n }\n /**\n * 打开模块并忽略已过期的异步结果。\n * Open a module and ignore stale asynchronous results.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.\n */\n async function open(module) {\n const version = ++generation;\n active = module;\n back.disabled = window.history.length <= 1;\n heading.textContent = module;\n publishNavigation();\n viewport.replaceChildren(element(\"p\", \"pp-loading\", \"读取设置…\"));\n try {\n await client.open(module);\n if (version === generation) controls();\n } catch (error) {\n if (version !== generation) return;\n viewport.replaceChildren(errorView(error, () => open(module)));\n publishNavigation();\n }\n }\n /**\n * 从会话快照创建控件与操作按钮,不重新读取网络配置。\n * Build controls and actions from the session snapshot without fetching config again.\n * @returns {void} 无返回值 / No return value.\n */\n function controls() {\n const { definition, values } = client.snapshot(active);\n heading.textContent = definition.metadata?.name || active;\n const view = element(\"section\", \"pp-fields\");\n view.append(logo);\n const search = element(\"input\", \"\");\n search.type = \"search\";\n search.placeholder = \"搜索设置项\";\n search.setAttribute(\"aria-label\", \"搜索设置\");\n const searchField = fieldControl(search);\n searchField.classList.add(\"pp-search\");\n view.append(searchField);\n const searchRows = [];\n /**\n * 挂载后执行的多行高度更新\n * Textarea sizing callbacks run after mounting.\n * @type {Array<() => void>}\n */\n const growingInputs = [];\n const editors = new Map();\n const summaries = [];\n const groups = new Map();\n let queue = Promise.resolve(),\n pendingWrites = 0;\n /**\n * 导航组件处理页面切换,表单只更新当前标题与返回按钮。\n * Let navigation own transitions; the form only updates the title and back button.\n * @returns {void} 无返回值 / No return value.\n */\n const updateNavigation = () => {\n const editor = editors.get(navigation.current);\n heading.textContent = editor?.title ?? definition.metadata?.name ?? active;\n back.disabled = saving || !navigation.canGoBack;\n publishNavigation();\n };\n /**\n * 串行执行模块操作,保持输入可编辑。\n * Serialize module actions while keeping inputs editable.\n * @param {() => Promise<void>} action 请求或写入 / Request or mutation.\n * @param {() => void} success 成功后的局部更新 / Local update after success.\n * @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.\n * @returns {Promise<void>} 操作完成 / Operation completion.\n */\n function perform(action, success, failure = () => {}) {\n pendingWrites++;\n saving = true;\n back.disabled = true;\n publishNavigation();\n return (queue = queue\n .then(action)\n .then(() => {\n if (!destroyed) success();\n })\n .catch(() => {\n /* 请求层已通知错误。\n * The request layer has already reported the error. */\n if (!destroyed) failure();\n })\n .finally(() => {\n pendingWrites--;\n saving = pendingWrites > 0;\n if (destroyed && !saving) client.leave(active);\n back.disabled = saving || !navigation.canGoBack;\n publishNavigation();\n }));\n }\n const metadata = definition.metadata;\n if (metadata) {\n const info = element(\"div\", \"pp-module-info\");\n const details = element(\"div\", \"pp-module-details\");\n for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element(\"p\", \"pp-description\", description));\n if (metadata.repo) {\n const link = element(\"a\", \"pp-module-source\", \"项目主页\");\n link.href = resourceURL(metadata.repo);\n link.target = \"_blank\";\n link.rel = \"noopener noreferrer\";\n details.append(link);\n }\n info.append(details);\n view.append(info);\n }\n for (const field of definition.fields) {\n const match = /^\\[([^\\]]+)\\]\\s*(.*)$/.exec(field.name);\n const group = match?.[1] ?? \"通用\";\n if (!groups.has(group)) {\n const section = element(\"section\", \"pp-group mt_md\");\n const rows = element(\"div\", \"pp-rows\");\n section.append(element(\"h2\", \"text3 fs_4 fw_400 mb_sm\", group), rows);\n groups.set(group, rows);\n view.append(section);\n }\n const row = settingRow(\"div\");\n row.classList.add(\"pp-field\");\n const label = element(\"div\", \"pp-label flex_col items_start mr_md\");\n label.append(element(\"span\", \"text1 fs_4\", match?.[2] ?? field.name));\n if (field.description) label.append(element(\"span\", \"text3 fs_5 mt_2\", field.description));\n row.append(label);\n const value = values[field.key];\n /**\n * 读取尚未保存的输入\n * Read the unsaved input.\n * @type {() => unknown}\n */\n let read;\n /**\n * 更新当前控件\n * Update the current control.\n * @type {(value: unknown) => void}\n */\n let write;\n let inputContainer = row;\n let eventName = \"change\";\n switch (true) {\n case Boolean(field.options) && field.type !== \"array\": {\n const select = element(\"select\", \"\");\n select.setAttribute(\"aria-label\", field.name);\n field.options.forEach((option, index) => {\n const item = element(\"option\", \"\", option.label);\n item.value = String(index);\n select.append(item);\n });\n write = value => {\n select.selectedIndex = field.options.findIndex(option => option.key === value);\n };\n row.append(fieldControl(select));\n read = () => field.options[select.selectedIndex]?.key;\n break;\n }\n case field.type === \"array\" && Boolean(field.options): {\n const page = element(\"section\", \"pp-choice-page\");\n if (field.description) page.append(element(\"p\", \"pp-description\", field.description));\n const choices = element(\"div\", \"pp-rows\");\n page.append(choices);\n inputContainer = choices;\n editors.set(field.key, { node: page, title: match?.[2] ?? field.name });\n const summary = element(\"span\", \"pp-summary\");\n const link = element(\"button\", \"pp-choice-link\");\n link.type = \"button\";\n link.setAttribute(\"aria-label\", field.name);\n link.append(summary, element(\"span\", \"pp-chevron\", \"›\"));\n row.append(link);\n const refresh = () => {\n const value = client.snapshot(active).values[field.key];\n summary.textContent =\n field.options\n .filter(option => Array.isArray(value) && value.includes(option.key))\n .map(option => option.label)\n .join(\"、\") || \"未选择\";\n };\n summaries.push(refresh);\n refresh();\n link.onclick = () => navigation.open(field.key);\n row.addEventListener(\"click\", event => {\n if (!link.contains(event.target)) link.click();\n });\n const inputs = field.options.map(option => {\n const label = settingRow(\"label\");\n label.classList.add(\"pp-choice\");\n label.textContent = option.label;\n const input = element(\"input\", \"\");\n input.type = \"checkbox\";\n input.setAttribute(\"aria-label\", option.label);\n label.append(input);\n choices.append(label);\n return { input, key: option.key };\n });\n read = () => inputs.filter(option => option.input.checked).map(option => option.key);\n write = value => {\n for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);\n };\n break;\n }\n case field.type === \"boolean\": {\n const toggle = element(\"input\", \"pp-switch\");\n toggle.type = \"checkbox\";\n toggle.setAttribute(\"switch\", \"\");\n toggle.setAttribute(\"role\", \"switch\");\n toggle.setAttribute(\"aria-label\", field.name);\n write = value => {\n toggle.checked = value === true;\n };\n read = () => toggle.checked;\n row.append(toggle);\n break;\n }\n default: {\n const multiline = field.control === \"textarea\" || field.type === \"array\";\n const input = element(multiline ? \"textarea\" : \"input\", \"\");\n if (multiline) row.classList.add(\"pp-multiline\");\n input.setAttribute(\"aria-label\", field.name);\n if (field.placeholder) input.placeholder = field.placeholder;\n if (multiline && field.rows) input.rows = field.rows;\n /**\n * 在挂载后根据内容调整高度,同时保留基础行数。\n * Size mounted textareas to their contents while retaining baseline rows.\n * @returns {void} 无返回值 / No return value.\n */\n const grow = () => {\n if (!multiline || !field.autoGrow || !input.isConnected) return;\n input.style.height = \"auto\";\n const baseline = input.getBoundingClientRect().height;\n const style = window.getComputedStyle(input);\n const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);\n input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;\n };\n if (multiline && field.autoGrow) {\n input.addEventListener(\"input\", grow);\n growingInputs.push(grow);\n }\n eventName = \"input\";\n if (!multiline) input.type = field.type === \"number\" ? \"number\" : \"text\";\n write = value => {\n input.value = field.type === \"array\" ? JSON.stringify(value ?? []) : (value ?? \"\");\n grow();\n };\n read = () => {\n switch (field.type) {\n case \"array\":\n return JSON.parse(input.value);\n case \"number\":\n return input.value === \"\" ? Number.NaN : Number(input.value);\n default:\n return input.value;\n }\n };\n row.append(fieldControl(input));\n break;\n }\n }\n write(value);\n let inputVersion = 0;\n inputContainer.addEventListener(eventName, event => {\n if (event.isComposing) return;\n const version = ++inputVersion,\n module = active;\n let value;\n try {\n value = read();\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n return;\n }\n const restore = () => {\n if (version === inputVersion) write(client.snapshot(module).values[field.key]);\n };\n perform(\n () => client.set(module, field.key, value),\n () => {\n for (const refresh of summaries) refresh();\n },\n restore,\n );\n });\n if (eventName === \"input\") inputContainer.addEventListener(\"compositionend\", event => event.target.dispatchEvent(new window.Event(\"input\", { bubbles: true })));\n groups.get(group).append(row);\n searchRows.push({ row, text: [field.name, field.key, field.description, ...(field.options ?? []).map(option => option.label)].join(\" \").toLocaleLowerCase() });\n }\n const empty = element(\"p\", \"pp-description\", \"没有匹配的设置项\");\n empty.hidden = true;\n empty.setAttribute(\"role\", \"status\");\n view.append(empty);\n search.oninput = () => {\n const words = search.value.trim().toLocaleLowerCase().split(/\\s+/);\n for (const { row, text } of searchRows) row.hidden = !words.every(word => text.includes(word));\n for (const rows of groups.values()) rows.parentElement.hidden = [...rows.children].every(row => row.hidden);\n empty.hidden = searchRows.some(({ row }) => !row.hidden);\n };\n const cachePage = element(\"section\", \"pp-cache-page\");\n const output = element(\"pre\", \"pp-cache\");\n output.textContent = \"暂无缓存\";\n output.setAttribute(\"aria-label\", \"Caches 内容\");\n cachePage.append(output);\n editors.set(\"$caches\", { node: cachePage, title: \"缓存\" });\n handlers.set(\"viewCaches\", () => {\n if (saving) return;\n let value;\n return perform(\n async () => {\n try {\n value = await client.readCaches(active);\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n throw error;\n }\n },\n () => {\n output.textContent = value === undefined ? \"暂无缓存\" : JSON.stringify(value, null, 2);\n navigation.open(\"$caches\");\n },\n );\n });\n handlers.set(\"clearCaches\", async () => {\n if (saving) return;\n if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;\n return perform(\n () => client.clearCaches(active),\n () => {\n output.textContent = \"暂无缓存\";\n },\n );\n });\n handlers.set(\"reset\", async () => {\n if (saving) return;\n if (!(await requestConfirmation(window, `重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;\n return perform(() => client.reset(active), controls);\n });\n navigation?.destroy();\n navigation = new Navigation(viewport, view, key => editors.get(key)?.node);\n navigation.addEventListener(\"change\", updateNavigation);\n for (const grow of growingInputs) grow();\n updateNavigation();\n }\n /**\n * 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。\n * Loaded forms delegate back to navigation; loading views can return to the previous document.\n * @returns {void} 无返回值 / No return value.\n */\n back.onclick = () => {\n if (saving) return;\n if (navigation) navigation.back();\n else window.history.back();\n };\n open(catalog.module.module);\n return {\n /**\n * 移除监听器、定时器、会话和挂载内容。\n * Remove listeners, timers, session and mounted content.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n destroyed = true;\n menu.destroy();\n window.frameElement?.removeEventListener(\"preferencepanes:action\", onAction);\n navigation?.destroy();\n generation++;\n if (active && !saving) client.leave(active);\n clearTimeout(timer);\n shell.remove();\n },\n };\n}\n\n/**\n * 挂载模块设置页;内置资源只引用官方地址,CSS 输入仅用于该页。\n * Mount the module page with official resource URLs and optional page-specific CSS.\n * @param {import(\"../index.js\").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.\n * @param {string} [css] 可选 CSS 正文 / Optional CSS text.\n * @returns {import(\"./index.js\").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.\n */\nfunction mount(boxjs, css = \"\") {\n if (typeof css !== \"string\") throw new TypeError(\"CSS must be a string\");\n const catalog = new BoxJS(boxjs);\n const metadata = catalog.module.metadata;\n const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];\n if (image) resourceURL(image);\n if (metadata.repo) resourceURL(metadata.repo);\n const existing = document.querySelector(\"#preferences\");\n const root = existing ?? element(\"main\", \"\");\n if (!existing) {\n root.id = \"preferences\";\n document.body.append(root);\n }\n // 远程视觉资源与基础布局分开加载,网络状态不控制分页定位。\n // Load remote visual resources separately so network state cannot control page positioning.\n const links = styleURLs.map(url => {\n const link = element(\"link\", \"\");\n link.rel = \"stylesheet\";\n link.href = url;\n return link;\n });\n const base = element(\"style\", \"\"),\n custom = element(\"style\", \"\");\n base.textContent = defaults;\n custom.textContent = css;\n document.head.append(...links, base, custom);\n const previousTitle = document.title;\n const previousTheme = document.documentElement.dataset.theme;\n const previousDark = document.documentElement.classList.contains(\"bili_dark\");\n const systemTheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const previousKeyboard = document.documentElement.style.getPropertyValue(\"--pp-keyboard-height\");\n const host = window.frameElement?.ownerDocument.documentElement;\n /**\n * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。\n * Follow generic host appearance without detecting a business app or parsing its user agent.\n * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.\n */\n const syncAppearance = () => {\n const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? \"dark\" : \"light\");\n document.documentElement.dataset.theme = theme;\n document.documentElement.classList.toggle(\"bili_dark\", theme === \"dark\");\n if (host) document.documentElement.style.setProperty(\"--pp-keyboard-height\", host.style.getPropertyValue(\"--pp-keyboard-height\"));\n };\n let observer;\n syncAppearance();\n systemTheme.addEventListener(\"change\", syncAppearance);\n if (host) {\n observer = new MutationObserver(syncAppearance);\n observer.observe(host, { attributes: true, attributeFilter: [\"data-theme\", \"style\"] });\n }\n document.title = metadata.name ?? catalog.module.module;\n let panel;\n const view = {\n /**\n * 释放模块视图、样式与会话,不操作项目入口页。\n * Release the module view, styles and session without operating a project landing page.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n observer?.disconnect();\n systemTheme.removeEventListener(\"change\", syncAppearance);\n document.documentElement.classList.toggle(\"bili_dark\", previousDark);\n panel?.destroy();\n for (const link of links) link.remove();\n base.remove();\n custom.remove();\n if (existing) root.replaceChildren();\n else root.remove();\n document.title = previousTitle;\n if (previousTheme === undefined) delete document.documentElement.dataset.theme;\n else document.documentElement.dataset.theme = previousTheme;\n document.documentElement.style.setProperty(\"--pp-keyboard-height\", previousKeyboard);\n },\n };\n try {\n root.replaceChildren();\n panel = mountPanel(root, catalog);\n return view;\n } catch (error) {\n view.destroy();\n throw error;\n }\n}\n\nlet view;\n/**\n * 从 URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。\n * Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.\n * @returns {Promise<void>} 启动完成 / Startup completion.\n */\nasync function start() {\n try {\n view?.destroy();\n view = undefined;\n const context = document.querySelector('meta[name=\"preference-panes-inputs\"]');\n const embedded = window.frameElement?.dataset.preferencePanes;\n let inputs;\n switch (true) {\n case embedded !== undefined:\n inputs = JSON.parse(embedded);\n document.documentElement.dataset.preferencePanesEmbedded = \"\";\n break;\n case context !== null:\n inputs = JSON.parse(decodeURIComponent(context.content));\n break;\n default:\n inputs = pageInputs(new URL(location.href));\n }\n const resources = [inputs.json, inputs.css].map(source => {\n if (!source) return null;\n const url = new URL(source, inputs.url);\n if (![\"http:\", \"https:\"].includes(url.protocol)) throw new TypeError(\"Resources must use HTTP(S) URLs\");\n return url.href;\n });\n const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: \"no-store\", credentials: \"omit\" }) : null)));\n if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);\n const boxjs = await data.json();\n if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error(\"Imported JSON does not match the module URL\");\n view = mount(boxjs, style ? await style.text() : \"\");\n } catch (error) {\n document.querySelector(\"#preferences\").replaceChildren(errorView(error, start));\n }\n}\nstart();\nwindow.addEventListener(\"pageshow\", event => {\n if (event.persisted) start();\n});\n"}};
345
345
 
346
346
  /**
347
347
  * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
@@ -125,28 +125,30 @@ function pageInputs(url, headers = {}) {
125
125
  function element(tag, className, text) {
126
126
  const node = document.createElement(tag);
127
127
  node.className = className;
128
- // 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。
129
- // Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.
130
- if (/\bform-row(?:\b|__)/.test(className)) node.setAttribute("data-v-b69aa1ea", "");
131
- if (/\bform-group(?:\b|__)/.test(className)) node.setAttribute("data-v-e590be47", "");
132
128
  if (text !== undefined) node.textContent = text;
133
129
  return node;
134
130
  }
135
131
 
136
132
  /**
137
- * 搜索、选择和文本控件共用官方 VField DOM 结构。
138
- * Share the official VField DOM structure across search, select and text controls.
133
+ * 用官方 b-style 组合行布局,不绑定某个 App 内置页面的编译作用域。
134
+ * Compose rows with official b-style utilities without private app-page compilation scopes.
135
+ * @template {"div" | "label"} T
136
+ * @param {T} tag 行元素 / Row element.
137
+ * @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
138
+ */
139
+ function settingRow(tag) {
140
+ return element(tag, "pp-row flex_between pd_md bb_1 bc_line_regular bg_bg1");
141
+ }
142
+
143
+ /**
144
+ * 搜索、选择和文本控件共用官方输入配色与间距,交互由标准 HTML 控件负责。
145
+ * Share official colors and spacing while native HTML controls own input interaction.
139
146
  * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
140
- * @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.
141
- * @returns {HTMLDivElement} 字段容器 / Field container.
147
+ * @returns {HTMLElement} 输入控件 / Input control.
142
148
  */
143
- function fieldControl(control, multiline = false) {
144
- const field = element("div", `v-field pp-editor${multiline ? " v-field--textarea" : ""}`);
145
- const body = element("div", "v-field__body");
146
- control.classList.add("v-field__control");
147
- body.append(control);
148
- field.append(body);
149
- return field;
149
+ function fieldControl(control) {
150
+ control.classList.add("pp-editor", "bg_bg3", "text1", "pd_sm", "bd_radius_md");
151
+ return control;
150
152
  }
151
153
 
152
154
  /**
@@ -211,7 +213,9 @@ function requestConfirmation(host, message) {
211
213
  });
212
214
  }
213
215
 
214
- var defaults = "@import url(\"https://hilo.bilibili.com/h5_common/theme.min.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/h5_common/b-style.min.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/app_settings/assets/messageSettingsLayout-ltzQ1gMi.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/app_settings/assets/message-settings-BD3N1lqQ.css\") layer(preference-panes);\n@layer preference-panes {\n/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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 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/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\n * Keep module icons in content so embedded hosts can use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 64px;\n height: 64px;\n margin: 8px auto 20px;\n}\n.pp-module-logo:not(:empty) {\n display: block;\n}\n.pp-module-logo 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:not(.v-toggle) {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + 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-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}\n.pp-search {\n width: 100%;\n margin-bottom: 12px;\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-panel .v-toggle {\n border: 0;\n padding: 0;\n flex: none;\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-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n\n}";
216
+ var styleURLs = ["https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/theme.min.css","https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/b-style.min.css"];
217
+
218
+ var defaults = "/* 官方 b-style 负责行布局与配色;本文件只定义面板容器和控件约束。\n * Official b-style owns row layout and colors; this file defines panel containers and control constraints. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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 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/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\n * Keep module icons in content so embedded hosts can use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 64px;\n height: 64px;\n margin: 8px auto 20px;\n}\n.pp-module-logo:not(:empty) {\n display: block;\n}\n.pp-module-logo img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + 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-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 font: inherit;\n border: 0;\n}\n.pp-search {\n width: 100%;\n margin-bottom: 12px;\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}\n.pp-row {\n min-height: 48px;\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-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
215
219
 
216
220
  /**
217
221
  * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
@@ -1037,16 +1041,17 @@ function mountPanel(root, catalog) {
1037
1041
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
1038
1042
  const group = match?.[1] ?? "通用";
1039
1043
  if (!groups.has(group)) {
1040
- const section = element("section", "form-group form-group--has-title");
1041
- const rows = element("div", "form-group__row");
1042
- section.append(element("h2", "form-group__title", group), rows);
1044
+ const section = element("section", "pp-group mt_md");
1045
+ const rows = element("div", "pp-rows");
1046
+ section.append(element("h2", "text3 fs_4 fw_400 mb_sm", group), rows);
1043
1047
  groups.set(group, rows);
1044
1048
  view.append(section);
1045
1049
  }
1046
- const row = element("div", "form-row pp-field");
1047
- const label = element("div", "form-row__text");
1048
- label.append(element("span", "form-row__title", match?.[2] ?? field.name));
1049
- if (field.description) label.append(element("span", "form-row__subtitle", field.description));
1050
+ const row = settingRow("div");
1051
+ row.classList.add("pp-field");
1052
+ const label = element("div", "pp-label flex_col items_start mr_md");
1053
+ label.append(element("span", "text1 fs_4", match?.[2] ?? field.name));
1054
+ if (field.description) label.append(element("span", "text3 fs_5 mt_2", field.description));
1050
1055
  row.append(label);
1051
1056
  const value = values[field.key];
1052
1057
  /**
@@ -1082,11 +1087,11 @@ function mountPanel(root, catalog) {
1082
1087
  case field.type === "array" && Boolean(field.options): {
1083
1088
  const page = element("section", "pp-choice-page");
1084
1089
  if (field.description) page.append(element("p", "pp-description", field.description));
1085
- const choices = element("div", "form-group__row");
1090
+ const choices = element("div", "pp-rows");
1086
1091
  page.append(choices);
1087
1092
  inputContainer = choices;
1088
1093
  editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
1089
- const summary = element("span", "form-row__value pp-summary");
1094
+ const summary = element("span", "pp-summary");
1090
1095
  const link = element("button", "pp-choice-link");
1091
1096
  link.type = "button";
1092
1097
  link.setAttribute("aria-label", field.name);
@@ -1107,7 +1112,9 @@ function mountPanel(root, catalog) {
1107
1112
  if (!link.contains(event.target)) link.click();
1108
1113
  });
1109
1114
  const inputs = field.options.map(option => {
1110
- const label = element("label", "form-row pp-choice", option.label);
1115
+ const label = settingRow("label");
1116
+ label.classList.add("pp-choice");
1117
+ label.textContent = option.label;
1111
1118
  const input = element("input", "");
1112
1119
  input.type = "checkbox";
1113
1120
  input.setAttribute("aria-label", option.label);
@@ -1122,20 +1129,15 @@ function mountPanel(root, catalog) {
1122
1129
  break;
1123
1130
  }
1124
1131
  case field.type === "boolean": {
1125
- const toggle = element("button", "v-toggle v-toggle--small form-row__toggle");
1126
- toggle.type = "button";
1132
+ const toggle = element("input", "pp-switch");
1133
+ toggle.type = "checkbox";
1134
+ toggle.setAttribute("switch", "");
1127
1135
  toggle.setAttribute("role", "switch");
1128
1136
  toggle.setAttribute("aria-label", field.name);
1129
- toggle.append(element("span", "v-toggle__circle"));
1130
1137
  write = value => {
1131
- toggle.setAttribute("aria-checked", String(value === true));
1132
- toggle.classList.toggle("v-toggle--closed", value !== true);
1133
- };
1134
- read = () => toggle.getAttribute("aria-checked") === "true";
1135
- toggle.onclick = () => {
1136
- write(!read());
1137
- toggle.dispatchEvent(new window.Event("change", { bubbles: true }));
1138
+ toggle.checked = value === true;
1138
1139
  };
1140
+ read = () => toggle.checked;
1139
1141
  row.append(toggle);
1140
1142
  break;
1141
1143
  }
@@ -1179,7 +1181,7 @@ function mountPanel(root, catalog) {
1179
1181
  return input.value;
1180
1182
  }
1181
1183
  };
1182
- row.append(fieldControl(input, multiline));
1184
+ row.append(fieldControl(input));
1183
1185
  break;
1184
1186
  }
1185
1187
  }
@@ -1316,11 +1318,19 @@ function mount(boxjs, css = "") {
1316
1318
  root.id = "preferences";
1317
1319
  document.body.append(root);
1318
1320
  }
1321
+ // 远程视觉资源与基础布局分开加载,网络状态不控制分页定位。
1322
+ // Load remote visual resources separately so network state cannot control page positioning.
1323
+ const links = styleURLs.map(url => {
1324
+ const link = element("link", "");
1325
+ link.rel = "stylesheet";
1326
+ link.href = url;
1327
+ return link;
1328
+ });
1319
1329
  const base = element("style", ""),
1320
1330
  custom = element("style", "");
1321
1331
  base.textContent = defaults;
1322
1332
  custom.textContent = css;
1323
- document.head.append(base, custom);
1333
+ document.head.append(...links, base, custom);
1324
1334
  const previousTitle = document.title;
1325
1335
  const previousTheme = document.documentElement.dataset.theme;
1326
1336
  const previousDark = document.documentElement.classList.contains("bili_dark");
@@ -1358,6 +1368,7 @@ function mount(boxjs, css = "") {
1358
1368
  systemTheme.removeEventListener("change", syncAppearance);
1359
1369
  document.documentElement.classList.toggle("bili_dark", previousDark);
1360
1370
  panel?.destroy();
1371
+ for (const link of links) link.remove();
1361
1372
  base.remove();
1362
1373
  custom.remove();
1363
1374
  if (existing) root.replaceChildren();
@@ -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.9.3"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.9.4"></script>
13
13
  </body>
14
14
  </html>
@@ -1,4 +1,6 @@
1
- var defaults = "@import url(\"https://hilo.bilibili.com/h5_common/theme.min.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/h5_common/b-style.min.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/app_settings/assets/messageSettingsLayout-ltzQ1gMi.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/app_settings/assets/message-settings-BD3N1lqQ.css\") layer(preference-panes);\n@layer preference-panes {\n/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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 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/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\n * Keep module icons in content so embedded hosts can use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 64px;\n height: 64px;\n margin: 8px auto 20px;\n}\n.pp-module-logo:not(:empty) {\n display: block;\n}\n.pp-module-logo 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:not(.v-toggle) {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + 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-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}\n.pp-search {\n width: 100%;\n margin-bottom: 12px;\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-panel .v-toggle {\n border: 0;\n padding: 0;\n flex: none;\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-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n\n}";
1
+ var styleURLs = ["https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/theme.min.css","https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/b-style.min.css"];
2
+
3
+ var defaults = "/* 官方 b-style 负责行布局与配色;本文件只定义面板容器和控件约束。\n * Official b-style owns row layout and colors; this file defines panel containers and control constraints. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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 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/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\n * Keep module icons in content so embedded hosts can use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 64px;\n height: 64px;\n margin: 8px auto 20px;\n}\n.pp-module-logo:not(:empty) {\n display: block;\n}\n.pp-module-logo img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + 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-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 font: inherit;\n border: 0;\n}\n.pp-search {\n width: 100%;\n margin-bottom: 12px;\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}\n.pp-row {\n min-height: 48px;\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-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
2
4
 
3
5
  /**
4
6
  * 校验原始路径片段,不进行 URL 编码转换。
@@ -109,28 +111,30 @@ function metadata(source) {
109
111
  function element(tag, className, text) {
110
112
  const node = document.createElement(tag);
111
113
  node.className = className;
112
- // 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。
113
- // Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.
114
- if (/\bform-row(?:\b|__)/.test(className)) node.setAttribute("data-v-b69aa1ea", "");
115
- if (/\bform-group(?:\b|__)/.test(className)) node.setAttribute("data-v-e590be47", "");
116
114
  if (text !== undefined) node.textContent = text;
117
115
  return node;
118
116
  }
119
117
 
120
118
  /**
121
- * 搜索、选择和文本控件共用官方 VField DOM 结构。
122
- * Share the official VField DOM structure across search, select and text controls.
119
+ * 用官方 b-style 组合行布局,不绑定某个 App 内置页面的编译作用域。
120
+ * Compose rows with official b-style utilities without private app-page compilation scopes.
121
+ * @template {"div" | "label"} T
122
+ * @param {T} tag 行元素 / Row element.
123
+ * @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
124
+ */
125
+ function settingRow(tag) {
126
+ return element(tag, "pp-row flex_between pd_md bb_1 bc_line_regular bg_bg1");
127
+ }
128
+
129
+ /**
130
+ * 搜索、选择和文本控件共用官方输入配色与间距,交互由标准 HTML 控件负责。
131
+ * Share official colors and spacing while native HTML controls own input interaction.
123
132
  * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
124
- * @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.
125
- * @returns {HTMLDivElement} 字段容器 / Field container.
133
+ * @returns {HTMLElement} 输入控件 / Input control.
126
134
  */
127
- function fieldControl(control, multiline = false) {
128
- const field = element("div", `v-field pp-editor${multiline ? " v-field--textarea" : ""}`);
129
- const body = element("div", "v-field__body");
130
- control.classList.add("v-field__control");
131
- body.append(control);
132
- field.append(body);
133
- return field;
135
+ function fieldControl(control) {
136
+ control.classList.add("pp-editor", "bg_bg3", "text1", "pd_sm", "bd_radius_md");
137
+ return control;
134
138
  }
135
139
 
136
140
  /**
@@ -1019,16 +1023,17 @@ function mountPanel(root, catalog) {
1019
1023
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
1020
1024
  const group = match?.[1] ?? "通用";
1021
1025
  if (!groups.has(group)) {
1022
- const section = element("section", "form-group form-group--has-title");
1023
- const rows = element("div", "form-group__row");
1024
- section.append(element("h2", "form-group__title", group), rows);
1026
+ const section = element("section", "pp-group mt_md");
1027
+ const rows = element("div", "pp-rows");
1028
+ section.append(element("h2", "text3 fs_4 fw_400 mb_sm", group), rows);
1025
1029
  groups.set(group, rows);
1026
1030
  view.append(section);
1027
1031
  }
1028
- const row = element("div", "form-row pp-field");
1029
- const label = element("div", "form-row__text");
1030
- label.append(element("span", "form-row__title", match?.[2] ?? field.name));
1031
- if (field.description) label.append(element("span", "form-row__subtitle", field.description));
1032
+ const row = settingRow("div");
1033
+ row.classList.add("pp-field");
1034
+ const label = element("div", "pp-label flex_col items_start mr_md");
1035
+ label.append(element("span", "text1 fs_4", match?.[2] ?? field.name));
1036
+ if (field.description) label.append(element("span", "text3 fs_5 mt_2", field.description));
1032
1037
  row.append(label);
1033
1038
  const value = values[field.key];
1034
1039
  /**
@@ -1064,11 +1069,11 @@ function mountPanel(root, catalog) {
1064
1069
  case field.type === "array" && Boolean(field.options): {
1065
1070
  const page = element("section", "pp-choice-page");
1066
1071
  if (field.description) page.append(element("p", "pp-description", field.description));
1067
- const choices = element("div", "form-group__row");
1072
+ const choices = element("div", "pp-rows");
1068
1073
  page.append(choices);
1069
1074
  inputContainer = choices;
1070
1075
  editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
1071
- const summary = element("span", "form-row__value pp-summary");
1076
+ const summary = element("span", "pp-summary");
1072
1077
  const link = element("button", "pp-choice-link");
1073
1078
  link.type = "button";
1074
1079
  link.setAttribute("aria-label", field.name);
@@ -1089,7 +1094,9 @@ function mountPanel(root, catalog) {
1089
1094
  if (!link.contains(event.target)) link.click();
1090
1095
  });
1091
1096
  const inputs = field.options.map(option => {
1092
- const label = element("label", "form-row pp-choice", option.label);
1097
+ const label = settingRow("label");
1098
+ label.classList.add("pp-choice");
1099
+ label.textContent = option.label;
1093
1100
  const input = element("input", "");
1094
1101
  input.type = "checkbox";
1095
1102
  input.setAttribute("aria-label", option.label);
@@ -1104,20 +1111,15 @@ function mountPanel(root, catalog) {
1104
1111
  break;
1105
1112
  }
1106
1113
  case field.type === "boolean": {
1107
- const toggle = element("button", "v-toggle v-toggle--small form-row__toggle");
1108
- toggle.type = "button";
1114
+ const toggle = element("input", "pp-switch");
1115
+ toggle.type = "checkbox";
1116
+ toggle.setAttribute("switch", "");
1109
1117
  toggle.setAttribute("role", "switch");
1110
1118
  toggle.setAttribute("aria-label", field.name);
1111
- toggle.append(element("span", "v-toggle__circle"));
1112
1119
  write = value => {
1113
- toggle.setAttribute("aria-checked", String(value === true));
1114
- toggle.classList.toggle("v-toggle--closed", value !== true);
1115
- };
1116
- read = () => toggle.getAttribute("aria-checked") === "true";
1117
- toggle.onclick = () => {
1118
- write(!read());
1119
- toggle.dispatchEvent(new window.Event("change", { bubbles: true }));
1120
+ toggle.checked = value === true;
1120
1121
  };
1122
+ read = () => toggle.checked;
1121
1123
  row.append(toggle);
1122
1124
  break;
1123
1125
  }
@@ -1161,7 +1163,7 @@ function mountPanel(root, catalog) {
1161
1163
  return input.value;
1162
1164
  }
1163
1165
  };
1164
- row.append(fieldControl(input, multiline));
1166
+ row.append(fieldControl(input));
1165
1167
  break;
1166
1168
  }
1167
1169
  }
@@ -1298,11 +1300,19 @@ function mount(boxjs, css = "") {
1298
1300
  root.id = "preferences";
1299
1301
  document.body.append(root);
1300
1302
  }
1303
+ // 远程视觉资源与基础布局分开加载,网络状态不控制分页定位。
1304
+ // Load remote visual resources separately so network state cannot control page positioning.
1305
+ const links = styleURLs.map(url => {
1306
+ const link = element("link", "");
1307
+ link.rel = "stylesheet";
1308
+ link.href = url;
1309
+ return link;
1310
+ });
1301
1311
  const base = element("style", ""),
1302
1312
  custom = element("style", "");
1303
1313
  base.textContent = defaults;
1304
1314
  custom.textContent = css;
1305
- document.head.append(base, custom);
1315
+ document.head.append(...links, base, custom);
1306
1316
  const previousTitle = document.title;
1307
1317
  const previousTheme = document.documentElement.dataset.theme;
1308
1318
  const previousDark = document.documentElement.classList.contains("bili_dark");
@@ -1340,6 +1350,7 @@ function mount(boxjs, css = "") {
1340
1350
  systemTheme.removeEventListener("change", syncAppearance);
1341
1351
  document.documentElement.classList.toggle("bili_dark", previousDark);
1342
1352
  panel?.destroy();
1353
+ for (const link of links) link.remove();
1343
1354
  base.remove();
1344
1355
  custom.remove();
1345
1356
  if (existing) root.replaceChildren();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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",
@@ -10,28 +10,30 @@
10
10
  export function element(tag, className, text) {
11
11
  const node = document.createElement(tag);
12
12
  node.className = className;
13
- // 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。
14
- // Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.
15
- if (/\bform-row(?:\b|__)/.test(className)) node.setAttribute("data-v-b69aa1ea", "");
16
- if (/\bform-group(?:\b|__)/.test(className)) node.setAttribute("data-v-e590be47", "");
17
13
  if (text !== undefined) node.textContent = text;
18
14
  return node;
19
15
  }
20
16
 
21
17
  /**
22
- * 搜索、选择和文本控件共用官方 VField DOM 结构。
23
- * Share the official VField DOM structure across search, select and text controls.
18
+ * 用官方 b-style 组合行布局,不绑定某个 App 内置页面的编译作用域。
19
+ * Compose rows with official b-style utilities without private app-page compilation scopes.
20
+ * @template {"div" | "label"} T
21
+ * @param {T} tag 行元素 / Row element.
22
+ * @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
23
+ */
24
+ export function settingRow(tag) {
25
+ return element(tag, "pp-row flex_between pd_md bb_1 bc_line_regular bg_bg1");
26
+ }
27
+
28
+ /**
29
+ * 搜索、选择和文本控件共用官方输入配色与间距,交互由标准 HTML 控件负责。
30
+ * Share official colors and spacing while native HTML controls own input interaction.
24
31
  * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
25
- * @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.
26
- * @returns {HTMLDivElement} 字段容器 / Field container.
32
+ * @returns {HTMLElement} 输入控件 / Input control.
27
33
  */
28
- export function fieldControl(control, multiline = false) {
29
- const field = element("div", `v-field pp-editor${multiline ? " v-field--textarea" : ""}`);
30
- const body = element("div", "v-field__body");
31
- control.classList.add("v-field__control");
32
- body.append(control);
33
- field.append(body);
34
- return field;
34
+ export function fieldControl(control) {
35
+ control.classList.add("pp-editor", "bg_bg3", "text1", "pd_sm", "bd_radius_md");
36
+ return control;
35
37
  }
36
38
 
37
39
  /**
@@ -1,3 +1,4 @@
1
+ import styleURLs from "#style-urls";
1
2
  import defaults from "#styles";
2
3
  import { BoxJS } from "../BoxJS.mjs";
3
4
  import { element, resourceURL } from "./components.mjs";
@@ -23,11 +24,19 @@ export function mount(boxjs, css = "") {
23
24
  root.id = "preferences";
24
25
  document.body.append(root);
25
26
  }
27
+ // 远程视觉资源与基础布局分开加载,网络状态不控制分页定位。
28
+ // Load remote visual resources separately so network state cannot control page positioning.
29
+ const links = styleURLs.map(url => {
30
+ const link = element("link", "");
31
+ link.rel = "stylesheet";
32
+ link.href = url;
33
+ return link;
34
+ });
26
35
  const base = element("style", ""),
27
36
  custom = element("style", "");
28
37
  base.textContent = defaults;
29
38
  custom.textContent = css;
30
- document.head.append(base, custom);
39
+ document.head.append(...links, base, custom);
31
40
  const previousTitle = document.title;
32
41
  const previousTheme = document.documentElement.dataset.theme;
33
42
  const previousDark = document.documentElement.classList.contains("bili_dark");
@@ -65,6 +74,7 @@ export function mount(boxjs, css = "") {
65
74
  systemTheme.removeEventListener("change", syncAppearance);
66
75
  document.documentElement.classList.toggle("bili_dark", previousDark);
67
76
  panel?.destroy();
77
+ for (const link of links) link.remove();
68
78
  base.remove();
69
79
  custom.remove();
70
80
  if (existing) root.replaceChildren();
@@ -1,6 +1,4 @@
1
1
  {
2
- "https://hilo.bilibili.com/h5_common/theme.min.css": "theme.min.css",
3
- "https://hilo.bilibili.com/h5_common/b-style.min.css": "b-style.min.css",
4
- "https://hilo.bilibili.com/app_settings/assets/messageSettingsLayout-ltzQ1gMi.css": "messageSettingsLayout-ltzQ1gMi.css",
5
- "https://hilo.bilibili.com/app_settings/assets/message-settings-BD3N1lqQ.css": "message-settings-BD3N1lqQ.css"
2
+ "https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/theme.min.css": "theme.min.css",
3
+ "https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/b-style.min.css": "b-style.min.css"
6
4
  }
@@ -1,5 +1,5 @@
1
- /* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。
2
- * Grouped rows follow the Bilibili settings layout, scoped to the panel. */
1
+ /* 官方 b-style 负责行布局与配色;本文件只定义面板容器和控件约束。
2
+ * Official b-style owns row layout and colors; this file defines panel containers and control constraints. */
3
3
  .pp-panel {
4
4
  --pp-text: var(--text1);
5
5
  --pp-background: var(--bg2);
@@ -62,7 +62,7 @@
62
62
  width: 44px;
63
63
  flex: none;
64
64
  }
65
- .pp-panel button:not(.v-toggle) {
65
+ .pp-panel button {
66
66
  font: inherit;
67
67
  cursor: pointer;
68
68
  border: 0;
@@ -141,6 +141,9 @@
141
141
  flex: none;
142
142
  width: 45%;
143
143
  min-width: 0;
144
+ min-height: 36px;
145
+ font: inherit;
146
+ border: 0;
144
147
  }
145
148
  .pp-search {
146
149
  width: 100%;
@@ -156,10 +159,19 @@
156
159
  .pp-panel [hidden] {
157
160
  display: none !important;
158
161
  }
159
- .pp-panel .v-toggle {
160
- border: 0;
161
- padding: 0;
162
+ .pp-label {
163
+ flex: 1;
164
+ min-width: 0;
165
+ }
166
+ .pp-row {
167
+ min-height: 48px;
168
+ }
169
+ .pp-rows > :last-child {
170
+ border-bottom: 0 !important;
171
+ }
172
+ .pp-switch {
162
173
  flex: none;
174
+ accent-color: var(--pp-accent);
163
175
  }
164
176
  .pp-choice {
165
177
  justify-content: space-between;
@@ -1,6 +1,6 @@
1
1
  import { ActionMenu } from "./ActionMenu.mjs";
2
2
  import { createPreferencesClient } from "./client.mjs";
3
- import { errorView, fieldControl, icon, element as node, requestConfirmation, resourceURL } from "./components.mjs";
3
+ import { errorView, fieldControl, icon, element as node, requestConfirmation, resourceURL, settingRow } from "./components.mjs";
4
4
  import { Navigation } from "./Navigation.mjs";
5
5
 
6
6
  /**
@@ -231,16 +231,17 @@ export function mountPanel(root, catalog) {
231
231
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
232
232
  const group = match?.[1] ?? "通用";
233
233
  if (!groups.has(group)) {
234
- const section = node("section", "form-group form-group--has-title");
235
- const rows = node("div", "form-group__row");
236
- section.append(node("h2", "form-group__title", group), rows);
234
+ const section = node("section", "pp-group mt_md");
235
+ const rows = node("div", "pp-rows");
236
+ section.append(node("h2", "text3 fs_4 fw_400 mb_sm", group), rows);
237
237
  groups.set(group, rows);
238
238
  view.append(section);
239
239
  }
240
- const row = node("div", "form-row pp-field");
241
- const label = node("div", "form-row__text");
242
- label.append(node("span", "form-row__title", match?.[2] ?? field.name));
243
- if (field.description) label.append(node("span", "form-row__subtitle", field.description));
240
+ const row = settingRow("div");
241
+ row.classList.add("pp-field");
242
+ const label = node("div", "pp-label flex_col items_start mr_md");
243
+ label.append(node("span", "text1 fs_4", match?.[2] ?? field.name));
244
+ if (field.description) label.append(node("span", "text3 fs_5 mt_2", field.description));
244
245
  row.append(label);
245
246
  const value = values[field.key];
246
247
  /**
@@ -276,11 +277,11 @@ export function mountPanel(root, catalog) {
276
277
  case field.type === "array" && Boolean(field.options): {
277
278
  const page = node("section", "pp-choice-page");
278
279
  if (field.description) page.append(node("p", "pp-description", field.description));
279
- const choices = node("div", "form-group__row");
280
+ const choices = node("div", "pp-rows");
280
281
  page.append(choices);
281
282
  inputContainer = choices;
282
283
  editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
283
- const summary = node("span", "form-row__value pp-summary");
284
+ const summary = node("span", "pp-summary");
284
285
  const link = node("button", "pp-choice-link");
285
286
  link.type = "button";
286
287
  link.setAttribute("aria-label", field.name);
@@ -301,7 +302,9 @@ export function mountPanel(root, catalog) {
301
302
  if (!link.contains(event.target)) link.click();
302
303
  });
303
304
  const inputs = field.options.map(option => {
304
- const label = node("label", "form-row pp-choice", option.label);
305
+ const label = settingRow("label");
306
+ label.classList.add("pp-choice");
307
+ label.textContent = option.label;
305
308
  const input = node("input", "");
306
309
  input.type = "checkbox";
307
310
  input.setAttribute("aria-label", option.label);
@@ -316,20 +319,15 @@ export function mountPanel(root, catalog) {
316
319
  break;
317
320
  }
318
321
  case field.type === "boolean": {
319
- const toggle = node("button", "v-toggle v-toggle--small form-row__toggle");
320
- toggle.type = "button";
322
+ const toggle = node("input", "pp-switch");
323
+ toggle.type = "checkbox";
324
+ toggle.setAttribute("switch", "");
321
325
  toggle.setAttribute("role", "switch");
322
326
  toggle.setAttribute("aria-label", field.name);
323
- toggle.append(node("span", "v-toggle__circle"));
324
327
  write = value => {
325
- toggle.setAttribute("aria-checked", String(value === true));
326
- toggle.classList.toggle("v-toggle--closed", value !== true);
327
- };
328
- read = () => toggle.getAttribute("aria-checked") === "true";
329
- toggle.onclick = () => {
330
- write(!read());
331
- toggle.dispatchEvent(new window.Event("change", { bubbles: true }));
328
+ toggle.checked = value === true;
332
329
  };
330
+ read = () => toggle.checked;
333
331
  row.append(toggle);
334
332
  break;
335
333
  }
@@ -373,7 +371,7 @@ export function mountPanel(root, catalog) {
373
371
  return input.value;
374
372
  }
375
373
  };
376
- row.append(fieldControl(input, multiline));
374
+ row.append(fieldControl(input));
377
375
  break;
378
376
  }
379
377
  }