dsh-background-by-model 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -25,7 +25,7 @@ window.__ModuleLoader__.load({
25
25
  pageModelBg: "模型背景",
26
26
  pageProfile: "配置",
27
27
  descInterface: "为主界面的各个区域单独调节透明度与模糊,营造空间层次感。这里的设置对所有模型生效",
28
- descModelBg: "每条规则 = 一个匹配串 + 一套背景外观。切换模型时,从上往下找第一条匹配串出现在模型名里的规则;都没中就使用第 1 条",
28
+ descModelBg: "每条规则 = 一个匹配串 + 一组背景图与外观。切换模型时,从上往下找第一条匹配串出现在模型名里的规则;都没中就使用第 1 条。一条规则可以放多张图,按你设的节奏轮换",
29
29
  descProfile: "把当前全部规则连同图片导出备份,或从文件一键恢复",
30
30
  uiTitle: "主界面",
31
31
  uiOpacity: "透明度",
@@ -46,15 +46,16 @@ window.__ModuleLoader__.load({
46
46
  statusNoteEmptySelection: "本会话的模型选择投影还没有值(切一次模型即可写入)",
47
47
  rulesTitle: "规则列表",
48
48
  rulesHint: "从上到下即为优先级。规则 1 同时兼任兜底:所有匹配串都没命中时使用它",
49
+ hostStaleHint: "检测到 DSH 主进程仍在运行旧版插件:新版配置格式它还读不懂,为避免丢规则,本次只改界面不写入配置。重启 DSH 后多图与轮换即可正常保存",
49
50
  tryoutTitle: "匹配试跑",
50
51
  tryoutPlaceholder: "输入模型名,如 deepseek-flash",
51
52
  tryoutUseCurrent: "填入当前模型",
52
53
  tryoutHit: "命中规则",
53
54
  tryoutFallback: "无匹配 · 走兜底规则",
54
- tryoutNone: "没有可用规则(规则需要启用并有图片)",
55
+ tryoutNone: "没有可用规则(规则需要启用,并且有图片或主题色)",
55
56
  tryoutEmpty: "输入模型名即可试跑",
56
57
  autoExtract: "选图后自动取主题色",
57
- autoExtractHint: "只在规则还没有颜色时自动取色,绝不覆盖你手动选的颜色",
58
+ autoExtractHint: "只在图片还没有自己的颜色时自动取色,绝不覆盖你手动选的颜色",
58
59
  ruleAdd: "新增规则",
59
60
  ruleFallbackBadge: "兜底",
60
61
  ruleMatchPlaceholder: "如 flash / glm(忽略大小写,留空则不参与匹配)",
@@ -63,8 +64,6 @@ window.__ModuleLoader__.load({
63
64
  ruleDown: "下移",
64
65
  ruleRemove: "删除规则",
65
66
  ruleNoImage: "尚未选择图片",
66
- rulePickImage: "选择图片",
67
- ruleChangeImage: "更换图片",
68
67
  ruleFromUrl: "从网址",
69
68
  ruleUrlPlaceholder: "粘贴图片网址 https://…",
70
69
  ruleUrlApply: "应用",
@@ -73,18 +72,49 @@ window.__ModuleLoader__.load({
73
72
  ruleUrlBadHttp: "仅支持 http/https 图片网址",
74
73
  ruleUrlFail: "获取图片失败",
75
74
  ruleImageRemove: "移除图片",
76
- ruleEmptyHint: "这条规则还没有图片,不会被匹配到,也不会作为兜底",
75
+ ruleEmptyHint: "这条规则还没有图片,也没有自己的主题色:没有可显示的内容,匹配和兜底都会跳过它",
76
+ ruleColorOnlyHint: "这条规则还没有图片:不会铺壁纸,界面只用这个主题色",
77
+ ruleImagePendingHint: "这张图片还没有取到内容,稍后会自己出现(也可以直接替换它)",
77
78
  ruleColor: "主题色",
78
79
  ruleColorNone: "跟随系统主题",
79
80
  ruleColorNoneHint: "未设颜色时跟随系统主题:界面用宿主的配色,壁纸与各部位的透明度、模糊照旧生效",
80
81
  ruleColorPick: "自定义颜色",
81
82
  ruleColorPickHint: "优先从本图取色,取不到就用默认色",
83
+ ruleColorNoImageHint: "这条规则还没有图片,取不到色:选一个颜色可以只铺主题色,或先添加图片",
84
+ ruleColorPendingImageHint: "这张图片还没有取到内容,暂时取不了色:可以直接选一个颜色,或等它加载完",
82
85
  ruleColorExtract: "从本图提取",
86
+ ruleColorTargetImage: "作用于选中的图片:",
87
+ ruleColorTargetRule: "作用于本规则(它还没有图片)",
83
88
  ruleLayout: "布局模式",
84
89
  ruleFramingEdit: "编辑位置",
85
90
  ruleOpacity: "背景透明度",
86
91
  ruleBlur: "背景模糊",
87
92
  ruleBlurHint: "背景模糊作用于壁纸层;界面页里的模糊作用于界面各区域,两者互不影响",
93
+ rotTitle: "多图轮换",
94
+ rotEnable: "轮换这组图",
95
+ rotNeedTwo: "至少两张图才能轮换",
96
+ rotEvery: "每张停留",
97
+ rotCustom: "自定义秒数",
98
+ rotSeconds: "秒",
99
+ rotOrder: "换图顺序",
100
+ rotOrderSeq: "按顺序",
101
+ rotOrderShuffle: "随机",
102
+ rotOnSwitch: "切换模型时也换一张",
103
+ rotNext: "下一张",
104
+ rotNextInactive: "只有正在生效的规则能换画面上的图",
105
+ rotPos: "当前第",
106
+ rotShowing: "正在显示",
107
+ rotAddImage: "添加图片",
108
+ rotReplaceImage: "替换这张",
109
+ rotImageEarlier: "前移一张",
110
+ rotImageLater: "后移一张",
111
+ rotMakeFirst: "设为第一张",
112
+ rotEvery10s: "10 秒",
113
+ rotEvery30s: "30 秒",
114
+ rotEvery1m: "1 分钟",
115
+ rotEvery5m: "5 分钟",
116
+ rotEvery30m: "30 分钟",
117
+ rotEvery1h: "1 小时",
88
118
  bgModeFit: "适应",
89
119
  bgModeFill: "填充",
90
120
  bgModeStretch: "拉伸",
@@ -125,7 +155,7 @@ window.__ModuleLoader__.load({
125
155
  pageModelBg: "Model background",
126
156
  pageProfile: "Profile",
127
157
  descInterface: "Tune opacity and blur per surface to build depth. These settings are global and apply to every model",
128
- descModelBg: "Each rule is a match string plus 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",
158
+ 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",
129
159
  descProfile: "Back up every rule together with its image, or restore one from a file",
130
160
  uiTitle: "Interface",
131
161
  uiOpacity: "Opacity",
@@ -146,15 +176,16 @@ window.__ModuleLoader__.load({
146
176
  statusNoteEmptySelection: "The model-selection projection exists but carries no value yet (selecting a model once writes it)",
147
177
  rulesTitle: "Rules",
148
178
  rulesHint: "Top to bottom is the priority. Rule 1 doubles as the fallback — it is used when no match string hits",
179
+ 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",
149
180
  tryoutTitle: "Match test",
150
181
  tryoutPlaceholder: "Type a model name, e.g. deepseek-flash",
151
182
  tryoutUseCurrent: "Use current model",
152
183
  tryoutHit: "matches rule",
153
184
  tryoutFallback: "no match · fallback rule",
154
- tryoutNone: "No usable rule (a rule needs to be enabled and have an image)",
185
+ tryoutNone: "No usable rule (a rule needs to be enabled and carry an image or a theme color)",
155
186
  tryoutEmpty: "Type a model name to test the list",
156
187
  autoExtract: "Extract the theme color when an image is chosen",
157
- autoExtractHint: "Only fills a rule that has no color yet — a color you picked is never overwritten",
188
+ autoExtractHint: "Only fills an image that has no color of its own yet — a color you picked is never overwritten",
158
189
  ruleAdd: "Add rule",
159
190
  ruleFallbackBadge: "fallback",
160
191
  ruleMatchPlaceholder: "e.g. flash / glm (case-insensitive, empty never matches)",
@@ -163,8 +194,6 @@ window.__ModuleLoader__.load({
163
194
  ruleDown: "Move down",
164
195
  ruleRemove: "Remove rule",
165
196
  ruleNoImage: "No image yet",
166
- rulePickImage: "Choose image",
167
- ruleChangeImage: "Replace image",
168
197
  ruleFromUrl: "From URL",
169
198
  ruleUrlPlaceholder: "Paste an image URL https://…",
170
199
  ruleUrlApply: "Apply",
@@ -173,18 +202,49 @@ window.__ModuleLoader__.load({
173
202
  ruleUrlBadHttp: "Only http/https image URLs are supported",
174
203
  ruleUrlFail: "Could not fetch the image",
175
204
  ruleImageRemove: "Remove image",
176
- ruleEmptyHint: "This rule has no image — it can neither match nor serve as the fallback",
205
+ 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",
206
+ ruleColorOnlyHint: "No image in this rule yet: no wallpaper is shown, and the interface is painted from this theme color alone",
207
+ ruleImagePendingHint: "This image has no content yet — it appears once it loads; you can also replace it",
177
208
  ruleColor: "Theme color",
178
209
  ruleColorNone: "Follow system theme",
179
210
  ruleColorNoneHint: "Without a color this rule follows the system theme: the host palette paints the interface while the wallpaper and every opacity/blur slider keep working",
180
211
  ruleColorPick: "Custom color",
181
212
  ruleColorPickHint: "Extracts from this image when it can, otherwise uses a default color",
213
+ ruleColorNoImageHint: "This rule has no image to take a color from: pick a color to tint the interface alone, or add an image first",
214
+ ruleColorPendingImageHint: "This image has no content yet, so there is nothing to extract from: pick a color directly, or wait for it to load",
215
+ ruleColorTargetImage: "Applies to the selected image:",
216
+ ruleColorTargetRule: "Applies to this rule (it has no image yet)",
182
217
  ruleColorExtract: "Extract from image",
183
218
  ruleLayout: "Layout mode",
184
219
  ruleFramingEdit: "Edit position",
185
220
  ruleOpacity: "Background opacity",
186
221
  ruleBlur: "Background blur",
187
222
  ruleBlurHint: "Background blur affects the wallpaper layer; the blur on the Interface page affects interface surfaces — they are independent",
223
+ rotTitle: "Image rotation",
224
+ rotEnable: "Rotate these images",
225
+ rotNeedTwo: "At least two images are needed to rotate",
226
+ rotEvery: "Dwell time per image",
227
+ rotCustom: "Custom seconds",
228
+ rotSeconds: "s",
229
+ rotOrder: "Order",
230
+ rotOrderSeq: "In order",
231
+ rotOrderShuffle: "Shuffle",
232
+ rotOnSwitch: "Also step on a model switch",
233
+ rotNext: "Next image",
234
+ rotNextInactive: "Only the active rule can step the background",
235
+ rotPos: "Image",
236
+ rotShowing: "On screen",
237
+ rotAddImage: "Add images",
238
+ rotReplaceImage: "Replace this image",
239
+ rotImageEarlier: "Move earlier",
240
+ rotImageLater: "Move later",
241
+ rotMakeFirst: "Make first",
242
+ rotEvery10s: "10 s",
243
+ rotEvery30s: "30 s",
244
+ rotEvery1m: "1 min",
245
+ rotEvery5m: "5 min",
246
+ rotEvery30m: "30 min",
247
+ rotEvery1h: "1 h",
188
248
  bgModeFit: "Fit",
189
249
  bgModeFill: "Fill",
190
250
  bgModeStretch: "Stretch",
@@ -351,7 +411,7 @@ window.__ModuleLoader__.load({
351
411
  if (!master || todayId === null) return null;
352
412
  const item = items.find((i) => i.id === todayId);
353
413
  if (item === void 0 || !item.enabled) return null;
354
- return hasImage(item.slot) ? item : null;
414
+ return item.images.some((image) => hasImage(image.slot)) ? item : null;
355
415
  }
356
416
  //#endregion
357
417
  //#region src/schema.ts
@@ -391,6 +451,26 @@ window.__ModuleLoader__.load({
391
451
  "tile",
392
452
  "center"
393
453
  ];
454
+ /** Every accepted `rotate.order`, in UI order. */
455
+ const ROTATE_ORDERS = ["order", "shuffle"];
456
+ /** Fastest allowed dwell time — below this the cross-fade never settles. */
457
+ const ROTATE_MIN_MS = 5e3;
458
+ /** Slowest allowed dwell time (24 h): one image per day is still "rotation". */
459
+ const ROTATE_MAX_MS = 864e5;
460
+ /** Longest allowed cross-fade; 0 is a hard cut. */
461
+ const ROTATE_FADE_MAX_MS = 3e3;
462
+ /** Everything off: the shipped default, and what a config predating this feature gets. */
463
+ const DEFAULT_ROTATION = {
464
+ enabled: false,
465
+ intervalMs: 6e4,
466
+ order: "order",
467
+ advanceOnSwitch: false,
468
+ fadeMs: 320
469
+ };
470
+ /** A fresh rotation block (never hand out the shared default object). */
471
+ function defaultRotation() {
472
+ return { ...DEFAULT_ROTATION };
473
+ }
394
474
  /**
395
475
  * Main interface opacities (0..1), global (Interface page):
396
476
  * `bg` main background (`--dsw-alias-bg-base`)
@@ -442,6 +522,37 @@ window.__ModuleLoader__.load({
442
522
  rightbar: 0,
443
523
  input: 0
444
524
  };
525
+ /** Every slot a rule owns, in order. The one thing the render and store layers
526
+ * ask of the image list, so a rule's slots are never enumerated ad hoc. */
527
+ function ruleSlots(rule) {
528
+ return rule.images.map((i) => i.slot);
529
+ }
530
+ /**
531
+ * Whether a rule has anything to show at all: an image, or a theme color of its
532
+ * own. Used by matching (`usable` in ./client/modelbg) and by the panel's copy.
533
+ *
534
+ * An image is the obvious way a rule paints, and until 0.7 it was the ONLY one —
535
+ * which stopped being true the moment the last image became removable. A
536
+ * rule-level theme color is the second: it recolors the interface through the
537
+ * token palette with no wallpaper involved, so a rule whose pictures were all
538
+ * removed still paints, and treating it as unusable made every control left on
539
+ * its card (the wheel, the swatches, the clear-to-system-theme button) dead — the
540
+ * one state where the panel was confidently showing a lie. It also silently
541
+ * handed the model to the NEXT rule, so clearing the default rule's images put
542
+ * another model's wallpaper on the screen.
543
+ *
544
+ * Note that a per-image color (0.7.1) does not enter this question at all: an
545
+ * image is already enough, so the answer here is about the IMAGE LIST being
546
+ * non-empty, never about what those images are painted in.
547
+ *
548
+ * A rule with no image and no color of its own paints nothing: the system theme
549
+ * is not a paint of this rule's own, so such a rule is skipped by matching, which
550
+ * is what makes a freshly added rule (empty, colorless) invisible until the user
551
+ * gives it something.
552
+ */
553
+ function ruleCanPaint(rule) {
554
+ return rule.enabled && (ruleSlots(rule).length > 0 || rule.color !== null);
555
+ }
445
556
  /**
446
557
  * `#RRGGBB` → the `[h, s, l]` triple every rule stores (`s` and `l` in 0..1), or
447
558
  * null when the string is not a hex color.
@@ -487,16 +598,21 @@ window.__ModuleLoader__.load({
487
598
  * art was not cut for.
488
599
  */
489
600
  function defaultHolidayRule(def) {
601
+ const color = hexToHsl(def.color);
490
602
  return {
491
603
  id: def.id,
492
- slot: def.slot,
604
+ images: [{
605
+ slot: def.slot,
606
+ bgState: { ...DEFAULT_BG_STATE },
607
+ color
608
+ }],
493
609
  match: "",
494
610
  enabled: true,
495
- color: hexToHsl(def.color),
611
+ color,
496
612
  bgMode: "fill",
497
613
  wallpaperOpacity: 1,
498
614
  blur: 0,
499
- bgState: { ...DEFAULT_BG_STATE }
615
+ rotate: defaultRotation()
500
616
  };
501
617
  }
502
618
  /** Every built-in holiday at its default, in `HOLIDAYS` order. */
@@ -546,34 +662,115 @@ window.__ModuleLoader__.load({
546
662
  clamp$1(raw[2], 0, 1, .25)
547
663
  ];
548
664
  }
549
- /** Coerce one persisted rule, or null when it lacks a usable id/slot. */
665
+ /** Coerce one persisted rotation block; a config without one gets the shipped default. */
666
+ function normalizeRotation(raw) {
667
+ const r = raw ?? {};
668
+ const d = DEFAULT_ROTATION;
669
+ return {
670
+ enabled: r.enabled === true,
671
+ intervalMs: clamp$1(r.intervalMs, ROTATE_MIN_MS, ROTATE_MAX_MS, d.intervalMs),
672
+ order: ROTATE_ORDERS.includes(r.order) ? r.order : d.order,
673
+ advanceOnSwitch: r.advanceOnSwitch === true,
674
+ fadeMs: clamp$1(r.fadeMs, 0, ROTATE_FADE_MAX_MS, d.fadeMs)
675
+ };
676
+ }
677
+ /** Own-property test for values that may be anything at all (a raw JSON entry). */
678
+ function hasOwn(o, key) {
679
+ return Object.prototype.hasOwnProperty.call(o, key);
680
+ }
681
+ /**
682
+ * Coerce one persisted image entry, or null when it lacks a usable slot.
683
+ *
684
+ * `inherit` is the RULE's own color, and it is applied only to an entry that has
685
+ * no `color` key at all — the shape every release before 0.7.1 wrote, where a
686
+ * rule's color was the only color there was. Lifting it here, once, is what makes
687
+ * an upgrade a no-op on screen: a themed wallpaper keeps its theme instead of
688
+ * silently dropping to the system palette, and it happens in the shared sanitizer
689
+ * so both halves lift identically.
690
+ *
691
+ * The KEY's presence is the whole distinction, which is why `null` is not the
692
+ * same as absent: an entry with `color: null` is an image the user explicitly set
693
+ * to "follow the system theme", and re-inheriting the rule's color over that
694
+ * would undo a deliberate choice on every load.
695
+ */
696
+ function normalizeImage(raw, inherit = null) {
697
+ const i = raw ?? {};
698
+ if (typeof i.slot !== "string" || !SLOT_RE.test(i.slot)) return null;
699
+ return {
700
+ slot: i.slot,
701
+ bgState: normalizeBgState(i.bgState),
702
+ color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit
703
+ };
704
+ }
705
+ /**
706
+ * Coerce one persisted rule, or null when it names no image AT ALL.
707
+ *
708
+ * Accepts BOTH image shapes: the 0.7 list (`images`), and the single
709
+ * `slot`/`bgState` pair every earlier version wrote — the latter is lifted to a
710
+ * one-entry list here rather than at a dozen call sites, which is what keeps the
711
+ * rest of the codebase free of "old config" branches. Duplicate slots inside one
712
+ * rule are dropped: two entries pointing at the same bytes would only make the
713
+ * rotation look stuck.
714
+ *
715
+ * The rule's color is resolved FIRST and handed to every image entry, because it
716
+ * is what an entry written before 0.7.1 inherits (`normalizeImage`). The rule keeps
717
+ * it as well: it is not a default for its images at paint time any more, but it
718
+ * is still what the rule paints once its last picture is gone.
719
+ *
720
+ * An EMPTY list is a legitimate rule, not a broken one: removing a rule's last
721
+ * picture has to be expressible, and forcing an entry back in is what produced a
722
+ * phantom blank image that took slot 1 and could never be deleted again (the next
723
+ * upload landed at position 2 and the rule kept painting nothing). What such a
724
+ * rule then does is `ruleCanPaint`'s business: with a color of its own it paints
725
+ * the interface, with neither it is skipped by matching — which the card's own
726
+ * hint says in so many words. Only a rule that names no image anywhere — no list,
727
+ * no legacy slot, or a slot that could never be a filename — is dropped.
728
+ */
550
729
  function normalizeRule(raw) {
551
730
  const r = raw ?? {};
552
731
  const id = typeof r.id === "string" && r.id !== "" ? r.id : null;
553
- const slot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
554
- if (id === null || slot === null) return null;
732
+ if (id === null) return null;
733
+ const color = normalizeHsl(r.color);
734
+ const images = [];
735
+ const seen = /* @__PURE__ */ new Set();
736
+ const hadList = Array.isArray(r.images);
737
+ if (hadList) for (const entry of r.images) {
738
+ const image = normalizeImage(entry, color);
739
+ if (image === null || seen.has(image.slot)) continue;
740
+ seen.add(image.slot);
741
+ images.push(image);
742
+ }
743
+ const legacySlot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
744
+ if (images.length === 0 && legacySlot !== null) images.push({
745
+ slot: legacySlot,
746
+ bgState: normalizeBgState(r.bgState),
747
+ color
748
+ });
749
+ if (images.length === 0 && !hadList) return null;
555
750
  const mode = BG_MODES$1.includes(r.bgMode) ? r.bgMode : "fit";
556
751
  return {
557
752
  id,
558
- slot,
753
+ images,
559
754
  match: typeof r.match === "string" ? r.match : "",
560
755
  enabled: r.enabled !== false,
561
- color: normalizeHsl(r.color),
756
+ color,
562
757
  bgMode: mode,
563
758
  wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
564
759
  blur: clamp$1(r.blur, 0, 60, 0),
565
- bgState: normalizeBgState(r.bgState)
760
+ rotate: normalizeRotation(r.rotate)
566
761
  };
567
762
  }
568
763
  /**
569
764
  * Coerce one persisted holiday entry.
570
765
  *
571
- * `id`, `slot` and `color` come from the DEFINITION, never from disk: a holiday's
572
- * slot is the only thing tying it to its bytes, and its theme color is a fixed
573
- * part of what that holiday looks like — so a stale or hand-edited value would
574
- * either orphan the wallpaper, point the holiday at a rule's image, or paint a
575
- * festival in a color it does not have. Everything else is sanitized exactly like
576
- * a rule's field.
766
+ * `id`, `images` and every theme color (the rule's and each image's) come from
767
+ * the DEFINITION, never from disk: a holiday's slot is the only thing tying it to
768
+ * its bytes, and its theme color is a fixed part of what that holiday looks like —
769
+ * so a stale or hand-edited value would either orphan the wallpaper, point the
770
+ * holiday at a rule's image, or paint a festival in a color it does not have.
771
+ * Everything else is sanitized exactly like a rule's field — except the image
772
+ * list and the rotation, which stay the definition's own single image and stay
773
+ * off, so a festival can never be swapped or cycled.
577
774
  */
578
775
  function normalizeHolidayRule(def, raw) {
579
776
  const r = raw ?? {};
@@ -584,7 +781,11 @@ window.__ModuleLoader__.load({
584
781
  bgMode: BG_MODES$1.includes(r.bgMode) ? r.bgMode : base.bgMode,
585
782
  wallpaperOpacity: clamp01(r.wallpaperOpacity, base.wallpaperOpacity),
586
783
  blur: clamp$1(r.blur, 0, 60, base.blur),
587
- bgState: normalizeBgState(r.bgState)
784
+ images: base.images.map((image) => ({
785
+ slot: image.slot,
786
+ bgState: normalizeBgState(r.images?.[0]?.bgState ?? r.bgState),
787
+ color: image.color
788
+ }))
588
789
  };
589
790
  }
590
791
  /** Coerce the whole holiday block; unknown ids are dropped, missing ones defaulted. */
@@ -720,13 +921,68 @@ window.__ModuleLoader__.load({
720
921
  let activeRuleId = null;
721
922
  let activeMatched = false;
722
923
  let modelLabel = "";
924
+ /**
925
+ * Which image of the ACTIVE rule is painted right now.
926
+ *
927
+ * Deliberately NOT persisted: a background that comes back on image 3 of 7 after
928
+ * a reload, with no way to say why, is worse than one that starts at the first
929
+ * image every time. Clamped to the rule's own length on every read, so an index
930
+ * left over from a longer list can never point at nothing.
931
+ */
932
+ let rotIndex = 0;
723
933
  function setActive(ruleId, matched) {
934
+ if (ruleId !== activeRuleId) rotIndex = 0;
724
935
  activeRuleId = ruleId;
725
936
  activeMatched = matched;
726
937
  }
727
938
  function setModelLabel(label) {
728
939
  modelLabel = label;
729
940
  }
941
+ /** The image index the render layer is on, clamped into `rule`'s own range. */
942
+ function imageIndexOf(rule) {
943
+ if (rule.images.length === 0) return 0;
944
+ const i = Math.floor(rotIndex);
945
+ return i >= 0 && i < rule.images.length ? i : 0;
946
+ }
947
+ /** Point the active rule at another of its images (the rotation's one mutator). */
948
+ function setRotIndex(i) {
949
+ rotIndex = Math.floor(i);
950
+ }
951
+ /** The image the active rule is painting now, or null when it has none. */
952
+ function activeImage() {
953
+ const rule = activeRule();
954
+ if (rule === null) return null;
955
+ return rule.images[imageIndexOf(rule)] ?? null;
956
+ }
957
+ /** Slot of the active rule's current image; '' when there is nothing to paint. */
958
+ function activeSlot() {
959
+ return activeImage()?.slot ?? "";
960
+ }
961
+ /**
962
+ * The theme color in force right now, or null for "follow the system theme".
963
+ *
964
+ * Two rungs, in this order and no others:
965
+ * 1. the color of the IMAGE that is on screen, when the rule has one;
966
+ * 2. the rule's own color, which is all an image-less rule has to paint with.
967
+ *
968
+ * There is deliberately no third rung where an image INHERITS its rule's color:
969
+ * a rule's color is not a default for its pictures (see `BgRule.color`). If it
970
+ * were, a rotation through a green picture, a red one and a system-themed one
971
+ * could not be expressed at all — clearing a picture's color would only hand it
972
+ * back to the rule's, and the clear button would look broken. Images that predate
973
+ * per-image colors get the rule's color LIFTED onto them at read time instead
974
+ * (`normalizeImage`), so nothing loses its theme on upgrade.
975
+ *
976
+ * The image is the one being PAINTED (`imageIndexOf`), not the one the panel has
977
+ * selected — the panel's selection is an editing cursor and must not repaint the
978
+ * interface while the user is fixing another picture's framing.
979
+ */
980
+ function activeColor() {
981
+ const rule = activeRule();
982
+ if (rule === null) return null;
983
+ if (rule.images.length === 0) return rule.color;
984
+ return activeImage()?.color ?? null;
985
+ }
730
986
  /**
731
987
  * One rule by id — from the USER's list first, then from the holiday overrides.
732
988
  *
@@ -745,22 +1001,46 @@ window.__ModuleLoader__.load({
745
1001
  function holidayById(id) {
746
1002
  return cfg.holidays.items.find((r) => r.id === id) ?? null;
747
1003
  }
748
- function newRule(id, slot) {
1004
+ /**
1005
+ * A fresh rule, holding NO image yet.
1006
+ *
1007
+ * It used to be born with an allocated slot and an empty entry, which is what put
1008
+ * a blank tile at position 1 of a new rule's strip — and, worse, stopped the
1009
+ * picture the user added next from being the first one (a rule paints its first
1010
+ * image). A rule with no pictures is a valid state: `ruleCanPaint` (./schema) skips
1011
+ * it — it has no image and no color yet — until either arrives, and the slot is
1012
+ * allocated when the first picture does.
1013
+ */
1014
+ function newRule(id) {
749
1015
  return {
750
1016
  id,
751
- slot,
1017
+ images: [],
752
1018
  match: "",
753
1019
  enabled: true,
754
1020
  color: null,
755
1021
  bgMode: "fit",
756
1022
  wallpaperOpacity: 1,
757
1023
  blur: 0,
758
- bgState: { ...DEFAULT_BG_STATE }
1024
+ rotate: defaultRotation()
759
1025
  };
760
1026
  }
761
- /** First free image slot (`m1`, `m2`, …). */
1027
+ /** Every slot any rule owns — the user's rules and the holiday entries alike. */
1028
+ function takenSlots() {
1029
+ const taken = /* @__PURE__ */ new Set();
1030
+ for (const rule of cfg.rules) for (const slot of ruleSlots(rule)) taken.add(slot);
1031
+ for (const rule of cfg.holidays.items) for (const slot of ruleSlots(rule)) taken.add(slot);
1032
+ return taken;
1033
+ }
1034
+ /**
1035
+ * First free image slot (`m1`, `m2`, …).
1036
+ *
1037
+ * Scans EVERY slot in the config, not just the primary one of each rule: with
1038
+ * several images per rule, "the slots this file already uses" is the only set
1039
+ * that can be safely handed out, and a collision here would silently point two
1040
+ * cards at the same bytes (and let one card's delete orphan the other).
1041
+ */
762
1042
  function nextSlot() {
763
- const taken = new Set(cfg.rules.map((r) => r.slot));
1043
+ const taken = takenSlots();
764
1044
  for (let i = 1; i < 1e3; i++) {
765
1045
  const slot = `m${i}`;
766
1046
  if (!taken.has(slot)) return slot;
@@ -776,11 +1056,24 @@ window.__ModuleLoader__.load({
776
1056
  }
777
1057
  return `r${Date.now()}`;
778
1058
  }
1059
+ /** Patch one rule's rotation block, through the shared sanitizer so a live edit
1060
+ * can never hold a value the next load would clamp differently. */
1061
+ function patchRotation(rule, patch) {
1062
+ rule.rotate = normalizeRotation({
1063
+ ...rule.rotate,
1064
+ ...patch
1065
+ });
1066
+ }
1067
+ /** Re-sanitize one rule in place, so what the UI holds equals what would persist. */
1068
+ function normalizeRuleInPlace(rule) {
1069
+ const normalized = normalizeRule(rule);
1070
+ if (normalized !== null) Object.assign(rule, normalized);
1071
+ }
779
1072
  function rHasColor() {
780
- return activeRule()?.color !== null && activeRule() !== null;
1073
+ return activeColor() !== null;
781
1074
  }
782
1075
  function rColor() {
783
- return activeRule()?.color ?? [
1076
+ return activeColor() ?? [
784
1077
  220,
785
1078
  .55,
786
1079
  .25
@@ -795,13 +1088,22 @@ window.__ModuleLoader__.load({
795
1088
  function rBl() {
796
1089
  return clamp$1(activeRule()?.blur, 0, 60, 0);
797
1090
  }
1091
+ /** Framing of the CURRENT image (per image, see BgImage). */
798
1092
  function rBgState() {
799
- return activeRule()?.bgState ?? DEFAULT_BG_STATE;
1093
+ return activeImage()?.bgState ?? DEFAULT_BG_STATE;
1094
+ }
1095
+ /** Rotation of the active rule; the shipped default when nothing is active. */
1096
+ function rRotation() {
1097
+ return activeRule()?.rotate ?? defaultRotation();
1098
+ }
1099
+ /** Cross-fade duration the active rule asks for, in ms (0 = hard cut). */
1100
+ function rFadeMs() {
1101
+ return rRotation().fadeMs;
800
1102
  }
801
- /** Paintable URL of the active rule's image, or null when it has none. */
1103
+ /** Paintable URL of the active rule's CURRENT image, or null when it has none. */
802
1104
  function rWp() {
803
- const rule = activeRule();
804
- return rule === null ? null : displayImageOf(rule.slot);
1105
+ const slot = activeSlot();
1106
+ return slot === "" ? null : displayImageOf(slot);
805
1107
  }
806
1108
  function rOps() {
807
1109
  const o = cfg.opacities ?? {};
@@ -836,12 +1138,126 @@ window.__ModuleLoader__.load({
836
1138
  activeRuleId = null;
837
1139
  activeMatched = false;
838
1140
  }
1141
+ rotIndex = 0;
1142
+ }
1143
+ //#endregion
1144
+ //#region src/client/rotation.ts
1145
+ /** Dwell-time presets the settings UI offers, in ms. */
1146
+ const ROTATE_PRESETS = [
1147
+ {
1148
+ ms: 1e4,
1149
+ key: "rotEvery10s"
1150
+ },
1151
+ {
1152
+ ms: 3e4,
1153
+ key: "rotEvery30s"
1154
+ },
1155
+ {
1156
+ ms: 6e4,
1157
+ key: "rotEvery1m"
1158
+ },
1159
+ {
1160
+ ms: 3e5,
1161
+ key: "rotEvery5m"
1162
+ },
1163
+ {
1164
+ ms: 18e5,
1165
+ key: "rotEvery30m"
1166
+ },
1167
+ {
1168
+ ms: 36e5,
1169
+ key: "rotEvery1h"
1170
+ }
1171
+ ];
1172
+ /**
1173
+ * The index to paint after the current one, or null when there is nowhere to go
1174
+ * (fewer than two images — the caller must not start a timer at all then).
1175
+ *
1176
+ * `order` walks the list and wraps; `shuffle` picks uniformly among every index
1177
+ * EXCEPT the current one, because landing on the same image reads as a missed
1178
+ * tick. `current` outside the list is treated as 0 rather than trusted: it comes
1179
+ * from the UI as often as from the timer, and a stale index must not be able to
1180
+ * index past the end.
1181
+ *
1182
+ * `rand` is injectable so the shuffle branch is testable without stubbing a
1183
+ * global.
1184
+ */
1185
+ function nextIndex(len, order, current, rand = Math.random) {
1186
+ if (!Number.isFinite(len) || len < 2) return null;
1187
+ const count = Math.floor(len);
1188
+ const cur = Number.isFinite(current) && current >= 0 && current < count ? Math.floor(current) : 0;
1189
+ if (order === "shuffle") {
1190
+ const roll = Math.min(Math.floor(Math.max(rand(), 0) * (count - 1)), count - 2);
1191
+ return roll >= cur ? roll + 1 : roll;
1192
+ }
1193
+ return (cur + 1) % count;
1194
+ }
1195
+ /**
1196
+ * Whether a rotation is worth running: two images, the switch on, and a positive
1197
+ * dwell time. Kept here (rather than at the call site) so the UI's "rotating"
1198
+ * badge and the timer agree by construction — a badge that says "rotating" while
1199
+ * no timer exists is the one failure this feature can produce silently.
1200
+ */
1201
+ function isRotating(len, enabled, intervalMs) {
1202
+ return enabled && len >= 2 && Number.isFinite(intervalMs) && intervalMs > 0;
1203
+ }
1204
+ //#endregion
1205
+ //#region src/client/repaint.ts
1206
+ /**
1207
+ * The repaint decision, as a pure function.
1208
+ *
1209
+ * Every edit to a rule ends with the same question: does the interface have to be
1210
+ * repainted, or is refreshing the panel's readout enough? Repainting is the
1211
+ * expensive half (the theme skin is re-registered, the wallpaper layer is
1212
+ * rewritten, the host's theme is switched), so it must not be done for a rule
1213
+ * that is not the one on screen — but it MUST be done the moment the edit
1214
+ * changes what should be on screen.
1215
+ *
1216
+ * The naive test — "is the edited rule the active one?" — is wrong in exactly the
1217
+ * case that kept being reported as "更新的不太及时,只有我动调色盘或者切模型的时候
1218
+ * 才出现": an edit that CREATES the winner. Uploading a rule's first picture, or
1219
+ * an auto-extracted color landing on a rule that had none, makes that rule
1220
+ * paintable *through that very edit* (see `ruleCanPaint`), so the rule is not the
1221
+ * active one at the moment of the check; the wallpaper and the skin then only
1222
+ * showed up later, when an unrelated drag or model switch happened to re-run the
1223
+ * resolution.
1224
+ *
1225
+ * Sampling the winner on BOTH sides of the edit is what makes such a change land
1226
+ * at once, and `editedId === winnerAfter` additionally covers the edit that does
1227
+ * not move the resolution but changes the look of the rule that IS painting (a
1228
+ * color, a slider, a framing).
1229
+ *
1230
+ * `null` is "nothing of ours is painting" (the interface falls back to the host's
1231
+ * own look), which is a winner like any other id.
1232
+ *
1233
+ * Kept free of imports on purpose: `scripts/repaint-check.ts` exercises it on
1234
+ * plain node, the same way `rotation.ts` is exercised. A rule's `color`/`match`
1235
+ * are the inputs that make the resolution move, which is why they are the fields
1236
+ * whose write paths all end here.
1237
+ */
1238
+ function shouldRepaint(editedId, winnerBefore, winnerAfter) {
1239
+ return winnerAfter === editedId || winnerAfter !== winnerBefore;
839
1240
  }
840
1241
  //#endregion
841
1242
  //#region src/client/rpc.ts
842
1243
  const RPC_CHANNEL = "/dsh-background-by-model";
843
1244
  const RPC_NS = "dshBackgroundByModel";
844
1245
  const rpcEndpoint = (method) => `${RPC_NS}/${method}`;
1246
+ let hostShape = "unknown";
1247
+ let warnedLegacy = false;
1248
+ /** Whether this bundle may write its config to the host it is talking to. */
1249
+ function canWriteConfig() {
1250
+ return hostShape !== "legacy";
1251
+ }
1252
+ /** Whether the running host process is older than this client bundle. */
1253
+ function hostIsLegacy() {
1254
+ return hostShape === "legacy";
1255
+ }
1256
+ function holdWrite() {
1257
+ if (warnedLegacy) return;
1258
+ warnedLegacy = true;
1259
+ 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.");
1260
+ }
845
1261
  let rpcCallFn = null;
846
1262
  function initRpc(call) {
847
1263
  rpcCallFn = call;
@@ -870,6 +1286,10 @@ window.__ModuleLoader__.load({
870
1286
  const SAVE_DEBOUNCE_MS = 250;
871
1287
  let saveTimer;
872
1288
  function saveConfig() {
1289
+ if (!canWriteConfig()) {
1290
+ holdWrite();
1291
+ return;
1292
+ }
873
1293
  if (saveTimer !== void 0) window.clearTimeout(saveTimer);
874
1294
  saveTimer = window.setTimeout(() => {
875
1295
  saveTimer = void 0;
@@ -880,10 +1300,18 @@ window.__ModuleLoader__.load({
880
1300
  if (saveTimer === void 0) return;
881
1301
  window.clearTimeout(saveTimer);
882
1302
  saveTimer = void 0;
1303
+ if (!canWriteConfig()) {
1304
+ holdWrite();
1305
+ return;
1306
+ }
883
1307
  rpcCall("writeConfig", { config: cfg });
884
1308
  }
885
1309
  /** Persist the current config immediately (no debounce). */
886
1310
  function persistConfig() {
1311
+ if (!canWriteConfig()) {
1312
+ holdWrite();
1313
+ return;
1314
+ }
887
1315
  rpcCall("writeConfig", { config: cfg });
888
1316
  }
889
1317
  /** Load the persisted config plus the list of stored image slots. */
@@ -892,9 +1320,13 @@ window.__ModuleLoader__.load({
892
1320
  if (data === null || typeof data !== "object") return null;
893
1321
  const d = data;
894
1322
  const slots = Array.isArray(d.slots) ? d.slots.filter((s) => typeof s === "string") : [];
1323
+ const schema = typeof d.schema === "number" && isFinite(d.schema) ? d.schema : null;
1324
+ hostShape = schema === null ? "legacy" : schema >= 4 ? "current" : "legacy";
1325
+ if (hostShape === "legacy") holdWrite();
895
1326
  return {
896
1327
  config: d.config,
897
- slots
1328
+ slots,
1329
+ schema
898
1330
  };
899
1331
  }
900
1332
  /** Read one rule image as a data URL (null when the slot is empty). */
@@ -1371,6 +1803,9 @@ window.__ModuleLoader__.load({
1371
1803
  .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}
1372
1804
  .dab-wheel{position:relative;cursor:crosshair;border-radius:50%;box-shadow:0 12px 32px -14px rgba(0,0,0,.4)}
1373
1805
  .dab-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);padding:0 4px}
1806
+ /* The "your edits are being held" notice: it reports a state the user has to act
1807
+ on, so it is tinted like a warning rather than left as grey hint text. */
1808
+ .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}
1374
1809
 
1375
1810
  /* ── precise color inputs ────────────────────────────────────────────────── */
1376
1811
  .dab-inputs{display:flex;flex-direction:column;gap:10px;min-width:172px}
@@ -1505,12 +1940,48 @@ window.__ModuleLoader__.load({
1505
1940
  .dab-icon-btn:disabled{opacity:.35;cursor:not-allowed}
1506
1941
  .dab-icon-btn-danger:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
1507
1942
  .dab-rule-body{margin-top:13px;display:flex;flex-direction:column;gap:13px}
1508
- .dab-rule-thumb{position:relative;border-radius:12px;overflow:hidden;aspect-ratio:16/9;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);cursor:pointer}
1943
+ .dab-rule-thumb{position:relative;border-radius:12px;overflow:hidden;aspect-ratio:16/9;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2)}
1509
1944
  .dab-rule-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
1510
1945
  .dab-rule-thumb-empty{position:absolute;inset:5px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1.5px dashed var(--dsw-alias-border-l2);border-radius:9px;color:var(--dsw-alias-label-tertiary);font-size:12px;text-align:center;padding:0 10px}
1511
- .dab-rule-thumb.is-over .dab-rule-thumb-empty{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
1946
+ /* The empty tile is the upload button while the rule has no picture at all, so
1947
+ it is a real <button> (focusable, keyboard-reachable) reset down to the box the
1948
+ plain preview occupies — a button brings its own padding, font and border. The
1949
+ dashed frame it looks like is .dab-rule-thumb-empty, which is why the hover and
1950
+ the drag highlight have to reach it through a descendant selector too. */
1951
+ .dab-rule-thumb-add{display:block;width:100%;padding:0;margin:0;font:inherit;color:inherit;cursor:pointer;-webkit-appearance:none;appearance:none;transition:border-color .2s,background .2s}
1952
+ .dab-rule-thumb-add:hover,.dab-rule-thumb-add.is-over{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,var(--dsw-alias-bg-layer-2))}
1953
+ .dab-rule-thumb-add:hover .dab-rule-thumb-empty,.dab-rule-thumb-add.is-over .dab-rule-thumb-empty{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
1954
+ .dab-rule-thumb-add:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
1512
1955
  .dab-rule-cols{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:18px;align-items:start}
1513
1956
  .dab-rule-section-title{font-size:12px;font-weight:600;margin-bottom:9px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
1957
+
1958
+ /* ── image strip (multi-image rules) ─────────────────────────────────────── */
1959
+ /* A horizontally scrolling row of thumbnails: the edited one is ringed, the one
1960
+ the wallpaper is actually painting carries a live dot. Drops land here, which
1961
+ is why the border reacts to a drag rather than to hover. */
1962
+ .dab-strip{display:flex;gap:7px;margin-top:9px;padding:7px;border-radius:12px;border:1.5px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);overflow-x:auto;scrollbar-width:thin}
1963
+ .dab-strip.is-over{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent)}
1964
+ .dab-strip-item{position:relative;flex:none;width:62px;height:44px;padding:0;border-radius:8px;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);cursor:pointer;transition:border-color .2s,box-shadow .2s,transform .18s}
1965
+ .dab-strip-item img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
1966
+ .dab-strip-item:hover{transform:translateY(-1px);border-color:var(--dsw-alias-brand-primary)}
1967
+ .dab-strip-item.is-sel{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary) 40%,transparent)}
1968
+ .dab-strip-item.is-live{box-shadow:0 0 0 2px var(--dsw-alias-brand-primary)}
1969
+ .dab-strip-wait{position:absolute;inset:0;background:repeating-linear-gradient(45deg,transparent 0 4px,color-mix(in srgb,var(--dsw-alias-label-tertiary) 14%,transparent) 4px 8px)}
1970
+ .dab-strip-num{position:absolute;left:3px;bottom:2px;font-family:var(--dab-mono);font-size:9.5px;line-height:1;padding:1px 4px;border-radius:5px;background:rgba(0,0,0,.45);color:#fff}
1971
+ .dab-strip-live{position:absolute;right:3px;top:3px;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px rgba(0,0,0,.35);animation:dab-pulse 1.8s ease-in-out infinite}
1972
+ /* Each image's OWN theme color, when it has one. It shares the right edge with
1973
+ the live dot but sits at the bottom, where nothing else is: the item number is
1974
+ bottom-LEFT. Two rings (dark outside, light inside) keep the swatch readable
1975
+ over a dark and a light thumbnail alike. */
1976
+ .dab-strip-dot{position:absolute;right:3px;bottom:3px;width:8px;height:8px;border-radius:50%;box-shadow:0 0 0 1.5px rgba(0,0,0,.45),inset 0 0 0 1px rgba(255,255,255,.35)}
1977
+ .dab-strip-add{flex:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;width:62px;height:44px;border-radius:8px;border:1.5px dashed var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:9.5px;line-height:1.15;cursor:pointer;transition:border-color .2s,color .2s}
1978
+ .dab-strip-add:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
1979
+ .dab-strip-pos{display:inline-flex;align-items:center;font-family:var(--dab-mono);font-size:11px;color:var(--dsw-alias-label-tertiary);padding:0 4px}
1980
+
1981
+ /* ── rotation panel ──────────────────────────────────────────────────────── */
1982
+ .dab-rot-panel{margin-top:10px;padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2)}
1983
+ .dab-num-sec{flex:none;width:88px;min-width:0}
1984
+
1514
1985
  @container (max-width:760px){.dab-rule-cols{grid-template-columns:1fr}}
1515
1986
 
1516
1987
  /* ── host self-check ─────────────────────────────────────────────────────── */
@@ -1570,6 +2041,9 @@ window.__ModuleLoader__.load({
1570
2041
  @keyframes dab-toast-in{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
1571
2042
  @keyframes dab-zoom-in{from{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:none}}
1572
2043
  @keyframes dab-flow{to{background-position:300% 50%}}
2044
+ /* The strip's "this is the image on screen" dot. It is the only marker that says
2045
+ the rotation is alive, so it breathes rather than merely sitting there. */
2046
+ @keyframes dab-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.72)}}
1573
2047
 
1574
2048
  /* ── responsive & motion preferences ─────────────────────────────────────── */
1575
2049
  @container (max-width:620px){
@@ -2315,7 +2789,6 @@ window.__ModuleLoader__.load({
2315
2789
  themeObserver = null;
2316
2790
  };
2317
2791
  }
2318
- const FADE_MS = 320;
2319
2792
  let layers = null;
2320
2793
  /** Index of the layer the user is looking at. */
2321
2794
  let front = 0;
@@ -2521,7 +2994,8 @@ window.__ModuleLoader__.load({
2521
2994
  from.el.style.zIndex = "0";
2522
2995
  paintLayer(target, url);
2523
2996
  applyWpEffects();
2524
- if (!canFade(from)) {
2997
+ const fadeMs = rFadeMs();
2998
+ if (!canFade(from) || fadeMs <= 0) {
2525
2999
  el.style.opacity = "1";
2526
3000
  front = idx;
2527
3001
  clearLayer(from);
@@ -2531,10 +3005,10 @@ window.__ModuleLoader__.load({
2531
3005
  pending = idx;
2532
3006
  requestAnimationFrame(() => {
2533
3007
  if (pending !== idx || layers === null) return;
2534
- el.style.transition = `opacity ${FADE_MS}ms ease`;
3008
+ el.style.transition = `opacity ${fadeMs}ms ease`;
2535
3009
  el.style.opacity = "1";
2536
3010
  });
2537
- fadeTimer = window.setTimeout(() => finishFade(idx), 480);
3011
+ fadeTimer = window.setTimeout(() => finishFade(idx), fadeMs + 160);
2538
3012
  }
2539
3013
  function applyWpEffects() {
2540
3014
  if (wpEl === null || layers === null) return;
@@ -2599,9 +3073,9 @@ window.__ModuleLoader__.load({
2599
3073
  }
2600
3074
  //#endregion
2601
3075
  //#region src/client/modelbg.ts
2602
- /** Rules that can actually paint: enabled and carrying an image slot. */
3076
+ /** Rules that can actually paint: see `ruleCanPaint` (./schema). */
2603
3077
  function usable(rules) {
2604
- return rules.filter((r) => r.enabled && r.slot !== "");
3078
+ return rules.filter(ruleCanPaint);
2605
3079
  }
2606
3080
  /**
2607
3081
  * Resolve the rule for one model text.
@@ -2609,6 +3083,10 @@ window.__ModuleLoader__.load({
2609
3083
  * Top→bottom, first case-insensitive substring hit wins; an empty `match` never
2610
3084
  * hits on its own. When nothing hits, the FIRST usable rule is the fallback
2611
3085
  * (rule 1 deliberately doubles as both a matcher and the fallback).
3086
+ *
3087
+ * "Usable" is `ruleCanPaint` — an image, or a theme color of its own — so a rule
3088
+ * whose pictures were all removed still holds the models it matches (and paints
3089
+ * its color alone) instead of quietly handing them to the next rule.
2612
3090
  */
2613
3091
  function matchRule(rules, modelText) {
2614
3092
  const list = usable(rules);
@@ -4232,6 +4710,11 @@ window.__ModuleLoader__.load({
4232
4710
  className: "dab-card dab-rise",
4233
4711
  style: { "--d": 1 },
4234
4712
  children: [
4713
+ store.hostStale ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4714
+ className: "dab-hint dab-stale",
4715
+ style: { marginBottom: 10 },
4716
+ children: t("hostStaleHint")
4717
+ }) : null,
4235
4718
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4236
4719
  className: "dab-swatch-title",
4237
4720
  children: t("tryoutTitle")
@@ -4329,6 +4812,7 @@ window.__ModuleLoader__.load({
4329
4812
  rule,
4330
4813
  index: i,
4331
4814
  total: rules.length,
4815
+ liveIndex: rule.id === store.activeRuleId ? store.rotIndex : -1,
4332
4816
  active: rule.id === store.activeRuleId,
4333
4817
  notify
4334
4818
  }, rule.id))
@@ -4346,7 +4830,7 @@ window.__ModuleLoader__.load({
4346
4830
  })
4347
4831
  ] });
4348
4832
  }
4349
- function RuleCard({ p, rule, index, total, active, notify }) {
4833
+ function RuleCard({ p, rule, index, total, active, liveIndex, notify }) {
4350
4834
  const { t } = p;
4351
4835
  const [open, setOpen] = (0, react.useState)(active);
4352
4836
  const [dragOver, setDragOver] = (0, react.useState)(false);
@@ -4357,49 +4841,98 @@ window.__ModuleLoader__.load({
4357
4841
  const [editorOpen, setEditorOpen] = (0, react.useState)(false);
4358
4842
  const [pickerOpen, setPickerOpen] = (0, react.useState)(false);
4359
4843
  const [extracting, setExtracting] = (0, react.useState)(false);
4844
+ const [secDraft, setSecDraft] = (0, react.useState)(null);
4845
+ /** Whether the next file picked replaces the selected image or is added. */
4846
+ const [replace, setReplace] = (0, react.useState)(false);
4360
4847
  const fileRef = (0, react.useRef)(null);
4361
- const url = p.imageOf(rule.slot);
4362
- const [h, s, l] = rule.color ?? SEED_COLOR;
4848
+ const [sel, setSel] = (0, react.useState)(0);
4849
+ const selIdx = rule.images.length === 0 ? 0 : Math.min(sel, rule.images.length - 1);
4850
+ const image = rule.images[selIdx];
4851
+ const url = image === void 0 ? null : p.imageOf(image.slot);
4852
+ const ownColor = image === void 0 ? rule.color : image.color;
4853
+ const setColor = (color) => {
4854
+ if (image === void 0) p.setRule(rule.id, { color });
4855
+ else p.setImageColor(rule.id, image.slot, color);
4856
+ };
4857
+ const [h, s, l] = ownColor ?? SEED_COLOR;
4363
4858
  const wheel = hslToHsv(h, s, l);
4859
+ (0, react.useEffect)(() => {
4860
+ if (open) p.loadRuleImages(rule.id);
4861
+ }, [
4862
+ open,
4863
+ rule.id,
4864
+ rule.images.length,
4865
+ p
4866
+ ]);
4364
4867
  const onColor = (nh, ns, nl) => {
4365
4868
  const [sh, ss, sl] = hsvToHsl(nh, ns, nl);
4366
- p.setRule(rule.id, { color: [
4869
+ setColor([
4367
4870
  sh,
4368
4871
  ss,
4369
4872
  sl
4370
- ] });
4873
+ ]);
4371
4874
  };
4372
- const onFile = (f) => {
4373
- readImg(f, (d) => {
4374
- if (d !== null) p.setRuleImage(rule.id, d);
4875
+ const onFiles = (files) => {
4876
+ const list = files.filter((f) => f.type.startsWith("image/"));
4877
+ if (list.length === 0) return;
4878
+ if (replace) {
4879
+ const slot = rule.images[selIdx]?.slot;
4880
+ if (slot !== void 0) readImg(list[0], (d) => {
4881
+ if (d !== null) p.setRuleImage(rule.id, slot, d);
4882
+ });
4883
+ return;
4884
+ }
4885
+ const out = [];
4886
+ const first = rule.images.length;
4887
+ let pending = list.length;
4888
+ list.forEach((f, i) => {
4889
+ readImg(f, (d) => {
4890
+ if (d !== null) out[i] = d;
4891
+ pending--;
4892
+ if (pending === 0) {
4893
+ p.addRuleImages(rule.id, out.filter((x) => typeof x === "string"));
4894
+ setSel(first);
4895
+ }
4896
+ });
4375
4897
  });
4376
4898
  };
4377
4899
  const onDrop = (e) => {
4378
4900
  e.preventDefault();
4379
4901
  setDragOver(false);
4380
- const f = e.dataTransfer.files?.[0];
4381
- if (f && f.type.startsWith("image/")) onFile(f);
4902
+ const files = Array.from(e.dataTransfer.files ?? []);
4903
+ if (files.length > 0) onFiles(files);
4382
4904
  };
4383
4905
  const applyUrl = async () => {
4384
- const u = urlVal.trim();
4385
- if (!/^https?:\/\//i.test(u)) {
4906
+ const urls = urlVal.split(/\s+/).map((u) => u.trim()).filter((u) => u !== "");
4907
+ if (urls.length === 0) {
4908
+ setUrlErr(t("ruleUrlBadHttp"));
4909
+ return;
4910
+ }
4911
+ if (urls.some((u) => !/^https?:\/\//i.test(u))) {
4386
4912
  setUrlErr(t("ruleUrlBadHttp"));
4387
4913
  return;
4388
4914
  }
4389
4915
  setUrlBusy(true);
4390
4916
  setUrlErr(null);
4391
- const res = await p.setRuleImageFromUrl(rule.id, u);
4917
+ const first = rule.images.length;
4918
+ for (const u of urls) {
4919
+ const res = await p.addRuleImageFromUrl(rule.id, u);
4920
+ if (!res.ok) {
4921
+ setUrlBusy(false);
4922
+ setUrlErr(res.error === "invalid url" || res.error === "unsupported scheme" ? t("ruleUrlBadHttp") : res.error ?? t("ruleUrlFail"));
4923
+ return;
4924
+ }
4925
+ }
4392
4926
  setUrlBusy(false);
4393
- if (res.ok) {
4394
- setUrlOpen(false);
4395
- setUrlVal("");
4396
- } else setUrlErr(res.error === "invalid url" || res.error === "unsupported scheme" ? t("ruleUrlBadHttp") : res.error ?? t("ruleUrlFail"));
4927
+ setUrlOpen(false);
4928
+ setUrlVal("");
4929
+ setSel(first);
4397
4930
  };
4398
4931
  const onExtract = async () => {
4399
- if (url === null || extracting) return;
4932
+ if (url === null || extracting || image === void 0) return;
4400
4933
  setExtracting(true);
4401
4934
  try {
4402
- const ok = await p.extractColor(rule.id);
4935
+ const ok = await p.extractColor(rule.id, image.slot);
4403
4936
  notify(ok ? t("extractDone") : t("extractFail"), ok);
4404
4937
  } catch {
4405
4938
  notify(t("extractFail"), false);
@@ -4414,17 +4947,37 @@ window.__ModuleLoader__.load({
4414
4947
  * extract button runs and returns false when nothing vivid was found.)
4415
4948
  */
4416
4949
  const onPickColor = async () => {
4417
- if (url !== null) {
4950
+ if (url !== null && image !== void 0) {
4418
4951
  setExtracting(true);
4419
4952
  try {
4420
- if (await p.extractColor(rule.id)) return;
4953
+ if (await p.extractColor(rule.id, image.slot)) return;
4421
4954
  } catch {} finally {
4422
4955
  setExtracting(false);
4423
4956
  }
4424
4957
  }
4425
- p.setRule(rule.id, { color: SEED_COLOR });
4958
+ setColor(SEED_COLOR);
4426
4959
  };
4427
4960
  const cls = `dab-rule${active ? " is-active" : ""}${rule.enabled ? "" : " is-off"}`;
4961
+ const rotatable = rule.images.length >= 2;
4962
+ const preset = ROTATE_PRESETS.find((x) => x.ms === rule.rotate.intervalMs);
4963
+ const selSlot = image?.slot ?? null;
4964
+ const moveSel = (dir) => {
4965
+ if (selSlot !== null) p.moveRuleImage(rule.id, selSlot, dir);
4966
+ };
4967
+ const removeSel = () => {
4968
+ if (selSlot === null) return;
4969
+ const at = selIdx;
4970
+ p.removeRuleImage(rule.id, selSlot);
4971
+ setSel(Math.max(0, Math.min(at, rule.images.length - 2)));
4972
+ };
4973
+ /** Commit the custom dwell time the moment it is typed (seconds → ms). */
4974
+ const commitSeconds = () => {
4975
+ if (secDraft === null) return;
4976
+ const secs = Number(secDraft.trim());
4977
+ setSecDraft(null);
4978
+ if (!Number.isFinite(secs) || secs <= 0) return;
4979
+ p.setRuleRotation(rule.id, { intervalMs: Math.round(secs * 1e3) });
4980
+ };
4428
4981
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
4429
4982
  className: cls,
4430
4983
  children: [
@@ -4492,33 +5045,107 @@ window.__ModuleLoader__.load({
4492
5045
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4493
5046
  className: "dab-rule-cols",
4494
5047
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
4495
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4496
- className: `dab-rule-thumb${dragOver ? " is-over" : ""}`,
4497
- onClick: () => fileRef.current?.click(),
5048
+ url !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5049
+ className: "dab-rule-thumb",
5050
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
5051
+ src: url,
5052
+ alt: "",
5053
+ draggable: false
5054
+ })
5055
+ }) : rule.images.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5056
+ type: "button",
5057
+ className: `dab-rule-thumb dab-rule-thumb-add${dragOver ? " is-over" : ""}`,
5058
+ title: t("rotAddImage"),
4498
5059
  onDragOver: (e) => {
4499
5060
  e.preventDefault();
4500
5061
  setDragOver(true);
4501
5062
  },
4502
5063
  onDragLeave: () => setDragOver(false),
4503
5064
  onDrop,
4504
- children: url !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
4505
- src: url,
4506
- alt: "",
4507
- draggable: false
4508
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5065
+ onClick: () => {
5066
+ setReplace(false);
5067
+ fileRef.current?.click();
5068
+ },
5069
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5070
+ className: "dab-rule-thumb-empty",
5071
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 18 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rotAddImage") })]
5072
+ })
5073
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5074
+ className: "dab-rule-thumb",
5075
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4509
5076
  className: "dab-rule-thumb-empty",
4510
5077
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 18 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("ruleNoImage") })]
4511
5078
  })
4512
5079
  }),
5080
+ rule.images.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5081
+ className: `dab-strip${dragOver ? " is-over" : ""}`,
5082
+ onDragOver: (e) => {
5083
+ e.preventDefault();
5084
+ setDragOver(true);
5085
+ },
5086
+ onDragLeave: () => setDragOver(false),
5087
+ onDrop,
5088
+ children: [rule.images.map((img, i) => {
5089
+ const thumb = p.imageOf(img.slot);
5090
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5091
+ type: "button",
5092
+ className: `dab-strip-item${i === selIdx ? " is-sel" : ""}${liveIndex === i ? " is-live" : ""}`,
5093
+ title: `${i + 1} / ${rule.images.length}`,
5094
+ onClick: () => setSel(i),
5095
+ children: [
5096
+ thumb !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
5097
+ src: thumb,
5098
+ alt: "",
5099
+ draggable: false
5100
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-strip-wait" }),
5101
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5102
+ className: "dab-strip-num",
5103
+ children: i + 1
5104
+ }),
5105
+ img.color !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5106
+ className: "dab-strip-dot",
5107
+ title: toHex(hslToRgb(img.color[0], img.color[1], img.color[2])).toUpperCase(),
5108
+ style: { background: `hsl(${img.color[0]} ${Math.round(img.color[1] * 100)}% ${Math.round(img.color[2] * 100)}%)` }
5109
+ }) : null,
5110
+ liveIndex === i ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5111
+ className: "dab-strip-live",
5112
+ title: t("rotShowing")
5113
+ }) : null
5114
+ ]
5115
+ }, img.slot);
5116
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5117
+ type: "button",
5118
+ className: "dab-strip-add",
5119
+ title: t("rotAddImage"),
5120
+ onClick: () => {
5121
+ setReplace(false);
5122
+ fileRef.current?.click();
5123
+ },
5124
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 16 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("rotAddImage") })]
5125
+ })]
5126
+ }) : null,
4513
5127
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4514
5128
  className: "dab-chip-row",
4515
5129
  style: { marginTop: 10 },
4516
5130
  children: [
5131
+ rule.images.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5132
+ className: "dab-strip-pos",
5133
+ children: [
5134
+ t("rotPos"),
5135
+ " ",
5136
+ selIdx + 1,
5137
+ " / ",
5138
+ rule.images.length
5139
+ ]
5140
+ }) : null,
4517
5141
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4518
5142
  type: "button",
4519
5143
  className: "dab-btn",
4520
- onClick: () => fileRef.current?.click(),
4521
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 13 }), url === null ? t("rulePickImage") : t("ruleChangeImage")]
5144
+ onClick: () => {
5145
+ setReplace(false);
5146
+ fileRef.current?.click();
5147
+ },
5148
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 13 }), t("rotAddImage")]
4522
5149
  }),
4523
5150
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4524
5151
  type: "button",
@@ -4526,16 +5153,51 @@ window.__ModuleLoader__.load({
4526
5153
  onClick: () => setUrlOpen((o) => !o),
4527
5154
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LinkIcon, { size: 13 }), t("ruleFromUrl")]
4528
5155
  }),
5156
+ selSlot !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5157
+ type: "button",
5158
+ className: "dab-btn dab-btn-ghost",
5159
+ onClick: () => {
5160
+ setReplace(true);
5161
+ fileRef.current?.click();
5162
+ },
5163
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 13 }), t("rotReplaceImage")]
5164
+ }) : null,
4529
5165
  url !== null && rule.bgMode === "fit" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4530
5166
  type: "button",
4531
5167
  className: "dab-btn",
4532
5168
  onClick: () => setEditorOpen(true),
4533
5169
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(EditIcon, { size: 13 }), t("ruleFramingEdit")]
4534
5170
  }) : null,
4535
- url !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5171
+ rule.images.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5172
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5173
+ type: "button",
5174
+ className: "dab-icon-btn",
5175
+ disabled: selIdx === 0,
5176
+ title: t("rotImageEarlier"),
5177
+ onClick: () => moveSel(-1),
5178
+ children: "↑"
5179
+ }),
5180
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5181
+ type: "button",
5182
+ className: "dab-icon-btn",
5183
+ disabled: selIdx === rule.images.length - 1,
5184
+ title: t("rotImageLater"),
5185
+ onClick: () => moveSel(1),
5186
+ children: "↓"
5187
+ }),
5188
+ selIdx !== 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5189
+ type: "button",
5190
+ className: "dab-btn dab-btn-ghost",
5191
+ onClick: () => {
5192
+ if (selSlot !== null) p.setCurrentImage(rule.id, selSlot);
5193
+ },
5194
+ children: t("rotMakeFirst")
5195
+ }) : null
5196
+ ] }) : null,
5197
+ selSlot !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4536
5198
  type: "button",
