dsh-background-by-model 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -4,8 +4,8 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
- let react = require("react");
8
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
+ let react = require("react");
9
9
  let react_dom = require("react-dom");
10
10
  //#region src/client/runtime.ts
11
11
  function resolveStore() {
@@ -16,384 +16,39 @@ window.__ModuleLoader__.load({
16
16
  }
17
17
  const defineStore = resolveStore().defineStore;
18
18
  //#endregion
19
- //#region src/client/i18n.ts
20
- const NS = "settings.anyBg";
21
- const zh = {
22
- nav: "模型背景",
23
- brandTag: "按模型换背景",
24
- pageInterface: "界面",
25
- pageModelBg: "模型背景",
26
- pageProfile: "配置",
27
- descInterface: "为主界面的各个区域单独调节透明度,营造空间层次感。这里的设置对所有模型生效",
28
- descModelBg: "每条规则 = 一个匹配串 + 一组背景图与外观。切换模型时,从上往下找第一条匹配串出现在模型名里的规则;都没中就使用第 1 条。一条规则可以放多张图,按你设的节奏轮换",
29
- descProfile: "调整壁纸切换效果,并把当前全部规则连同图片导出备份,或从文件一键恢复",
30
- uiTitle: "主界面",
31
- uiOpacity: "透明度",
32
- uiOpacityBg: "主背景",
33
- uiOpacitySide: "左侧栏",
34
- uiOpacityCard: "卡片与菜单面板",
35
- uiOpacityCode: "代码块",
36
- uiOpacityInput: "输入框与控件",
37
- uiSop: "设置界面透明度",
38
- uiChatRegion: "对话文本框",
39
- uiTrajectory: "轨迹页",
40
- uiPage: "插件页面",
41
- uiPreview: "右侧栏",
42
- statusUnknownHint: "检测不到时一律使用第 1 条可用规则;切换一次模型或稍等片刻会自动重试",
43
- statusSourceDefaultHint: "只读到宿主默认模型,它不是本会话当前选择的模型;本会话的模型选择可用后会自动纠正",
44
- statusNoteNoService: "仍未拿到会话服务(会话控制器晚于本插件加载),正在自动重试",
45
- statusNoteNoSession: "会话服务已就绪,但还没有当前会话 id",
46
- statusNoteNoProjection: "拿不到本会话的模型选择投影(binding / projections 不可用)",
47
- statusNoteEmptySelection: "本会话的模型选择投影还没有值(切一次模型即可写入)",
48
- hostStaleHint: "检测到 DSH 主进程仍在运行旧版插件:新版配置格式它还读不懂,为避免丢规则,本次只改界面不写入配置。重启 DSH 后多图与轮换即可正常保存",
49
- tryoutTitle: "匹配试跑",
50
- tryoutPlaceholder: "输入模型名,如 deepseek-flash",
51
- tryoutUseCurrent: "填入当前模型",
52
- tryoutHit: "命中规则",
53
- tryoutFallback: "无匹配 · 走兜底规则",
54
- tryoutNone: "没有可用规则(规则需要启用,并且有图片或主题色)",
55
- tryoutEmpty: "输入模型名即可试跑",
56
- autoExtract: "选图后自动取主题色",
57
- ruleAdd: "新增规则",
58
- ruleNoneHint: "还没有规则,点上面的「新增规则」新建一条",
59
- ruleFallbackBadge: "兜底",
60
- ruleMatchPlaceholder: "如 flash / glm(忽略大小写,留空则不参与匹配)",
61
- ruleEnabled: "启用",
62
- ruleUp: "上移",
63
- ruleDown: "下移",
64
- ruleRemove: "删除规则",
65
- tabListLabel: "规则",
66
- tabPick: "选择规则",
67
- tabMore: "更多规则",
68
- tabUnnamed: "未命名规则",
69
- tabLive: "正在使用",
70
- tabRotating: "轮换中",
71
- tabOff: "已停用",
72
- ruleRemoveConfirm: "再点一次删除",
73
- ruleNoImage: "尚未选择图片",
74
- ruleFromUrl: "从网址",
75
- ruleUrlPlaceholder: "粘贴图片网址 https://…",
76
- ruleUrlApply: "应用",
77
- ruleUrlCancel: "取消",
78
- ruleUrlApplying: "加载中…",
79
- ruleUrlBadHttp: "仅支持 http/https 图片网址",
80
- ruleUrlFail: "获取图片失败",
81
- ruleImageRemove: "移除",
82
- ruleEmptyHint: "这条规则还没有图片,也没有自己的主题色:没有可显示的内容,匹配和兜底都会跳过它",
83
- ruleColorOnlyHint: "这条规则还没有图片:不会铺壁纸,界面只用这个主题色",
84
- ruleImagePendingHint: "这张图片还没有取到内容,稍后会自己出现(也可以直接替换它)",
85
- ruleColor: "主题色",
86
- ruleColorNone: "跟随系统主题",
87
- ruleColorNoneHint: "未设颜色时跟随系统主题:界面用宿主的配色,壁纸与各部位的透明度照旧生效",
88
- ruleColorPick: "自定义颜色",
89
- ruleColorPickHint: "优先从本图取色,取不到就用默认色",
90
- ruleColorNoImageHint: "这条规则还没有图片,取不到色:选一个颜色可以只铺主题色,或先添加图片",
91
- ruleColorPendingImageHint: "这张图片还没有取到内容,暂时取不了色:可以直接选一个颜色,或等它加载完",
92
- ruleColorExtract: "从本图提取",
93
- ruleColorTargetImage: "作用于选中的图片:",
94
- ruleColorTargetRule: "作用于本规则(它还没有图片)",
95
- ruleLayout: "布局模式",
96
- ruleLayoutNoImage: "添加图片后可分别设置每张图的布局模式",
97
- ruleFramingEdit: "编辑位置",
98
- ruleOpacity: "背景透明度",
99
- ruleBrightness: "背景亮度",
100
- ruleBlur: "背景模糊",
101
- ruleBlurHint: "这三条滑块都作用于壁纸层;界面页里的透明度作用于界面各区域,两者互不影响",
102
- rotTitle: "多图轮换",
103
- rotEnable: "轮换这组图",
104
- rotNeedTwo: "至少两张图才能轮换",
105
- rotEvery: "每张停留",
106
- rotCustom: "自定义秒数",
107
- rotSeconds: "秒",
108
- rotOrder: "换图顺序",
109
- rotOrderSeq: "按顺序",
110
- rotOrderShuffle: "随机",
111
- rotOnSwitch: "切换模型时也换一张",
112
- rotNext: "下一张",
113
- rotNextInactive: "只有正在生效的规则能换画面上的图",
114
- rotPos: "第",
115
- rotShowing: "正在显示",
116
- rotAddImage: "添加",
117
- rotReplaceImage: "替换",
118
- rotDragHint: "拖动排序",
119
- rotDblHint: "双击切换",
120
- rotEvery10s: "10 秒",
121
- rotEvery30s: "30 秒",
122
- rotEvery1m: "1 分钟",
123
- rotEvery5m: "5 分钟",
124
- rotEvery30m: "30 分钟",
125
- bgModeFit: "适应",
126
- bgModeFill: "填充",
127
- bgModeStretch: "拉伸",
128
- bgModeTile: "平铺",
129
- bgModeCenter: "居中",
130
- editorTitle: "背景编辑器",
131
- editorHint: "拖动移动画面,滚轮缩放大小",
132
- editorCommit: "确认",
133
- editorCancel: "取消",
134
- editorReset: "重置",
135
- extracting: "取色中…",
136
- extractDone: "已应用图片配色",
137
- extractFail: "未在这张图里找到鲜明的颜色,请换一张",
138
- eyedropper: "从图片取色",
139
- pickerTitle: "从图片取色",
140
- pickerHint: "移动鼠标预览颜色,点击图片选取为主题色",
141
- pickerClose: "关闭",
142
- crashTitle: "界面渲染出错",
143
- crashDesc: "设置面板遇到问题,点击下方按钮重置后重试",
144
- crashReset: "重置面板",
145
- exportTheme: "导出配置",
146
- importTheme: "导入配置",
147
- exportCardTitle: "导出配置",
148
- exportCardDesc: "把全部规则、每条规则的颜色/布局/透明度/模糊与图片打包为 dsh-background-by-model-theme.json",
149
- importCardTitle: "导入配置",
150
- importCardDesc: "从之前导出的 JSON 文件一键恢复全部规则(会覆盖当前规则与图片)",
151
- toastExportDone: "配置文件已开始下载",
152
- importDone: "已导入配置",
153
- importFail: "导入失败,文件格式不正确",
154
- recCardTitle: "推荐配置",
155
- recCardDesc: "从项目素材库下载作者维护的一整套配置(规则、壁纸与接口设置),一键替换当前的全部内容",
156
- recAdopt: "采用推荐配置",
157
- recBusy: "正在下载",
158
- recConfirmTitle: "替换当前的全部配置?",
159
- recConfirmBody: "会先删除你现在的全部规则与壁纸,再写入这份推荐配置。删除的文件无法恢复。",
160
- recStatRules: "条规则",
161
- recStatImages: "张壁纸",
162
- recConfirmOk: "删除并替换",
163
- recConfirmCancel: "取消",
164
- recDone: "已采用推荐配置",
165
- recFailNetwork: "推荐配置下载失败,请检查网络后重试",
166
- recFailNotProfile: "素材库返回的内容不是配置,请稍后重试",
167
- recFailNewer: "这份推荐配置需要更新的插件版本,未做任何改动",
168
- recFailTooMany: "这份推荐配置列出的图片数量超出限制,未做任何改动",
169
- recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
170
- recFailImage: "这张壁纸没拿到(素材库里缺这个文件):",
171
- recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
172
- footerTag: "按模型换背景插件",
173
- repoLink: "在 GitHub 上打开仓库",
174
- holidayTitle: "节日特殊背景",
175
- holidayEnable: "启用节日特殊背景(中秋当天、国庆 10 月 1–7 日)",
176
- trTitle: "切换效果",
177
- trEffect: "切换方式",
178
- trEffectFade: "淡入淡出",
179
- trEffectNone: "直接切换",
180
- trEffectZoom: "缩放淡入",
181
- trEffectSlide: "平移推入",
182
- trEasing: "缓动",
183
- trEasingEase: "标准",
184
- trEasingLinear: "匀速",
185
- trEasingOut: "先快后慢",
186
- trEasingInOut: "两端慢",
187
- trDuration: "切换时长",
188
- trPreview: "试放",
189
- trHint: "效果对所有换图生效:切模型、轮换、手动下一张、节日。「直接切换」和 0 秒一样是硬切",
190
- trReducedHint: "系统开启了「减少动态效果」,实际切换不会播放动画"
191
- };
192
- const en = {
193
- nav: "Model background",
194
- brandTag: "Per-model wallpaper",
195
- pageInterface: "Interface",
196
- pageModelBg: "Model background",
197
- pageProfile: "Profile",
198
- descInterface: "Tune opacity per surface to build depth. These settings are global and apply to every model",
199
- descModelBg: "Each rule is a match string plus a set of wallpapers and one look. On a model switch the list is scanned top-down for the first match string contained in the model name; if nothing hits, rule 1 is used. A rule can hold several images and rotate through them on a rhythm you set",
200
- descProfile: "Tune how the wallpaper switches, and back up every rule together with its image — or restore one from a file",
201
- uiTitle: "Interface",
202
- uiOpacity: "Opacity",
203
- uiOpacityBg: "Main background",
204
- uiOpacitySide: "Left panel",
205
- uiOpacityCard: "Cards & menus",
206
- uiOpacityCode: "Code blocks",
207
- uiOpacityInput: "Input & controls",
208
- uiSop: "Settings interface opacity",
209
- uiChatRegion: "Conversation text frame",
210
- uiTrajectory: "Trajectory view",
211
- uiPage: "Plugin pages",
212
- uiPreview: "Right panel",
213
- statusUnknownHint: "Without a detected model rule 1 is always used; switching the model once or waiting a moment retries automatically",
214
- statusSourceDefaultHint: "Only the host-wide default model could be read — that is not necessarily this session's selection. It self-corrects once this session's model selection is available.",
215
- statusNoteNoService: "The sessions service is not available yet (the session controller loads after this plugin); retrying automatically",
216
- statusNoteNoSession: "The sessions service is up but reports no current session yet",
217
- statusNoteNoProjection: "This session's model-selection projection is unreachable (no binding / projections)",
218
- statusNoteEmptySelection: "The model-selection projection exists but carries no value yet (selecting a model once writes it)",
219
- hostStaleHint: "The DSH host process is still running the older plugin, which cannot read the new config shape. To avoid dropping rules, this session changes the interface but holds every config write — restart DSH and multi-image plus rotation will save normally",
220
- tryoutTitle: "Match test",
221
- tryoutPlaceholder: "Type a model name, e.g. deepseek-flash",
222
- tryoutUseCurrent: "Use current model",
223
- tryoutHit: "matches rule",
224
- tryoutFallback: "no match · fallback rule",
225
- tryoutNone: "No usable rule (a rule needs to be enabled and carry an image or a theme color)",
226
- tryoutEmpty: "Type a model name to test the list",
227
- autoExtract: "Extract the theme color when an image is chosen",
228
- ruleAdd: "Add rule",
229
- ruleNoneHint: "No rules yet — use “Add rule” in the tab strip above",
230
- ruleFallbackBadge: "fallback",
231
- ruleMatchPlaceholder: "e.g. flash / glm (case-insensitive, empty never matches)",
232
- ruleEnabled: "Enabled",
233
- ruleUp: "Move up",
234
- ruleDown: "Move down",
235
- ruleRemove: "Remove rule",
236
- tabListLabel: "Rules",
237
- tabPick: "Pick a rule",
238
- tabMore: "More rules",
239
- tabUnnamed: "Unnamed rule",
240
- tabLive: "In use",
241
- tabRotating: "Rotating",
242
- tabOff: "Disabled",
243
- ruleRemoveConfirm: "Click again to delete",
244
- ruleNoImage: "No image yet",
245
- ruleFromUrl: "From URL",
246
- ruleUrlPlaceholder: "Paste an image URL https://…",
247
- ruleUrlApply: "Apply",
248
- ruleUrlCancel: "Cancel",
249
- ruleUrlApplying: "Loading…",
250
- ruleUrlBadHttp: "Only http/https image URLs are supported",
251
- ruleUrlFail: "Could not fetch the image",
252
- ruleImageRemove: "Remove",
253
- ruleEmptyHint: "This rule has no image and no theme color of its own: it has nothing to show, so matching and the fallback both skip it",
254
- ruleColorOnlyHint: "No image in this rule yet: no wallpaper is shown, and the interface is painted from this theme color alone",
255
- ruleImagePendingHint: "This image has no content yet — it appears once it loads; you can also replace it",
256
- ruleColor: "Theme color",
257
- ruleColorNone: "Follow system theme",
258
- ruleColorNoneHint: "Without a color this rule follows the system theme: the host palette paints the interface while the wallpaper and every opacity slider keep working",
259
- ruleColorPick: "Custom color",
260
- ruleColorPickHint: "Extracts from this image when it can, otherwise uses a default color",
261
- ruleColorNoImageHint: "This rule has no image to take a color from: pick a color to tint the interface alone, or add an image first",
262
- ruleColorPendingImageHint: "This image has no content yet, so there is nothing to extract from: pick a color directly, or wait for it to load",
263
- ruleColorTargetImage: "Applies to the selected image:",
264
- ruleColorTargetRule: "Applies to this rule (it has no image yet)",
265
- ruleColorExtract: "Extract from image",
266
- ruleLayout: "Layout mode",
267
- ruleLayoutNoImage: "Add an image to give each one its own layout mode",
268
- ruleFramingEdit: "Edit position",
269
- ruleOpacity: "Background opacity",
270
- ruleBrightness: "Background brightness",
271
- ruleBlur: "Background blur",
272
- ruleBlurHint: "These three sliders all treat the wallpaper layer; the opacity on the Interface page affects interface surfaces — they are independent",
273
- rotTitle: "Image rotation",
274
- rotEnable: "Rotate these images",
275
- rotNeedTwo: "At least two images are needed to rotate",
276
- rotEvery: "Dwell time per image",
277
- rotCustom: "Custom seconds",
278
- rotSeconds: "s",
279
- rotOrder: "Order",
280
- rotOrderSeq: "In order",
281
- rotOrderShuffle: "Shuffle",
282
- rotOnSwitch: "Also step on a model switch",
283
- rotNext: "Next image",
284
- rotNextInactive: "Only the active rule can step the background",
285
- rotPos: "Image",
286
- rotShowing: "On screen",
287
- rotAddImage: "Add",
288
- rotReplaceImage: "Replace",
289
- rotDragHint: "drag to reorder",
290
- rotDblHint: "double-click to show",
291
- rotEvery10s: "10 s",
292
- rotEvery30s: "30 s",
293
- rotEvery1m: "1 min",
294
- rotEvery5m: "5 min",
295
- rotEvery30m: "30 min",
296
- bgModeFit: "Fit",
297
- bgModeFill: "Fill",
298
- bgModeStretch: "Stretch",
299
- bgModeTile: "Tile",
300
- bgModeCenter: "Center",
301
- editorTitle: "Background editor",
302
- editorHint: "Drag to move, scroll to zoom",
303
- editorCommit: "Confirm",
304
- editorCancel: "Cancel",
305
- editorReset: "Reset",
306
- extracting: "Extracting…",
307
- extractDone: "Image colors applied",
308
- extractFail: "No vivid color found in this image, try another",
309
- eyedropper: "Eyedropper",
310
- pickerTitle: "Pick from image",
311
- pickerHint: "Hover to preview, click to pick as the theme color",
312
- pickerClose: "Close",
313
- crashTitle: "Section crashed",
314
- crashDesc: "The panel hit an error. Reset below to recover.",
315
- crashReset: "Reset panel",
316
- exportTheme: "Export",
317
- importTheme: "Import",
318
- exportCardTitle: "Export profile",
319
- exportCardDesc: "Bundle every rule — color, layout, opacity, blur — and its image into dsh-background-by-model-theme.json",
320
- importCardTitle: "Import profile",
321
- importCardDesc: "Restore every rule and image from a previously exported JSON file (replaces the current set)",
322
- toastExportDone: "Export started",
323
- importDone: "Profile imported",
324
- importFail: "Import failed — invalid file",
325
- recCardTitle: "Recommended profile",
326
- recCardDesc: "Download the author's own full setup — rules, wallpapers and interface settings — and replace everything with it",
327
- recAdopt: "Use recommended profile",
328
- recBusy: "Downloading",
329
- recConfirmTitle: "Replace your entire profile?",
330
- recConfirmBody: "Every rule and wallpaper you have is deleted first, then this profile is written in its place. Deleted files cannot be recovered.",
331
- recStatRules: "rules",
332
- recStatImages: "wallpapers",
333
- recConfirmOk: "Delete and replace",
334
- recConfirmCancel: "Cancel",
335
- recDone: "Recommended profile applied",
336
- recFailNetwork: "Could not download the recommended profile — check your connection and retry",
337
- recFailNotProfile: "The asset host answered with something that is not a profile — try again later",
338
- recFailNewer: "This profile needs a newer plugin version. Nothing was changed.",
339
- recFailTooMany: "The recommended profile names more images than the plugin will fetch. Nothing was changed.",
340
- recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
341
- recFailImage: "Could not fetch this wallpaper — the asset host has no such file:",
342
- recFailApply: "Could not write the recommended profile — your own is untouched",
343
- footerTag: "Per-model wallpaper plugin",
344
- repoLink: "Open the repository on GitHub",
345
- holidayTitle: "Holiday backgrounds",
346
- holidayEnable: "Enable holiday backgrounds (Mid-Autumn day, Oct 1–7)",
347
- trTitle: "Switch effect",
348
- trEffect: "Transition",
349
- trEffectFade: "Cross-fade",
350
- trEffectNone: "Instant",
351
- trEffectZoom: "Zoom in",
352
- trEffectSlide: "Slide in",
353
- trEasing: "Easing",
354
- trEasingEase: "Standard",
355
- trEasingLinear: "Linear",
356
- trEasingOut: "Ease out",
357
- trEasingInOut: "Ease in-out",
358
- trDuration: "Duration",
359
- trPreview: "Preview",
360
- trHint: "The effect applies to every change: model switch, rotation, manual next, holidays. “Instant” and 0 s are the same hard cut",
361
- trReducedHint: "Your system asks for reduced motion, so a real switch will not animate"
362
- };
363
- //#endregion
364
- //#region src/client/transition.ts
365
- /**
366
- * Resolve the duration one switch uses, in ms (`0` = a hard cut).
367
- *
368
- * 1. `effect: 'none'` is a hard cut whatever the duration says — asking for no
369
- * animation and asking for 0 ms are the same request, and the effect is the
370
- * more explicit of the two;
371
- * 2. otherwise the global `durationMs` decides, because a rule has no notion of
372
- * a transition and there is exactly one duration for every switch.
373
- *
374
- * A non-finite or negative value answers `0` (a hard cut) rather than a default:
375
- * animating for an unknown length is the one answer that cannot be right. The
376
- * input is already clamped by the shared sanitizer (`./schema`), so this is a
377
- * guard against a hand-edited config, not a second clamp.
378
- */
379
- function resolveFadeMs(transition) {
380
- if (transition.effect === "none") return 0;
381
- return Number.isFinite(transition.durationMs) && transition.durationMs > 0 ? transition.durationMs : 0;
382
- }
383
- /**
384
- * The transform the incoming layer starts from, per effect.
385
- *
386
- * `zoom` starts ABOVE 1 and settles back to exactly `none`, never below or past
387
- * it: the wallpaper would otherwise be left larger than the viewport, cropping a
388
- * `fit` framing the user committed by hand. `slide` is a small push because a
389
- * full-width travel of a full-bleed wallpaper reads as two wallpapers, not one
390
- * transition.
391
- */
392
- const START_TRANSFORM = {
393
- fade: "none",
394
- none: "none",
395
- zoom: "scale(1.06)",
396
- slide: "translateX(6%)"
19
+ //#region src/client/transition.ts
20
+ /**
21
+ * Resolve the duration one switch uses, in ms (`0` = a hard cut).
22
+ *
23
+ * 1. `effect: 'none'` is a hard cut whatever the duration says — asking for no
24
+ * animation and asking for 0 ms are the same request, and the effect is the
25
+ * more explicit of the two;
26
+ * 2. otherwise the global `durationMs` decides, because a rule has no notion of
27
+ * a transition and there is exactly one duration for every switch.
28
+ *
29
+ * A non-finite or negative value answers `0` (a hard cut) rather than a default:
30
+ * animating for an unknown length is the one answer that cannot be right. The
31
+ * input is already clamped by the shared sanitizer (`./schema`), so this is a
32
+ * guard against a hand-edited config, not a second clamp.
33
+ */
34
+ function resolveFadeMs(transition) {
35
+ if (transition.effect === "none") return 0;
36
+ return Number.isFinite(transition.durationMs) && transition.durationMs > 0 ? transition.durationMs : 0;
37
+ }
38
+ /**
39
+ * The transform the incoming layer starts from, per effect.
40
+ *
41
+ * `zoom` starts ABOVE 1 and settles back to exactly `none`, never below or past
42
+ * it: the wallpaper would otherwise be left larger than the viewport, cropping a
43
+ * `fit` framing the user committed by hand. `slide` is a small push because a
44
+ * full-width travel of a full-bleed wallpaper reads as two wallpapers, not one
45
+ * transition.
46
+ */
47
+ const START_TRANSFORM = {
48
+ fade: "none",
49
+ none: "none",
50
+ zoom: "scale(1.06)",
51
+ slide: "translateX(6%)"
397
52
  };
398
53
  /** The properties one effect actually animates — an unchanged property must not
399
54
  * be put on the transition list, or `fade` would stop being byte-for-byte the
@@ -582,23 +237,16 @@ window.__ModuleLoader__.load({
582
237
  /**
583
238
  * The persisted configuration shape — declared exactly ONCE, for both halves.
584
239
  *
585
- * This shape used to be written out twice: the node half owns persistence and
586
- * sanitizes every write, the browser half owns the UI and its own reading of the
587
- * same file. A field added to only one side was silently dropped by the other
588
- * side's sanitizer — the matching slider stayed live in memory, `writeConfig`
589
- * wrote a config without it, and the next load fell back to the default.
590
- * `blurs.rightbar` / `rightbarOpacity` in 0.3.2 was exactly that bug, and the key
591
- * lists were duplicated 6 times across the two halves, so it was due to happen.
592
- *
593
- * The types, the key lists, the defaults and the pure normalizers therefore live
594
- * here and both halves import them: adding a field means editing this file, and
595
- * the two halves cannot disagree about the shape. The host additionally logs one
596
- * warning per unknown key while sanitizing a write (see `warnUnknownConfigKeys`
597
- * in `src/index.ts`), so a field that reaches the node half without being
598
- * declared here shows up in the host log instead of quietly disappearing.
240
+ * The types, the key lists, the defaults and the pure normalizers live here and
241
+ * both halves import them, so adding a field means editing this file and the two
242
+ * halves cannot disagree about the shape. (This was written twice until 0.3.2,
243
+ * when a field declared on one side was silently dropped by the other side's
244
+ * sanitizer.) The host also logs one warning per unknown key while sanitizing a
245
+ * write (`warnUnknownConfigKeys` in `src/index.ts`), so a field that reaches the
246
+ * node half without being declared here shows up in the log.
599
247
  *
600
- * Nothing in this module may touch the DOM, Node or the Cordis context: it is
601
- * imported by the browser bundle and by the node half alike.
248
+ * Nothing here may touch the DOM, Node or the Cordis context: it is imported by
249
+ * the browser bundle and by the node half alike.
602
250
  */
603
251
  const DEFAULT_BG_STATE = {
604
252
  zoom: 1,
@@ -633,14 +281,14 @@ window.__ModuleLoader__.load({
633
281
  return { ...DEFAULT_ROTATION };
634
282
  }
635
283
  /**
636
- * Main interface opacities (0..1), global (Interface page):
637
- * `bg` main background (`--dsw-alias-bg-base`)
638
- * `sidebar` sidebar (`--dsw-specific-sidebar-fill`)
639
- * `card` cards/panels (`--dsw-alias-bg-layer-1/2/3`, `--dsw-specific-menu`
640
- * and its 0.1.7 alias `--dsw-menu-surface-fill`)
641
- * `code` code blocks, banner included (`--dsw-alias-markdown-code-block`,
642
- * `--dsw-alias-markdown-code-block-banner`)
643
- * `input` input/control surfaces (`--dsw-specific-input-major`)
284
+ * Main interface opacities (0..1), global (Interface page) — each part is the token
285
+ * set it owns:
286
+ * `bg` `--dsw-alias-bg-base`
287
+ * `sidebar` `--dsw-specific-sidebar-fill`
288
+ * `card` `--dsw-alias-bg-layer-1/2/3`, `--dsw-specific-menu` (and its alias
289
+ * `--dsw-menu-surface-fill`)
290
+ * `code` `--dsw-alias-markdown-code-block`, `…-banner`
291
+ * `input` `--dsw-specific-input-major`
644
292
  */
645
293
  const PART_OPACITY_KEYS = [
646
294
  "bg",
@@ -688,27 +336,17 @@ window.__ModuleLoader__.load({
688
336
  return rule.images.map((i) => i.slot);
689
337
  }
690
338
  /**
691
- * Whether a rule has anything to show at all: an image, or a theme color of its
339
+ * Whether a rule has anything to show at all: an image, or a theme colour of its
692
340
  * own. Used by matching (`usable` in ./client/modelbg) and by the panel's copy.
693
341
  *
694
- * An image is the obvious way a rule paints, and until 0.7 it was the ONLY one —
695
- * which stopped being true the moment the last image became removable. A
696
- * rule-level theme color is the second: it recolors the interface through the
697
- * token palette with no wallpaper involved, so a rule whose pictures were all
698
- * removed still paints, and treating it as unusable made every control left on
699
- * its card (the wheel, the swatches, the clear-to-system-theme button) dead — the
700
- * one state where the panel was confidently showing a lie. It also silently
701
- * handed the model to the NEXT rule, so clearing the default rule's images put
702
- * another model's wallpaper on the screen.
342
+ * The rule's own colour counts because it recolours the interface with no
343
+ * wallpaper involved: a rule whose pictures were all removed still paints, and
344
+ * treating it as unusable handed the model to the NEXT rule (another model's
345
+ * wallpaper on screen) while every control left on its card went dead. Per-image
346
+ * colours do not enter the question at all — an image is already enough.
703
347
  *
704
- * Note that a per-image color (0.7.1) does not enter this question at all: an
705
- * image is already enough, so the answer here is about the IMAGE LIST being
706
- * non-empty, never about what those images are painted in.
707
- *
708
- * A rule with no image and no color of its own paints nothing: the system theme
709
- * is not a paint of this rule's own, so such a rule is skipped by matching, which
710
- * is what makes a freshly added rule (empty, colorless) invisible until the user
711
- * gives it something.
348
+ * A rule with neither paints nothing, so matching skips it: a freshly added rule
349
+ * stays invisible until the user gives it something.
712
350
  */
713
351
  function ruleCanPaint(rule) {
714
352
  return rule.enabled && (ruleSlots(rule).length > 0 || rule.color !== null);
@@ -718,12 +356,10 @@ window.__ModuleLoader__.load({
718
356
  const DEFAULT_RIGHTBAR_OPACITY = .45;
719
357
  /**
720
358
  * `#RRGGBB` → the `[h, s, l]` triple every rule stores (`s` and `l` in 0..1), or
721
- * null when the string is not a hex color.
359
+ * null when the string is not a hex colour.
722
360
  *
723
- * A holiday names its color as hex because that is how a color is quoted, and a
724
- * hand-converted triple is a magic number nothing downstream can check. The
725
- * conversion lives here rather than in ./holiday because `[h, s, l]` is this
726
- * module's own rule shape.
361
+ * Holidays name their colour as hex (`HOLIDAYS`) because a hand-converted triple
362
+ * is a magic number nothing downstream can check.
727
363
  */
728
364
  function hexToHsl(hex) {
729
365
  const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
@@ -756,11 +392,8 @@ window.__ModuleLoader__.load({
756
392
  /**
757
393
  * One holiday's appearance, untouched.
758
394
  *
759
- * Holidays default to `fill` rather than a rule's `fit`: these are full-bleed
760
- * festival wallpapers, and `fit` would letterbox them on every aspect ratio the
761
- * art was not cut for. The mode now lives on the IMAGE (see `BgImage.bgMode`), so
762
- * that default is written there — and `fill` is what a holiday's read-back
763
- * sanitizer refuses to lose (see `normalizeHolidayRule`).
395
+ * The mode is `fill` rather than a rule's `fit`: full-bleed festival art would be
396
+ * letterboxed by `fit` on every aspect ratio it was not cut for.
764
397
  */
765
398
  function defaultHolidayRule(def) {
766
399
  const color = hexToHsl(def.color);
@@ -786,21 +419,13 @@ window.__ModuleLoader__.load({
786
419
  return HOLIDAYS.map(defaultHolidayRule);
787
420
  }
788
421
  /**
789
- * A rule with nothing in it yet: no picture, no match string, no color.
790
- *
791
- * Two states need this factory and they are the same state — a fresh install
792
- * (see `freshThemeConfig` and `normalizeConfig`), and the "add rule" tile one
793
- * entry further down the list.
422
+ * A rule with nothing in it yet: no picture, no match string, no colour — a fresh
423
+ * install's rule 1 and the "add rule" tile alike.
794
424
  *
795
- * Such a rule is deliberately valid rather than half-built: it is skipped by
796
- * matching until it has something of its own to paint (`ruleCanPaint` — a picture,
797
- * or a theme color), which is what makes a rule nobody has filled in yet invisible
798
- * instead of letting an empty background steal the fallback from rule 1.
799
- *
800
- * It used to be born with an allocated slot and an empty image entry, which is
801
- * what put a blank tile at position 1 of a new rule's strip — and, worse, stopped
802
- * the picture the user added next from being the first one (a rule paints its
803
- * FIRST image). The slot is allocated when the first picture arrives.
425
+ * Valid rather than half-built: `ruleCanPaint` skips it until it has something of
426
+ * its own, so it cannot steal the fallback from rule 1. It deliberately carries NO
427
+ * image entry: a rule paints its FIRST image, and a placeholder slot left the
428
+ * picture the user added next in position 2. The slot is allocated on arrival.
804
429
  */
805
430
  function blankRule(id) {
806
431
  return {
@@ -816,14 +441,11 @@ window.__ModuleLoader__.load({
816
441
  };
817
442
  }
818
443
  /**
819
- * A default config with independent nested objects (never hand out the shared ones).
444
+ * A default config with independent nested objects (never the shared ones).
820
445
  *
821
- * The rule list is seeded with one blank rule rather than left empty: "no rules at
822
- * all" is not a state the panel should ever open in, because the first thing a new
823
- * user has to do is name a match string and drop a picture in — and the drop target
824
- * for that is the rule card, which an empty list has no way to show. The empty
825
- * state is still reachable (deleting the last rule is a thing the user can mean),
826
- * and is described by `ruleNoneHint`.
446
+ * Seeded with one blank rule: an empty list has no rule card, and the card is the
447
+ * only drop target a first picture has. Deleting every rule is still reachable and
448
+ * is described by `ruleNoneHint`.
827
449
  */
828
450
  function freshThemeConfig() {
829
451
  return {
@@ -843,7 +465,7 @@ window.__ModuleLoader__.load({
843
465
  };
844
466
  }
845
467
  /** Slot names reach the filesystem, so they are strictly whitelisted. */
846
- const SLOT_RE = /^[A-Za-z0-9_-]{1,32}$/;
468
+ const SLOT_RE$1 = /^[A-Za-z0-9_-]{1,32}$/;
847
469
  function clamp$1(n, lo, hi, def) {
848
470
  return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
849
471
  }
@@ -908,18 +530,12 @@ window.__ModuleLoader__.load({
908
530
  }
909
531
  }
910
532
  /**
911
- * Coerce one persisted transition block; a config written before this setting
912
- * existed has no `transition` key at all and gets the shipped default, which is
913
- * the behaviour it already had.
533
+ * Coerce one persisted transition block; a config without one gets the shipped
534
+ * default, which is what it was already showing.
914
535
  *
915
- * `legacyMs` is the per-rule `fadeMs` this shape replaced (see `legacyFadeMs`),
916
- * adopted only when the incoming config has no `durationMs` of its own — so a
917
- * deliberate hand-tuned blend survives the upgrade instead of being dropped with
918
- * the field it used to live on.
919
- *
920
- * `durationMs` is clamped with `0` kept as a real value (a hard cut) rather than
921
- * read as "absent": 0 is exactly how a hard cut is asked for without changing
922
- * the effect.
536
+ * `legacyMs` is the per-rule `fadeMs` this shape replaced (`legacyFadeMs`), adopted
537
+ * only when the incoming config carries no `durationMs` of its own. `0` is kept as
538
+ * a real value (a hard cut), not read as "absent".
923
539
  */
924
540
  function normalizeTransition(raw, legacyMs) {
925
541
  const t = raw ?? {};
@@ -934,29 +550,19 @@ window.__ModuleLoader__.load({
934
550
  /**
935
551
  * Coerce one persisted image entry, or null when it lacks a usable slot.
936
552
  *
937
- * `inherit` is the RULE's own color, and it is applied only to an entry that has
938
- * no `color` key at all — the shape every release before 0.7.1 wrote, where a
939
- * rule's color was the only color there was. Lifting it here, once, is what makes
940
- * an upgrade a no-op on screen: a themed wallpaper keeps its theme instead of
941
- * silently dropping to the system palette, and it happens in the shared sanitizer
942
- * so both halves lift identically.
943
- *
944
- * The KEY's presence is the whole distinction, which is why `null` is not the
945
- * same as absent: an entry with `color: null` is an image the user explicitly set
946
- * to "follow the system theme", and re-inheriting the rule's color over that
947
- * would undo a deliberate choice on every load.
553
+ * `inherit` (the RULE's colour) is applied only to an entry with no `color` KEY at
554
+ * all — the shape written before per-image colours, where the rule's colour was the
555
+ * only one there was. The key's PRESENCE is the whole distinction: `color: null` is
556
+ * a deliberate "follow the system theme" and must not be re-inherited on every load.
948
557
  *
949
- * `inheritMode` is the same one-shot lift for the layout mode, and it is the
950
- * caller's to resolve because the two callers answer differently: a user rule
951
- * passes the mode its config carried at the RULE level (a schema-5 config, where
952
- * 适应/填充 was one choice for the whole rotation), a holiday passes `fill` (its
953
- * own default, which is not up for the lift to change). An entry that HAS a
954
- * `bgMode` key keeps it — including one that has just been set to a value equal
955
- * to the default, which is a decision and not an absence.
558
+ * `inheritMode` is the same one-shot lift for the layout mode. It is the caller's to
559
+ * resolve because the callers answer differently: a user rule passes the mode its
560
+ * config carried at the RULE level (schema 5), a holiday passes its own `fill`. An
561
+ * entry that HAS a `bgMode` key keeps it, even when it equals the default.
956
562
  */
957
563
  function normalizeImage(raw, inherit = null, inheritMode = "fit") {
958
564
  const i = raw ?? {};
959
- if (typeof i.slot !== "string" || !SLOT_RE.test(i.slot)) return null;
565
+ if (typeof i.slot !== "string" || !SLOT_RE$1.test(i.slot)) return null;
960
566
  return {
961
567
  slot: i.slot,
962
568
  bgMode: BG_MODES$1.includes(i.bgMode) ? i.bgMode : inheritMode,
@@ -965,36 +571,21 @@ window.__ModuleLoader__.load({
965
571
  };
966
572
  }
967
573
  /**
968
- * Coerce one persisted rule, or null when it names no image AT ALL.
574
+ * Coerce one persisted rule, or null when it names no image ANYWHERE.
969
575
  *
970
- * Accepts BOTH image shapes: the 0.7 list (`images`), and the single
971
- * `slot`/`bgState` pair every earlier version wrote — the latter is lifted to a
972
- * one-entry list here rather than at a dozen call sites, which is what keeps the
973
- * rest of the codebase free of "old config" branches. Duplicate slots inside one
974
- * rule are dropped: two entries pointing at the same bytes would only make the
975
- * rotation look stuck.
576
+ * Both image shapes are accepted here and nowhere else: the 0.7 list (`images`),
577
+ * and the single `slot`/`bgState` pair every earlier version wrote, lifted into a
578
+ * one-entry list. That is what keeps the rest of the codebase free of "old config"
579
+ * branches. Duplicate slots inside one rule are dropped — two entries pointing at
580
+ * the same bytes only make a rotation look stuck.
976
581
  *
977
- * The rule's color is resolved FIRST and handed to every image entry, because it
978
- * is what an entry written before 0.7.1 inherits (`normalizeImage`). The rule keeps
979
- * it as well: it is not a default for its images at paint time any more, but it
980
- * is still what the rule paints once its last picture is gone.
582
+ * The rule's colour is resolved FIRST and handed to every image entry (what a
583
+ * pre-0.7.1 entry inherits), and the rule keeps it: it is what the rule paints once
584
+ * its last picture is gone. The layout mode makes the same trip from the RULE level
585
+ * a pre-0.7.5 config carries, so the user's choice survives the move.
981
586
  *
982
- * The layout mode makes the same trip in the other direction, and this is the
983
- * last release that has to: a config written before 0.7.5 keeps `bgMode` at the RULE
984
- * level, where one value covered every picture of a rotation, so it is read here
985
- * once and handed to each image as its inherited mode. What the user chose
986
- * therefore survives the move the same way it survived the color's — the mode is
987
- * not reset to the default on upgrade, and the field it used to live on is simply
988
- * not written back.
989
- *
990
- * An EMPTY list is a legitimate rule, not a broken one: removing a rule's last
991
- * picture has to be expressible, and forcing an entry back in is what produced a
992
- * phantom blank image that took slot 1 and could never be deleted again (the next
993
- * upload landed at position 2 and the rule kept painting nothing). What such a
994
- * rule then does is `ruleCanPaint`'s business: with a color of its own it paints
995
- * the interface, with neither it is skipped by matching — which the card's own
996
- * hint says in so many words. Only a rule that names no image anywhere — no list,
997
- * no legacy slot, or a slot that could never be a filename — is dropped.
587
+ * An EMPTY list is a legitimate rule: removing a rule's last picture has to be
588
+ * expressible. `ruleCanPaint` decides what such a rule then does.
998
589
  */
999
590
  function normalizeRule(raw) {
1000
591
  const r = raw ?? {};
@@ -1011,7 +602,7 @@ window.__ModuleLoader__.load({
1011
602
  seen.add(image.slot);
1012
603
  images.push(image);
1013
604
  }
1014
- const legacySlot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
605
+ const legacySlot = typeof r.slot === "string" && SLOT_RE$1.test(r.slot) ? r.slot : null;
1015
606
  if (images.length === 0 && legacySlot !== null) images.push({
1016
607
  slot: legacySlot,
1017
608
  bgMode: mode,
@@ -1038,17 +629,11 @@ window.__ModuleLoader__.load({
1038
629
  /**
1039
630
  * Coerce one persisted holiday entry.
1040
631
  *
1041
- * `id`, `images` and every theme color (the rule's and each image's) come from
1042
- * the DEFINITION, never from disk: a holiday's slot is the only thing tying it to
1043
- * its bytes, and its theme color is a fixed part of what that holiday looks like —
1044
- * so a stale or hand-edited value would either orphan the wallpaper, point the
1045
- * holiday at a rule's image, or paint a festival in a color it does not have.
1046
- * Everything else is sanitized exactly like a rule's field — except three things
1047
- * that stay the definition's own: the image list and the rotation (so a festival
1048
- * can never be swapped or cycled), and the layout mode, which is the definition's
1049
- * `fill` for the same reason the color is its own — full-bleed art letterboxed by
1050
- * a stale `fit` is the one thing this feature must not do, and the mode is not
1051
- * editable for a holiday anywhere in the panel.
632
+ * `id`, the image list and every theme colour come from the DEFINITION, never from
633
+ * disk: a stale or hand-edited value would orphan the wallpaper, point the holiday
634
+ * at a rule's image, or paint a festival in a colour it does not have. The layout
635
+ * mode and the rotation stay the definition's own for the same reason (a festival
636
+ * is never swapped or cycled). Everything else is sanitized like a rule's field.
1052
637
  */
1053
638
  function normalizeHolidayRule(def, raw) {
1054
639
  const r = raw ?? {};
@@ -1081,21 +666,12 @@ window.__ModuleLoader__.load({
1081
666
  };
1082
667
  }
1083
668
  /**
1084
- * The file-preview panel's own opacity.
1085
- *
1086
- * Three states, and the middle one is why this is a function rather than the
1087
- * one-line fallback every other field gets: a real number is the card's own
1088
- * value, `null` is "never owned, so follow `opacities.bg`", and a KEY that is not
1089
- * there at all — a hand-written config, or one written before the option existed
1090
- * — is the shipped profile's 45% (see DEFAULT_RIGHTBAR_OPACITY).
669
+ * The file-preview panel's own opacity: a number is the card's own value, `null` is
670
+ * "never owned, follow `opacities.bg`", and a MISSING key is the shipped 45%.
1091
671
  *
1092
- * `null` has to survive as ITSELF instead of falling through to the default:
1093
- * every release so far has written it — a panel the user never dragged is
1094
- * persisted as an explicit `null` — so treating it as "missing" would hand the
1095
- * shipped 45% to every configuration whose preview panel was following the main
1096
- * background, a silent change to a setup nobody asked to change. It is also
1097
- * unreachable from the UI (no control returns to it); it exists for configs that
1098
- * were written while the shipped default was `null`.
672
+ * `null` has to survive as itself: every release so far persisted it for a panel the
673
+ * user never dragged, so treating it as missing would silently hand the shipped 45%
674
+ * to every configuration that was following the main background.
1099
675
  */
1100
676
  function normalizeRightbarOpacity(v) {
1101
677
  if (v === null) return null;
@@ -1179,7 +755,6 @@ window.__ModuleLoader__.load({
1179
755
  }
1180
756
  let activeRuleId = null;
1181
757
  let activeMatched = false;
1182
- let modelLabel = "";
1183
758
  /**
1184
759
  * Which image of the ACTIVE rule is painted right now.
1185
760
  *
@@ -1194,9 +769,6 @@ window.__ModuleLoader__.load({
1194
769
  activeRuleId = ruleId;
1195
770
  activeMatched = matched;
1196
771
  }
1197
- function setModelLabel(label) {
1198
- modelLabel = label;
1199
- }
1200
772
  /** The image index the render layer is on, clamped into `rule`'s own range. */
1201
773
  function imageIndexOf(rule) {
1202
774
  if (rule.images.length === 0) return 0;
@@ -1218,23 +790,16 @@ window.__ModuleLoader__.load({
1218
790
  return activeImage()?.slot ?? "";
1219
791
  }
1220
792
  /**
1221
- * The theme color in force right now, or null for "follow the system theme".
1222
- *
1223
- * Two rungs, in this order and no others:
1224
- * 1. the color of the IMAGE that is on screen, when the rule has one;
1225
- * 2. the rule's own color, which is all an image-less rule has to paint with.
793
+ * The theme colour in force right now, or null for "follow the system theme".
1226
794
  *
1227
- * There is deliberately no third rung where an image INHERITS its rule's color:
1228
- * a rule's color is not a default for its pictures (see `BgRule.color`). If it
1229
- * were, a rotation through a green picture, a red one and a system-themed one
1230
- * could not be expressed at all — clearing a picture's color would only hand it
1231
- * back to the rule's, and the clear button would look broken. Images that predate
1232
- * per-image colors get the rule's color LIFTED onto them at read time instead
1233
- * (`normalizeImage`), so nothing loses its theme on upgrade.
795
+ * Two rungs and no others: the colour of the IMAGE being painted, else the rule's
796
+ * own colour (all an image-less rule has). There is deliberately no third rung
797
+ * where an image inherits its rule's colour — that would make a rotation through a
798
+ * green, a red and a system-themed picture inexpressible, and clearing a picture's
799
+ * colour would only hand it back to the rule's.
1234
800
  *
1235
- * The image is the one being PAINTED (`imageIndexOf`), not the one the panel has
1236
- * selected — the panel's selection is an editing cursor and must not repaint the
1237
- * interface while the user is fixing another picture's framing.
801
+ * The image is the one being PAINTED, not the one the panel has selected: the
802
+ * selection is an editing cursor and must not repaint the interface.
1238
803
  */
1239
804
  function activeColor() {
1240
805
  const rule = activeRule();
@@ -1414,187 +979,31 @@ window.__ModuleLoader__.load({
1414
979
  clearImages();
1415
980
  activeRuleId = null;
1416
981
  activeMatched = false;
1417
- modelLabel = "";
1418
982
  rotIndex = 0;
1419
983
  }
1420
984
  //#endregion
1421
- //#region src/preset.ts
1422
- /**
1423
- * JSON with object keys in a fixed (sorted) order — the same string for the same
1424
- * value, whatever order the object that carried it happened to build its keys in.
1425
- *
1426
- * `JSON.stringify` alone is not an identity for a value that came off the wire and
1427
- * through a sanitizer: key order is an accident of how the object was assembled,
1428
- * and a resume that missed on it would silently re-download everything (the safe
1429
- * direction) while a resume that MATCHED on a value it should not have would be
1430
- * the unsafe one — so this is deliberately total as well: anything JSON cannot
1431
- * carry (a function, a `Symbol`, `undefined`) serializes as `null` and an
1432
- * `undefined` property is skipped, exactly as `JSON.stringify` treats it.
1433
- */
1434
- function canonical(value) {
1435
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1436
- if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
1437
- return JSON.stringify(value) ?? "null";
985
+ //#region src/client/rpc.ts
986
+ const RPC_CHANNEL = "/dsh-background-by-model";
987
+ const RPC_NS = "dshBackgroundByModel";
988
+ const rpcEndpoint = (method) => `${RPC_NS}/${method}`;
989
+ let hostShape = "unknown";
990
+ let warnedLegacy = false;
991
+ /** Whether this bundle may write its config to the host it is talking to. */
992
+ function canWriteConfig() {
993
+ return hostShape !== "legacy";
1438
994
  }
1439
- /**
1440
- * The identity of one revision of the profile: the manifest's shape version plus
1441
- * the config, in canonical form.
1442
- *
1443
- * The profile is served at ONE version of the assets repository, but a version
1444
- * reference is only as fixed as the tag behind it: re-point the tag and every
1445
- * edge that has not been purged keeps answering with the previous bytes of the
1446
- * same URL. So "the profile" is still not guaranteed to be one fixed thing, and
1447
- * the bytes a previous attempt is holding are only known to belong to what is
1448
- * being installed when that config comes back the same. Order-insensitivity is
1449
- * what lets this be a comparison of VALUES rather than of spellings —
1450
- * `normalizeConfig` (./schema) rebuilds the config it reads, so pinning the key
1451
- * to the exact key order the wire happened to use would make the hold useless
1452
- * the moment anything upstream normalized differently.
1453
- */
1454
- function presetKey(version, config) {
1455
- return `${version}\n${canonical(config)}`;
995
+ /** Whether the running host process cannot read this bundle's config shape. */
996
+ function hostIsLegacy() {
997
+ return hostShape === "legacy";
1456
998
  }
1457
- /**
1458
- * The bytes of a failed attempt that may be reused for the revision `key` names.
1459
- *
1460
- * A fresh object every time, never the held record itself: the caller fills it in
1461
- * as the rest of the profile arrives, and handing out the same mutable record
1462
- * would let a re-fetch of one file edit the state the NEXT attempt resumes from.
1463
- * An empty answer — nothing held, or held for a different revision — is the honest
1464
- * one either way: the pool then asks for every slot the config names.
1465
- */
1466
- function presetResume(held, key) {
1467
- if (held === null || held.key !== key) return {};
1468
- return { ...held.images };
999
+ function holdWrite() {
1000
+ if (warnedLegacy) return;
1001
+ warnedLegacy = true;
1002
+ console.warn("dsh-background-by-model: the running host is an older build that cannot read this bundle's config shape — holding config writes so its sanitizer cannot drop this profile's rules. Restart DSH to load the new node half.");
1469
1003
  }
1470
- /**
1471
- * The slots of `slots` that `images` does not hold yet, in the config's own order.
1472
- *
1473
- * Order matters in the only way it can here: it decides which file a worker takes
1474
- * first, and the files are not the same size. Only names the CONFIG asked for are
1475
- * ever returned, so a held picture nothing points at — a slot the author removed
1476
- * from the profile — can never be fetched again or shipped.
1477
- */
1478
- function presetMissing(slots, images) {
1479
- return slots.filter((slot) => images[slot] === void 0);
1480
- }
1481
- //#endregion
1482
- //#region src/client/rotation.ts
1483
- /** Dwell-time presets the settings UI offers, in ms. */
1484
- const ROTATE_PRESETS = [
1485
- {
1486
- ms: 1e4,
1487
- key: "rotEvery10s"
1488
- },
1489
- {
1490
- ms: 3e4,
1491
- key: "rotEvery30s"
1492
- },
1493
- {
1494
- ms: 6e4,
1495
- key: "rotEvery1m"
1496
- },
1497
- {
1498
- ms: 3e5,
1499
- key: "rotEvery5m"
1500
- },
1501
- {
1502
- ms: 18e5,
1503
- key: "rotEvery30m"
1504
- }
1505
- ];
1506
- /**
1507
- * The index to paint after the current one, or null when there is nowhere to go
1508
- * (fewer than two images — the caller must not start a timer at all then).
1509
- *
1510
- * `order` walks the list and wraps; `shuffle` picks uniformly among every index
1511
- * EXCEPT the current one, because landing on the same image reads as a missed
1512
- * tick. `current` outside the list is treated as 0 rather than trusted: it comes
1513
- * from the UI as often as from the timer, and a stale index must not be able to
1514
- * index past the end.
1515
- *
1516
- * `rand` is injectable so the shuffle branch is testable without stubbing a
1517
- * global.
1518
- */
1519
- function nextIndex(len, order, current, rand = Math.random) {
1520
- if (!Number.isFinite(len) || len < 2) return null;
1521
- const count = Math.floor(len);
1522
- const cur = Number.isFinite(current) && current >= 0 && current < count ? Math.floor(current) : 0;
1523
- if (order === "shuffle") {
1524
- const roll = Math.min(Math.floor(Math.max(rand(), 0) * (count - 1)), count - 2);
1525
- return roll >= cur ? roll + 1 : roll;
1526
- }
1527
- return (cur + 1) % count;
1528
- }
1529
- /**
1530
- * Whether a rotation is worth running: two images, the switch on, and a positive
1531
- * dwell time. Kept here (rather than at the call site) so the UI's "rotating"
1532
- * badge and the timer agree by construction — a badge that says "rotating" while
1533
- * no timer exists is the one failure this feature can produce silently.
1534
- */
1535
- function isRotating(len, enabled, intervalMs) {
1536
- return enabled && len >= 2 && Number.isFinite(intervalMs) && intervalMs > 0;
1537
- }
1538
- //#endregion
1539
- //#region src/client/repaint.ts
1540
- /**
1541
- * The repaint decision, as a pure function.
1542
- *
1543
- * Every edit to a rule ends with the same question: does the interface have to be
1544
- * repainted, or is refreshing the panel's readout enough? Repainting is the
1545
- * expensive half (the theme skin is re-registered, the wallpaper layer is
1546
- * rewritten, the host's theme is switched), so it must not be done for a rule
1547
- * that is not the one on screen — but it MUST be done the moment the edit
1548
- * changes what should be on screen.
1549
- *
1550
- * The naive test — "is the edited rule the active one?" — is wrong in exactly the
1551
- * case that kept being reported as "更新的不太及时,只有我动调色盘或者切模型的时候
1552
- * 才出现": an edit that CREATES the winner. Uploading a rule's first picture, or
1553
- * an auto-extracted color landing on a rule that had none, makes that rule
1554
- * paintable *through that very edit* (see `ruleCanPaint`), so the rule is not the
1555
- * active one at the moment of the check; the wallpaper and the skin then only
1556
- * showed up later, when an unrelated drag or model switch happened to re-run the
1557
- * resolution.
1558
- *
1559
- * Sampling the winner on BOTH sides of the edit is what makes such a change land
1560
- * at once, and `editedId === winnerAfter` additionally covers the edit that does
1561
- * not move the resolution but changes the look of the rule that IS painting (a
1562
- * color, a slider, a framing).
1563
- *
1564
- * `null` is "nothing of ours is painting" (the interface falls back to the host's
1565
- * own look), which is a winner like any other id.
1566
- *
1567
- * Kept free of imports on purpose: `scripts/repaint-check.ts` exercises it on
1568
- * plain node, the same way `rotation.ts` is exercised. A rule's `color`/`match`
1569
- * are the inputs that make the resolution move, which is why they are the fields
1570
- * whose write paths all end here.
1571
- */
1572
- function shouldRepaint(editedId, winnerBefore, winnerAfter) {
1573
- return winnerAfter === editedId || winnerAfter !== winnerBefore;
1574
- }
1575
- //#endregion
1576
- //#region src/client/rpc.ts
1577
- const RPC_CHANNEL = "/dsh-background-by-model";
1578
- const RPC_NS = "dshBackgroundByModel";
1579
- const rpcEndpoint = (method) => `${RPC_NS}/${method}`;
1580
- let hostShape = "unknown";
1581
- let warnedLegacy = false;
1582
- /** Whether this bundle may write its config to the host it is talking to. */
1583
- function canWriteConfig() {
1584
- return hostShape !== "legacy";
1585
- }
1586
- /** Whether the running host process is older than this client bundle. */
1587
- function hostIsLegacy() {
1588
- return hostShape === "legacy";
1589
- }
1590
- function holdWrite() {
1591
- if (warnedLegacy) return;
1592
- warnedLegacy = true;
1593
- console.warn("dsh-background-by-model: the running host is an older build that cannot read the multi-image config shape — holding config writes so its sanitizer cannot drop this profile's rules. Restart DSH to load the new node half.");
1594
- }
1595
- let rpcCallFn = null;
1596
- function initRpc(call) {
1597
- rpcCallFn = call;
1004
+ let rpcCallFn = null;
1005
+ function initRpc(call) {
1006
+ rpcCallFn = call;
1598
1007
  }
1599
1008
  async function rpcCall(method, payload) {
1600
1009
  if (!rpcCallFn) return void 0;
@@ -1655,7 +1064,7 @@ window.__ModuleLoader__.load({
1655
1064
  const d = data;
1656
1065
  const slots = Array.isArray(d.slots) ? d.slots.filter((s) => typeof s === "string") : [];
1657
1066
  const schema = typeof d.schema === "number" && isFinite(d.schema) ? d.schema : null;
1658
- hostShape = schema === null ? "legacy" : schema >= 9 ? "current" : "legacy";
1067
+ hostShape = typeof schema === "number" && schema >= 9 ? "current" : "legacy";
1659
1068
  if (hostShape === "legacy") holdWrite();
1660
1069
  return {
1661
1070
  config: d.config,
@@ -1759,61 +1168,6 @@ window.__ModuleLoader__.load({
1759
1168
  return parts.length > 0 ? parts.join(" ") : null;
1760
1169
  }
1761
1170
  //#endregion
1762
- //#region src/client/palette-fade.ts
1763
- /**
1764
- * Whether a palette change may interpolate at all.
1765
- *
1766
- * Only while the SCHEME stays put. A light↔dark flip is not a tint of the same
1767
- * palette, it is a different palette: interpolating between them walks the whole
1768
- * interface through mid-tones whose ink is wrong in both directions (dark text
1769
- * half-way into a dark palette), which looks worse than the switch it replaces.
1770
- * The wallpaper has no such problem — one image fades into another either way —
1771
- * so its own animation is deliberately untouched by this.
1772
- *
1773
- * `previous === null` means nothing has been painted yet (the first apply after a
1774
- * load): there is no colour to come from, so there is nothing to fade.
1775
- */
1776
- function paletteFadeAllowed(previous, next) {
1777
- return previous === next;
1778
- }
1779
- /** One `@property` block per registered token. */
1780
- function registrations(names, initial) {
1781
- return names.map((n) => `@property ${n}{syntax:'<color>';inherits:true;initial-value:${initial[n]}}`).join("");
1782
- }
1783
- /**
1784
- * The stylesheet for one state: the registrations always, the transition only
1785
- * while a palette change is actually being painted.
1786
- *
1787
- * `durationMs = 0` deliberately emits NO transition rule. That is not only
1788
- * "instant": it is what keeps a slider drag instant, because the same call sites
1789
- * rewrite the `body{…}` rule on every frame of a drag and a live transition on
1790
- * those tokens would make the alphas lag behind the pointer.
1791
- */
1792
- function paletteFadeCss(input) {
1793
- const names = Object.keys(input.initial).filter((n) => n.startsWith("--"));
1794
- if (names.length === 0) return "";
1795
- const css = registrations(names, input.initial);
1796
- if (!(input.durationMs > 0)) return css;
1797
- const written = new Set(input.written);
1798
- const timing = (n) => `${n} ${input.durationMs}ms ${input.easing}`;
1799
- return `${css}${input.scopes.map((s) => ({
1800
- selector: s.selector,
1801
- transitions: [...s.tokens.filter((n) => names.includes(n) && written.has(n)), ...s.properties ?? []]
1802
- })).filter((s) => s.transitions.length > 0).map((s) => `${s.selector}{transition:${s.transitions.map(timing).join(",")}}`).join("")}`;
1803
- }
1804
- /**
1805
- * The same fade for an element the plugin paints INLINE (its columns and cards).
1806
- *
1807
- * No custom property is involved there — the element's own `background-color` is
1808
- * what changes — so the standard property is what has to be told. `0` means
1809
- * `none` rather than an empty value: an empty value would leave whatever the host
1810
- * had declared on that element, and the point of the 0 case is that a drag must
1811
- * not fade at all.
1812
- */
1813
- function paletteFadeInline(durationMs, easing) {
1814
- return durationMs > 0 ? `background-color ${durationMs}ms ${easing}` : "none";
1815
- }
1816
- //#endregion
1817
1171
  //#region src/client/utils/color.ts
1818
1172
  function hsvToHsl(h, s, v) {
1819
1173
  const l = v * (1 - s / 2);
@@ -2183,106 +1537,277 @@ window.__ModuleLoader__.load({
2183
1537
  Math.round((b + m) * 255)
2184
1538
  ];
2185
1539
  }
2186
- const UI_CSS = `
2187
- /* The section is rendered INLINE inside the host settings dialog's content
2188
- * column: the host provides the modal chrome (backdrop, centering, closing).
2189
- * These classes style only the embedded shell; transient fixed layers (toast,
2190
- * color picker, background editor) escape through Portals on <html>. */
2191
- .dab-root{position:relative;color:var(--dsw-alias-label-primary);animation:dab-fade-in .35s ease both;container-type:inline-size;display:flex;flex-direction:column;align-items:center;width:100%;min-width:0;--dab-mono:ui-monospace,"Cascadia Mono","SF Mono",Consolas,"Courier New",monospace}
2192
- .dab-root *,.dab-root *::before,.dab-root *::after{box-sizing:border-box}
2193
- .dab-root button{font-family:inherit}
2194
- /* ── scrollbars: the same bar as the settings panel we sit inside ────────────
2195
- * The host styles scrollbars globally off its --dsh-scrollbar-* tokens, but those
2196
- * declarations describe the app shell and its own scroll containers; a strip
2197
- * nested this deep got the platform default instead — a thick light bar with
2198
- * arrows, which is what the tab strip used to show beside the panel's own slim
2199
- * vertical one.
2200
- *
2201
- * So the plugin declares the block for its OWN scroll containers. The values are
2202
- * the host theme package's, for the layer-2 surface this panel is painted in:
2203
- * 5px, rounded, layer-2 tint, transparent track. Two notes for whoever edits it:
2204
- *
2205
- * - the tokens go ON THE SCROLLER (the selector that carries the overflow), not
2206
- * on .dab-root. A container that declares scrollbar-width:thin (as the nav
2207
- * rail does on narrow panels) paints its own standard bar regardless of the
2208
- * webkit block, and the host's standard-property fallback reads the thumb
2209
- * token from that same element.
2210
- * - every selector here is scoped to a dab- class: the stylesheet is injected
2211
- * into the host page once, so a bare webkit-scrollbar selector would restyle
2212
- * the whole harness. */
2213
- .dab-tabs,.dab-pick-menu{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);--dsh-scrollbar-width:5px}
2214
- .dab-tabs::-webkit-scrollbar,.dab-pick-menu::-webkit-scrollbar{width:var(--dsh-scrollbar-width,5px);height:var(--dsh-scrollbar-width,5px)}
2215
- .dab-tabs::-webkit-scrollbar-track,.dab-pick-menu::-webkit-scrollbar-track{background:transparent}
2216
- .dab-tabs::-webkit-scrollbar-thumb,.dab-pick-menu::-webkit-scrollbar-thumb{background:var(--dsh-scrollbar-thumb);background-clip:content-box;border-radius:999px}
2217
- .dab-tabs::-webkit-scrollbar-thumb:hover,.dab-pick-menu::-webkit-scrollbar-thumb:hover{background:var(--dsh-scrollbar-thumb-hover)}
2218
- .dab-tabs::-webkit-scrollbar-corner,.dab-pick-menu::-webkit-scrollbar-corner{background:transparent}
2219
- /* The host hides the platform's stepper arrows; without this the strip keeps the
2220
- little os arrows at both ends of the track while the panel's bar has none. */
2221
- .dab-tabs::-webkit-scrollbar-button,.dab-pick-menu::-webkit-scrollbar-button{display:none}
2222
-
2223
- /* ── shell: nav rail + page body ─────────────────────────────────────────── */
2224
- .dab-shell{display:grid;grid-template-columns:158px minmax(0,1fr);gap:26px;align-items:start;padding-bottom:8px;width:100%;max-width:980px;margin:0 auto}
2225
- .dab-nav{position:sticky;top:0;display:flex;flex-direction:column;gap:18px}
2226
- /* The brand is an <a> (it opens the repository): an anchor brings its own colour
2227
- and underline, so both are reset here rather than left to the host's link
2228
- styles, which would repaint the tile's label. */
2229
- .dab-brand{display:flex;align-items:center;gap:10px;padding:2px 6px;color:inherit;text-decoration:none}
2230
- .dab-brand:hover .dab-brand-name{color:var(--dsw-alias-brand-primary)}
2231
- .dab-brand-tile{width:30px;height:30px;flex:none;border-radius:9px;display:grid;place-items:center;color:var(--dsw-alias-label-primary-inverted);background:var(--dsw-alias-brand-primary);box-shadow:0 4px 14px -4px var(--dsw-alias-brand-primary)}
2232
- .dab-brand-name{font-size:13px;font-weight:650;letter-spacing:.01em;line-height:1.25}
2233
- /* The weak token was written as quaternary-WITH-a-fallback, so this tag has
2234
- always painted tertiary: the dead name is dropped rather than being kept as
2235
- decoration nothing resolves to (check:ui rejects the name outright now). */
2236
- .dab-brand-tag{font-size:9px;letter-spacing:.16em;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase}
2237
- .dab-nav-list{position:relative;display:flex;flex-direction:column;gap:4px}
2238
- .dab-nav-ind{position:absolute;left:0;right:0;top:0;height:38px;border-radius:11px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 13%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);transition:transform .38s cubic-bezier(.22,1,.36,1)}
2239
- .dab-nav-item{position:relative;z-index:1;display:flex;align-items:center;gap:10px;height:38px;padding:0 12px;border:0;background:none;border-radius:11px;color:var(--dsw-alias-label-tertiary);font-size:13px;cursor:pointer;text-align:left;transition:color .22s ease}
2240
- .dab-nav-item:hover{color:var(--dsw-alias-label-primary)}
2241
- .dab-nav-item.is-active{color:var(--dsw-alias-brand-primary);font-weight:600}
2242
- .dab-nav-item svg{flex:none;transition:transform .3s cubic-bezier(.34,1.56,.64,1)}
2243
- .dab-nav-item:hover svg{transform:scale(1.14) rotate(-5deg)}
2244
-
2245
- /* ── page chrome ─────────────────────────────────────────────────────────── */
2246
- .dab-page{animation:dab-page-in .4s cubic-bezier(.22,1,.36,1) both;min-width:0;display:flex;flex-direction:column;gap:13px}
2247
- .dab-head{margin:2px 0 5px}
2248
- .dab-overline{font-size:10.5px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:var(--dsw-alias-brand-primary);opacity:.9}
2249
- .dab-h1{margin:4px 0 0;font-size:21px;font-weight:700;letter-spacing:-.01em}
2250
- /* No max-width on the page description: it is a single line of context under the
2251
- title, and capping it at a prose measure left it wrapping early with dead space
2252
- to its right while the cards below ran the full width of the panel. */
2253
- .dab-desc{margin:6px 0 0;font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary)}
2254
- .dab-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;padding:18px}
2255
- .dab-card-hover{transition:transform .28s ease,box-shadow .28s ease,border-color .28s ease}
2256
- .dab-card-hover:hover{transform:translateY(-2px);box-shadow:0 10px 28px -12px rgba(0,0,0,.28)}
2257
- .dab-rise{animation:dab-rise-in .55s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--d,0) * 62ms)}
2258
-
2259
- /* ── accent hero (color orb) ─────────────────────────────────────────────── */
2260
- .dab-hero-accent{display:flex;align-items:center;gap:24px;flex-wrap:wrap}
2261
- .dab-orb-wrap{position:relative;width:118px;height:118px;flex:none}
2262
- .dab-orb{position:absolute;inset:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,rgba(255,255,255,.5),rgba(255,255,255,0) 44%),var(--c,#888);box-shadow:0 16px 36px -10px var(--c-soft,transparent),inset 0 -10px 20px rgba(0,0,0,.16);animation:dab-orb-in .7s cubic-bezier(.22,1,.36,1) both}
2263
- .dab-orb-ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 30%,var(--c,#888) 46%,transparent 62%,transparent 76%,var(--c,#888) 90%,transparent 100%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 3.5px),#000 calc(100% - 2.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 3.5px),#000 calc(100% - 2.5px));animation:dab-spin 7s linear infinite;opacity:.9}
2264
- .dab-hex{font-family:var(--dab-mono);font-size:24px;font-weight:600;letter-spacing:.02em;line-height:1.2;margin-top:2px}
2265
- .dab-hsl-row{display:flex;gap:16px;margin-top:7px;font-family:var(--dab-mono);font-size:11px;color:var(--dsw-alias-label-tertiary)}
2266
- .dab-hsl-row b{font-weight:600;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
2267
-
2268
- /* ── swatches ────────────────────────────────────────────────────────────── */
2269
- .dab-swatch-title{font-size:12px;font-weight:600;margin-bottom:10px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
2270
-
2271
- /* ── wheel card ──────────────────────────────────────────────────────────── */
2272
- .dab-wheel-card{position:relative;display:flex;align-items:center;justify-content:center;gap:28px;flex-wrap:wrap;padding:24px 18px}
2273
- .dab-wheel-glow{position:absolute;width:230px;height:230px;border-radius:50%;filter:blur(48px);opacity:.2;background:var(--c,#888);pointer-events:none;transition:background .4s ease}
2274
- .dab-wheel{position:relative;cursor:crosshair;border-radius:50%;box-shadow:0 12px 32px -14px rgba(0,0,0,.4)}
2275
- .dab-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);padding:0 4px}
2276
- /* The "your edits are being held" notice: it reports a state the user has to act
2277
- on, so it is tinted like a warning rather than left as grey hint text. */
2278
- .dab-stale{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 35%,transparent);border-radius:9px;padding:8px 10px}
2279
-
2280
- /* ── precise color inputs ────────────────────────────────────────────────── */
2281
- .dab-inputs{display:flex;flex-direction:column;gap:10px;min-width:172px}
2282
- .dab-field{display:flex;align-items:center;gap:8px}
2283
- .dab-field-label{width:14px;text-align:center;font-family:var(--dab-mono);font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary)}
2284
- .dab-num{flex:1;min-width:0;height:30px;padding:0 10px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-family:var(--dab-mono);font-size:12px;outline:none;transition:border-color .2s,box-shadow .2s}
2285
- .dab-num:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent)}
1540
+ //#endregion
1541
+ //#region src/client/auto-extract.ts
1542
+ function createAutoExtract(host) {
1543
+ /** The entry this extraction is about, re-resolved on every read. */
1544
+ const target = (ruleId, slot) => {
1545
+ const rule = ruleById(ruleId);
1546
+ if (rule === null) return void 0;
1547
+ return (slot === void 0 ? void 0 : rule.images.find((i) => i.slot === slot)) ?? rule.images[0];
1548
+ };
1549
+ const one = async (ruleId, slot) => {
1550
+ if (!cfg.autoExtract) return;
1551
+ const image = target(ruleId, slot);
1552
+ if (image === void 0 || image.color !== null) return;
1553
+ const url = host.paintable(image.slot);
1554
+ if (url === null) return;
1555
+ let hsl = null;
1556
+ try {
1557
+ hsl = await extractWallpaperColor(url, image.bgState);
1558
+ } catch {
1559
+ hsl = null;
1560
+ }
1561
+ if (hsl === null) return;
1562
+ const current = target(ruleId, slot);
1563
+ if (current === void 0 || current.color !== null) return;
1564
+ const winner = host.winnerId();
1565
+ current.color = hsl;
1566
+ host.bumpRulesRev();
1567
+ saveConfig();
1568
+ host.repaint(ruleId, winner);
1569
+ };
1570
+ return {
1571
+ one,
1572
+ async each(ruleId, slots) {
1573
+ for (const slot of slots) await one(ruleId, slot);
1574
+ }
1575
+ };
1576
+ }
1577
+ //#endregion
1578
+ //#region src/client/theme-skin.ts
1579
+ /** Id of the plugin's own registered theme. */
1580
+ const CUSTOM_ID = "custom-color";
1581
+ /** How long a colour drag waits before the (host-visible) skin switch is made. */
1582
+ const SKIN_DEBOUNCE_MS = 60;
1583
+ function createSkin(ctx) {
1584
+ let customDispose = null;
1585
+ let skinTimer = null;
1586
+ /** Colour scheme of the skin this plugin registered, or null when none is. */
1587
+ let skinScheme = null;
1588
+ /** Scheme the host currently holds for our theme id; null when it is not there. */
1589
+ const registeredScheme = () => {
1590
+ const theme = ctx.theme.getTheme().themes.find((t) => t.id === CUSTOM_ID);
1591
+ return theme === void 0 ? null : theme.colorScheme;
1592
+ };
1593
+ const hasSkin = () => ctx.theme.getTheme().themes.some((t) => t.id === CUSTOM_ID);
1594
+ const register = (h, s, l) => {
1595
+ const { colorScheme, tokens } = genTokens(h, s, l);
1596
+ if (skinScheme === colorScheme && registeredScheme() !== null) {
1597
+ if (ctx.theme.getTheme().preference !== "custom-color") ctx.theme.setTheme(CUSTOM_ID);
1598
+ return;
1599
+ }
1600
+ customDispose?.();
1601
+ try {
1602
+ customDispose = ctx.theme.register({
1603
+ id: CUSTOM_ID,
1604
+ colorScheme,
1605
+ tokens
1606
+ });
1607
+ } catch {
1608
+ customDispose = null;
1609
+ }
1610
+ skinScheme = registeredScheme() ?? colorScheme;
1611
+ if (hasSkin()) ctx.theme.setTheme(CUSTOM_ID);
1612
+ };
1613
+ const drop = () => {
1614
+ customDispose?.();
1615
+ customDispose = null;
1616
+ skinScheme = null;
1617
+ try {
1618
+ if (ctx.theme.getTheme().preference === "custom-color") ctx.theme.setTheme("system");
1619
+ } catch {}
1620
+ };
1621
+ return {
1622
+ commit(color) {
1623
+ if (skinTimer !== null) window.clearTimeout(skinTimer);
1624
+ if (color === null) {
1625
+ skinTimer = null;
1626
+ drop();
1627
+ return;
1628
+ }
1629
+ skinTimer = window.setTimeout(() => {
1630
+ skinTimer = null;
1631
+ register(color[0], color[1], color[2]);
1632
+ }, SKIN_DEBOUNCE_MS);
1633
+ },
1634
+ reassert(color) {
1635
+ if (!hasSkin()) {
1636
+ register(color[0], color[1], color[2]);
1637
+ return true;
1638
+ }
1639
+ if (ctx.theme.getTheme().preference !== "custom-color") {
1640
+ ctx.theme.setTheme(CUSTOM_ID);
1641
+ return true;
1642
+ }
1643
+ return false;
1644
+ },
1645
+ drop,
1646
+ dispose() {
1647
+ if (skinTimer !== null) {
1648
+ window.clearTimeout(skinTimer);
1649
+ skinTimer = null;
1650
+ }
1651
+ customDispose?.();
1652
+ customDispose = null;
1653
+ }
1654
+ };
1655
+ }
1656
+ //#endregion
1657
+ //#region src/client/palette-fade.ts
1658
+ /**
1659
+ * Whether a palette change may interpolate at all.
1660
+ *
1661
+ * Only while the SCHEME stays put. A light↔dark flip is not a tint of the same
1662
+ * palette, it is a different palette: interpolating between them walks the whole
1663
+ * interface through mid-tones whose ink is wrong in both directions (dark text
1664
+ * half-way into a dark palette), which looks worse than the switch it replaces.
1665
+ * The wallpaper has no such problem — one image fades into another either way —
1666
+ * so its own animation is deliberately untouched by this.
1667
+ *
1668
+ * `previous === null` means nothing has been painted yet (the first apply after a
1669
+ * load): there is no colour to come from, so there is nothing to fade.
1670
+ */
1671
+ function paletteFadeAllowed(previous, next) {
1672
+ return previous === next;
1673
+ }
1674
+ /** One `@property` block per registered token. */
1675
+ function registrations(names, initial) {
1676
+ return names.map((n) => `@property ${n}{syntax:'<color>';inherits:true;initial-value:${initial[n]}}`).join("");
1677
+ }
1678
+ /**
1679
+ * The stylesheet for one state: the registrations always, the transition only
1680
+ * while a palette change is actually being painted.
1681
+ *
1682
+ * `durationMs = 0` deliberately emits NO transition rule. That is not only
1683
+ * "instant": it is what keeps a slider drag instant, because the same call sites
1684
+ * rewrite the `body{…}` rule on every frame of a drag and a live transition on
1685
+ * those tokens would make the alphas lag behind the pointer.
1686
+ */
1687
+ function paletteFadeCss(input) {
1688
+ const names = Object.keys(input.initial).filter((n) => n.startsWith("--"));
1689
+ if (names.length === 0) return "";
1690
+ const css = registrations(names, input.initial);
1691
+ if (!(input.durationMs > 0)) return css;
1692
+ const written = new Set(input.written);
1693
+ const timing = (n) => `${n} ${input.durationMs}ms ${input.easing}`;
1694
+ return `${css}${input.scopes.map((s) => ({
1695
+ selector: s.selector,
1696
+ transitions: [...s.tokens.filter((n) => names.includes(n) && written.has(n)), ...s.properties ?? []]
1697
+ })).filter((s) => s.transitions.length > 0).map((s) => `${s.selector}{transition:${s.transitions.map(timing).join(",")}}`).join("")}`;
1698
+ }
1699
+ /**
1700
+ * The same fade for an element the plugin paints INLINE (its columns and cards).
1701
+ *
1702
+ * No custom property is involved there — the element's own `background-color` is
1703
+ * what changes — so the standard property is what has to be told. `0` means
1704
+ * `none` rather than an empty value: an empty value would leave whatever the host
1705
+ * had declared on that element, and the point of the 0 case is that a drag must
1706
+ * not fade at all.
1707
+ */
1708
+ function paletteFadeInline(durationMs, easing) {
1709
+ return durationMs > 0 ? `background-color ${durationMs}ms ${easing}` : "none";
1710
+ }
1711
+ const UI_CSS = `
1712
+ /* The section is rendered INLINE inside the host settings dialog's content
1713
+ * column: the host provides the modal chrome (backdrop, centering, closing).
1714
+ * These classes style only the embedded shell; transient fixed layers (toast,
1715
+ * color picker, background editor) escape through Portals on <html>. */
1716
+ .dab-root{position:relative;color:var(--dsw-alias-label-primary);animation:dab-fade-in .35s ease both;container-type:inline-size;display:flex;flex-direction:column;align-items:center;width:100%;min-width:0;--dab-mono:ui-monospace,"Cascadia Mono","SF Mono",Consolas,"Courier New",monospace}
1717
+ .dab-root *,.dab-root *::before,.dab-root *::after{box-sizing:border-box}
1718
+ .dab-root button{font-family:inherit}
1719
+ /* ── scrollbars: the same bar as the settings panel we sit inside ────────────
1720
+ * The host styles scrollbars globally off its --dsh-scrollbar-* tokens, but those
1721
+ * declarations describe the app shell and its own scroll containers; a strip
1722
+ * nested this deep got the platform default instead — a thick light bar with
1723
+ * arrows, which is what the tab strip used to show beside the panel's own slim
1724
+ * vertical one.
1725
+ *
1726
+ * So the plugin declares the block for its OWN scroll containers. The values are
1727
+ * the host theme package's, for the layer-2 surface this panel is painted in:
1728
+ * 5px, rounded, layer-2 tint, transparent track. Two notes for whoever edits it:
1729
+ *
1730
+ * - the tokens go ON THE SCROLLER (the selector that carries the overflow), not
1731
+ * on .dab-root. A container that declares scrollbar-width:thin (as the nav
1732
+ * rail does on narrow panels) paints its own standard bar regardless of the
1733
+ * webkit block, and the host's standard-property fallback reads the thumb
1734
+ * token from that same element.
1735
+ * - every selector here is scoped to a dab- class: the stylesheet is injected
1736
+ * into the host page once, so a bare webkit-scrollbar selector would restyle
1737
+ * the whole harness. */
1738
+ .dab-tabs,.dab-pick-menu{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);--dsh-scrollbar-width:5px}
1739
+ .dab-tabs::-webkit-scrollbar,.dab-pick-menu::-webkit-scrollbar{width:var(--dsh-scrollbar-width,5px);height:var(--dsh-scrollbar-width,5px)}
1740
+ .dab-tabs::-webkit-scrollbar-track,.dab-pick-menu::-webkit-scrollbar-track{background:transparent}
1741
+ .dab-tabs::-webkit-scrollbar-thumb,.dab-pick-menu::-webkit-scrollbar-thumb{background:var(--dsh-scrollbar-thumb);background-clip:content-box;border-radius:999px}
1742
+ .dab-tabs::-webkit-scrollbar-thumb:hover,.dab-pick-menu::-webkit-scrollbar-thumb:hover{background:var(--dsh-scrollbar-thumb-hover)}
1743
+ .dab-tabs::-webkit-scrollbar-corner,.dab-pick-menu::-webkit-scrollbar-corner{background:transparent}
1744
+ /* The host hides the platform's stepper arrows; without this the strip keeps the
1745
+ little os arrows at both ends of the track while the panel's bar has none. */
1746
+ .dab-tabs::-webkit-scrollbar-button,.dab-pick-menu::-webkit-scrollbar-button{display:none}
1747
+
1748
+ /* ── shell: nav rail + page body ─────────────────────────────────────────── */
1749
+ .dab-shell{display:grid;grid-template-columns:158px minmax(0,1fr);gap:26px;align-items:start;padding-bottom:8px;width:100%;max-width:980px;margin:0 auto}
1750
+ .dab-nav{position:sticky;top:0;display:flex;flex-direction:column;gap:18px}
1751
+ /* The brand is an <a> (it opens the repository): an anchor brings its own colour
1752
+ and underline, so both are reset here rather than left to the host's link
1753
+ styles, which would repaint the tile's label. */
1754
+ .dab-brand{display:flex;align-items:center;gap:10px;padding:2px 6px;color:inherit;text-decoration:none}
1755
+ .dab-brand:hover .dab-brand-name{color:var(--dsw-alias-brand-primary)}
1756
+ .dab-brand-tile{width:30px;height:30px;flex:none;border-radius:9px;display:grid;place-items:center;color:var(--dsw-alias-label-primary-inverted);background:var(--dsw-alias-brand-primary);box-shadow:0 4px 14px -4px var(--dsw-alias-brand-primary)}
1757
+ .dab-brand-name{font-size:13px;font-weight:650;letter-spacing:.01em;line-height:1.25}
1758
+ /* The weak token was written as quaternary-WITH-a-fallback, so this tag has
1759
+ always painted tertiary: the dead name is dropped rather than being kept as
1760
+ decoration nothing resolves to (check:ui rejects the name outright now). */
1761
+ .dab-brand-tag{font-size:9px;letter-spacing:.16em;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase}
1762
+ .dab-nav-list{position:relative;display:flex;flex-direction:column;gap:4px}
1763
+ .dab-nav-ind{position:absolute;left:0;right:0;top:0;height:38px;border-radius:11px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 13%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);transition:transform .38s cubic-bezier(.22,1,.36,1)}
1764
+ .dab-nav-item{position:relative;z-index:1;display:flex;align-items:center;gap:10px;height:38px;padding:0 12px;border:0;background:none;border-radius:11px;color:var(--dsw-alias-label-tertiary);font-size:13px;cursor:pointer;text-align:left;transition:color .22s ease}
1765
+ .dab-nav-item:hover{color:var(--dsw-alias-label-primary)}
1766
+ .dab-nav-item.is-active{color:var(--dsw-alias-brand-primary);font-weight:600}
1767
+ .dab-nav-item svg{flex:none;transition:transform .3s cubic-bezier(.34,1.56,.64,1)}
1768
+ .dab-nav-item:hover svg{transform:scale(1.14) rotate(-5deg)}
1769
+
1770
+ /* ── page chrome ─────────────────────────────────────────────────────────── */
1771
+ .dab-page{animation:dab-page-in .4s cubic-bezier(.22,1,.36,1) both;min-width:0;display:flex;flex-direction:column;gap:13px}
1772
+ .dab-head{margin:2px 0 5px}
1773
+ .dab-overline{font-size:10.5px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:var(--dsw-alias-brand-primary);opacity:.9}
1774
+ .dab-h1{margin:4px 0 0;font-size:21px;font-weight:700;letter-spacing:-.01em}
1775
+ /* No max-width on the page description: it is a single line of context under the
1776
+ title, and capping it at a prose measure left it wrapping early with dead space
1777
+ to its right while the cards below ran the full width of the panel. */
1778
+ .dab-desc{margin:6px 0 0;font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary)}
1779
+ .dab-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;padding:18px}
1780
+ .dab-card-hover{transition:transform .28s ease,box-shadow .28s ease,border-color .28s ease}
1781
+ .dab-card-hover:hover{transform:translateY(-2px);box-shadow:0 10px 28px -12px rgba(0,0,0,.28)}
1782
+ .dab-rise{animation:dab-rise-in .55s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--d,0) * 62ms)}
1783
+
1784
+ /* ── accent hero (color orb) ─────────────────────────────────────────────── */
1785
+ .dab-hero-accent{display:flex;align-items:center;gap:24px;flex-wrap:wrap}
1786
+ .dab-orb-wrap{position:relative;width:118px;height:118px;flex:none}
1787
+ .dab-orb{position:absolute;inset:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,rgba(255,255,255,.5),rgba(255,255,255,0) 44%),var(--c,#888);box-shadow:0 16px 36px -10px var(--c-soft,transparent),inset 0 -10px 20px rgba(0,0,0,.16);animation:dab-orb-in .7s cubic-bezier(.22,1,.36,1) both}
1788
+ .dab-orb-ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 30%,var(--c,#888) 46%,transparent 62%,transparent 76%,var(--c,#888) 90%,transparent 100%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 3.5px),#000 calc(100% - 2.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 3.5px),#000 calc(100% - 2.5px));animation:dab-spin 7s linear infinite;opacity:.9}
1789
+ .dab-hex{font-family:var(--dab-mono);font-size:24px;font-weight:600;letter-spacing:.02em;line-height:1.2;margin-top:2px}
1790
+ .dab-hsl-row{display:flex;gap:16px;margin-top:7px;font-family:var(--dab-mono);font-size:11px;color:var(--dsw-alias-label-tertiary)}
1791
+ .dab-hsl-row b{font-weight:600;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
1792
+
1793
+ /* ── swatches ────────────────────────────────────────────────────────────── */
1794
+ .dab-swatch-title{font-size:12px;font-weight:600;margin-bottom:10px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
1795
+
1796
+ /* ── wheel card ──────────────────────────────────────────────────────────── */
1797
+ .dab-wheel-card{position:relative;display:flex;align-items:center;justify-content:center;gap:28px;flex-wrap:wrap;padding:24px 18px}
1798
+ .dab-wheel-glow{position:absolute;width:230px;height:230px;border-radius:50%;filter:blur(48px);opacity:.2;background:var(--c,#888);pointer-events:none;transition:background .4s ease}
1799
+ .dab-wheel{position:relative;cursor:crosshair;border-radius:50%;box-shadow:0 12px 32px -14px rgba(0,0,0,.4)}
1800
+ .dab-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);padding:0 4px}
1801
+ /* The "your edits are being held" notice: it reports a state the user has to act
1802
+ on, so it is tinted like a warning rather than left as grey hint text. */
1803
+ .dab-stale{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 35%,transparent);border-radius:9px;padding:8px 10px}
1804
+
1805
+ /* ── precise color inputs ────────────────────────────────────────────────── */
1806
+ .dab-inputs{display:flex;flex-direction:column;gap:10px;min-width:172px}
1807
+ .dab-field{display:flex;align-items:center;gap:8px}
1808
+ .dab-field-label{width:14px;text-align:center;font-family:var(--dab-mono);font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary)}
1809
+ .dab-num{flex:1;min-width:0;height:30px;padding:0 10px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-family:var(--dab-mono);font-size:12px;outline:none;transition:border-color .2s,box-shadow .2s}
1810
+ .dab-num:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent)}
2286
1811
  .dab-num::-webkit-outer-spin-button,.dab-num::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
2287
1812
  .dab-num{-moz-appearance:textfield;appearance:textfield}
2288
1813
  .dab-urlinput{flex:1;min-width:180px;height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12.5px;outline:none;transition:border-color .2s}
@@ -2708,6 +2233,7 @@ window.__ModuleLoader__.load({
2708
2233
  appliedTokenNames = [];
2709
2234
  baseTokenKey = "";
2710
2235
  lastBgKey = "";
2236
+ document.documentElement.removeAttribute(THEMED_ATTR);
2711
2237
  }
2712
2238
  /** Drop every surface the plugin owns: the variables the scoped
2713
2239
  * dialog/trajectory rules read, and the inline backgrounds written onto the
@@ -2873,6 +2399,18 @@ window.__ModuleLoader__.load({
2873
2399
  }
2874
2400
  }
2875
2401
  /**
2402
+ * A scheme verdict for the third caller: "whoever is in force". Reads the host
2403
+ * preference the browser half published, falling back to the host's own boot
2404
+ * flag and then the OS query — the same two rungs `resolveHostDark` uses.
2405
+ */
2406
+ function hostSchemeIsDark() {
2407
+ return resolveHostDark();
2408
+ }
2409
+ /** Whether this plugin is the one holding the scheme flag right now. */
2410
+ function ownsSchemeFlag() {
2411
+ return document.body.getAttribute(DARK_ATTR) === OWN_SCHEME;
2412
+ }
2413
+ /**
2876
2414
  * Keep `data-ds-dark-theme` in the HOST's own form (a boolean attribute) while
2877
2415
  * the plugin does not own a palette.
2878
2416
  *
@@ -2882,15 +2420,23 @@ window.__ModuleLoader__.load({
2882
2420
  * its light palette on a dark system, the readback then hands those light
2883
2421
  * surfaces back to the plugin, and the interface stays light until some
2884
2422
  * unrelated event happens to re-run an apply. Restoring the flag from the
2885
- * resolved scheme closes that hole, and the write is skipped when it already
2886
- * matches so this never churns the theme observer.
2423
+ * resolved scheme closes that hole.
2424
+ *
2425
+ * It must not PUSH a host scheme either, though, and that is the other half of
2426
+ * the same hole: the verdict it works from is the plugin's own last reading,
2427
+ * which is already stale by the time the host has dropped the flag without
2428
+ * telling anyone. Writing the stale verdict turns a host that just went dark
2429
+ * back to light and sticks it there — the flag now "matches", so every later
2430
+ * pass skips it. So only the OWNER of the flag is corrected: the plugin's own
2431
+ * value is replaced, and a flag nobody has claimed is left to the host.
2432
+ *
2433
+ * The write is skipped when it already matches, so this never churns the theme
2434
+ * observer.
2887
2435
  */
2888
2436
  function adoptHostScheme() {
2889
- const dark = resolveHostDark();
2890
- const current = document.body.getAttribute(DARK_ATTR);
2891
- if (dark) {
2892
- if (current !== "") document.body.setAttribute(DARK_ATTR, "");
2893
- } else if (current !== null) document.body.removeAttribute(DARK_ATTR);
2437
+ if (!ownsSchemeFlag()) return;
2438
+ if (resolveHostDark()) document.body.setAttribute(DARK_ATTR, "");
2439
+ else document.body.removeAttribute(DARK_ATTR);
2894
2440
  }
2895
2441
  /** Re-read the host palette and repaint from it when it moved. Called while a
2896
2442
  * color-less rule is active (the host can re-project its palette at any time,
@@ -3235,11 +2781,10 @@ window.__ModuleLoader__.load({
3235
2781
  /**
3236
2782
  * Whether one of the element's class names ENDS with `suffix`.
3237
2783
  *
3238
- * A SUFFIX test, deliberately not `classList.contains`: the harness emits these
3239
- * classes from a CSS module, so a column carries `pI_x6G_centerCol` and matching
3240
- * the whole string `centerCol` is false for every column of every build. The
3241
- * failure mode is the quiet one — the class never matches, the part falls through
3242
- * to a weaker guess and a setting disappears without an error.
2784
+ * A SUFFIX test, not `classList.contains`: the host emits these classes from a CSS
2785
+ * module, so a column carries `pI_x6G_centerCol` and an exact match is false for
2786
+ * every column of every build — quietly, with the part falling through to a weaker
2787
+ * guess.
3243
2788
  */
3244
2789
  function hasClassSuffix(el, suffix) {
3245
2790
  for (const name of Array.from(el.classList)) if (name === suffix || name.endsWith(suffix)) return true;
@@ -3291,13 +2836,11 @@ window.__ModuleLoader__.load({
3291
2836
  }
3292
2837
  }
3293
2838
  /**
3294
- * Re-derive every plugin-owned interface surface that is not a palette token:
3295
- * the conversation view card and the function pages in the main slot.
2839
+ * Re-derive every plugin-owned interface surface that is not a palette token: the
2840
+ * conversation view card and the function pages in the main slot.
3296
2841
  *
3297
- * ONE pass, run by the full apply and by the frame observer: a page mounts and
3298
- * unmounts with a NAVIGATION, not with a slider, so its discovery has to happen
3299
- * on the same beat as everything else. `fade` carries the palette-moved case
3300
- * through to the inline-painted view card (see `applyCustomTokens`).
2842
+ * ONE pass, run by the full apply and the frame observer — a page mounts and
2843
+ * unmounts with a NAVIGATION, so its discovery happens on the same beat.
3301
2844
  */
3302
2845
  function applyInterfaceParts(fade = false) {
3303
2846
  applyViewCards(fade);
@@ -3306,10 +2849,9 @@ window.__ModuleLoader__.load({
3306
2849
  const PAGE_ANCHOR_SEL = "[data-slot=\"main\"]";
3307
2850
  const PAGE_MARK = "data-dab-page";
3308
2851
  /**
3309
- * `!important`, because the page's own background is a class rule of the host's
3310
- * and equal-specificity cascade order is not something to rely on. The fallback
3311
- * is the host's own surface token, so a mark that ever outlives its discovery
3312
- * paints what that page would have painted anyway.
2852
+ * `!important`, because the page's own background is a host class rule and cascade
2853
+ * order at equal specificity is not something to rely on. The fallback is the
2854
+ * host's own token, so a stale mark paints what the page would have painted.
3313
2855
  */
3314
2856
  const PAGE_STYLE_RULE = `[${PAGE_MARK}]{background:var(--dsh-any-bg-page,var(--dsw-alias-bg-base))!important}`;
3315
2857
  /** The elements currently marked — unmarked again at zero or on teardown. */
@@ -3346,14 +2888,7 @@ window.__ModuleLoader__.load({
3346
2888
  function pageIsShowing() {
3347
2889
  return discoverPageEls().length > 0;
3348
2890
  }
3349
- /**
3350
- * Point the 插件页面 item at whatever the main slot is showing.
3351
- *
3352
- * Called from `applyInterfaceParts`, the one pass the full apply and the frame
3353
- * observer both run, and from the item's own drag: a page mounts and unmounts
3354
- * with a NAVIGATION, not with a slider, so the discovery has to happen on the
3355
- * same beat as everything else.
3356
- */
2891
+ /** Point the 插件页面 item at whatever the main slot is showing. */
3357
2892
  function applyPageSurfaces() {
3358
2893
  discoverParts();
3359
2894
  const base = palette()?.tokens["--dsw-alias-bg-base"];
@@ -3917,32 +3452,182 @@ window.__ModuleLoader__.load({
3917
3452
  stopWatchingParts();
3918
3453
  }
3919
3454
  //#endregion
3920
- //#region src/client/modelbg.ts
3921
- /** Rules that can actually paint: see `ruleCanPaint` (./schema). */
3922
- function usable(rules) {
3923
- return rules.filter(ruleCanPaint);
3924
- }
3455
+ //#region src/client/cadence.ts
3925
3456
  /**
3926
- * Resolve the rule for one model text.
3927
- *
3928
- * Top→bottom, first case-insensitive substring hit wins; an empty `match` never
3929
- * hits on its own. When nothing hits, the FIRST usable rule is the fallback
3930
- * (rule 1 deliberately doubles as both a matcher and the fallback).
3457
+ * The scheduler entry points the plugin registers, and the cadences they run at.
3931
3458
  *
3932
- * "Usable" is `ruleCanPaint` — an image, or a theme color of its own — so a rule
3933
- * whose pictures were all removed still holds the models it matches (and paints
3934
- * its color alone) instead of quietly handing them to the next rule.
3459
+ * Every interval the plugin owns is declared HERE rather than at its call site, so
3460
+ * "what does this plugin poll, and how often" has one answer. The scheduler itself
3461
+ * lives in ./watchdogs; the questions live in their own modules (boot restore and
3462
+ * the theme watchdog in ./appearance, the holiday rollover in ./holiday-status).
3935
3463
  */
3936
- function matchRule(rules, modelText) {
3937
- const list = usable(rules);
3938
- if (list.length === 0) return {
3939
- rule: null,
3940
- matched: false
3941
- };
3942
- const hay = modelText.toLowerCase();
3943
- if (hay.trim() !== "") for (const rule of list) {
3944
- const needle = rule.match.trim().toLowerCase();
3945
- if (needle === "") continue;
3464
+ /** The theme skin / host palette re-assert, once a second while a colour is live. */
3465
+ const THEME_WATCH_MS = 1e3;
3466
+ /**
3467
+ * The delayed boot restores. Two of them because the theme service and the host
3468
+ * settings scope settle asynchronously after `apply` — a single late pass misses a
3469
+ * host adoption that happens after it, and an early one misses the one before it.
3470
+ */
3471
+ const RESTORE_DELAYS_MS = [300, 1500];
3472
+ /** The holiday rollover: the answer only moves at Beijing midnight, a whole day wide. */
3473
+ const HOLIDAY_WATCH_MS = 3e4;
3474
+ //#endregion
3475
+ //#region src/client/appearance.ts
3476
+ function createAppearance(host) {
3477
+ const skin = createSkin(host.ctx);
3478
+ /** The host scheme last published, so a move can be told from a repeat. */
3479
+ let lastScheme = null;
3480
+ /**
3481
+ * Publish the scheme the host's own preference resolves to. The theme service is
3482
+ * the only place that knows it (a custom active theme included); the wallpaper
3483
+ * layer needs it to hand `data-ds-dark-theme` back in the host's own form while a
3484
+ * colour-less rule follows that theme, instead of leaving the flag off — which
3485
+ * pins the host to its LIGHT palette on a dark system.
3486
+ */
3487
+ const syncHostScheme = () => {
3488
+ try {
3489
+ setHostScheme(host.ctx.theme.getTheme().active.colorScheme === "dark");
3490
+ } catch {
3491
+ setHostScheme(null);
3492
+ }
3493
+ };
3494
+ const reassert = () => {
3495
+ const color = activeColor();
3496
+ return color === null ? false : skin.reassert(color);
3497
+ };
3498
+ /**
3499
+ * Re-read the scheme the HOST preference resolves to, repainting when it moved.
3500
+ *
3501
+ * The host can flip it without telling anyone: the OS while the preference is
3502
+ * `system`, a host-side settings adoption, and — the one that hides — a
3503
+ * re-projection the host performs on its own, which our flag write can swallow.
3504
+ * So the value is compared, not assumed: a moved scheme re-publishes it and
3505
+ * repaints; an unchanged one only does the cheap readback while a
3506
+ * colour-less rule paints from the host palette.
3507
+ *
3508
+ * @returns whether the host scheme moved (the caller repaints).
3509
+ */
3510
+ const syncScheme = () => {
3511
+ let dark;
3512
+ try {
3513
+ dark = host.ctx.theme.getTheme().active.colorScheme === "dark";
3514
+ } catch {
3515
+ dark = hostSchemeIsDark();
3516
+ }
3517
+ const moved = dark !== null && dark !== lastScheme;
3518
+ if (dark !== null) {
3519
+ lastScheme = dark;
3520
+ setHostScheme(dark);
3521
+ }
3522
+ if (!moved && activeColor() === null) refreshSystemTheme();
3523
+ return moved;
3524
+ };
3525
+ const restore = () => {
3526
+ syncHostScheme();
3527
+ if (reassert()) host.refresh();
3528
+ applyWp();
3529
+ };
3530
+ return {
3531
+ syncHostScheme,
3532
+ reassert,
3533
+ restore,
3534
+ applyPalette() {
3535
+ skin.commit(activeColor());
3536
+ syncHostScheme();
3537
+ },
3538
+ watch() {
3539
+ if (syncScheme()) {
3540
+ host.refresh();
3541
+ return;
3542
+ }
3543
+ if (reassert()) host.refresh();
3544
+ },
3545
+ /**
3546
+ * Two delayed restores, because the theme service and the host settings scope
3547
+ * settle asynchronously after `apply`: a single late pass misses an adoption
3548
+ * that happens after it, and an early one misses the one before it.
3549
+ */
3550
+ armRestores() {
3551
+ const timers = RESTORE_DELAYS_MS.map((ms) => window.setTimeout(restore, ms));
3552
+ return () => {
3553
+ for (const id of timers) window.clearTimeout(id);
3554
+ };
3555
+ },
3556
+ dispose() {
3557
+ skin.dispose();
3558
+ }
3559
+ };
3560
+ }
3561
+ //#endregion
3562
+ //#region src/client/appearance-checks.ts
3563
+ /**
3564
+ * The theme watchdog. The theme service keeps only built-in preferences in
3565
+ * memory, so any host-scope adoption can silently drop the custom theme —
3566
+ * reverting the label colours and the inner surfaces to the system palette.
3567
+ *
3568
+ * A colour-less rule needs the same slow check for the opposite reason: it paints
3569
+ * from the HOST palette, which the host can re-project (or which the scheme flag
3570
+ * can fall behind) with no event of ours to hang a repaint on.
3571
+ */
3572
+ function themeWatchdog(host) {
3573
+ return {
3574
+ name: "theme",
3575
+ everyMs: THEME_WATCH_MS,
3576
+ run() {
3577
+ if (host.activeColor() === null) {
3578
+ host.refreshHostPalette();
3579
+ return;
3580
+ }
3581
+ host.restore();
3582
+ }
3583
+ };
3584
+ }
3585
+ /**
3586
+ * The holiday rollover: a slow tick that does nothing at all until the day key
3587
+ * actually moves. 30 s is plenty for a boundary that is a whole day wide.
3588
+ */
3589
+ function holidayRollover(host) {
3590
+ let day = host.activeHolidayId() ?? "";
3591
+ return {
3592
+ name: "holiday",
3593
+ everyMs: HOLIDAY_WATCH_MS,
3594
+ run() {
3595
+ const next = host.activeHolidayId() ?? "";
3596
+ if (next === day) return;
3597
+ day = next;
3598
+ const slot = host.todayHolidaySlot();
3599
+ if (slot === null) host.applyActive();
3600
+ else host.loadAndRepaint(slot);
3601
+ }
3602
+ };
3603
+ }
3604
+ //#endregion
3605
+ //#region src/client/modelbg.ts
3606
+ /** Rules that can actually paint: see `ruleCanPaint` (./schema). */
3607
+ function usable(rules) {
3608
+ return rules.filter(ruleCanPaint);
3609
+ }
3610
+ /**
3611
+ * Resolve the rule for one model text.
3612
+ *
3613
+ * Top→bottom, first case-insensitive substring hit wins; an empty `match` never
3614
+ * hits on its own. When nothing hits, the FIRST usable rule is the fallback
3615
+ * (rule 1 deliberately doubles as both a matcher and the fallback).
3616
+ *
3617
+ * "Usable" is `ruleCanPaint` — an image, or a theme color of its own — so a rule
3618
+ * whose pictures were all removed still holds the models it matches (and paints
3619
+ * its color alone) instead of quietly handing them to the next rule.
3620
+ */
3621
+ function matchRule(rules, modelText) {
3622
+ const list = usable(rules);
3623
+ if (list.length === 0) return {
3624
+ rule: null,
3625
+ matched: false
3626
+ };
3627
+ const hay = modelText.toLowerCase();
3628
+ if (hay.trim() !== "") for (const rule of list) {
3629
+ const needle = rule.match.trim().toLowerCase();
3630
+ if (needle === "") continue;
3946
3631
  if (hay.includes(needle)) return {
3947
3632
  rule,
3948
3633
  matched: true
@@ -4183,6 +3868,265 @@ window.__ModuleLoader__.load({
4183
3868
  };
4184
3869
  }
4185
3870
  //#endregion
3871
+ //#region src/client/boot.ts
3872
+ /**
3873
+ * Boot: which image slots are read before the first frame, and in what order.
3874
+ *
3875
+ * Deliberately its own module, because the ORDER is the whole feature. A setup
3876
+ * with five rules and four pictures each holds twenty full-size data URLs, and
3877
+ * pulling all of them before painting would mean the interface opens blank for
3878
+ * as long as they take. So exactly three things are read eagerly:
3879
+ *
3880
+ * 1. the slot that paints FIRST — today's holiday art, else the current model's
3881
+ * first image (see `prioritySlot`);
3882
+ * 2. the first image of every other rule, because a rule card draws its first
3883
+ * image as its thumbnail;
3884
+ * 3. today's holiday art, when it is not already (1).
3885
+ *
3886
+ * Everything past that is read ON DEMAND: by the rotation, which warms the image
3887
+ * it is about to need as soon as its schedule is armed, and by the settings page
3888
+ * when a card is expanded. The slot list the node half reports is not hydrated at
3889
+ * all — an orphaned slot nothing points at has no card to appear on.
3890
+ */
3891
+ /** One slot, read once: the cache is authoritative for "already here". */
3892
+ async function loadSlot(slot) {
3893
+ if (imageOf(slot) !== null) return;
3894
+ const url = await readImage(slot);
3895
+ if (url !== null) setImage(slot, url);
3896
+ }
3897
+ /**
3898
+ * The slot that paints before anything else: the holiday in force when there is
3899
+ * one, otherwise the FIRST image of the current model's rule. A holiday outranks
3900
+ * the rule here for the same reason it outranks it when painting.
3901
+ *
3902
+ * Always the first image (never the one `advanceOnSwitch` would step to): boot
3903
+ * paints without an advance, and hydrating an image the first frame is not going
3904
+ * to use would leave the frame blank until the real one arrived.
3905
+ */
3906
+ function prioritySlot(spec) {
3907
+ if (spec.holidaySlot !== null) return spec.holidaySlot;
3908
+ return matchRule(cfg.rules, spec.modelText).rule?.images[0]?.slot ?? null;
3909
+ }
3910
+ /** Every slot boot hydrates, the painted one first. */
3911
+ function bootSlots(spec) {
3912
+ const firsts = cfg.rules.map((r) => r.images[0]?.slot).filter((slot) => slot !== void 0);
3913
+ const first = prioritySlot(spec);
3914
+ return Array.from(/* @__PURE__ */ new Set([
3915
+ ...first === null ? [] : [first],
3916
+ ...firsts,
3917
+ ...spec.holidaySlot === null ? [] : [spec.holidaySlot]
3918
+ ]));
3919
+ }
3920
+ /**
3921
+ * Read the boot set, calling `painted` once the first slot is in.
3922
+ *
3923
+ * Sequential on purpose: the painted one is requested first and the rest stream
3924
+ * in after it, so a large multi-rule setup never delays the first frame. The
3925
+ * callback fires on EVERY slot's turn, not once — the caller's repaint is cheap
3926
+ * when the URL has not moved.
3927
+ */
3928
+ async function hydrateBoot(spec, painted) {
3929
+ const first = prioritySlot(spec);
3930
+ for (const slot of bootSlots(spec)) {
3931
+ await loadSlot(slot);
3932
+ if (slot === first) painted();
3933
+ }
3934
+ }
3935
+ //#endregion
3936
+ //#region src/client/model-state.ts
3937
+ /** A key that no real model text can equal, so the first report always lands. */
3938
+ const EMPTY = "\0";
3939
+ function createModelTracker() {
3940
+ let state = {
3941
+ text: "",
3942
+ label: "",
3943
+ source: "default",
3944
+ note: "waiting"
3945
+ };
3946
+ let key = EMPTY;
3947
+ const take = (next) => {
3948
+ const nextKey = `${next.text}\u0001${next.source}\u0001${next.note}`;
3949
+ const changed = nextKey !== key;
3950
+ const textChanged = next.text !== state.text;
3951
+ key = nextKey;
3952
+ state = next;
3953
+ return {
3954
+ textChanged,
3955
+ changed
3956
+ };
3957
+ };
3958
+ return {
3959
+ get state() {
3960
+ return state;
3961
+ },
3962
+ update(text, label, source, note) {
3963
+ return take({
3964
+ text,
3965
+ label: label !== "" ? label : text,
3966
+ source,
3967
+ note
3968
+ });
3969
+ },
3970
+ adopt(text, source, note) {
3971
+ return take({
3972
+ text,
3973
+ label: text,
3974
+ source,
3975
+ note
3976
+ }).changed;
3977
+ },
3978
+ facts(rule, matched) {
3979
+ return {
3980
+ text: state.text,
3981
+ source: state.source,
3982
+ note: state.note,
3983
+ rule,
3984
+ matched
3985
+ };
3986
+ }
3987
+ };
3988
+ }
3989
+ //#endregion
3990
+ //#region src/client/rotation.ts
3991
+ /** Dwell-time presets the settings UI offers, in ms. */
3992
+ const ROTATE_PRESETS = [
3993
+ {
3994
+ ms: 1e4,
3995
+ key: "rotEvery10s"
3996
+ },
3997
+ {
3998
+ ms: 3e4,
3999
+ key: "rotEvery30s"
4000
+ },
4001
+ {
4002
+ ms: 6e4,
4003
+ key: "rotEvery1m"
4004
+ },
4005
+ {
4006
+ ms: 3e5,
4007
+ key: "rotEvery5m"
4008
+ },
4009
+ {
4010
+ ms: 18e5,
4011
+ key: "rotEvery30m"
4012
+ }
4013
+ ];
4014
+ /**
4015
+ * The index to paint after the current one, or null when there is nowhere to go
4016
+ * (fewer than two images — the caller must not start a timer at all then).
4017
+ *
4018
+ * `order` walks the list and wraps; `shuffle` picks uniformly among every index
4019
+ * EXCEPT the current one, because landing on the same image reads as a missed
4020
+ * tick. `current` outside the list is treated as 0 rather than trusted: it comes
4021
+ * from the UI as often as from the timer, and a stale index must not be able to
4022
+ * index past the end.
4023
+ *
4024
+ * `rand` is injectable so the shuffle branch is testable without stubbing a
4025
+ * global.
4026
+ */
4027
+ function nextIndex(len, order, current, rand = Math.random) {
4028
+ if (!Number.isFinite(len) || len < 2) return null;
4029
+ const count = Math.floor(len);
4030
+ const cur = Number.isFinite(current) && current >= 0 && current < count ? Math.floor(current) : 0;
4031
+ if (order === "shuffle") {
4032
+ const roll = Math.min(Math.floor(Math.max(rand(), 0) * (count - 1)), count - 2);
4033
+ return roll >= cur ? roll + 1 : roll;
4034
+ }
4035
+ return (cur + 1) % count;
4036
+ }
4037
+ /**
4038
+ * Whether a rotation is worth running: two images, the switch on, and a positive
4039
+ * dwell time. Kept here (rather than at the call site) so the UI's "rotating"
4040
+ * badge and the timer agree by construction — a badge that says "rotating" while
4041
+ * no timer exists is the one failure this feature can produce silently.
4042
+ */
4043
+ function isRotating(len, enabled, intervalMs) {
4044
+ return enabled && len >= 2 && Number.isFinite(intervalMs) && intervalMs > 0;
4045
+ }
4046
+ //#endregion
4047
+ //#region src/client/rotation-timer.ts
4048
+ /**
4049
+ * The image rotation timer: ONE interval, always aimed at the ACTIVE rule.
4050
+ *
4051
+ * A timer per rule would burn wakeups on rules that are not painting anything,
4052
+ * and since only one rule's images can be on screen at a time, one timer is all
4053
+ * that is needed. It is kept out of `apply()` because a rotation is the one piece
4054
+ * of this plugin that must keep working while nothing else changes — and the
4055
+ * questions it has to answer (did the schedule's inputs move? is the tab
4056
+ * hidden? which image is next?) read better gathered in one place.
4057
+ *
4058
+ * The schedule is armed by SIGNATURE rather than by "already armed": a slider drag
4059
+ * on the active rule re-runs the whole apply dozens of times per second, and
4060
+ * restarting the interval on each of those would mean the dwell timer never
4061
+ * elapses while the user is dragging (a 10 s rotation would never advance). The
4062
+ * signature carries exactly the inputs the schedule depends on, so a real change —
4063
+ * another rule, another interval, the tab becoming visible again — re-arms while a
4064
+ * repaint does not.
4065
+ */
4066
+ function createRotation(host) {
4067
+ let timer = null;
4068
+ /** Identity of the schedule currently armed; a change means "re-arm". */
4069
+ let signature = "";
4070
+ const tick = () => {
4071
+ const rule = activeRule();
4072
+ if (rule === null) return;
4073
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
4074
+ if (next === null) {
4075
+ schedule(true);
4076
+ return;
4077
+ }
4078
+ setRotIndex(next);
4079
+ host.paintImage(rule, next);
4080
+ const after = nextIndex(rule.images.length, rule.rotate.order, next);
4081
+ if (after !== null) host.warmImage(rule, after);
4082
+ };
4083
+ const schedule = (force = false) => {
4084
+ const rule = activeRule();
4085
+ const sig = rule === null ? "" : [
4086
+ rule.id,
4087
+ rule.rotate.enabled,
4088
+ rule.rotate.intervalMs,
4089
+ rule.rotate.order,
4090
+ rule.images.length,
4091
+ document.hidden
4092
+ ].join("|");
4093
+ if (!force && sig === signature) return;
4094
+ signature = sig;
4095
+ if (timer !== null) {
4096
+ window.clearInterval(timer);
4097
+ timer = null;
4098
+ }
4099
+ if (rule === null || document.hidden) return;
4100
+ if (!isRotating(rule.images.length, rule.rotate.enabled, rule.rotate.intervalMs)) return;
4101
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
4102
+ if (next !== null) host.warmImage(rule, next);
4103
+ timer = window.setInterval(tick, rule.rotate.intervalMs);
4104
+ };
4105
+ return {
4106
+ schedule,
4107
+ step() {
4108
+ const rule = activeRule();
4109
+ if (rule === null) return false;
4110
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
4111
+ if (next === null) return false;
4112
+ setRotIndex(next);
4113
+ host.paintImage(rule, next);
4114
+ schedule(true);
4115
+ return true;
4116
+ },
4117
+ onVisibility() {
4118
+ schedule();
4119
+ host.sync();
4120
+ },
4121
+ dispose() {
4122
+ if (timer !== null) {
4123
+ window.clearInterval(timer);
4124
+ timer = null;
4125
+ }
4126
+ }
4127
+ };
4128
+ }
4129
+ //#endregion
4186
4130
  //#region src/client/components/icons.tsx
4187
4131
  /** Sun glyph, migrated from @deepseek-ai/dsh-client-ui-primitives IconLightOutline16. */
4188
4132
  const SUN_PATHS = "<path d=\"M11.3496 8C11.3496 6.14985 9.85015 4.65039 8 4.65039C6.14985 4.65039 4.65039 6.14985 4.65039 8C4.65039 9.85015 6.14985 11.3496 8 11.3496C9.85015 11.3496 11.3496 9.85015 11.3496 8ZM12.6504 8C12.6504 10.5681 10.5681 12.6504 8 12.6504C5.43188 12.6504 3.34961 10.5681 3.34961 8C3.34961 5.43188 5.43188 3.34961 8 3.34961C10.5681 3.34961 12.6504 5.43188 12.6504 8Z\" fill=\"currentColor\"/><path d=\"M8.65039 0.5V2.5H7.34961V0.5H8.65039Z\" fill=\"currentColor\"/><path d=\"M8.65039 13.5V15.5H7.34961V13.5H8.65039Z\" fill=\"currentColor\"/><path d=\"M3.15808 2.24035L4.57229 3.65456L3.6525 4.57435L2.23829 3.16014L3.15808 2.24035Z\" fill=\"currentColor\"/><path d=\"M12.3505 11.4327L13.7647 12.8469L12.8449 13.7667L11.4307 12.3525L12.3505 11.4327Z\" fill=\"currentColor\"/><path d=\"M2.24537 12.8469L3.65958 11.4327L4.57937 12.3525L3.16516 13.7667L2.24537 12.8469Z\" fill=\"currentColor\"/><path d=\"M11.4377 3.65455L12.852 2.24033L13.7718 3.16012L12.3575 4.57434L11.4377 3.65455Z\" fill=\"currentColor\"/><path d=\"M0.5 7.35461H2.5V8.6554H0.5L0.5 7.35461Z\" fill=\"currentColor\"/><path d=\"M13.5 7.35461H15.5V8.6554H13.5V8.6554Z\" fill=\"currentColor\"/>";
@@ -4442,6 +4386,667 @@ window.__ModuleLoader__.load({
4442
4386
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4.3 6.4 8 10.1l3.7-3.7" })
4443
4387
  });
4444
4388
  //#endregion
4389
+ //#region src/client/nav-icon.ts
4390
+ /**
4391
+ * The settings-nav glyph.
4392
+ *
4393
+ * The harness derives every section's nav icon from its id and exposes no hook for
4394
+ * a plugin to supply one, so the mounted nav cell has to be found in the DOM and
4395
+ * its `<svg>` swapped for this plugin's sun. That is a patch on the host's markup,
4396
+ * and the two failure modes are both silent: patching too early finds no cell, and
4397
+ * patching on every mutation burns a scan of the whole document per keystroke in
4398
+ * the composer. Hence the narrow trigger below — only a settings DIALOG arriving
4399
+ * in the tree starts a scan.
4400
+ */
4401
+ /** The host's settings dialog, the only place this section's nav exists. */
4402
+ const DIALOG_SEL = "[role=\"dialog\"][aria-modal=\"true\"][aria-labelledby]";
4403
+ const MARK = "dabNavIcon";
4404
+ /**
4405
+ * Replace the nav glyph of the section whose label is `navLabel()`.
4406
+ *
4407
+ * Reads the label on every scan rather than capturing it: the section is rebuilt
4408
+ * when the language changes, and a stale label would simply stop matching.
4409
+ */
4410
+ function applyNavIcon(navLabel) {
4411
+ const nav = document.querySelector(DIALOG_SEL)?.querySelector("nav");
4412
+ if (!nav) return;
4413
+ for (const cell of Array.from(nav.querySelectorAll("button"))) {
4414
+ const label = cell.querySelector("span");
4415
+ if (label === null || label.textContent?.trim() !== navLabel) continue;
4416
+ const svg = cell.querySelector("svg");
4417
+ if (svg !== null && svg.dataset[MARK] === "1") return;
4418
+ const sun = document.createElementNS("http://www.w3.org/2000/svg", "svg");
4419
+ sun.setAttribute("width", "16");
4420
+ sun.setAttribute("height", "16");
4421
+ sun.setAttribute("viewBox", "0 0 16 16");
4422
+ sun.setAttribute("fill", "none");
4423
+ sun.setAttribute("xmlns", "http://www.w3.org/2000/svg");
4424
+ sun.dataset[MARK] = "1";
4425
+ sun.innerHTML = SUN_PATHS;
4426
+ svg?.replaceWith(sun);
4427
+ if (svg === null) cell.prepend(sun);
4428
+ return;
4429
+ }
4430
+ }
4431
+ /** Watch for the settings dialog (re)appearing; returns the disposer. */
4432
+ function watchNavIcon(navLabel) {
4433
+ if (typeof MutationObserver === "undefined") return () => void 0;
4434
+ const observer = new MutationObserver((records) => {
4435
+ if (records.some((r) => {
4436
+ for (const n of r.addedNodes) {
4437
+ if (n.nodeType !== 1) continue;
4438
+ const el = n;
4439
+ if (el.matches?.(DIALOG_SEL) || el.querySelector?.(DIALOG_SEL)) return true;
4440
+ }
4441
+ return false;
4442
+ })) applyNavIcon(navLabel());
4443
+ });
4444
+ observer.observe(document.body, {
4445
+ childList: true,
4446
+ subtree: true
4447
+ });
4448
+ applyNavIcon(navLabel());
4449
+ return () => observer.disconnect();
4450
+ }
4451
+ //#endregion
4452
+ //#region src/client/theme-file.ts
4453
+ /** Slot names reach the filesystem, so an incoming file's are whitelisted. */
4454
+ const SLOT_RE = /^[A-Za-z0-9_-]{1,32}$/;
4455
+ /** The JSON document `exportTheme` writes; `images` carries every rule slot. */
4456
+ function buildThemeFile() {
4457
+ const images = {};
4458
+ for (const slot of cfg.rules.flatMap(ruleSlots)) {
4459
+ const url = imageOf(slot);
4460
+ if (url !== null) images[slot] = url;
4461
+ }
4462
+ return {
4463
+ version: 9,
4464
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4465
+ config: cfg,
4466
+ images
4467
+ };
4468
+ }
4469
+ /**
4470
+ * Write the current rules and wallpapers out as one JSON file.
4471
+ *
4472
+ * The object URL is attached to a hidden anchor and released on the NEXT task:
4473
+ * a click is not a synchronous download, and releasing in the same tick cancels
4474
+ * it in some browsers — while Firefox only honours the click on an anchor that is
4475
+ * in the document.
4476
+ */
4477
+ function saveThemeFile(file) {
4478
+ const blob = new Blob([JSON.stringify(file, null, 2)], { type: "application/json" });
4479
+ const url = URL.createObjectURL(blob);
4480
+ const a = document.createElement("a");
4481
+ a.href = url;
4482
+ a.download = "dsh-background-by-model-theme.json";
4483
+ a.style.display = "none";
4484
+ document.body.append(a);
4485
+ a.click();
4486
+ window.setTimeout(() => {
4487
+ a.remove();
4488
+ URL.revokeObjectURL(url);
4489
+ }, 0);
4490
+ }
4491
+ /**
4492
+ * Write a whole theme file into the store: its rules, its wallpapers, nothing
4493
+ * else.
4494
+ *
4495
+ * `wipeFirst` is the entire difference between the two imports, and it belongs to
4496
+ * the recommended profile alone: `nextSlot` hands slots out as `m1`, `m2`, … — a
4497
+ * counter, not a random token — so a downloaded profile and the store it lands in
4498
+ * collide on those names as a matter of course. Emptying first is what makes
4499
+ * "replace everything with this" literally true.
4500
+ */
4501
+ async function applyThemeFile(host, data, wipeFirst) {
4502
+ if (typeof data.version === "number" && data.version > 9) return false;
4503
+ if (typeof data.config !== "object" || data.config === null) return false;
4504
+ if (wipeFirst) {
4505
+ await resetStore();
4506
+ resetConfig();
4507
+ }
4508
+ adoptConfig(data.config);
4509
+ const incoming = data.images ?? {};
4510
+ const keep = /* @__PURE__ */ new Set();
4511
+ for (const slot of cfg.rules.flatMap(ruleSlots)) {
4512
+ keep.add(slot);
4513
+ const raw = incoming[slot];
4514
+ if (typeof raw === "string" && /^data:image\//.test(raw)) {
4515
+ setImage(slot, raw);
4516
+ writeImage(slot, raw);
4517
+ } else {
4518
+ setImage(slot, null);
4519
+ deleteImage(slot);
4520
+ }
4521
+ }
4522
+ for (const slot of Object.keys(incoming)) if (!keep.has(slot) && SLOT_RE.test(slot)) deleteImage(slot);
4523
+ persistConfig();
4524
+ host.applyActive();
4525
+ return true;
4526
+ }
4527
+ //#endregion
4528
+ //#region src/client/watchdogs.ts
4529
+ /** The shortest cadence allowed — also the scheduler's own tick. */
4530
+ const MIN_TICK_MS = 1e3;
4531
+ /**
4532
+ * Start one scheduler. It does NOT run anything on registration: the caller
4533
+ * decides when the first pass happens (the plugin's boot sequence needs the
4534
+ * order — wallpaper, model, palette), and registers with the intent that the
4535
+ * first tick is the first run.
4536
+ *
4537
+ * The clock is `performance.now()` rather than `Date.now()`: a wall-clock jump
4538
+ * (NTP, a laptop waking from sleep) would otherwise fire every entry at once or
4539
+ * park them all for hours, and "how long ago did we last ask" is a duration, not
4540
+ * a date.
4541
+ */
4542
+ function createSchedule() {
4543
+ const entries = /* @__PURE__ */ new Map();
4544
+ let timer = null;
4545
+ let disposed = false;
4546
+ const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
4547
+ const fire = (w, stamp) => {
4548
+ stamp.last = now();
4549
+ try {
4550
+ w.run();
4551
+ } catch (e) {
4552
+ console.warn(`dsh-background-by-model: the "${w.name}" check threw`, e);
4553
+ }
4554
+ };
4555
+ const tick = () => {
4556
+ if (disposed || document.hidden) return;
4557
+ const at = now();
4558
+ for (const [w, stamp] of entries) if (at - stamp.last >= w.everyMs) fire(w, stamp);
4559
+ };
4560
+ /**
4561
+ * One beat for the whole page, and it is the SHORTEST cadence any entry asks
4562
+ * for, so each one fires within one beat of its own due time. With nothing
4563
+ * registered there is no timer at all.
4564
+ */
4565
+ const resync = () => {
4566
+ if (timer !== null) {
4567
+ window.clearInterval(timer);
4568
+ timer = null;
4569
+ }
4570
+ if (disposed || entries.size === 0) return;
4571
+ let beat = Infinity;
4572
+ for (const w of entries.keys()) beat = Math.min(beat, w.everyMs);
4573
+ timer = window.setInterval(tick, Math.max(MIN_TICK_MS, beat));
4574
+ };
4575
+ const onVisibility = () => {
4576
+ if (!document.hidden) tick();
4577
+ };
4578
+ document.addEventListener("visibilitychange", onVisibility);
4579
+ resync();
4580
+ return {
4581
+ add(watchdog) {
4582
+ if (disposed) return () => void 0;
4583
+ entries.set(watchdog, { last: now() });
4584
+ resync();
4585
+ return () => {
4586
+ entries.delete(watchdog);
4587
+ resync();
4588
+ };
4589
+ },
4590
+ runAll() {
4591
+ if (disposed) return;
4592
+ for (const [w, stamp] of entries) fire(w, stamp);
4593
+ },
4594
+ dispose() {
4595
+ if (disposed) return;
4596
+ disposed = true;
4597
+ entries.clear();
4598
+ if (timer !== null) {
4599
+ window.clearInterval(timer);
4600
+ timer = null;
4601
+ }
4602
+ document.removeEventListener("visibilitychange", onVisibility);
4603
+ }
4604
+ };
4605
+ }
4606
+ //#endregion
4607
+ //#region src/client/i18n.ts
4608
+ const NS = "settings.anyBg";
4609
+ const zh = {
4610
+ nav: "模型背景",
4611
+ brandTag: "按模型换背景",
4612
+ pageInterface: "界面",
4613
+ pageModelBg: "模型背景",
4614
+ pageProfile: "配置",
4615
+ descInterface: "为主界面的各个区域单独调节透明度,营造空间层次感。这里的设置对所有模型生效",
4616
+ descModelBg: "每条规则 = 一个匹配串 + 一组背景图与外观。切换模型时,从上往下找第一条匹配串出现在模型名里的规则;都没中就使用第 1 条。一条规则可以放多张图,按你设的节奏轮换",
4617
+ descProfile: "调整壁纸切换效果,并把当前全部规则连同图片导出备份,或从文件一键恢复",
4618
+ uiTitle: "主界面",
4619
+ uiOpacity: "透明度",
4620
+ uiOpacityBg: "主背景",
4621
+ uiOpacitySide: "左侧栏",
4622
+ uiOpacityCard: "卡片与菜单面板",
4623
+ uiOpacityCode: "代码块",
4624
+ uiOpacityInput: "输入框与控件",
4625
+ uiSop: "设置界面透明度",
4626
+ uiChatRegion: "对话文本框",
4627
+ uiTrajectory: "轨迹页",
4628
+ uiPage: "插件页面",
4629
+ uiPreview: "右侧栏",
4630
+ statusUnknownHint: "检测不到时一律使用第 1 条可用规则;切换一次模型或稍等片刻会自动重试",
4631
+ statusSourceDefaultHint: "只读到宿主默认模型,它不是本会话当前选择的模型;本会话的模型选择可用后会自动纠正",
4632
+ statusNoteNoService: "仍未拿到会话服务(会话控制器晚于本插件加载),正在自动重试",
4633
+ statusNoteNoSession: "会话服务已就绪,但还没有当前会话 id",
4634
+ statusNoteNoProjection: "拿不到本会话的模型选择投影(binding / projections 不可用)",
4635
+ statusNoteEmptySelection: "本会话的模型选择投影还没有值(切一次模型即可写入)",
4636
+ hostStaleHint: "检测到 DSH 主进程仍在运行旧版插件:新版配置格式它还读不懂,为避免丢规则,本次只改界面不写入配置。重启 DSH 后多图与轮换即可正常保存",
4637
+ tryoutTitle: "匹配试跑",
4638
+ tryoutPlaceholder: "输入模型名,如 deepseek-flash",
4639
+ tryoutUseCurrent: "填入当前模型",
4640
+ tryoutHit: "命中规则",
4641
+ tryoutFallback: "无匹配 · 走兜底规则",
4642
+ tryoutNone: "没有可用规则(规则需要启用,并且有图片或主题色)",
4643
+ tryoutEmpty: "输入模型名即可试跑",
4644
+ autoExtract: "选图后自动取主题色",
4645
+ ruleAdd: "新增规则",
4646
+ ruleNoneHint: "还没有规则,点上面的「新增规则」新建一条",
4647
+ ruleFallbackBadge: "兜底",
4648
+ ruleMatchPlaceholder: "如 flash / glm(忽略大小写,留空则不参与匹配)",
4649
+ ruleEnabled: "启用",
4650
+ ruleUp: "上移",
4651
+ ruleDown: "下移",
4652
+ ruleRemove: "删除规则",
4653
+ tabListLabel: "规则",
4654
+ tabPick: "选择规则",
4655
+ tabMore: "更多规则",
4656
+ tabUnnamed: "未命名规则",
4657
+ tabLive: "正在使用",
4658
+ tabRotating: "轮换中",
4659
+ tabOff: "已停用",
4660
+ ruleRemoveConfirm: "再点一次删除",
4661
+ ruleNoImage: "尚未选择图片",
4662
+ ruleFromUrl: "从网址",
4663
+ ruleUrlPlaceholder: "粘贴图片网址 https://…",
4664
+ ruleUrlApply: "应用",
4665
+ ruleUrlCancel: "取消",
4666
+ ruleUrlApplying: "加载中…",
4667
+ ruleUrlBadHttp: "仅支持 http/https 图片网址",
4668
+ ruleUrlFail: "获取图片失败",
4669
+ ruleImageRemove: "移除",
4670
+ ruleEmptyHint: "这条规则还没有图片,也没有自己的主题色:没有可显示的内容,匹配和兜底都会跳过它",
4671
+ ruleColorOnlyHint: "这条规则还没有图片:不会铺壁纸,界面只用这个主题色",
4672
+ ruleImagePendingHint: "这张图片还没有取到内容,稍后会自己出现(也可以直接替换它)",
4673
+ ruleColor: "主题色",
4674
+ ruleColorNone: "跟随系统主题",
4675
+ ruleColorNoneHint: "未设颜色时跟随系统主题:界面用宿主的配色,壁纸与各部位的透明度照旧生效",
4676
+ ruleColorPick: "自定义颜色",
4677
+ ruleColorPickHint: "优先从本图取色,取不到就用默认色",
4678
+ ruleColorNoImageHint: "这条规则还没有图片,取不到色:选一个颜色可以只铺主题色,或先添加图片",
4679
+ ruleColorPendingImageHint: "这张图片还没有取到内容,暂时取不了色:可以直接选一个颜色,或等它加载完",
4680
+ ruleColorExtract: "从本图提取",
4681
+ ruleColorTargetImage: "作用于选中的图片:",
4682
+ ruleColorTargetRule: "作用于本规则(它还没有图片)",
4683
+ ruleLayout: "布局模式",
4684
+ ruleLayoutNoImage: "添加图片后可分别设置每张图的布局模式",
4685
+ ruleFramingEdit: "编辑位置",
4686
+ ruleOpacity: "背景透明度",
4687
+ ruleBrightness: "背景亮度",
4688
+ ruleBlur: "背景模糊",
4689
+ ruleBlurHint: "这三条滑块都作用于壁纸层;界面页里的透明度作用于界面各区域,两者互不影响",
4690
+ rotTitle: "多图轮换",
4691
+ rotEnable: "轮换这组图",
4692
+ rotNeedTwo: "至少两张图才能轮换",
4693
+ rotEvery: "每张停留",
4694
+ rotCustom: "自定义秒数",
4695
+ rotSeconds: "秒",
4696
+ rotOrder: "换图顺序",
4697
+ rotOrderSeq: "按顺序",
4698
+ rotOrderShuffle: "随机",
4699
+ rotOnSwitch: "切换模型时也换一张",
4700
+ rotNext: "下一张",
4701
+ rotNextInactive: "只有正在生效的规则能换画面上的图",
4702
+ rotPos: "第",
4703
+ rotShowing: "正在显示",
4704
+ rotAddImage: "添加",
4705
+ rotReplaceImage: "替换",
4706
+ rotDragHint: "拖动排序",
4707
+ rotDblHint: "双击切换",
4708
+ rotEvery10s: "10 秒",
4709
+ rotEvery30s: "30 秒",
4710
+ rotEvery1m: "1 分钟",
4711
+ rotEvery5m: "5 分钟",
4712
+ rotEvery30m: "30 分钟",
4713
+ bgModeFit: "适应",
4714
+ bgModeFill: "填充",
4715
+ bgModeStretch: "拉伸",
4716
+ bgModeTile: "平铺",
4717
+ bgModeCenter: "居中",
4718
+ editorTitle: "背景编辑器",
4719
+ editorHint: "拖动移动画面,滚轮缩放大小",
4720
+ editorCommit: "确认",
4721
+ editorCancel: "取消",
4722
+ editorReset: "重置",
4723
+ extracting: "取色中…",
4724
+ extractDone: "已应用图片配色",
4725
+ extractFail: "未在这张图里找到鲜明的颜色,请换一张",
4726
+ eyedropper: "从图片取色",
4727
+ pickerTitle: "从图片取色",
4728
+ pickerHint: "移动鼠标预览颜色,点击图片选取为主题色",
4729
+ pickerClose: "关闭",
4730
+ crashTitle: "界面渲染出错",
4731
+ crashDesc: "设置面板遇到问题,点击下方按钮重置后重试",
4732
+ crashReset: "重置面板",
4733
+ exportTheme: "导出配置",
4734
+ importTheme: "导入配置",
4735
+ exportCardTitle: "导出配置",
4736
+ exportCardDesc: "把全部规则、每条规则的颜色/布局/透明度/模糊与图片打包为 dsh-background-by-model-theme.json",
4737
+ importCardTitle: "导入配置",
4738
+ importCardDesc: "从之前导出的 JSON 文件一键恢复全部规则(会覆盖当前规则与图片)",
4739
+ toastExportDone: "配置文件已开始下载",
4740
+ importDone: "已导入配置",
4741
+ importFail: "导入失败,文件格式不正确",
4742
+ recCardTitle: "推荐配置",
4743
+ recCardDesc: "从项目素材库下载作者维护的一整套配置(规则、壁纸与接口设置),一键替换当前的全部内容",
4744
+ recAdopt: "采用推荐配置",
4745
+ recBusy: "正在下载",
4746
+ recConfirmTitle: "替换当前的全部配置?",
4747
+ recConfirmBody: "会先删除你现在的全部规则与壁纸,再写入这份推荐配置。删除的文件无法恢复。",
4748
+ recStatRules: "条规则",
4749
+ recStatImages: "张壁纸",
4750
+ recConfirmOk: "删除并替换",
4751
+ recConfirmCancel: "取消",
4752
+ recDone: "已采用推荐配置",
4753
+ recFailNetwork: "推荐配置下载失败,请检查网络后重试",
4754
+ recFailNotProfile: "素材库返回的内容不是配置,请稍后重试",
4755
+ recFailNewer: "这份推荐配置需要更新的插件版本,未做任何改动",
4756
+ recFailTooMany: "这份推荐配置列出的图片数量超出限制,未做任何改动",
4757
+ recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
4758
+ recFailImage: "这张壁纸没拿到(素材库里缺这个文件):",
4759
+ recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
4760
+ footerTag: "按模型换背景插件",
4761
+ repoLink: "在 GitHub 上打开仓库",
4762
+ holidayTitle: "节日特殊背景",
4763
+ holidayEnable: "启用节日特殊背景(中秋当天、国庆 10 月 1–7 日)",
4764
+ trTitle: "切换效果",
4765
+ trEffect: "切换方式",
4766
+ trEffectFade: "淡入淡出",
4767
+ trEffectNone: "直接切换",
4768
+ trEffectZoom: "缩放淡入",
4769
+ trEffectSlide: "平移推入",
4770
+ trEasing: "缓动",
4771
+ trEasingEase: "标准",
4772
+ trEasingLinear: "匀速",
4773
+ trEasingOut: "先快后慢",
4774
+ trEasingInOut: "两端慢",
4775
+ trDuration: "切换时长",
4776
+ trPreview: "试放",
4777
+ trHint: "效果对所有换图生效:切模型、轮换、手动下一张、节日。「直接切换」和 0 秒一样是硬切",
4778
+ trReducedHint: "系统开启了「减少动态效果」,实际切换不会播放动画"
4779
+ };
4780
+ const en = {
4781
+ nav: "Model background",
4782
+ brandTag: "Per-model wallpaper",
4783
+ pageInterface: "Interface",
4784
+ pageModelBg: "Model background",
4785
+ pageProfile: "Profile",
4786
+ descInterface: "Tune opacity per surface to build depth. These settings are global and apply to every model",
4787
+ descModelBg: "Each rule is a match string plus a set of wallpapers and one look. On a model switch the list is scanned top-down for the first match string contained in the model name; if nothing hits, rule 1 is used. A rule can hold several images and rotate through them on a rhythm you set",
4788
+ descProfile: "Tune how the wallpaper switches, and back up every rule together with its image — or restore one from a file",
4789
+ uiTitle: "Interface",
4790
+ uiOpacity: "Opacity",
4791
+ uiOpacityBg: "Main background",
4792
+ uiOpacitySide: "Left panel",
4793
+ uiOpacityCard: "Cards & menus",
4794
+ uiOpacityCode: "Code blocks",
4795
+ uiOpacityInput: "Input & controls",
4796
+ uiSop: "Settings interface opacity",
4797
+ uiChatRegion: "Conversation text frame",
4798
+ uiTrajectory: "Trajectory view",
4799
+ uiPage: "Plugin pages",
4800
+ uiPreview: "Right panel",
4801
+ statusUnknownHint: "Without a detected model rule 1 is always used; switching the model once or waiting a moment retries automatically",
4802
+ statusSourceDefaultHint: "Only the host-wide default model could be read — that is not necessarily this session's selection. It self-corrects once this session's model selection is available.",
4803
+ statusNoteNoService: "The sessions service is not available yet (the session controller loads after this plugin); retrying automatically",
4804
+ statusNoteNoSession: "The sessions service is up but reports no current session yet",
4805
+ statusNoteNoProjection: "This session's model-selection projection is unreachable (no binding / projections)",
4806
+ statusNoteEmptySelection: "The model-selection projection exists but carries no value yet (selecting a model once writes it)",
4807
+ hostStaleHint: "The DSH host process is still running the older plugin, which cannot read the new config shape. To avoid dropping rules, this session changes the interface but holds every config write — restart DSH and multi-image plus rotation will save normally",
4808
+ tryoutTitle: "Match test",
4809
+ tryoutPlaceholder: "Type a model name, e.g. deepseek-flash",
4810
+ tryoutUseCurrent: "Use current model",
4811
+ tryoutHit: "matches rule",
4812
+ tryoutFallback: "no match · fallback rule",
4813
+ tryoutNone: "No usable rule (a rule needs to be enabled and carry an image or a theme color)",
4814
+ tryoutEmpty: "Type a model name to test the list",
4815
+ autoExtract: "Extract the theme color when an image is chosen",
4816
+ ruleAdd: "Add rule",
4817
+ ruleNoneHint: "No rules yet — use “Add rule” in the tab strip above",
4818
+ ruleFallbackBadge: "fallback",
4819
+ ruleMatchPlaceholder: "e.g. flash / glm (case-insensitive, empty never matches)",
4820
+ ruleEnabled: "Enabled",
4821
+ ruleUp: "Move up",
4822
+ ruleDown: "Move down",
4823
+ ruleRemove: "Remove rule",
4824
+ tabListLabel: "Rules",
4825
+ tabPick: "Pick a rule",
4826
+ tabMore: "More rules",
4827
+ tabUnnamed: "Unnamed rule",
4828
+ tabLive: "In use",
4829
+ tabRotating: "Rotating",
4830
+ tabOff: "Disabled",
4831
+ ruleRemoveConfirm: "Click again to delete",
4832
+ ruleNoImage: "No image yet",
4833
+ ruleFromUrl: "From URL",
4834
+ ruleUrlPlaceholder: "Paste an image URL https://…",
4835
+ ruleUrlApply: "Apply",
4836
+ ruleUrlCancel: "Cancel",
4837
+ ruleUrlApplying: "Loading…",
4838
+ ruleUrlBadHttp: "Only http/https image URLs are supported",
4839
+ ruleUrlFail: "Could not fetch the image",
4840
+ ruleImageRemove: "Remove",
4841
+ ruleEmptyHint: "This rule has no image and no theme color of its own: it has nothing to show, so matching and the fallback both skip it",
4842
+ ruleColorOnlyHint: "No image in this rule yet: no wallpaper is shown, and the interface is painted from this theme color alone",
4843
+ ruleImagePendingHint: "This image has no content yet — it appears once it loads; you can also replace it",
4844
+ ruleColor: "Theme color",
4845
+ ruleColorNone: "Follow system theme",
4846
+ ruleColorNoneHint: "Without a color this rule follows the system theme: the host palette paints the interface while the wallpaper and every opacity slider keep working",
4847
+ ruleColorPick: "Custom color",
4848
+ ruleColorPickHint: "Extracts from this image when it can, otherwise uses a default color",
4849
+ ruleColorNoImageHint: "This rule has no image to take a color from: pick a color to tint the interface alone, or add an image first",
4850
+ ruleColorPendingImageHint: "This image has no content yet, so there is nothing to extract from: pick a color directly, or wait for it to load",
4851
+ ruleColorTargetImage: "Applies to the selected image:",
4852
+ ruleColorTargetRule: "Applies to this rule (it has no image yet)",
4853
+ ruleColorExtract: "Extract from image",
4854
+ ruleLayout: "Layout mode",
4855
+ ruleLayoutNoImage: "Add an image to give each one its own layout mode",
4856
+ ruleFramingEdit: "Edit position",
4857
+ ruleOpacity: "Background opacity",
4858
+ ruleBrightness: "Background brightness",
4859
+ ruleBlur: "Background blur",
4860
+ ruleBlurHint: "These three sliders all treat the wallpaper layer; the opacity on the Interface page affects interface surfaces — they are independent",
4861
+ rotTitle: "Image rotation",
4862
+ rotEnable: "Rotate these images",
4863
+ rotNeedTwo: "At least two images are needed to rotate",
4864
+ rotEvery: "Dwell time per image",
4865
+ rotCustom: "Custom seconds",
4866
+ rotSeconds: "s",
4867
+ rotOrder: "Order",
4868
+ rotOrderSeq: "In order",
4869
+ rotOrderShuffle: "Shuffle",
4870
+ rotOnSwitch: "Also step on a model switch",
4871
+ rotNext: "Next image",
4872
+ rotNextInactive: "Only the active rule can step the background",
4873
+ rotPos: "Image",
4874
+ rotShowing: "On screen",
4875
+ rotAddImage: "Add",
4876
+ rotReplaceImage: "Replace",
4877
+ rotDragHint: "drag to reorder",
4878
+ rotDblHint: "double-click to show",
4879
+ rotEvery10s: "10 s",
4880
+ rotEvery30s: "30 s",
4881
+ rotEvery1m: "1 min",
4882
+ rotEvery5m: "5 min",
4883
+ rotEvery30m: "30 min",
4884
+ bgModeFit: "Fit",
4885
+ bgModeFill: "Fill",
4886
+ bgModeStretch: "Stretch",
4887
+ bgModeTile: "Tile",
4888
+ bgModeCenter: "Center",
4889
+ editorTitle: "Background editor",
4890
+ editorHint: "Drag to move, scroll to zoom",
4891
+ editorCommit: "Confirm",
4892
+ editorCancel: "Cancel",
4893
+ editorReset: "Reset",
4894
+ extracting: "Extracting…",
4895
+ extractDone: "Image colors applied",
4896
+ extractFail: "No vivid color found in this image, try another",
4897
+ eyedropper: "Eyedropper",
4898
+ pickerTitle: "Pick from image",
4899
+ pickerHint: "Hover to preview, click to pick as the theme color",
4900
+ pickerClose: "Close",
4901
+ crashTitle: "Section crashed",
4902
+ crashDesc: "The panel hit an error. Reset below to recover.",
4903
+ crashReset: "Reset panel",
4904
+ exportTheme: "Export",
4905
+ importTheme: "Import",
4906
+ exportCardTitle: "Export profile",
4907
+ exportCardDesc: "Bundle every rule — color, layout, opacity, blur — and its image into dsh-background-by-model-theme.json",
4908
+ importCardTitle: "Import profile",
4909
+ importCardDesc: "Restore every rule and image from a previously exported JSON file (replaces the current set)",
4910
+ toastExportDone: "Export started",
4911
+ importDone: "Profile imported",
4912
+ importFail: "Import failed — invalid file",
4913
+ recCardTitle: "Recommended profile",
4914
+ recCardDesc: "Download the author's own full setup — rules, wallpapers and interface settings — and replace everything with it",
4915
+ recAdopt: "Use recommended profile",
4916
+ recBusy: "Downloading",
4917
+ recConfirmTitle: "Replace your entire profile?",
4918
+ recConfirmBody: "Every rule and wallpaper you have is deleted first, then this profile is written in its place. Deleted files cannot be recovered.",
4919
+ recStatRules: "rules",
4920
+ recStatImages: "wallpapers",
4921
+ recConfirmOk: "Delete and replace",
4922
+ recConfirmCancel: "Cancel",
4923
+ recDone: "Recommended profile applied",
4924
+ recFailNetwork: "Could not download the recommended profile — check your connection and retry",
4925
+ recFailNotProfile: "The asset host answered with something that is not a profile — try again later",
4926
+ recFailNewer: "This profile needs a newer plugin version. Nothing was changed.",
4927
+ recFailTooMany: "The recommended profile names more images than the plugin will fetch. Nothing was changed.",
4928
+ recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
4929
+ recFailImage: "Could not fetch this wallpaper — the asset host has no such file:",
4930
+ recFailApply: "Could not write the recommended profile — your own is untouched",
4931
+ footerTag: "Per-model wallpaper plugin",
4932
+ repoLink: "Open the repository on GitHub",
4933
+ holidayTitle: "Holiday backgrounds",
4934
+ holidayEnable: "Enable holiday backgrounds (Mid-Autumn day, Oct 1–7)",
4935
+ trTitle: "Switch effect",
4936
+ trEffect: "Transition",
4937
+ trEffectFade: "Cross-fade",
4938
+ trEffectNone: "Instant",
4939
+ trEffectZoom: "Zoom in",
4940
+ trEffectSlide: "Slide in",
4941
+ trEasing: "Easing",
4942
+ trEasingEase: "Standard",
4943
+ trEasingLinear: "Linear",
4944
+ trEasingOut: "Ease out",
4945
+ trEasingInOut: "Ease in-out",
4946
+ trDuration: "Duration",
4947
+ trPreview: "Preview",
4948
+ trHint: "The effect applies to every change: model switch, rotation, manual next, holidays. “Instant” and 0 s are the same hard cut",
4949
+ trReducedHint: "Your system asks for reduced motion, so a real switch will not animate"
4950
+ };
4951
+ //#endregion
4952
+ //#region src/preset.ts
4953
+ /**
4954
+ * JSON with object keys in a fixed (sorted) order — the same string for the same
4955
+ * value, whatever order the object that carried it happened to build its keys in.
4956
+ *
4957
+ * `JSON.stringify` alone is not an identity for a value that came off the wire and
4958
+ * through a sanitizer: key order is an accident of how the object was assembled,
4959
+ * and a resume that missed on it would silently re-download everything (the safe
4960
+ * direction) while a resume that MATCHED on a value it should not have would be
4961
+ * the unsafe one — so this is deliberately total as well: anything JSON cannot
4962
+ * carry (a function, a `Symbol`, `undefined`) serializes as `null` and an
4963
+ * `undefined` property is skipped, exactly as `JSON.stringify` treats it.
4964
+ */
4965
+ function canonical(value) {
4966
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
4967
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
4968
+ return JSON.stringify(value) ?? "null";
4969
+ }
4970
+ /**
4971
+ * The identity of one revision of the profile: the manifest's shape version plus
4972
+ * the config, in canonical form.
4973
+ *
4974
+ * The profile is served at ONE version of the assets repository, but a version
4975
+ * reference is only as fixed as the tag behind it: re-point the tag and every
4976
+ * edge that has not been purged keeps answering with the previous bytes of the
4977
+ * same URL. So "the profile" is still not guaranteed to be one fixed thing, and
4978
+ * the bytes a previous attempt is holding are only known to belong to what is
4979
+ * being installed when that config comes back the same. Order-insensitivity is
4980
+ * what lets this be a comparison of VALUES rather than of spellings —
4981
+ * `normalizeConfig` (./schema) rebuilds the config it reads, so pinning the key
4982
+ * to the exact key order the wire happened to use would make the hold useless
4983
+ * the moment anything upstream normalized differently.
4984
+ */
4985
+ function presetKey(version, config) {
4986
+ return `${version}\n${canonical(config)}`;
4987
+ }
4988
+ /**
4989
+ * The bytes of a failed attempt that may be reused for the revision `key` names.
4990
+ *
4991
+ * A fresh object every time, never the held record itself: the caller fills it in
4992
+ * as the rest of the profile arrives, and handing out the same mutable record
4993
+ * would let a re-fetch of one file edit the state the NEXT attempt resumes from.
4994
+ * An empty answer — nothing held, or held for a different revision — is the honest
4995
+ * one either way: the pool then asks for every slot the config names.
4996
+ */
4997
+ function presetResume(held, key) {
4998
+ if (held === null || held.key !== key) return {};
4999
+ return { ...held.images };
5000
+ }
5001
+ /**
5002
+ * The slots of `slots` that `images` does not hold yet, in the config's own order.
5003
+ *
5004
+ * Order matters in the only way it can here: it decides which file a worker takes
5005
+ * first, and the files are not the same size. Only names the CONFIG asked for are
5006
+ * ever returned, so a held picture nothing points at — a slot the author removed
5007
+ * from the profile — can never be fetched again or shipped.
5008
+ */
5009
+ function presetMissing(slots, images) {
5010
+ return slots.filter((slot) => images[slot] === void 0);
5011
+ }
5012
+ //#endregion
5013
+ //#region src/client/repaint.ts
5014
+ /**
5015
+ * The repaint decision, as a pure function.
5016
+ *
5017
+ * Every edit to a rule ends with the same question: does the interface have to be
5018
+ * repainted, or is refreshing the panel's readout enough? Repainting is the
5019
+ * expensive half (the theme skin is re-registered, the wallpaper layer is
5020
+ * rewritten, the host's theme is switched), so it must not be done for a rule
5021
+ * that is not the one on screen — but it MUST be done the moment the edit
5022
+ * changes what should be on screen.
5023
+ *
5024
+ * The naive test — "is the edited rule the active one?" — is wrong in exactly the
5025
+ * case that kept being reported as "更新的不太及时,只有我动调色盘或者切模型的时候
5026
+ * 才出现": an edit that CREATES the winner. Uploading a rule's first picture, or
5027
+ * an auto-extracted color landing on a rule that had none, makes that rule
5028
+ * paintable *through that very edit* (see `ruleCanPaint`), so the rule is not the
5029
+ * active one at the moment of the check; the wallpaper and the skin then only
5030
+ * showed up later, when an unrelated drag or model switch happened to re-run the
5031
+ * resolution.
5032
+ *
5033
+ * Sampling the winner on BOTH sides of the edit is what makes such a change land
5034
+ * at once, and `editedId === winnerAfter` additionally covers the edit that does
5035
+ * not move the resolution but changes the look of the rule that IS painting (a
5036
+ * color, a slider, a framing).
5037
+ *
5038
+ * `null` is "nothing of ours is painting" (the interface falls back to the host's
5039
+ * own look), which is a winner like any other id.
5040
+ *
5041
+ * Kept free of imports on purpose: `scripts/repaint-check.ts` exercises it on
5042
+ * plain node, the same way `rotation.ts` is exercised. A rule's `color`/`match`
5043
+ * are the inputs that make the resolution move, which is why they are the fields
5044
+ * whose write paths all end here.
5045
+ */
5046
+ function shouldRepaint(editedId, winnerBefore, winnerAfter) {
5047
+ return winnerAfter === editedId || winnerAfter !== winnerBefore;
5048
+ }
5049
+ //#endregion
4445
5050
  //#region src/client/components/ErrorBoundary.tsx
4446
5051
  /**
4447
5052
  * Catches render errors from the theme section subtree (wheel, sliders, editor)
@@ -7255,10 +7860,11 @@ window.__ModuleLoader__.load({
7255
7860
  /**
7256
7861
  * dsh-background-by-model — browser half entry.
7257
7862
  *
7258
- * Wires the plugin lifecycle: rule resolution against the current model,
7259
- * wallpaper layer, theme skin, viewport watch, i18n, settings-section injection,
7260
- * boot restore and watchdogs. The heavy lifting lives in the sibling modules
7261
- * (state / modelbg / rpc / wallpaper / components).
7863
+ * Wires the plugin lifecycle and nothing else: rule resolution, the repaint gate,
7864
+ * the settings section's actions and the teardown effects. The machinery lives in
7865
+ * the sibling modules — appearance (skin, palette, restores), appearance-checks and
7866
+ * watchdogs (recurring checks on one schedule), rotation-timer, boot, model-state,
7867
+ * auto-extract, theme-file, nav-icon, wallpaper, state, modelbg, rpc.
7262
7868
  */
7263
7869
  const name = "dsh-background-by-model";
7264
7870
  const inject = [
@@ -7267,7 +7873,6 @@ window.__ModuleLoader__.load({
7267
7873
  "theme",
7268
7874
  "connection"
7269
7875
  ];
7270
- const CUSTOM_ID = "custom-color";
7271
7876
  /**
7272
7877
  * Ceiling on what one recommended profile may add up to, across every wallpaper.
7273
7878
  *
@@ -7297,76 +7902,13 @@ window.__ModuleLoader__.load({
7297
7902
  const PRESET_CONCURRENCY = 4;
7298
7903
  function apply(ctx) {
7299
7904
  initRpc((endpoint, payload) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload).then((res) => res));
7300
- let customDispose = null;
7301
- let skinTimer = null;
7302
- /** Colour scheme of the skin this plugin has registered, or null when none is. */
7303
- let skinScheme = null;
7304
- /** Scheme the host currently holds for our theme id; null when it is not there. */
7305
- const registeredScheme = () => {
7306
- const theme = ctx.theme.getTheme().themes.find((t) => t.id === CUSTOM_ID);
7307
- return theme === void 0 ? null : theme.colorScheme;
7308
- };
7309
- const registerCustom = (h, s, l) => {
7310
- const { colorScheme, tokens } = genTokens(h, s, l);
7311
- if (skinScheme === colorScheme && registeredScheme() !== null) {
7312
- if (ctx.theme.getTheme().preference !== CUSTOM_ID) ctx.theme.setTheme(CUSTOM_ID);
7313
- return;
7314
- }
7315
- customDispose?.();
7316
- try {
7317
- customDispose = ctx.theme.register({
7318
- id: CUSTOM_ID,
7319
- colorScheme,
7320
- tokens
7321
- });
7322
- } catch {
7323
- customDispose = null;
7324
- }
7325
- skinScheme = registeredScheme() ?? colorScheme;
7326
- if (ctx.theme.getTheme().themes.some((t) => t.id === CUSTOM_ID)) ctx.theme.setTheme(CUSTOM_ID);
7327
- };
7328
- /** A rule without a saved color means "follow the system theme". */
7329
- const dropCustom = () => {
7330
- customDispose?.();
7331
- customDispose = null;
7332
- skinScheme = null;
7333
- try {
7334
- if (ctx.theme.getTheme().preference === CUSTOM_ID) ctx.theme.setTheme("system");
7335
- } catch {}
7336
- };
7337
- /**
7338
- * Re-emit the interface palette for whatever is painted RIGHT NOW.
7339
- *
7340
- * Called from two places, and the second one is why this is a function: a
7341
- * model switch (`applyActive`) and a ROTATION STEP (`paintImage`). A theme color
7342
- * belongs to an image since 0.7.1, so stepping to the next picture changes the
7343
- * palette as much as switching models does — and a step that only repainted the
7344
- * wallpaper would leave the previous picture's color on the interface until
7345
- * something else happened to re-run an apply (dragging the wheel, switching
7346
- * models), which is precisely the "it only updates when I touch the color wheel"
7347
- * report this plugin already had once.
7348
- *
7349
- * The debounce is for the OTHER caller: a color drag runs a full apply dozens of
7350
- * times per second, and the host-visible skin switch (dispose + register +
7351
- * activate) must not happen on every pointer move. A rotation step pays the same
7352
- * 60 ms, which is invisible next to a dwell time of 5 s or more.
7353
- */
7354
- const applyPalette = () => {
7355
- const color = activeColor();
7356
- if (skinTimer !== null) window.clearTimeout(skinTimer);
7357
- if (color === null) {
7358
- skinTimer = null;
7359
- dropCustom();
7360
- } else skinTimer = window.setTimeout(() => {
7361
- skinTimer = null;
7362
- registerCustom(color[0], color[1], color[2]);
7363
- }, 60);
7364
- syncHostScheme();
7365
- };
7366
- ctx.effect(() => () => {
7367
- if (skinTimer !== null) window.clearTimeout(skinTimer);
7368
- customDispose?.();
7369
- }, "dsh-background-by-model: skin dispose");
7905
+ const appearance = createAppearance({
7906
+ ctx,
7907
+ refresh: () => applyWp()
7908
+ });
7909
+ ctx.effect(() => () => appearance.dispose(), "dsh-background-by-model: skin dispose");
7910
+ const applyPalette = () => appearance.applyPalette();
7911
+ const syncHostScheme = () => appearance.syncHostScheme();
7370
7912
  const styleEl = document.createElement("style");
7371
7913
  styleEl.dataset.plugin = "dsh-background-by-model";
7372
7914
  markOwnSheet(styleEl);
@@ -7377,10 +7919,8 @@ window.__ModuleLoader__.load({
7377
7919
  }, "dsh-background-by-model: gradient");
7378
7920
  let rev = 0;
7379
7921
  let rulesRev = 0;
7380
- let modelText = "";
7381
- let modelSource = "default";
7382
- let modelNote = "waiting";
7383
- let modelKey = "\0";
7922
+ /** The model the rules are matched against, and whether it actually moved. */
7923
+ const model = createModelTracker();
7384
7924
  const store = defineStore({
7385
7925
  init: () => ({
7386
7926
  url: null,
@@ -7431,21 +7971,8 @@ window.__ModuleLoader__.load({
7431
7971
  const sync = () => {
7432
7972
  rev++;
7433
7973
  const rot = rotationFacts();
7434
- bound?.sync(rWp(), rev, rulesRev, modelLabel !== "" ? modelLabel : modelText, modelSource, modelNote, activeRuleId, activeMatched, rot.index, rot.total, rot.rotating, hostIsLegacy());
7435
- };
7436
- /**
7437
- * Publish the scheme the host's own preference resolves to. The theme service
7438
- * is the only place that knows it (a custom active theme included); the
7439
- * wallpaper layer needs it to hand `data-ds-dark-theme` back in the host's own
7440
- * form while a color-less rule follows that theme, instead of leaving the flag
7441
- * off — which pins the host to its LIGHT palette on a dark system.
7442
- */
7443
- const syncHostScheme = () => {
7444
- try {
7445
- setHostScheme(ctx.theme.getTheme().active.colorScheme === "dark");
7446
- } catch {
7447
- setHostScheme(null);
7448
- }
7974
+ const m = model.state;
7975
+ bound?.sync(rWp(), rev, rulesRev, m.label !== "" ? m.label : m.text, m.source, m.note, activeRuleId, activeMatched, rot.index, rot.total, rot.rotating, hostIsLegacy());
7449
7976
  };
7450
7977
  /**
7451
7978
  * Which rule wins for the current model RIGHT NOW, and whether it was a
@@ -7464,7 +7991,7 @@ window.__ModuleLoader__.load({
7464
7991
  return holiday !== null ? {
7465
7992
  rule: holiday,
7466
7993
  matched: false
7467
- } : matchRule(cfg.rules, modelText);
7994
+ } : matchRule(cfg.rules, model.state.text);
7468
7995
  };
7469
7996
  /** Id of the rule that would paint right now; `null` = the host's own look. */
7470
7997
  const winnerId = () => resolveRule().rule?.id ?? null;
@@ -7501,9 +8028,6 @@ window.__ModuleLoader__.load({
7501
8028
  applyWp();
7502
8029
  sync();
7503
8030
  };
7504
- let rotTimer = null;
7505
- /** Identity of the schedule currently armed; a change means "re-arm". */
7506
- let rotSig = "";
7507
8031
  /** Warm one image so a later switch fades toward bytes that are already here. */
7508
8032
  const warmImage = (rule, idx) => {
7509
8033
  const slot = rule.images[idx]?.slot;
@@ -7524,64 +8048,18 @@ window.__ModuleLoader__.load({
7524
8048
  sync();
7525
8049
  });
7526
8050
  };
7527
- const rotateTick = () => {
7528
- const rule = activeRule();
7529
- if (rule === null) return;
7530
- const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
7531
- if (next === null) {
7532
- scheduleRotation(true);
7533
- return;
7534
- }
7535
- setRotIndex(next);
7536
- paintImage(rule, next);
7537
- const after = nextIndex(rule.images.length, rule.rotate.order, next);
7538
- if (after !== null) warmImage(rule, after);
7539
- };
7540
- /**
7541
- * Arm (or re-arm) the rotation for the active rule.
7542
- *
7543
- * Idempotent by SIGNATURE rather than by "already armed": a slider drag on the
7544
- * active rule re-runs the whole apply dozens of times per second, and restarting
7545
- * the interval on each of those would mean the dwell timer never elapses while
7546
- * the user is dragging (and a 10 s rotation would never advance). The signature
7547
- * carries exactly the inputs the schedule depends on, so a real change — another
7548
- * rule, another interval, the tab becoming visible again — re-arms while a
7549
- * repaint does not.
7550
- */
7551
- const scheduleRotation = (force = false) => {
7552
- const rule = activeRule();
7553
- const sig = rule === null ? "" : [
7554
- rule.id,
7555
- rule.rotate.enabled,
7556
- rule.rotate.intervalMs,
7557
- rule.rotate.order,
7558
- rule.images.length,
7559
- document.hidden
7560
- ].join("|");
7561
- if (!force && sig === rotSig) return;
7562
- rotSig = sig;
7563
- if (rotTimer !== null) {
7564
- window.clearInterval(rotTimer);
7565
- rotTimer = null;
7566
- }
7567
- if (rule === null || document.hidden) return;
7568
- if (!isRotating(rule.images.length, rule.rotate.enabled, rule.rotate.intervalMs)) return;
7569
- const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
7570
- if (next !== null) warmImage(rule, next);
7571
- rotTimer = window.setInterval(rotateTick, rule.rotate.intervalMs);
7572
- };
7573
- ctx.effect(() => () => {
7574
- if (rotTimer !== null) {
7575
- window.clearInterval(rotTimer);
7576
- rotTimer = null;
7577
- }
7578
- }, "dsh-background-by-model: rotation timer");
7579
- const onVisibility = () => {
7580
- scheduleRotation();
7581
- sync();
7582
- };
8051
+ const rotation = createRotation({
8052
+ paintImage,
8053
+ warmImage,
8054
+ sync
8055
+ });
8056
+ const scheduleRotation = (force = false) => rotation.schedule(force);
8057
+ const onVisibility = () => rotation.onVisibility();
7583
8058
  document.addEventListener("visibilitychange", onVisibility);
7584
- ctx.effect(() => () => document.removeEventListener("visibilitychange", onVisibility), "dsh-background-by-model: rotation visibility");
8059
+ ctx.effect(() => () => {
8060
+ document.removeEventListener("visibilitychange", onVisibility);
8061
+ rotation.dispose();
8062
+ }, "dsh-background-by-model: rotation");
7585
8063
  /**
7586
8064
  * The holiday the background must follow right now, or null.
7587
8065
  *
@@ -7595,57 +8073,27 @@ window.__ModuleLoader__.load({
7595
8073
  sync();
7596
8074
  watchParts();
7597
8075
  const offModel = watchModel(ctx, (text, label, source, note) => {
7598
- const key = `${text}\u0001${source}\u0001${note}`;
7599
- if (key === modelKey) return;
7600
- const textChanged = text !== modelText;
7601
- modelKey = key;
7602
- modelText = text;
7603
- modelSource = source;
7604
- modelNote = note;
7605
- setModelLabel(label !== "" ? label : text);
7606
- if (textChanged) applyActive(true);
8076
+ const change = model.update(text, label, source, note);
8077
+ if (!change.changed) return;
8078
+ if (change.textChanged) applyActive(true);
7607
8079
  else sync();
7608
8080
  });
7609
8081
  ctx.effect(() => () => offModel(), "dsh-background-by-model: model watch");
7610
8082
  (async () => {
7611
- if (modelText !== "") return;
8083
+ if (model.state.text !== "") return;
7612
8084
  const fallback = await readDefaultModel();
7613
- if (fallback !== null && modelText === "") {
7614
- modelKey = `${fallback}\u0001default\u0001fallback`;
7615
- modelText = fallback;
7616
- modelSource = "default";
7617
- modelNote = "fallback";
7618
- setModelLabel(fallback);
8085
+ if (fallback !== null && model.state.text === "") {
8086
+ model.adopt(fallback, "default", "fallback");
7619
8087
  applyActive();
7620
8088
  }
7621
8089
  })();
7622
- let holidayKey = activeHoliday() ?? "";
7623
- const holidayTimer = window.setInterval(() => {
7624
- const next = activeHoliday() ?? "";
7625
- if (next === holidayKey) return;
7626
- holidayKey = next;
7627
- const slot = todayHolidaySlot();
7628
- if (slot === null) applyActive();
7629
- else loadSlot(slot).then(() => applyActive());
7630
- }, 3e4);
7631
- ctx.effect(() => () => {
7632
- window.clearInterval(holidayTimer);
7633
- }, "dsh-background-by-model: holiday rollover");
7634
- /** Read one slot's bytes into the cache unless they are already there. */
7635
- const loadSlot = async (slot) => {
7636
- if (imageOf(slot) !== null) return;
7637
- const url = await readImage(slot);
7638
- if (url !== null) setImage(slot, url);
7639
- };
7640
8090
  /**
7641
8091
  * The slot of the holiday that could paint TODAY, or null.
7642
8092
  *
7643
- * Gated on the CALENDAR, not merely on the switch — and this gate is worth more
7644
- * than it used to be. The art does not ship in the package any more: the first
7645
- * read on the day sends the node half to the network, so a profile that is
7646
- * simply not on a holiday must not merely avoid ~730 KB of base64 over this
7647
- * channel, it must avoid starting a download at all. On every other day this
7648
- * returns null and nothing is fetched, cached or paid for.
8093
+ * Gated on the CALENDAR, not merely on the switch: the art does not ship in the
8094
+ * package any more, so the first read on the day sends the node half to the
8095
+ * network. On every other day this returns null and nothing is fetched, cached
8096
+ * or paid for.
7649
8097
  */
7650
8098
  const todayHolidaySlot = () => {
7651
8099
  if (!cfg.holidays.enabled) return null;
@@ -7655,57 +8103,38 @@ window.__ModuleLoader__.load({
7655
8103
  if (item === null || !item.enabled) return null;
7656
8104
  return ruleSlots(item)[0] ?? null;
7657
8105
  };
7658
- /**
7659
- * The slots boot hydrates, in no particular order: the FIRST image of every
7660
- * rule, today's holiday, and the image the current model paints.
7661
- *
7662
- * That first-image set is exactly what this plugin loaded per rule before
7663
- * multi-image existed — a rule card draws its first image as its thumbnail, so
7664
- * the settings page looks the same as it always did. Everything past the first
7665
- * image of a rule is read ON DEMAND: by the rotation (which warms the image it
7666
- * is about to need as soon as its schedule is armed) and by the settings page
7667
- * when a card is expanded. Hydrating every image of every rule here would mean
7668
- * twenty full-size data URLs over this channel before the first frame on a
7669
- * five-rule setup with four pictures each, which is the one cost this feature
7670
- * must not pay.
7671
- *
7672
- * The slot list the store knows about (`readPersisted().slots`) is deliberately
7673
- * NOT hydrated any more: an orphaned slot that no rule references has no card to
7674
- * appear on, so pulling its bytes over the channel at boot is pure waste. It
7675
- * stays on disk — and is still reported by the node half — until a rule
7676
- * references it again or it is deleted with its rule.
7677
- */
7678
- const bootSlots = () => {
7679
- const holiday = todayHolidaySlot();
7680
- const firsts = cfg.rules.map((r) => r.images[0]?.slot).filter((slot) => slot !== void 0);
7681
- const current = prioritySlot();
7682
- return Array.from(/* @__PURE__ */ new Set([
7683
- ...firsts,
7684
- ...holiday === null ? [] : [holiday],
7685
- ...current === null ? [] : [current]
7686
- ]));
8106
+ const activeHolidayId = () => activeHoliday();
8107
+ /** Load one slot and repaint once its bytes are here (the art download). */
8108
+ const loadAndRepaint = async (slot) => {
8109
+ await loadSlot(slot);
8110
+ applyActive();
7687
8111
  };
7688
- /**
7689
- * The slot that paints before anything else: the holiday in force when there
7690
- * is one, otherwise the FIRST image of the current model's rule. A holiday
7691
- * outranks the rule here for the same reason it outranks it when painting.
7692
- *
7693
- * Always the first image (never the one `advanceOnSwitch` would step to): boot
7694
- * paints without an advance, and hydrating an image the first frame is not going
7695
- * to use would leave the frame blank until the real one arrived.
7696
- */
7697
- const prioritySlot = () => todayHolidaySlot() ?? matchRule(cfg.rules, modelText).rule?.images[0]?.slot ?? null;
8112
+ const schedule = createSchedule();
8113
+ ctx.effect(() => () => schedule.dispose(), "dsh-background-by-model: schedule");
8114
+ ctx.effect(appearance.armRestores(), "dsh-background-by-model: boot restore");
8115
+ const checkHost = {
8116
+ restore: appearance.watch,
8117
+ activeColor,
8118
+ refreshHostPalette: () => {
8119
+ syncHostScheme();
8120
+ applyWp();
8121
+ },
8122
+ refresh: () => applyWp(),
8123
+ applyActive,
8124
+ activeHolidayId,
8125
+ todayHolidaySlot,
8126
+ loadAndRepaint
8127
+ };
8128
+ schedule.add(themeWatchdog(checkHost));
8129
+ schedule.add(holidayRollover(checkHost));
7698
8130
  (async () => {
7699
8131
  const persisted = await loadPersisted();
7700
8132
  if (persisted !== null) {
7701
8133
  adoptConfig(persisted.config);
7702
- const first = prioritySlot();
7703
- const slots = bootSlots();
7704
- const order = first === null ? slots : [first, ...slots.filter((s) => s !== first)];
7705
- for (const slot of order) {
7706
- await loadSlot(slot);
7707
- if (slot === first) applyActive();
7708
- }
8134
+ await hydrateBoot({
8135
+ modelText: model.state.text,
8136
+ holidaySlot: todayHolidaySlot()
8137
+ }, applyActive);
7709
8138
  }
7710
8139
  applyActive();
7711
8140
  })();
@@ -7714,10 +8143,7 @@ window.__ModuleLoader__.load({
7714
8143
  }, "dsh-background-by-model: wp cleanup");
7715
8144
  ctx.effect(() => ctx.on("theme/change", () => {
7716
8145
  syncHostScheme();
7717
- if (activeColor() !== null) {
7718
- const snapshot = ctx.theme.getTheme();
7719
- if (snapshot.preference !== CUSTOM_ID && snapshot.themes.some((t) => t.id === CUSTOM_ID)) ctx.theme.setTheme(CUSTOM_ID);
7720
- }
8146
+ appearance.reassert();
7721
8147
  applyWp();
7722
8148
  }), "dsh-background-by-model: theme change");
7723
8149
  let frame = 0;
@@ -7754,56 +8180,22 @@ window.__ModuleLoader__.load({
7754
8180
  * their own palette since 0.7.1 and the one the user just added is the one they
7755
8181
  * are looking at. Omitted (or unknown) falls back to the rule's first image.
7756
8182
  *
7757
- * The question is asked of the IMAGE, not of the rule: a rule whose pictures
7758
- * were all cleared and then re-filled must theme each new picture, and a rule
7759
- * with a color of its own still has no say over the colors of its pictures.
7760
- *
7761
8183
  * The decode is asynchronous, so the "still empty?" question is asked AGAIN on
7762
8184
  * arrival: a color chosen while it ran must win over the one being computed.
8185
+ * Sequential over a batch on purpose: a batch is a handful of full-size photos,
8186
+ * and ten parallel decodes buy nothing while landing the colors in list order
8187
+ * does (the card fills in from its first picture).
7763
8188
  */
7764
- const maybeAutoExtract = async (id, slot) => {
7765
- if (!cfg.autoExtract) return;
7766
- /** The entry this extraction is about, re-resolved on every read. */
7767
- const target = (rule) => {
7768
- if (rule === null) return void 0;
7769
- return (slot === void 0 ? void 0 : rule.images.find((i) => i.slot === slot)) ?? rule.images[0];
7770
- };
7771
- const image = target(ruleById(id));
7772
- if (image === void 0 || image.color !== null) return;
7773
- const url = displayImageOf(image.slot);
7774
- if (url === null) return;
7775
- let hsl = null;
7776
- try {
7777
- hsl = await extractWallpaperColor(url, image.bgState);
7778
- } catch {
7779
- hsl = null;
8189
+ const extract = createAutoExtract({
8190
+ paintable: (slot) => displayImageOf(slot),
8191
+ repaint: (id, winner) => repaintIfMoved(id, winner),
8192
+ winnerId,
8193
+ bumpRulesRev: () => {
8194
+ rulesRev++;
7780
8195
  }
7781
- if (hsl === null) return;
7782
- const current = target(ruleById(id));
7783
- if (current === void 0 || current.color !== null) return;
7784
- const winner = winnerId();
7785
- current.color = hsl;
7786
- rulesRev++;
7787
- saveConfig();
7788
- repaintIfMoved(id, winner);
7789
- };
7790
- /**
7791
- * The batch form, for one upload that carried several pictures.
7792
- *
7793
- * EVERY picture that arrived is themed from its own bytes, the first ones
7794
- * included. The single-slot reading of an upload ("the last image added is the
7795
- * one being looked at") is true of the CARD's selection and false of the
7796
- * palette: since a theme color belongs to an image, a batch that themed only
7797
- * its last file left the rest of a fresh rotation on the system theme, with
7798
- * nothing in the interface to say why.
7799
- *
7800
- * Sequential on purpose: a batch is a handful of full-size photos, and ten
7801
- * parallel decodes buy nothing the user can see, while landing the colors in
7802
- * list order does (the card fills in from its first picture).
7803
- */
7804
- const maybeAutoExtractEach = async (id, slots) => {
7805
- for (const slot of slots) await maybeAutoExtract(id, slot);
7806
- };
8196
+ });
8197
+ const maybeAutoExtract = (id, slot) => extract.one(id, slot);
8198
+ const maybeAutoExtractEach = (id, slots) => extract.each(id, slots);
7807
8199
  const activeLang = () => {
7808
8200
  try {
7809
8201
  const snapshot = ctx.locale.getSnapshot();
@@ -7815,74 +8207,8 @@ window.__ModuleLoader__.load({
7815
8207
  /** The model-resolution facts the host self-check reports, read on demand. */
7816
8208
  const readModelFacts = () => {
7817
8209
  const rule = activeRuleId === null ? null : ruleById(activeRuleId);
7818
- return {
7819
- text: modelText,
7820
- source: modelSource,
7821
- note: modelNote,
7822
- rule: rule === null ? "" : rule.match !== "" ? rule.match : rule.id,
7823
- matched: activeMatched
7824
- };
7825
- };
7826
- /**
7827
- * Write a whole theme file into the store: its rules, its wallpapers, nothing
7828
- * else. The ONE door both imports come through — the file the user picked and
7829
- * the profile downloaded from the project's asset host — so a downloaded
7830
- * profile cannot arrive by a laxer route than a hand-picked file does.
7831
- *
7832
- * `wipeFirst` is the entire difference between them, and it belongs to the
7833
- * recommended profile alone. `nextSlot` hands slots out as `m1`, `m2`, … — a
7834
- * counter, not a random token — so a downloaded profile and the store it lands
7835
- * in collide on those names as a matter of course rather than by coincidence.
7836
- * Emptying the store first is what makes "replace everything with this"
7837
- * literally true, instead of overwriting whichever of the user's own pictures
7838
- * happened to share a name. The file path keeps the older behaviour: it
7839
- * replaces the rules but pre-emptively deletes nothing.
7840
- *
7841
- * The wipe happens HERE rather than before the download, and that ordering is
7842
- * the one thing worth being pedantic about: an unreachable CDN must not cost
7843
- * the user their configuration on the way to a profile that never arrived. By
7844
- * the time this runs the bytes are already in hand, so the caller has nothing
7845
- * left to fail at.
7846
- */
7847
- const applyThemeFile = async (data, wipeFirst) => {
7848
- if (typeof data.version === "number" && data.version > 9) return false;
7849
- if (typeof data.config !== "object" || data.config === null) return false;
7850
- if (wipeFirst) {
7851
- await resetStore();
7852
- resetConfig();
7853
- }
7854
- adoptConfig(data.config);
7855
- const incoming = data.images ?? {};
7856
- const keep = /* @__PURE__ */ new Set();
7857
- for (const slot of cfg.rules.flatMap(ruleSlots)) {
7858
- keep.add(slot);
7859
- const raw = incoming[slot];
7860
- if (typeof raw === "string" && /^data:image\//.test(raw)) {
7861
- setImage(slot, raw);
7862
- writeImage(slot, raw);
7863
- } else {
7864
- setImage(slot, null);
7865
- deleteImage(slot);
7866
- }
7867
- }
7868
- for (const slot of Object.keys(incoming)) if (!keep.has(slot) && /^[A-Za-z0-9_-]{1,32}$/.test(slot)) deleteImage(slot);
7869
- persistConfig();
7870
- applyActive();
7871
- return true;
8210
+ return model.facts(rule === null ? "" : rule.match !== "" ? rule.match : rule.id, activeMatched);
7872
8211
  };
7873
- /**
7874
- * What the last FAILED profile attempt already had in hand.
7875
- *
7876
- * Only a failure leaves anything here — a download that completes is applied or
7877
- * thrown away whole, and holding megabytes of base64 for a re-click that a
7878
- * second re-download would settle is not a trade this makes. What it buys is the
7879
- * one case that had no answer at all before: a profile whose sixth of seven files
7880
- * failed now costs ONE file on the next press instead of seven.
7881
- *
7882
- * Held across section re-injections on purpose (it is declared here, in `apply`,
7883
- * not inside `sectionInject`): the retry is usually the next click of the same
7884
- * button, but the panel can be closed and reopened between them.
7885
- */
7886
8212
  let presetHeld = null;
7887
8213
  const sectionInject = (actions) => {
7888
8214
  bound = actions;
@@ -8010,16 +8336,11 @@ window.__ModuleLoader__.load({
8010
8336
  repaintIfMoved(id, winner);
8011
8337
  },
8012
8338
  /**
8013
- * Store ONE image's layout mode. Addressed by slot like `setImageFraming`,
8014
- * for the same reason: how a picture meets the viewport is a property of the
8015
- * picture, and 适应 on a tall screenshot says nothing about the landscape
8016
- * photo beside it in the rotation.
8339
+ * Store ONE image's layout mode. Addressed by slot like `setImageFraming`:
8340
+ * how a picture meets the viewport is a property of the picture.
8017
8341
  *
8018
- * A mode is a placement, not a paint: it changes nothing about WHICH image is
8019
- * on screen, only how it is laid out. So the patch is written, sanitized, and
8020
- * the layer repainted through the same `repaintIfMoved` the framing commit
8021
- * uses — that call repaints the current image whether or not the winner
8022
- * moved, which is what makes the chips feel live.
8342
+ * A mode is a placement, not a paint — nothing changes about WHICH image is
8343
+ * on screen — so it ends in the same gate the framing commit uses.
8023
8344
  */
8024
8345
  setImageMode: (id, slot, mode) => {
8025
8346
  const rule = ruleById(id);
@@ -8098,12 +8419,9 @@ window.__ModuleLoader__.load({
8098
8419
  return res;
8099
8420
  },
8100
8421
  /**
8101
- * Store ONE image's theme color (null = that image follows the system
8102
- * theme). Addressed by slot exactly like `setImageFraming`, because the
8103
- * color belongs to the image: editing image 3's color must not rewrite
8104
- * image 1's, and it must not touch the RULE's color either — that one is
8105
- * what the rule paints once its last picture is gone, and it is edited only
8106
- * while there is no image to edit (the panel's own branch).
8422
+ * Store ONE image's theme colour (null = that image follows the system theme).
8423
+ * Addressed by slot like `setImageFraming`: editing one picture's colour must
8424
+ * touch neither another picture's nor the RULE's own.
8107
8425
  */
8108
8426
  setImageColor: (id, slot, color) => {
8109
8427
  const rule = ruleById(id);
@@ -8156,14 +8474,10 @@ window.__ModuleLoader__.load({
8156
8474
  sync();
8157
8475
  },
8158
8476
  /**
8159
- * Patch the GLOBAL wallpaper-switch transition (effect, easing, where the
8160
- * duration comes from).
8477
+ * Patch the GLOBAL wallpaper-switch transition.
8161
8478
  *
8162
- * Deliberately no `applyWp()`: this setting decides what the NEXT switch
8163
- * looks like and nothing on screen changes right now, so re-running the
8164
- * whole apply (token rewrite, every interface part) for something that
8165
- * paints nothing would be the expensive way to say "saved". The Config
8166
- * page's own preview plays the effect instead.
8479
+ * Deliberately no `applyWp()`: this decides what the NEXT switch looks like
8480
+ * and paints nothing now. The page's own preview plays the effect.
8167
8481
  */
8168
8482
  setTransition: (patch) => {
8169
8483
  cfg.transition = normalizeTransition({
@@ -8181,61 +8495,33 @@ window.__ModuleLoader__.load({
8181
8495
  applyActive();
8182
8496
  },
8183
8497
  exportTheme: () => {
8184
- const images = {};
8185
- const slots = cfg.rules.flatMap(ruleSlots);
8186
- for (const slot of slots) {
8187
- const url = imageOf(slot);
8188
- if (url !== null) images[slot] = url;
8189
- }
8190
- const payload = {
8191
- version: 9,
8192
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8193
- config: cfg,
8194
- images
8195
- };
8196
- const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
8197
- const url = URL.createObjectURL(blob);
8198
- const a = document.createElement("a");
8199
- a.href = url;
8200
- a.download = "dsh-background-by-model-theme.json";
8201
- a.click();
8202
- URL.revokeObjectURL(url);
8498
+ saveThemeFile(buildThemeFile());
8203
8499
  },
8204
8500
  importTheme: async (file) => {
8205
8501
  try {
8206
8502
  const data = JSON.parse(await file.text());
8207
8503
  if (!data || typeof data !== "object") return false;
8208
- return await applyThemeFile(data, false);
8504
+ return await applyThemeFile({ applyActive }, data, false);
8209
8505
  } catch {
8210
8506
  return false;
8211
8507
  }
8212
8508
  },
8213
8509
  /**
8214
- * Fetch the project's recommended profile. Downloads only.
8215
- *
8216
- * Split from applying it on purpose: the page has to ask before anything is
8217
- * destroyed, and a confirmation can only describe what is actually in hand
8218
- * — "7 rules, 7 wallpapers, 2.9 MB" — which means the download comes first
8219
- * and the question second.
8510
+ * Fetch the project's recommended profile. Downloads only — applying it is
8511
+ * separate, because the page has to ask before anything is destroyed and the
8512
+ * question should describe what is actually in hand.
8220
8513
  *
8221
- * The profile is a DIRECTORY now, so this is a pool rather than one call:
8222
- * the manifest and config say which slots exist, and each wallpaper is one
8223
- * request, up to `PRESET_CONCURRENCY` of them at a time. What the fan-out
8224
- * buys is wall-clock time — the real profile measured 8.3 s fetched one at
8225
- * a time against 4.8 s with four in flight — while each file keeps its own
8226
- * mirror fallback, its own retry rounds (the node half's business, see
8227
- * ./preset), its own validation, and its own slot name in a failure.
8514
+ * The profile is a DIRECTORY, so this is a pool rather than one call: the
8515
+ * config names the slots and each wallpaper is one request, up to
8516
+ * `PRESET_CONCURRENCY` at a time. Each file keeps its own mirror fallback,
8517
+ * retry rounds (the node half's business, ./preset), validation and slot name
8518
+ * in a failure.
8228
8519
  *
8229
- * STRICT, and the pool is what makes that a decision rather than a
8230
- * formality: the first wallpaper that cannot be had stops the queue, so the
8231
- * files nobody has asked for yet are never asked for. Whatever was already
8232
- * in flight finishes and is thrown away — but what it FETCHED is kept for the
8233
- * next press (`presetHeld` below), which is the difference between retrying
8234
- * one file and retrying the lot. Nothing has been touched at this point, so
8235
- * "the author renamed a file" arrives as an error naming the file rather
8236
- * than as a profile that is quietly missing a picture — and that loudness
8237
- * is what makes a reference the plugin does not fully control acceptable
8238
- * here.
8520
+ * STRICT: the first wallpaper that cannot be had stops the queue. What had
8521
+ * already arrived is kept for the next press (`presetHeld` below) — the
8522
+ * difference between retrying one file and retrying the lot. Nothing is
8523
+ * touched here, so "the author renamed a file" arrives as an error naming the
8524
+ * file rather than as a profile quietly missing a picture.
8239
8525
  */
8240
8526
  fetchRecommended: async (onProgress) => {
8241
8527
  const head = await fetchPresetConfig();
@@ -8300,14 +8586,10 @@ window.__ModuleLoader__.load({
8300
8586
  }
8301
8587
  };
8302
8588
  },
8303
- /**
8304
- * Replace EVERYTHING with a downloaded profile: empty the store, then write
8305
- * the profile into it. Irreversible by design, which is why the page asks
8306
- * first and why nothing here is offered as an undo.
8307
- */
8589
+ /** Replace EVERYTHING with a downloaded profile: empty the store, then write it. */
8308
8590
  applyRecommended: async (preset) => {
8309
8591
  if (!canWriteConfig()) return false;
8310
- return await applyThemeFile(preset, true);
8592
+ return await applyThemeFile({ applyActive }, preset, true);
8311
8593
  }
8312
8594
  };
8313
8595
  };
@@ -8320,89 +8602,7 @@ window.__ModuleLoader__.load({
8320
8602
  store,
8321
8603
  inject: sectionInject
8322
8604
  }, ThemeSection));
8323
- const navLabel = () => ctx.locale.bind(NS)("nav");
8324
- const applyNavIcon = () => {
8325
- const nav = document.querySelector("[role=\"dialog\"][aria-modal=\"true\"][aria-labelledby]")?.querySelector("nav");
8326
- if (!nav) return;
8327
- const target = navLabel();
8328
- for (const cell of Array.from(nav.querySelectorAll("button"))) {
8329
- const label = cell.querySelector("span");
8330
- if (label && label.textContent?.trim() === target) {
8331
- const svg = cell.querySelector("svg");
8332
- if (svg && svg.dataset.dshAnyIcon !== "1") {
8333
- const sun = document.createElementNS("http://www.w3.org/2000/svg", "svg");
8334
- sun.setAttribute("width", "16");
8335
- sun.setAttribute("height", "16");
8336
- sun.setAttribute("viewBox", "0 0 16 16");
8337
- sun.setAttribute("fill", "none");
8338
- sun.setAttribute("xmlns", "http://www.w3.org/2000/svg");
8339
- sun.dataset.dshAnyIcon = "1";
8340
- sun.innerHTML = SUN_PATHS;
8341
- svg.replaceWith(sun);
8342
- }
8343
- return;
8344
- }
8345
- }
8346
- };
8347
- let navIconObserver = null;
8348
- const watchNavIcon = () => {
8349
- if (navIconObserver !== null || typeof MutationObserver === "undefined") return;
8350
- navIconObserver = new MutationObserver((records) => {
8351
- if (records.some((r) => {
8352
- for (const n of r.addedNodes) {
8353
- if (n.nodeType !== 1) continue;
8354
- const el = n;
8355
- if (el.matches?.("[role=\"dialog\"][aria-modal=\"true\"][aria-labelledby]") || el.querySelector?.("[role=\"dialog\"][aria-modal=\"true\"][aria-labelledby]")) return true;
8356
- }
8357
- return false;
8358
- })) applyNavIcon();
8359
- });
8360
- navIconObserver.observe(document.body, {
8361
- childList: true,
8362
- subtree: true
8363
- });
8364
- applyNavIcon();
8365
- };
8366
- watchNavIcon();
8367
- ctx.effect(() => () => {
8368
- navIconObserver?.disconnect();
8369
- navIconObserver = null;
8370
- }, "dsh-background-by-model: nav icon watch");
8371
- const restoreSaved = () => {
8372
- syncHostScheme();
8373
- const color = activeColor();
8374
- if (color !== null) {
8375
- const snapshot = ctx.theme.getTheme();
8376
- if (!snapshot.themes.some((t) => t.id === CUSTOM_ID)) registerCustom(color[0], color[1], color[2]);
8377
- else if (snapshot.preference !== CUSTOM_ID) ctx.theme.setTheme(CUSTOM_ID);
8378
- }
8379
- applyWp();
8380
- };
8381
- const restoreTimers = [300, 1500].map((delay) => window.setTimeout(restoreSaved, delay));
8382
- ctx.effect(() => () => {
8383
- restoreTimers.forEach((id) => window.clearTimeout(id));
8384
- }, "dsh-background-by-model: boot restore");
8385
- const watchdogId = window.setInterval(() => {
8386
- syncHostScheme();
8387
- const color = activeColor();
8388
- if (color === null) {
8389
- refreshSystemTheme();
8390
- return;
8391
- }
8392
- const snapshot = ctx.theme.getTheme();
8393
- let changed = false;
8394
- if (!snapshot.themes.some((t) => t.id === CUSTOM_ID)) {
8395
- registerCustom(color[0], color[1], color[2]);
8396
- changed = true;
8397
- } else if (snapshot.preference !== CUSTOM_ID) {
8398
- ctx.theme.setTheme(CUSTOM_ID);
8399
- changed = true;
8400
- }
8401
- if (changed) applyWp();
8402
- }, 1e3);
8403
- ctx.effect(() => () => {
8404
- window.clearInterval(watchdogId);
8405
- }, "dsh-background-by-model: theme watchdog");
8605
+ ctx.effect(() => watchNavIcon(() => ctx.locale.bind(NS)("nav")), "dsh-background-by-model: nav icon watch");
8406
8606
  const disposeThemeResets = watchThemeResets();
8407
8607
  ctx.effect(() => () => {
8408
8608
  disposeThemeResets();