4537
5199
  className: "dab-btn dab-btn-ghost dab-btn-danger",
4538
- onClick: () => p.setRuleImage(rule.id, null),
5200
+ onClick: removeSel,
4539
5201
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, { size: 13 }), t("ruleImageRemove")]
4540
5202
  }) : null
4541
5203
  ]
@@ -4590,11 +5252,150 @@ window.__ModuleLoader__.load({
4590
5252
  },
4591
5253
  children: urlErr
4592
5254
  }) : null,
4593
- url === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5255
+ rule.images.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4594
5256
  className: "dab-hint",
4595
5257
  style: { marginTop: 8 },
4596
- children: t("ruleEmptyHint")
5258
+ children: rule.color === null ? t("ruleEmptyHint") : t("ruleColorOnlyHint")
5259
+ }) : null,
5260
+ rule.images.length > 0 && url === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5261
+ className: "dab-hint",
5262
+ style: { marginTop: 8 },
5263
+ children: t("ruleImagePendingHint")
4597
5264
  }) : null,
5265
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5266
+ style: { marginTop: 14 },
5267
+ children: [
5268
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5269
+ className: "dab-rule-section-title",
5270
+ children: t("rotTitle")
5271
+ }),
5272
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5273
+ className: "dab-chip-row",
5274
+ style: { alignItems: "center" },
5275
+ children: [
5276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5277
+ type: "button",
5278
+ className: `dab-toggle${rule.rotate.enabled ? " is-on" : ""}`,
5279
+ role: "switch",
5280
+ "aria-checked": rule.rotate.enabled,
5281
+ title: t("rotEnable"),
5282
+ disabled: !rotatable,
5283
+ onClick: () => p.setRuleRotation(rule.id, { enabled: !rule.rotate.enabled }),
5284
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
5285
+ }),
5286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5287
+ className: "dab-hint",
5288
+ style: { padding: 0 },
5289
+ children: t("rotEnable")
5290
+ }),
5291
+ !rotatable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5292
+ className: "dab-hint",
5293
+ style: { padding: 0 },
5294
+ children: t("rotNeedTwo")
5295
+ }) : null
5296
+ ]
5297
+ }),
5298
+ rotatable && rule.rotate.enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5299
+ className: "dab-rot-panel",
5300
+ children: [
5301
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5302
+ className: "dab-rule-section-title",
5303
+ children: t("rotEvery")
5304
+ }),
5305
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5306
+ className: "dab-chip-row",
5307
+ children: [
5308
+ ROTATE_PRESETS.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5309
+ type: "button",
5310
+ className: `dab-chip${item.ms === rule.rotate.intervalMs ? " is-active" : ""}`,
5311
+ onClick: () => p.setRuleRotation(rule.id, { intervalMs: item.ms }),
5312
+ children: t(item.key)
5313
+ }, item.ms)),
5314
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5315
+ type: "number",
5316
+ className: "dab-num dab-num-sec",
5317
+ min: 5,
5318
+ max: 86400,
5319
+ step: 5,
5320
+ value: secDraft ?? (preset === void 0 ? Math.round(rule.rotate.intervalMs / 1e3) : ""),
5321
+ placeholder: t("rotCustom"),
5322
+ onChange: (e) => setSecDraft(e.target.value),
5323
+ onBlur: commitSeconds,
5324
+ onKeyDown: (e) => {
5325
+ if (e.key === "Enter") {
5326
+ e.preventDefault();
5327
+ commitSeconds();
5328
+ }
5329
+ }
5330
+ }),
5331
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5332
+ className: "dab-hint",
5333
+ style: { padding: 0 },
5334
+ children: t("rotSeconds")
5335
+ })
5336
+ ]
5337
+ }),
5338
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5339
+ className: "dab-rule-section-title",
5340
+ style: { marginTop: 12 },
5341
+ children: t("rotOrder")
5342
+ }),
5343
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5344
+ className: "dab-chip-row",
5345
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5346
+ type: "button",
5347
+ className: `dab-chip${rule.rotate.order === "order" ? " is-active" : ""}`,
5348
+ onClick: () => p.setRuleRotation(rule.id, { order: "order" }),
5349
+ children: t("rotOrderSeq")
5350
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5351
+ type: "button",
5352
+ className: `dab-chip${rule.rotate.order === "shuffle" ? " is-active" : ""}`,
5353
+ onClick: () => p.setRuleRotation(rule.id, { order: "shuffle" }),
5354
+ children: t("rotOrderShuffle")
5355
+ })]
5356
+ }),
5357
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5358
+ className: "dab-chip-row",
5359
+ style: {
5360
+ marginTop: 12,
5361
+ alignItems: "center"
5362
+ },
5363
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5364
+ type: "button",
5365
+ className: `dab-toggle${rule.rotate.advanceOnSwitch ? " is-on" : ""}`,
5366
+ role: "switch",
5367
+ "aria-checked": rule.rotate.advanceOnSwitch,
5368
+ title: t("rotOnSwitch"),
5369
+ onClick: () => p.setRuleRotation(rule.id, { advanceOnSwitch: !rule.rotate.advanceOnSwitch }),
5370
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
5371
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5372
+ className: "dab-hint",
5373
+ style: { padding: 0 },
5374
+ children: t("rotOnSwitch")
5375
+ })]
5376
+ }),
5377
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5378
+ className: "dab-chip-row",
5379
+ style: {
5380
+ marginTop: 12,
5381
+ alignItems: "center"
5382
+ },
5383
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5384
+ type: "button",
5385
+ className: "dab-btn",
5386
+ disabled: !active,
5387
+ onClick: () => p.rotateNow(rule.id),
5388
+ children: t("rotNext")
5389
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5390
+ className: "dab-hint",
5391
+ style: { padding: 0 },
5392
+ children: active ? `${t("rotShowing")} ${rule.images.length === 0 ? 0 : Math.min(liveIndex + 1, rule.images.length)} / ${rule.images.length}` : t("rotNextInactive")
5393
+ })]
5394
+ })
5395
+ ]
5396
+ }) : null
5397
+ ]
5398
+ }),
4598
5399
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4599
5400
  style: { marginTop: 14 },
4600
5401
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -4615,7 +5416,12 @@ window.__ModuleLoader__.load({
4615
5416
  className: "dab-rule-section-title",
4616
5417
  children: t("ruleColor")
4617
5418
  }),
4618
- rule.color === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5419
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5420
+ className: "dab-hint",
5421
+ style: { marginTop: 6 },
5422
+ children: image === void 0 ? t("ruleColorTargetRule") : `${t("ruleColorTargetImage")} ${selIdx + 1} / ${rule.images.length}`
5423
+ }),
5424
+ ownColor === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4619
5425
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4620
5426
  className: "dab-chip-row",
4621
5427
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -4637,10 +5443,18 @@ window.__ModuleLoader__.load({
4637
5443
  style: { marginTop: 8 },
4638
5444
  children: t("ruleColorNoneHint")
4639
5445
  }),
4640
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5446
+ url !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4641
5447
  className: "dab-hint",
4642
5448
  style: { marginTop: 6 },
4643
5449
  children: t("ruleColorPickHint")
5450
+ }) : image === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5451
+ className: "dab-hint",
5452
+ style: { marginTop: 6 },
5453
+ children: t("ruleColorNoImageHint")
5454
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5455
+ className: "dab-hint",
5456
+ style: { marginTop: 6 },
5457
+ children: t("ruleColorPendingImageHint")
4644
5458
  })
4645
5459
  ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4646
5460
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4686,7 +5500,7 @@ window.__ModuleLoader__.load({
4686
5500
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4687
5501
  type: "button",
4688
5502
  className: "dab-btn dab-btn-ghost",
4689
- onClick: () => p.setRule(rule.id, { color: null }),
5503
+ onClick: () => setColor(null),
4690
5504
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SunIcon, { size: 13 }), t("ruleColorNone")]
4691
5505
  })
4692
5506
  ]
@@ -4699,11 +5513,11 @@ window.__ModuleLoader__.load({
4699
5513
  className: "dab-swatch",
4700
5514
  style: { background: `hsl(${sh} ${Math.round(ss * 100)}% ${Math.round(sl * 100)}%)` },
4701
5515
  title: toHex(hslToRgb(sh, ss, sl)).toUpperCase(),
4702
- onClick: () => p.setRule(rule.id, { color: [
5516
+ onClick: () => setColor([
4703
5517
  sh,
4704
5518
  ss,
4705
5519
  sl
4706
- ] })
5520
+ ])
4707
5521
  }, i))
4708
5522
  })
4709
5523
  ] }),
@@ -4742,27 +5556,29 @@ window.__ModuleLoader__.load({
4742
5556
  ref: fileRef,
4743
5557
  type: "file",
4744
5558
  accept: "image/*",
5559
+ multiple: true,
4745
5560
  style: { display: "none" },
4746
5561
  onChange: (e) => {
4747
- const f = e.target.files?.[0];
4748
- if (!f) return;
4749
- onFile(f);
5562
+ const files = Array.from(e.target.files ?? []);
5563
+ if (files.length === 0) return;
5564
+ onFiles(files);
4750
5565
  e.target.value = "";
5566
+ setReplace(false);
4751
5567
  }
4752
5568
  }),
4753
- editorOpen && url !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BgEditor, {
5569
+ editorOpen && url !== null && image !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BgEditor, {
4754
5570
  url,
4755
- state: rule.bgState,
5571
+ state: image.bgState,
4756
5572
  t,
4757
5573
  onClose: () => setEditorOpen(false),
4758
5574
  onCommit: (z, x, y, iw, ih) => {
4759
- p.setRule(rule.id, { bgState: {
5575
+ p.setImageFraming(rule.id, image.slot, {
4760
5576
  zoom: z,
4761
5577
  x,
4762
5578
  y,
4763
5579
  iw,
4764
5580
  ih
4765
- } });
5581
+ });
4766
5582
  setEditorOpen(false);
4767
5583
  }
4768
5584
  }) : null,
@@ -5047,6 +5863,35 @@ window.__ModuleLoader__.load({
5047
5863
  if (ctx.theme.getTheme().preference === CUSTOM_ID) ctx.theme.setTheme("system");
5048
5864
  } catch {}
5049
5865
  };
5866
+ /**
5867
+ * Re-emit the interface palette for whatever is painted RIGHT NOW.
5868
+ *
5869
+ * Called from two places, and the second one is why this is a function: a
5870
+ * model switch (`applyActive`) and a ROTATION STEP (`paintImage`). A theme color
5871
+ * belongs to an image since 0.7.1, so stepping to the next picture changes the
5872
+ * palette as much as switching models does — and a step that only repainted the
5873
+ * wallpaper would leave the previous picture's color on the interface until
5874
+ * something else happened to re-run an apply (dragging the wheel, switching
5875
+ * models), which is precisely the "it only updates when I touch the color wheel"
5876
+ * report this plugin already had once.
5877
+ *
5878
+ * The debounce is for the OTHER caller: a color drag runs a full apply dozens of
5879
+ * times per second, and the host-visible skin switch (dispose + register +
5880
+ * activate) must not happen on every pointer move. A rotation step pays the same
5881
+ * 60 ms, which is invisible next to a dwell time of 5 s or more.
5882
+ */
5883
+ const applyPalette = () => {
5884
+ const color = activeColor();
5885
+ if (skinTimer !== null) window.clearTimeout(skinTimer);
5886
+ if (color === null) {
5887
+ skinTimer = null;
5888
+ dropCustom();
5889
+ } else skinTimer = window.setTimeout(() => {
5890
+ skinTimer = null;
5891
+ registerCustom(color[0], color[1], color[2]);
5892
+ }, 60);
5893
+ syncHostScheme();
5894
+ };
5050
5895
  ctx.effect(() => () => {
5051
5896
  if (skinTimer !== null) window.clearTimeout(skinTimer);
5052
5897
  customDispose?.();
@@ -5074,9 +5919,13 @@ window.__ModuleLoader__.load({
5074
5919
  modelSource: "session",
5075
5920
  modelNote: "waiting",
5076
5921
  activeRuleId: null,
5077
- matched: false
5922
+ matched: false,
5923
+ rotIndex: 0,
5924
+ rotTotal: 0,
5925
+ rotating: false,
5926
+ hostStale: false
5078
5927
  }),
5079
- actions: { sync: (d, url, r, rr, model, source, note, id, matched) => {
5928
+ actions: { sync: (d, url, r, rr, model, source, note, id, matched, rotIndex, rotTotal, rotating, hostStale) => {
5080
5929
  if (r > d.rev) {
5081
5930
  d.url = url;
5082
5931
  d.rev = r;
@@ -5087,12 +5936,31 @@ window.__ModuleLoader__.load({
5087
5936
  d.modelNote = note;
5088
5937
  d.activeRuleId = id;
5089
5938
  d.matched = matched;
5939
+ d.rotIndex = rotIndex;
5940
+ d.rotTotal = rotTotal;
5941
+ d.rotating = rotating;
5942
+ d.hostStale = hostStale;
5090
5943
  } }
5091
5944
  });
5092
5945
  let bound = null;
5946
+ /** Rotation facts of whatever is painted now, for the section's readout. */
5947
+ const rotationFacts = () => {
5948
+ const rule = activeRule();
5949
+ if (rule === null) return {
5950
+ index: 0,
5951
+ total: 0,
5952
+ rotating: false
5953
+ };
5954
+ return {
5955
+ index: imageIndexOf(rule),
5956
+ total: rule.images.length,
5957
+ rotating: isRotating(rule.images.length, rule.rotate.enabled, rule.rotate.intervalMs) && !document.hidden
5958
+ };
5959
+ };
5093
5960
  const sync = () => {
5094
5961
  rev++;
5095
- bound?.sync(rWp(), rev, rulesRev, modelLabel !== "" ? modelLabel : modelText, modelSource, modelNote, activeRuleId, activeMatched);
5962
+ const rot = rotationFacts();
5963
+ bound?.sync(rWp(), rev, rulesRev, modelLabel !== "" ? modelLabel : modelText, modelSource, modelNote, activeRuleId, activeMatched, rot.index, rot.total, rot.rotating, hostIsLegacy());
5096
5964
  };
5097
5965
  /**
5098
5966
  * Publish the scheme the host's own preference resolves to. The theme service
@@ -5108,27 +5976,141 @@ window.__ModuleLoader__.load({
5108
5976
  setHostScheme(null);
5109
5977
  }
5110
5978
  };
5111
- /** Resolve the active rule for the current model and repaint everything. */
5112
- const applyActive = () => {
5979
+ /**
5980
+ * Which rule wins for the current model RIGHT NOW, and whether it was a
5981
+ * substring hit (a holiday override is never a "match" of the model).
5982
+ *
5983
+ * Read by the repaint AND by the panel's write path, which has to know whether
5984
+ * an edit changed the answer: `color`, `enabled` and `match` all feed the
5985
+ * resolution (a rule with no image still paints from its own color — see
5986
+ * `ruleCanPaint`). Without this, editing a rule that was not the active one —
5987
+ * giving an emptied rule its first color, typing a match string that should
5988
+ * promote it — changed nothing on screen until some unrelated event happened to
5989
+ * re-run the resolution.
5990
+ */
5991
+ const resolveRule = () => {
5113
5992
  const holiday = activeHolidayRule();
5114
- const { rule, matched } = holiday !== null ? {
5993
+ return holiday !== null ? {
5115
5994
  rule: holiday,
5116
5995
  matched: false
5117
5996
  } : matchRule(cfg.rules, modelText);
5997
+ };
5998
+ /** Id of the rule that would paint right now; `null` = the host's own look. */
5999
+ const winnerId = () => resolveRule().rule?.id ?? null;
6000
+ /**
6001
+ * Close a rule edit: repaint when it could have moved what is on screen, and
6002
+ * merely refresh the panel's readout when it could not.
6003
+ *
6004
+ * `winnerBefore` is sampled by the caller BEFORE its mutation (`winnerId()`),
6005
+ * and the comparison itself lives in ./repaint, where it is checked on plain
6006
+ * node. Every write path below ends here — the old per-path test
6007
+ * (`id === activeRuleId`) missed the edit that CREATES the winner, which is the
6008
+ * "上传图片 / 提取主题色之后界面不动,动一下调色盘或切模型才出现" report.
6009
+ */
6010
+ const repaintIfMoved = (id, winnerBefore) => {
6011
+ if (shouldRepaint(id, winnerBefore, winnerId())) applyActive();
6012
+ else sync();
6013
+ };
6014
+ /**
6015
+ * Resolve the active rule for the current model and repaint everything.
6016
+ *
6017
+ * @param advance - true only on a MODEL switch: the rotation of the rule that
6018
+ * took over may then step once (`rotate.advanceOnSwitch`), which is how a
6019
+ * model gets "a different picture every time" without any timer at all.
6020
+ */
6021
+ const applyActive = (advance = false) => {
6022
+ const { rule, matched } = resolveRule();
5118
6023
  setActive(rule === null ? null : rule.id, matched);
5119
- const color = rule === null ? null : rule.color;
5120
- if (skinTimer !== null) window.clearTimeout(skinTimer);
5121
- if (color === null) {
5122
- skinTimer = null;
5123
- dropCustom();
5124
- } else skinTimer = window.setTimeout(() => {
5125
- skinTimer = null;
5126
- registerCustom(color[0], color[1], color[2]);
5127
- }, 60);
5128
- syncHostScheme();
6024
+ if (advance && rule !== null && rule.rotate.advanceOnSwitch) {
6025
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
6026
+ if (next !== null) setRotIndex(next);
6027
+ }
6028
+ scheduleRotation();
6029
+ applyPalette();
5129
6030
  applyWp();
5130
6031
  sync();
5131
6032
  };
6033
+ let rotTimer = null;
6034
+ /** Identity of the schedule currently armed; a change means "re-arm". */
6035
+ let rotSig = "";
6036
+ /** Warm one image so a later switch fades toward bytes that are already here. */
6037
+ const warmImage = (rule, idx) => {
6038
+ const slot = rule.images[idx]?.slot;
6039
+ if (slot !== void 0) loadSlot(slot);
6040
+ };
6041
+ /** Show the image at `idx` at once, loading it first when it is not in memory. */
6042
+ const paintImage = (rule, idx) => {
6043
+ const slot = rule.images[idx]?.slot;
6044
+ if (slot === void 0) return;
6045
+ applyPalette();
6046
+ if (imageOf(slot) !== null) {
6047
+ applyWp();
6048
+ sync();
6049
+ return;
6050
+ }
6051
+ loadSlot(slot).then(() => {
6052
+ applyWp();
6053
+ sync();
6054
+ });
6055
+ };
6056
+ const rotateTick = () => {
6057
+ const rule = activeRule();
6058
+ if (rule === null) return;
6059
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
6060
+ if (next === null) {
6061
+ scheduleRotation(true);
6062
+ return;
6063
+ }
6064
+ setRotIndex(next);
6065
+ paintImage(rule, next);
6066
+ const after = nextIndex(rule.images.length, rule.rotate.order, next);
6067
+ if (after !== null) warmImage(rule, after);
6068
+ };
6069
+ /**
6070
+ * Arm (or re-arm) the rotation for the active rule.
6071
+ *
6072
+ * Idempotent by SIGNATURE rather than by "already armed": a slider drag on the
6073
+ * active rule re-runs the whole apply dozens of times per second, and restarting
6074
+ * the interval on each of those would mean the dwell timer never elapses while
6075
+ * the user is dragging (and a 10 s rotation would never advance). The signature
6076
+ * carries exactly the inputs the schedule depends on, so a real change — another
6077
+ * rule, another interval, the tab becoming visible again — re-arms while a
6078
+ * repaint does not.
6079
+ */
6080
+ const scheduleRotation = (force = false) => {
6081
+ const rule = activeRule();
6082
+ const sig = rule === null ? "" : [
6083
+ rule.id,
6084
+ rule.rotate.enabled,
6085
+ rule.rotate.intervalMs,
6086
+ rule.rotate.order,
6087
+ rule.images.length,
6088
+ document.hidden
6089
+ ].join("|");
6090
+ if (!force && sig === rotSig) return;
6091
+ rotSig = sig;
6092
+ if (rotTimer !== null) {
6093
+ window.clearInterval(rotTimer);
6094
+ rotTimer = null;
6095
+ }
6096
+ if (rule === null || document.hidden) return;
6097
+ if (!isRotating(rule.images.length, rule.rotate.enabled, rule.rotate.intervalMs)) return;
6098
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
6099
+ if (next !== null) warmImage(rule, next);
6100
+ rotTimer = window.setInterval(rotateTick, rule.rotate.intervalMs);
6101
+ };
6102
+ ctx.effect(() => () => {
6103
+ if (rotTimer !== null) {
6104
+ window.clearInterval(rotTimer);
6105
+ rotTimer = null;
6106
+ }
6107
+ }, "dsh-background-by-model: rotation timer");
6108
+ const onVisibility = () => {
6109
+ scheduleRotation();
6110
+ sync();
6111
+ };
6112
+ document.addEventListener("visibilitychange", onVisibility);
6113
+ ctx.effect(() => () => document.removeEventListener("visibilitychange", onVisibility), "dsh-background-by-model: rotation visibility");
5132
6114
  /**
5133
6115
  * The holiday the background must follow right now, or null.
5134
6116
  *
@@ -5150,7 +6132,7 @@ window.__ModuleLoader__.load({
5150
6132
  modelSource = source;
5151
6133
  modelNote = note;
5152
6134
  setModelLabel(label !== "" ? label : text);
5153
- if (textChanged) applyActive();
6135
+ if (textChanged) applyActive(true);
5154
6136
  else sync();
5155
6137
  });
5156
6138
  ctx.effect(() => () => offModel(), "dsh-background-by-model: model watch");
@@ -5197,29 +6179,55 @@ window.__ModuleLoader__.load({
5197
6179
  const id = activeHoliday();
5198
6180
  if (id === null) return null;
5199
6181
  const item = holidayById(id);
5200
- return item !== null && item.enabled ? item.slot : null;
6182
+ if (item === null || !item.enabled) return null;
6183
+ return ruleSlots(item)[0] ?? null;
5201
6184
  };
5202
- /** Every slot that can paint: the stored ones plus every slot the config names. */
5203
- const allSlots = (stored) => {
6185
+ /**
6186
+ * The slots boot hydrates, in no particular order: the FIRST image of every
6187
+ * rule, today's holiday, and the image the current model paints.
6188
+ *
6189
+ * That first-image set is exactly what this plugin loaded per rule before
6190
+ * multi-image existed — a rule card draws its first image as its thumbnail, so
6191
+ * the settings page looks the same as it always did. Everything past the first
6192
+ * image of a rule is read ON DEMAND: by the rotation (which warms the image it
6193
+ * is about to need as soon as its schedule is armed) and by the settings page
6194
+ * when a card is expanded. Hydrating every image of every rule here would mean
6195
+ * twenty full-size data URLs over this channel before the first frame on a
6196
+ * five-rule setup with four pictures each, which is the one cost this feature
6197
+ * must not pay.
6198
+ *
6199
+ * The slot list the store knows about (`readPersisted().slots`) is deliberately
6200
+ * NOT hydrated any more: an orphaned slot that no rule references has no card to
6201
+ * appear on, so pulling its bytes over the channel at boot is pure waste. It
6202
+ * stays on disk — and is still reported by the node half — until a rule
6203
+ * references it again or it is deleted with its rule.
6204
+ */
6205
+ const bootSlots = () => {
5204
6206
  const holiday = todayHolidaySlot();
6207
+ const firsts = cfg.rules.map((r) => r.images[0]?.slot).filter((slot) => slot !== void 0);
6208
+ const current = prioritySlot();
5205
6209
  return Array.from(/* @__PURE__ */ new Set([
5206
- ...stored,
5207
- ...cfg.rules.map((r) => r.slot),
5208
- ...holiday === null ? [] : [holiday]
6210
+ ...firsts,
6211
+ ...holiday === null ? [] : [holiday],
6212
+ ...current === null ? [] : [current]
5209
6213
  ]));
5210
6214
  };
5211
6215
  /**
5212
6216
  * The slot that paints before anything else: the holiday in force when there
5213
- * is one, otherwise the slot the current model's rule uses. A holiday outranks
5214
- * the rule here for the same reason it outranks it when painting.
6217
+ * is one, otherwise the FIRST image of the current model's rule. A holiday
6218
+ * outranks the rule here for the same reason it outranks it when painting.
6219
+ *
6220
+ * Always the first image (never the one `advanceOnSwitch` would step to): boot
6221
+ * paints without an advance, and hydrating an image the first frame is not going
6222
+ * to use would leave the frame blank until the real one arrived.
5215
6223
  */
5216
- const prioritySlot = () => todayHolidaySlot() ?? matchRule(cfg.rules, modelText).rule?.slot ?? null;
6224
+ const prioritySlot = () => todayHolidaySlot() ?? matchRule(cfg.rules, modelText).rule?.images[0]?.slot ?? null;
5217
6225
  (async () => {
5218
6226
  const persisted = await loadPersisted();
5219
6227
  if (persisted !== null) {
5220
6228
  adoptConfig(persisted.config);
5221
6229
  const first = prioritySlot();
5222
- const slots = allSlots(persisted.slots);
6230
+ const slots = bootSlots();
5223
6231
  const order = first === null ? slots : [first, ...slots.filter((s) => s !== first)];
5224
6232
  for (const slot of order) {
5225
6233
  await loadSlot(slot);
@@ -5233,7 +6241,7 @@ window.__ModuleLoader__.load({
5233
6241
  }, "dsh-background-by-model: wp cleanup");
5234
6242
  ctx.effect(() => ctx.on("theme/change", () => {
5235
6243
  syncHostScheme();
5236
- if (activeRuleColor() !== null) {
6244
+ if (activeColor() !== null) {
5237
6245
  const snapshot = ctx.theme.getTheme();
5238
6246
  if (snapshot.preference !== CUSTOM_ID && snapshot.themes.some((t) => t.id === CUSTOM_ID)) ctx.theme.setTheme(CUSTOM_ID);
5239
6247
  }
@@ -5264,34 +6272,47 @@ window.__ModuleLoader__.load({
5264
6272
  en
5265
6273
  }), "dsh-background-by-model: i18n");
5266
6274
  /**
5267
- * The automatic half of "extract from image": fill a rule's theme color from
5268
- * the picture it just received, but only while the rule has no color at all —
5269
- * a color the user picked (or a previous extraction produced) is never
5270
- * overwritten, so this cannot fight a deliberate choice.
6275
+ * The automatic half of "extract from image": fill an IMAGE's theme color from
6276
+ * its own bytes when it has none of its own, and only then — a color the user
6277
+ * picked (or a previous extraction produced) is never overwritten, so this
6278
+ * cannot fight a deliberate choice.
6279
+ *
6280
+ * `slot` names the image that just arrived, because a rule's images each carry
6281
+ * their own palette since 0.7.1 and the one the user just added is the one they
6282
+ * are looking at. Omitted (or unknown) falls back to the rule's first image.
6283
+ *
6284
+ * The question is asked of the IMAGE, not of the rule: a rule whose pictures
6285
+ * were all cleared and then re-filled must theme each new picture, and a rule
6286
+ * with a color of its own still has no say over the colors of its pictures.
5271
6287
  *
5272
6288
  * The decode is asynchronous, so the "still empty?" question is asked AGAIN on
5273
6289
  * arrival: a color chosen while it ran must win over the one being computed.
5274
6290
  */
5275
- const maybeAutoExtract = async (id) => {
6291
+ const maybeAutoExtract = async (id, slot) => {
5276
6292
  if (!cfg.autoExtract) return;
5277
- const rule = ruleById(id);
5278
- if (rule === null || rule.color !== null) return;
5279
- const url = displayImageOf(rule.slot);
6293
+ /** The entry this extraction is about, re-resolved on every read. */
6294
+ const target = (rule) => {
6295
+ if (rule === null) return void 0;
6296
+ return (slot === void 0 ? void 0 : rule.images.find((i) => i.slot === slot)) ?? rule.images[0];
6297
+ };
6298
+ const image = target(ruleById(id));
6299
+ if (image === void 0 || image.color !== null) return;
6300
+ const url = displayImageOf(image.slot);
5280
6301
  if (url === null) return;
5281
6302
  let hsl = null;
5282
6303
  try {
5283
- hsl = await extractWallpaperColor(url, rule.bgState);
6304
+ hsl = await extractWallpaperColor(url, image.bgState);
5284
6305
  } catch {
5285
6306
  hsl = null;
5286
6307
  }
5287
6308
  if (hsl === null) return;
5288
- const current = ruleById(id);
5289
- if (current === null || current.color !== null) return;
6309
+ const current = target(ruleById(id));
6310
+ if (current === void 0 || current.color !== null) return;
6311
+ const winner = winnerId();
5290
6312
  current.color = hsl;
5291
6313
  rulesRev++;
5292
6314
  saveConfig();
5293
- if (id === activeRuleId) applyActive();
5294
- else sync();
6315
+ repaintIfMoved(id, winner);
5295
6316
  };
5296
6317
  const activeLang = () => {
5297
6318
  try {
@@ -5322,7 +6343,7 @@ window.__ModuleLoader__.load({
5322
6343
  readModelFacts,
5323
6344
  imageOf: (slot) => displayImageOf(slot),
5324
6345
  addRule: () => {
5325
- const rule = newRule(nextRuleId(), nextSlot());
6346
+ const rule = newRule(nextRuleId());
5326
6347
  cfg.rules.push(rule);
5327
6348
  rulesRev++;
5328
6349
  persistConfig();
@@ -5333,9 +6354,13 @@ window.__ModuleLoader__.load({
5333
6354
  const idx = cfg.rules.findIndex((r) => r.id === id);
5334
6355
  if (idx < 0) return;
5335
6356
  const [rule] = cfg.rules.splice(idx, 1);
5336
- if (rule !== void 0 && !cfg.rules.some((r) => r.slot === rule.slot)) {
5337
- setImage(rule.slot, null);
5338
- deleteImage(rule.slot);
6357
+ if (rule !== void 0) {
6358
+ const stillUsed = takenSlots();
6359
+ for (const slot of ruleSlots(rule)) {
6360
+ if (stillUsed.has(slot)) continue;
6361
+ setImage(slot, null);
6362
+ deleteImage(slot);
6363
+ }
5339
6364
  }
5340
6365
  rulesRev++;
5341
6366
  persistConfig();
@@ -5354,50 +6379,183 @@ window.__ModuleLoader__.load({
5354
6379
  setRule: (id, patch) => {
5355
6380
  const rule = ruleById(id);
5356
6381
  if (rule === null) return;
6382
+ const winner = winnerId();
5357
6383
  Object.assign(rule, patch);
5358
- const normalized = normalizeRule(rule);
5359
- if (normalized !== null) Object.assign(rule, normalized);
6384
+ normalizeRuleInPlace(rule);
6385
+ rulesRev++;
6386
+ saveConfig();
6387
+ repaintIfMoved(id, winner);
6388
+ },
6389
+ addRuleImages: (id, dataUrls) => {
6390
+ const rule = ruleById(id);
6391
+ if (rule === null || dataUrls.length === 0) return;
6392
+ const winner = winnerId();
6393
+ for (const dataUrl of dataUrls) {
6394
+ const slot = nextSlot();
6395
+ const image = {
6396
+ slot,
6397
+ bgState: { ...DEFAULT_BG_STATE },
6398
+ color: null
6399
+ };
6400
+ rule.images.push(image);
6401
+ setImage(slot, dataUrl);
6402
+ writeImage(slot, dataUrl);
6403
+ }
6404
+ rulesRev++;
6405
+ persistConfig();
6406
+ repaintIfMoved(id, winner);
6407
+ maybeAutoExtract(id, rule.images[rule.images.length - 1]?.slot);
6408
+ },
6409
+ removeRuleImage: (id, slot) => {
6410
+ const rule = ruleById(id);
6411
+ if (rule === null) return;
6412
+ const idx = rule.images.findIndex((i) => i.slot === slot);
6413
+ if (idx < 0) return;
6414
+ const winner = winnerId();
6415
+ rule.images.splice(idx, 1);
6416
+ if (id === activeRuleId) setRotIndex(0);
6417
+ rulesRev++;
6418
+ persistConfig();
6419
+ if (!takenSlots().has(slot)) {
6420
+ setImage(slot, null);
6421
+ deleteImage(slot);
6422
+ }
6423
+ repaintIfMoved(id, winner);
6424
+ },
6425
+ moveRuleImage: (id, slot, dir) => {
6426
+ const rule = ruleById(id);
6427
+ if (rule === null) return;
6428
+ const idx = rule.images.findIndex((i) => i.slot === slot);
6429
+ const to = idx + dir;
6430
+ if (idx < 0 || to < 0 || to >= rule.images.length) return;
6431
+ const winner = winnerId();
6432
+ const [image] = rule.images.splice(idx, 1);
6433
+ rule.images.splice(to, 0, image);
6434
+ rulesRev++;
6435
+ if (id === activeRuleId) {
6436
+ setRotIndex(Math.max(0, to));
6437
+ scheduleRotation(true);
6438
+ }
6439
+ persistConfig();
6440
+ repaintIfMoved(id, winner);
6441
+ },
6442
+ setCurrentImage: (id, slot) => {
6443
+ const rule = ruleById(id);
6444
+ if (rule === null) return;
6445
+ const idx = rule.images.findIndex((i) => i.slot === slot);
6446
+ if (idx < 0) return;
6447
+ const winner = winnerId();
6448
+ const [image] = rule.images.splice(idx, 1);
6449
+ rule.images.unshift(image);
6450
+ rulesRev++;
6451
+ if (id === activeRuleId) {
6452
+ setRotIndex(0);
6453
+ scheduleRotation(true);
6454
+ }
6455
+ persistConfig();
6456
+ repaintIfMoved(id, winner);
6457
+ },
6458
+ setImageFraming: (id, slot, bgState) => {
6459
+ const rule = ruleById(id);
6460
+ const image = rule?.images.find((i) => i.slot === slot);
6461
+ if (image === void 0) return;
6462
+ const winner = winnerId();
6463
+ image.bgState = bgState;
6464
+ normalizeRuleInPlace(rule);
5360
6465
  rulesRev++;
5361
6466
  saveConfig();
5362
- if (id === activeRuleId) applyActive();
5363
- else sync();
6467
+ repaintIfMoved(id, winner);
5364
6468
  },
5365
- setRuleImage: (id, dataUrl) => {
6469
+ setRuleRotation: (id, patch) => {
5366
6470
  const rule = ruleById(id);
5367
6471
  if (rule === null) return;
5368
- setImage(rule.slot, dataUrl);
5369
- dataUrl === null ? deleteImage(rule.slot) : writeImage(rule.slot, dataUrl);
5370
- if (id === activeRuleId) applyActive();
5371
- else sync();
5372
- if (dataUrl !== null) maybeAutoExtract(id);
6472
+ patchRotation(rule, patch);
6473
+ rulesRev++;
6474
+ persistConfig();
6475
+ scheduleRotation();
6476
+ sync();
6477
+ },
6478
+ rotateNow: (id) => {
6479
+ const rule = ruleById(id);
6480
+ if (rule === null || id !== activeRuleId) return;
6481
+ const next = nextIndex(rule.images.length, rule.rotate.order, imageIndexOf(rule));
6482
+ if (next === null) return;
6483
+ setRotIndex(next);
6484
+ paintImage(rule, next);
6485
+ scheduleRotation(true);
6486
+ },
6487
+ loadRuleImages: async (id) => {
6488
+ const rule = ruleById(id);
6489
+ if (rule === null) return;
6490
+ for (const slot of ruleSlots(rule)) await loadSlot(slot);
6491
+ sync();
6492
+ },
6493
+ setRuleImage: (id, slot, dataUrl) => {
6494
+ const rule = ruleById(id);
6495
+ if (rule === null || !rule.images.some((i) => i.slot === slot)) return;
6496
+ const winner = winnerId();
6497
+ setImage(slot, dataUrl);
6498
+ dataUrl === null ? deleteImage(slot) : writeImage(slot, dataUrl);
6499
+ repaintIfMoved(id, winner);
6500
+ if (dataUrl !== null) maybeAutoExtract(id, slot);
5373
6501
  },
5374
- setRuleImageFromUrl: async (id, url) => {
6502
+ addRuleImageFromUrl: async (id, url) => {
5375
6503
  const rule = ruleById(id);
5376
6504
  if (rule === null) return {
5377
6505
  ok: false,
5378
6506
  error: "unknown rule"
5379
6507
  };
5380
- const res = await fetchImageUrl(rule.slot, url);
5381
- if (res.ok) {
5382
- setImage(rule.slot, res.dataUrl ?? null);
5383
- if (id === activeRuleId) applyActive();
5384
- else sync();
5385
- maybeAutoExtract(id);
5386
- }
6508
+ const slot = nextSlot();
6509
+ const res = await fetchImageUrl(slot, url);
6510
+ if (!res.ok) return res;
6511
+ const winner = winnerId();
6512
+ rule.images.push({
6513
+ slot,
6514
+ bgState: { ...DEFAULT_BG_STATE },
6515
+ color: null
6516
+ });
6517
+ setImage(slot, res.dataUrl ?? null);
6518
+ rulesRev++;
6519
+ persistConfig();
6520
+ repaintIfMoved(id, winner);
6521
+ maybeAutoExtract(id, slot);
5387
6522
  return res;
5388
6523
  },
5389
- extractColor: async (id) => {
6524
+ /**
6525
+ * Store ONE image's theme color (null = that image follows the system
6526
+ * theme). Addressed by slot exactly like `setImageFraming`, because the
6527
+ * color belongs to the image: editing image 3's color must not rewrite
6528
+ * image 1's, and it must not touch the RULE's color either — that one is
6529
+ * what the rule paints once its last picture is gone, and it is edited only
6530
+ * while there is no image to edit (the panel's own branch).
6531
+ */
6532
+ setImageColor: (id, slot, color) => {
6533
+ const rule = ruleById(id);
6534
+ const image = rule?.images.find((i) => i.slot === slot);
6535
+ if (rule === null || rule === void 0 || image === void 0) return;
6536
+ const winner = winnerId();
6537
+ image.color = color;
6538
+ normalizeRuleInPlace(rule);
6539
+ rulesRev++;
6540
+ saveConfig();
6541
+ repaintIfMoved(id, winner);
6542
+ },
6543
+ extractColor: async (id, slot) => {
5390
6544
  const rule = ruleById(id);
5391
6545
  if (rule === null) return false;
5392
- const url = displayImageOf(rule.slot);
6546
+ const image = rule.images.find((i) => i.slot === slot) ?? rule.images[0];
6547
+ if (image === void 0) return false;
6548
+ const url = displayImageOf(image.slot);
5393
6549
  if (url === null) return false;
5394
- const hsl = await extractWallpaperColor(url, rule.bgState);
6550
+ const hsl = await extractWallpaperColor(url, image.bgState);
5395
6551
  if (hsl === null) return false;
5396
- rule.color = hsl;
6552
+ const current = ruleById(id)?.images.find((i) => i.slot === image.slot);
6553
+ if (current === void 0) return false;
6554
+ const winner = winnerId();
6555
+ current.color = hsl;
5397
6556
  rulesRev++;
5398
6557
  saveConfig();
5399
- if (id === activeRuleId) applyActive();
5400
- else sync();
6558
+ repaintIfMoved(id, winner);
5401
6559
  return true;
5402
6560
  },
5403
6561
  setOps: (ops) => {
@@ -5436,13 +6594,13 @@ window.__ModuleLoader__.load({
5436
6594
  },
5437
6595
  exportTheme: () => {
5438
6596
  const images = {};
5439
- const slots = cfg.rules.map((r) => r.slot);
6597
+ const slots = cfg.rules.flatMap(ruleSlots);
5440
6598
  for (const slot of slots) {
5441
6599
  const url = imageOf(slot);
5442
6600
  if (url !== null) images[slot] = url;
5443
6601
  }
5444
6602
  const payload = {
5445
- version: 3,
6603
+ version: 5,
5446
6604
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5447
6605
  config: cfg,
5448
6606
  images
@@ -5464,7 +6622,7 @@ window.__ModuleLoader__.load({
5464
6622
  adoptConfig(d.config);
5465
6623
  const incoming = d.images ?? {};
5466
6624
  const keep = /* @__PURE__ */ new Set();
5467
- for (const slot of cfg.rules.map((r) => r.slot)) {
6625
+ for (const slot of cfg.rules.flatMap(ruleSlots)) {
5468
6626
  keep.add(slot);
5469
6627
  const raw = incoming[slot];
5470
6628
  if (typeof raw === "string" && /^data:image\//.test(raw)) {
@@ -5544,7 +6702,7 @@ window.__ModuleLoader__.load({
5544
6702
  }, "dsh-background-by-model: nav icon watch");
5545
6703
  const restoreSaved = () => {
5546
6704
  syncHostScheme();
5547
- const color = activeRuleColor();
6705
+ const color = activeColor();
5548
6706
  if (color !== null) {
5549
6707
  const snapshot = ctx.theme.getTheme();
5550
6708
  if (!snapshot.themes.some((t) => t.id === CUSTOM_ID)) registerCustom(color[0], color[1], color[2]);
@@ -5558,7 +6716,7 @@ window.__ModuleLoader__.load({
5558
6716
  }, "dsh-background-by-model: boot restore");
5559
6717
  const watchdogId = window.setInterval(() => {
5560
6718
  syncHostScheme();
5561
- const color = activeRuleColor();
6719
+ const color = activeColor();
5562
6720
  if (color === null) {
5563
6721
  refreshSystemTheme();
5564
6722
  return;
@@ -5585,12 +6743,6 @@ window.__ModuleLoader__.load({
5585
6743
  window.addEventListener("pagehide", onPageHide);
5586
6744
  ctx.effect(() => () => window.removeEventListener("pagehide", onPageHide), "dsh-background-by-model: pagehide flush");
5587
6745
  }
5588
- /** Color of the currently active rule, or null when it uses the system theme. */
5589
- function activeRuleColor() {
5590
- if (activeRuleId === null) return null;
5591
- const rule = ruleById(activeRuleId);
5592
- return rule === null ? null : rule.color;
5593
- }
5594
6746
  //#endregion
5595
6747
  exports.apply = apply;
5596
6748
  exports.inject = inject;