dsh-background-by-model 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -21,7 +21,6 @@ window.__ModuleLoader__.load({
21
21
  const zh = {
22
22
  nav: "模型背景",
23
23
  brandTag: "按模型换背景",
24
- close: "关闭",
25
24
  pageInterface: "界面",
26
25
  pageModelBg: "模型背景",
27
26
  pageProfile: "配置",
@@ -58,7 +57,6 @@ window.__ModuleLoader__.load({
58
57
  autoExtractHint: "只在规则还没有颜色时自动取色,绝不覆盖你手动选的颜色",
59
58
  ruleAdd: "新增规则",
60
59
  ruleFallbackBadge: "兜底",
61
- ruleMatchLabel: "匹配串",
62
60
  ruleMatchPlaceholder: "如 flash / glm(忽略大小写,留空则不参与匹配)",
63
61
  ruleEnabled: "启用",
64
62
  ruleUp: "上移",
@@ -83,15 +81,10 @@ window.__ModuleLoader__.load({
83
81
  ruleColorPickHint: "优先从本图取色,取不到就用默认色",
84
82
  ruleColorExtract: "从本图提取",
85
83
  ruleLayout: "布局模式",
86
- ruleFraming: "取景",
87
84
  ruleFramingEdit: "编辑位置",
88
- ruleFramingLocked: "仅「适应」模式可编辑位置",
89
85
  ruleOpacity: "背景透明度",
90
86
  ruleBlur: "背景模糊",
91
87
  ruleBlurHint: "背景模糊作用于壁纸层;界面页里的模糊作用于界面各区域,两者互不影响",
92
- colorHint: "在色轮外圈选择色相,在内部方形中调整饱和度和明度;双击滑块可恢复默认值",
93
- swatchTitle: "灵感色板",
94
- hexCaption: "当前颜色",
95
88
  bgModeFit: "适应",
96
89
  bgModeFill: "填充",
97
90
  bgModeStretch: "拉伸",
@@ -102,9 +95,7 @@ window.__ModuleLoader__.load({
102
95
  editorCommit: "确认",
103
96
  editorCancel: "取消",
104
97
  editorReset: "重置",
105
- extractColor: "从图片提取主题色",
106
98
  extracting: "取色中…",
107
- extractNoWp: "请先为该规则选择图片",
108
99
  extractDone: "已应用图片配色",
109
100
  extractFail: "未在这张图里找到鲜明的颜色,请换一张",
110
101
  eyedropper: "从图片取色",
@@ -123,12 +114,13 @@ window.__ModuleLoader__.load({
123
114
  toastExportDone: "配置文件已开始下载",
124
115
  importDone: "已导入配置",
125
116
  importFail: "导入失败,文件格式不正确",
126
- footerTag: "按模型换背景插件"
117
+ footerTag: "按模型换背景插件",
118
+ holidayTitle: "节日特殊背景",
119
+ holidayEnable: "启用节日特殊背景(中秋当天、国庆 10 月 1–7 日)"
127
120
  };
128
121
  const en = {
129
122
  nav: "Model background",
130
123
  brandTag: "Per-model wallpaper",
131
- close: "Close",
132
124
  pageInterface: "Interface",
133
125
  pageModelBg: "Model background",
134
126
  pageProfile: "Profile",
@@ -165,7 +157,6 @@ window.__ModuleLoader__.load({
165
157
  autoExtractHint: "Only fills a rule that has no color yet — a color you picked is never overwritten",
166
158
  ruleAdd: "Add rule",
167
159
  ruleFallbackBadge: "fallback",
168
- ruleMatchLabel: "Match",
169
160
  ruleMatchPlaceholder: "e.g. flash / glm (case-insensitive, empty never matches)",
170
161
  ruleEnabled: "Enabled",
171
162
  ruleUp: "Move up",
@@ -190,15 +181,10 @@ window.__ModuleLoader__.load({
190
181
  ruleColorPickHint: "Extracts from this image when it can, otherwise uses a default color",
191
182
  ruleColorExtract: "Extract from image",
192
183
  ruleLayout: "Layout mode",
193
- ruleFraming: "Framing",
194
184
  ruleFramingEdit: "Edit position",
195
- ruleFramingLocked: "Position editing is only available in Fit mode",
196
185
  ruleOpacity: "Background opacity",
197
186
  ruleBlur: "Background blur",
198
187
  ruleBlurHint: "Background blur affects the wallpaper layer; the blur on the Interface page affects interface surfaces — they are independent",
199
- colorHint: "Pick hue on the outer ring, adjust saturation & lightness in the square; double-click a slider to reset",
200
- swatchTitle: "Quick swatches",
201
- hexCaption: "Current color",
202
188
  bgModeFit: "Fit",
203
189
  bgModeFill: "Fill",
204
190
  bgModeStretch: "Stretch",
@@ -209,9 +195,7 @@ window.__ModuleLoader__.load({
209
195
  editorCommit: "Confirm",
210
196
  editorCancel: "Cancel",
211
197
  editorReset: "Reset",
212
- extractColor: "Extract from image",
213
198
  extracting: "Extracting…",
214
- extractNoWp: "Choose an image for this rule first",
215
199
  extractDone: "Image colors applied",
216
200
  extractFail: "No vivid color found in this image, try another",
217
201
  eyedropper: "Eyedropper",
@@ -230,10 +214,168 @@ window.__ModuleLoader__.load({
230
214
  toastExportDone: "Export started",
231
215
  importDone: "Profile imported",
232
216
  importFail: "Import failed — invalid file",
233
- footerTag: "Per-model wallpaper plugin"
217
+ footerTag: "Per-model wallpaper plugin",
218
+ holidayTitle: "Holiday backgrounds",
219
+ holidayEnable: "Enable holiday backgrounds (Mid-Autumn day, Oct 1–7)"
234
220
  };
235
221
  //#endregion
222
+ //#region src/holiday.ts
223
+ const HOLIDAYS = [{
224
+ id: "mid-autumn",
225
+ slot: "h-midautumn",
226
+ asset: "mid-autumn.webp",
227
+ color: "#384A77",
228
+ kind: "lunar"
229
+ }, {
230
+ id: "national-day",
231
+ slot: "h-nationalday",
232
+ asset: "national-day.webp",
233
+ color: "#FFF6EB",
234
+ kind: "gregorian"
235
+ }];
236
+ const TZ = "Asia/Shanghai";
237
+ let civilFormatter;
238
+ function civilFmt() {
239
+ if (civilFormatter === void 0) try {
240
+ civilFormatter = new Intl.DateTimeFormat("en-US", {
241
+ timeZone: TZ,
242
+ year: "numeric",
243
+ month: "numeric",
244
+ day: "numeric"
245
+ });
246
+ } catch {
247
+ civilFormatter = null;
248
+ }
249
+ return civilFormatter;
250
+ }
251
+ let lunarFormatter;
252
+ function lunarFmt() {
253
+ if (lunarFormatter === void 0) try {
254
+ lunarFormatter = new Intl.DateTimeFormat("en-u-ca-chinese", {
255
+ timeZone: TZ,
256
+ year: "numeric",
257
+ month: "numeric",
258
+ day: "numeric"
259
+ });
260
+ } catch {
261
+ lunarFormatter = null;
262
+ }
263
+ return lunarFormatter;
264
+ }
265
+ function partsOf(fmt, date) {
266
+ try {
267
+ const out = {};
268
+ for (const p of fmt.formatToParts(date)) if (p.type !== "literal") out[p.type] = p.value;
269
+ return out;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ /** The civil (Gregorian) date in Asia/Shanghai, or null when ICU cannot answer. */
275
+ function shanghaiDate(date) {
276
+ const fmt = civilFmt();
277
+ if (fmt === null) return null;
278
+ const p = partsOf(fmt, date);
279
+ if (p === null) return null;
280
+ const y = Number(p.year);
281
+ const m = Number(p.month);
282
+ const d = Number(p.day);
283
+ if (!isFinite(y) || !isFinite(m) || !isFinite(d)) return null;
284
+ return {
285
+ y,
286
+ m,
287
+ d
288
+ };
289
+ }
290
+ /**
291
+ * The Chinese-calendar month and day in Asia/Shanghai.
292
+ *
293
+ * ICU marks a **leap** month by suffixing `bis` — a leap fourth month reads
294
+ * `4bis`, a leap eighth month would read `8bis`. 中秋 is the FIFTEENTH DAY OF THE
295
+ * REGULAR EIGHTH MONTH, so the exact `month === '8'` test below is what keeps a
296
+ * leap eighth month (which does occur, roughly once every few decades) from
297
+ * dragging the festival a month late.
298
+ */
299
+ function lunarMonthDay(date) {
300
+ const fmt = lunarFmt();
301
+ if (fmt === null) return null;
302
+ const p = partsOf(fmt, date);
303
+ if (p === null || p.month === void 0 || p.day === void 0) return null;
304
+ return {
305
+ month: p.month,
306
+ day: p.day
307
+ };
308
+ }
309
+ /** 农历八月十五, in the regular eighth month — i.e. 中秋 itself. */
310
+ function isMidAutumn(date = /* @__PURE__ */ new Date()) {
311
+ const lunar = lunarMonthDay(date);
312
+ return lunar !== null && lunar.month === "8" && lunar.day === "15";
313
+ }
314
+ /** 公历 10 月 1–7 日 — the National Day holiday period. */
315
+ function isNationalDay(date = /* @__PURE__ */ new Date()) {
316
+ const civil = shanghaiDate(date);
317
+ return civil !== null && civil.m === 10 && civil.d >= 1 && civil.d <= 7;
318
+ }
319
+ /**
320
+ * The holiday whose window contains `date`, or null.
321
+ *
322
+ * 中秋 wins the overlap: it is a single precise day, while National Day is a
323
+ * seven-day window, and the two do collide (2025-10-06 was both). On a tie the
324
+ * rarer, more specific answer is the better one.
325
+ */
326
+ function activeHoliday(date = /* @__PURE__ */ new Date()) {
327
+ if (isMidAutumn(date)) return "mid-autumn";
328
+ if (isNationalDay(date)) return "national-day";
329
+ return null;
330
+ }
331
+ /**
332
+ * The holiday the background must follow right now, or null.
333
+ *
334
+ * Four gates, every one of them deliberate:
335
+ *
336
+ * 1. the master switch — an override that discards the user's rule list must
337
+ * never happen without being asked for;
338
+ * 2. a built-in holiday whose window contains today;
339
+ * 3. that holiday's own switch;
340
+ * 4. actual bytes in its slot.
341
+ *
342
+ * The last one matters most, and it is the reason this is not a one-liner at the
343
+ * call site: a switched-on holiday with no image at all has to fall through to
344
+ * the model rules, not blank the wallpaper.
345
+ *
346
+ * It lives here, as a pure function, so the decision is covered by
347
+ * `scripts/holiday-check.ts` instead of hiding inside the plugin's apply closure
348
+ * where nothing can reach it.
349
+ */
350
+ function pickHoliday(master, items, todayId, hasImage) {
351
+ if (!master || todayId === null) return null;
352
+ const item = items.find((i) => i.id === todayId);
353
+ if (item === void 0 || !item.enabled) return null;
354
+ return hasImage(item.slot) ? item : null;
355
+ }
356
+ //#endregion
236
357
  //#region src/schema.ts
358
+ /**
359
+ * The persisted configuration shape — declared exactly ONCE, for both halves.
360
+ *
361
+ * This shape used to be written out twice: the node half owns persistence and
362
+ * sanitizes every write, the browser half owns the UI and its own reading of the
363
+ * same file. A field added to only one side was silently dropped by the other
364
+ * side's sanitizer — the matching slider stayed live in memory, `writeConfig`
365
+ * wrote a config without it, and the next load fell back to the default.
366
+ * `blurs.rightbar` / `rightbarOpacity` in 0.3.2 was exactly that bug, and the key
367
+ * lists were duplicated 6 times across the two halves, so it was due to happen.
368
+ *
369
+ * The types, the key lists, the defaults and the pure normalizers therefore live
370
+ * here and both halves import them: adding a field means editing this file, and
371
+ * the two halves cannot disagree about the shape. The host additionally logs one
372
+ * warning per unknown key while sanitizing a write (see `warnUnknownConfigKeys`
373
+ * in `src/index.ts`), so a field that reaches the node half without being
374
+ * declared here shows up in the host log instead of quietly disappearing.
375
+ *
376
+ * Nothing in this module may touch the DOM, Node or the Cordis context: it is
377
+ * imported by the browser bundle and by the node half alike.
378
+ */
237
379
  const DEFAULT_BG_STATE = {
238
380
  zoom: 1,
239
381
  x: 0,
@@ -300,6 +442,67 @@ window.__ModuleLoader__.load({
300
442
  rightbar: 0,
301
443
  input: 0
302
444
  };
445
+ /**
446
+ * `#RRGGBB` → the `[h, s, l]` triple every rule stores (`s` and `l` in 0..1), or
447
+ * null when the string is not a hex color.
448
+ *
449
+ * A holiday names its color as hex because that is how a color is quoted, and a
450
+ * hand-converted triple is a magic number nothing downstream can check. The
451
+ * conversion lives here rather than in ./holiday because `[h, s, l]` is this
452
+ * module's own rule shape.
453
+ */
454
+ function hexToHsl(hex) {
455
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
456
+ if (m === null) return null;
457
+ const n = parseInt(m[1], 16);
458
+ const r = (n >> 16 & 255) / 255;
459
+ const g = (n >> 8 & 255) / 255;
460
+ const b = (n & 255) / 255;
461
+ const max = Math.max(r, g, b);
462
+ const min = Math.min(r, g, b);
463
+ const l = (max + min) / 2;
464
+ const d = max - min;
465
+ if (d === 0) return [
466
+ 0,
467
+ 0,
468
+ l
469
+ ];
470
+ const s = d / (1 - Math.abs(2 * l - 1));
471
+ let h;
472
+ if (max === r) h = 60 * ((g - b) / d % 6);
473
+ else if (max === g) h = 60 * ((b - r) / d + 2);
474
+ else h = 60 * ((r - g) / d + 4);
475
+ if (h < 0) h += 360;
476
+ return [
477
+ clamp$1(h, 0, 360, 0),
478
+ clamp01(s, 0),
479
+ clamp01(l, .5)
480
+ ];
481
+ }
482
+ /**
483
+ * One holiday's appearance, untouched.
484
+ *
485
+ * Holidays default to `fill` rather than a rule's `fit`: these are full-bleed
486
+ * festival wallpapers, and `fit` would letterbox them on every aspect ratio the
487
+ * art was not cut for.
488
+ */
489
+ function defaultHolidayRule(def) {
490
+ return {
491
+ id: def.id,
492
+ slot: def.slot,
493
+ match: "",
494
+ enabled: true,
495
+ color: hexToHsl(def.color),
496
+ bgMode: "fill",
497
+ wallpaperOpacity: 1,
498
+ blur: 0,
499
+ bgState: { ...DEFAULT_BG_STATE }
500
+ };
501
+ }
502
+ /** Every built-in holiday at its default, in `HOLIDAYS` order. */
503
+ function defaultHolidayRules() {
504
+ return HOLIDAYS.map(defaultHolidayRule);
505
+ }
303
506
  /** A default config with independent nested objects (never hand out the shared ones). */
304
507
  function freshThemeConfig() {
305
508
  return {
@@ -310,7 +513,11 @@ window.__ModuleLoader__.load({
310
513
  chatTextOpacity: 0,
311
514
  trajectoryOpacity: 1,
312
515
  rightbarOpacity: null,
313
- autoExtract: true
516
+ autoExtract: true,
517
+ holidays: {
518
+ enabled: true,
519
+ items: defaultHolidayRules()
520
+ }
314
521
  };
315
522
  }
316
523
  /** Slot names reach the filesystem, so they are strictly whitelisted. */
@@ -358,6 +565,41 @@ window.__ModuleLoader__.load({
358
565
  bgState: normalizeBgState(r.bgState)
359
566
  };
360
567
  }
568
+ /**
569
+ * Coerce one persisted holiday entry.
570
+ *
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.
577
+ */
578
+ function normalizeHolidayRule(def, raw) {
579
+ const r = raw ?? {};
580
+ const base = defaultHolidayRule(def);
581
+ return {
582
+ ...base,
583
+ enabled: r.enabled !== false,
584
+ bgMode: BG_MODES$1.includes(r.bgMode) ? r.bgMode : base.bgMode,
585
+ wallpaperOpacity: clamp01(r.wallpaperOpacity, base.wallpaperOpacity),
586
+ blur: clamp$1(r.blur, 0, 60, base.blur),
587
+ bgState: normalizeBgState(r.bgState)
588
+ };
589
+ }
590
+ /** Coerce the whole holiday block; unknown ids are dropped, missing ones defaulted. */
591
+ function normalizeHolidays(raw) {
592
+ const r = raw ?? {};
593
+ const stored = /* @__PURE__ */ new Map();
594
+ if (Array.isArray(r.items)) for (const item of r.items) {
595
+ const id = item?.id;
596
+ if (typeof id === "string") stored.set(id, item);
597
+ }
598
+ return {
599
+ enabled: r.enabled !== false,
600
+ items: HOLIDAYS.map((def) => normalizeHolidayRule(def, stored.get(def.id)))
601
+ };
602
+ }
361
603
  /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
362
604
  function normalizeConfig(raw) {
363
605
  const r = raw ?? {};
@@ -376,7 +618,8 @@ window.__ModuleLoader__.load({
376
618
  chatTextOpacity: clamp01(r.chatTextOpacity, 0),
377
619
  trajectoryOpacity: clamp01(r.trajectoryOpacity, 1),
378
620
  rightbarOpacity: typeof r.rightbarOpacity === "number" && isFinite(r.rightbarOpacity) ? clamp01(r.rightbarOpacity, 1) : null,
379
- autoExtract: r.autoExtract !== false
621
+ autoExtract: r.autoExtract !== false,
622
+ holidays: normalizeHolidays(r.holidays)
380
623
  };
381
624
  }
382
625
  //#endregion
@@ -484,12 +727,24 @@ window.__ModuleLoader__.load({
484
727
  function setModelLabel(label) {
485
728
  modelLabel = label;
486
729
  }
730
+ /**
731
+ * One rule by id — from the USER's list first, then from the holiday overrides.
732
+ *
733
+ * Every render accessor (`rWp` / `rColor` / `rBgMode` / `rBgState` …) resolves
734
+ * through here, so teaching this one function about holidays is what makes an
735
+ * active holiday paint through the entire existing pipeline instead of needing a
736
+ * parallel one.
737
+ */
487
738
  function ruleById(id) {
488
- return cfg.rules.find((r) => r.id === id) ?? null;
739
+ return cfg.rules.find((r) => r.id === id) ?? cfg.holidays.items.find((r) => r.id === id) ?? null;
489
740
  }
490
741
  function activeRule() {
491
742
  return activeRuleId === null ? null : ruleById(activeRuleId);
492
743
  }
744
+ /** One holiday override entry, or null for an unknown id. */
745
+ function holidayById(id) {
746
+ return cfg.holidays.items.find((r) => r.id === id) ?? null;
747
+ }
493
748
  function newRule(id, slot) {
494
749
  return {
495
750
  id,
@@ -577,7 +832,7 @@ window.__ModuleLoader__.load({
577
832
  /** Move a possibly-absent partial config into the shape the UI reads. */
578
833
  function adoptConfig(raw) {
579
834
  cfg = normalizeConfig(raw);
580
- if (activeRuleId !== null && !cfg.rules.some((r) => r.id === activeRuleId)) {
835
+ if (activeRuleId !== null && ruleById(activeRuleId) === null) {
581
836
  activeRuleId = null;
582
837
  activeMatched = false;
583
838
  }
@@ -1300,6 +1555,11 @@ window.__ModuleLoader__.load({
1300
1555
  .dab-check-where code{font-family:var(--dab-mono);font-size:10.5px;overflow-wrap:anywhere}
1301
1556
  .dab-check-note{margin:12px 0 0;font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);position:relative;z-index:1}
1302
1557
 
1558
+ /* ── holiday override (Profile page) ─────────────────────────────────────── */
1559
+ /* Only a label: the whole feature is one switch, and dab-toggle already exists.
1560
+ The per-holiday cards that used to live here are gone by design. */
1561
+ .dab-holiday-label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
1562
+
1303
1563
  /* ── keyframes ───────────────────────────────────────────────────────────── */
1304
1564
  @keyframes dab-fade-in{from{opacity:0}to{opacity:1}}
1305
1565
  @keyframes dab-page-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@@ -4523,6 +4783,8 @@ window.__ModuleLoader__.load({
4523
4783
  function ProfilePage({ p, notify }) {
4524
4784
  const { t, exportTheme, importTheme } = p;
4525
4785
  const importRef = (0, react.useRef)(null);
4786
+ p.useStore((s) => s.rev + s.rulesRev);
4787
+ const holidays = cfg.holidays;
4526
4788
  const onImport = async (file) => {
4527
4789
  try {
4528
4790
  const ok = await importTheme(file);
@@ -4550,11 +4812,31 @@ window.__ModuleLoader__.load({
4550
4812
  })
4551
4813
  ]
4552
4814
  }),
4815
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
4816
+ className: "dab-card dab-rise",
4817
+ style: { "--d": 1 },
4818
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4819
+ className: "dab-chip-row",
4820
+ style: { alignItems: "center" },
4821
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4822
+ type: "button",
4823
+ className: `dab-toggle${holidays.enabled ? " is-on" : ""}`,
4824
+ role: "switch",
4825
+ "aria-checked": holidays.enabled,
4826
+ title: t("holidayEnable"),
4827
+ onClick: () => p.setHolidaysEnabled(!holidays.enabled),
4828
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
4829
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4830
+ className: "dab-holiday-label",
4831
+ children: t("holidayTitle")
4832
+ })]
4833
+ })
4834
+ }),
4553
4835
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4554
- className: "dab-profile-grid",
4836
+ className: "dab-profile-grid dab-rise",
4837
+ style: { "--d": 2 },
4555
4838
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
4556
- className: "dab-card dab-card-hover dab-rise",
4557
- style: { "--d": 1 },
4839
+ className: "dab-card dab-card-hover",
4558
4840
  children: [
4559
4841
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4560
4842
  className: "dab-profile-ico",
@@ -4579,8 +4861,7 @@ window.__ModuleLoader__.load({
4579
4861
  })
4580
4862
  ]
4581
4863
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
4582
- className: "dab-card dab-card-hover dab-rise",
4583
- style: { "--d": 2 },
4864
+ className: "dab-card dab-card-hover",
4584
4865
  children: [
4585
4866
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4586
4867
  className: "dab-profile-ico",
@@ -4829,7 +5110,11 @@ window.__ModuleLoader__.load({
4829
5110
  };
4830
5111
  /** Resolve the active rule for the current model and repaint everything. */
4831
5112
  const applyActive = () => {
4832
- const { rule, matched } = matchRule(cfg.rules, modelText);
5113
+ const holiday = activeHolidayRule();
5114
+ const { rule, matched } = holiday !== null ? {
5115
+ rule: holiday,
5116
+ matched: false
5117
+ } : matchRule(cfg.rules, modelText);
4833
5118
  setActive(rule === null ? null : rule.id, matched);
4834
5119
  const color = rule === null ? null : rule.color;
4835
5120
  if (skinTimer !== null) window.clearTimeout(skinTimer);
@@ -4844,6 +5129,14 @@ window.__ModuleLoader__.load({
4844
5129
  applyWp();
4845
5130
  sync();
4846
5131
  };
5132
+ /**
5133
+ * The holiday the background must follow right now, or null.
5134
+ *
5135
+ * The decision itself is `pickHoliday` (pure, and covered by
5136
+ * `scripts/holiday-check.ts`); this only supplies the two things it cannot
5137
+ * know — today's holiday from the calendar, and whether the slot has bytes.
5138
+ */
5139
+ const activeHolidayRule = () => pickHoliday(cfg.holidays.enabled, cfg.holidays.items, activeHoliday(), (slot) => imageOf(slot) !== null);
4847
5140
  syncHostScheme();
4848
5141
  applyWp();
4849
5142
  sync();
@@ -4873,18 +5166,63 @@ window.__ModuleLoader__.load({
4873
5166
  applyActive();
4874
5167
  }
4875
5168
  })();
5169
+ let holidayKey = activeHoliday() ?? "";
5170
+ const holidayTimer = window.setInterval(() => {
5171
+ const next = activeHoliday() ?? "";
5172
+ if (next === holidayKey) return;
5173
+ holidayKey = next;
5174
+ const slot = todayHolidaySlot();
5175
+ if (slot === null) applyActive();
5176
+ else loadSlot(slot).then(() => applyActive());
5177
+ }, 3e4);
5178
+ ctx.effect(() => () => {
5179
+ window.clearInterval(holidayTimer);
5180
+ }, "dsh-background-by-model: holiday rollover");
5181
+ /** Read one slot's bytes into the cache unless they are already there. */
5182
+ const loadSlot = async (slot) => {
5183
+ if (imageOf(slot) !== null) return;
5184
+ const url = await readImage(slot);
5185
+ if (url !== null) setImage(slot, url);
5186
+ };
5187
+ /**
5188
+ * The slot of the holiday that could paint TODAY, or null.
5189
+ *
5190
+ * Gated on the CALENDAR, not merely on the switch. Now that the override is on
5191
+ * by default and invisible, a profile that simply is not on a holiday must not
5192
+ * pull ~730 KB of bundled art (≈970 KB of base64 over this channel) at every
5193
+ * boot just because the feature is armed.
5194
+ */
5195
+ const todayHolidaySlot = () => {
5196
+ if (!cfg.holidays.enabled) return null;
5197
+ const id = activeHoliday();
5198
+ if (id === null) return null;
5199
+ const item = holidayById(id);
5200
+ return item !== null && item.enabled ? item.slot : null;
5201
+ };
5202
+ /** Every slot that can paint: the stored ones plus every slot the config names. */
5203
+ const allSlots = (stored) => {
5204
+ const holiday = todayHolidaySlot();
5205
+ return Array.from(/* @__PURE__ */ new Set([
5206
+ ...stored,
5207
+ ...cfg.rules.map((r) => r.slot),
5208
+ ...holiday === null ? [] : [holiday]
5209
+ ]));
5210
+ };
5211
+ /**
5212
+ * 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.
5215
+ */
5216
+ const prioritySlot = () => todayHolidaySlot() ?? matchRule(cfg.rules, modelText).rule?.slot ?? null;
4876
5217
  (async () => {
4877
5218
  const persisted = await loadPersisted();
4878
5219
  if (persisted !== null) {
4879
5220
  adoptConfig(persisted.config);
4880
- const slots = Array.from(/* @__PURE__ */ new Set([...persisted.slots, ...cfg.rules.map((r) => r.slot)]));
4881
- const priority = matchRule(cfg.rules, modelText).rule;
4882
- const first = priority === null ? null : priority.slot;
5221
+ const first = prioritySlot();
5222
+ const slots = allSlots(persisted.slots);
4883
5223
  const order = first === null ? slots : [first, ...slots.filter((s) => s !== first)];
4884
5224
  for (const slot of order) {
4885
- if (imageOf(slot) !== null) continue;
4886
- const url = await readImage(slot);
4887
- if (url !== null) setImage(slot, url);
5225
+ await loadSlot(slot);
4888
5226
  if (slot === first) applyActive();
4889
5227
  }
4890
5228
  }
@@ -5089,11 +5427,19 @@ window.__ModuleLoader__.load({
5089
5427
  saveConfig();
5090
5428
  sync();
5091
5429
  },
5430
+ setHolidaysEnabled: (v) => {
5431
+ cfg.holidays.enabled = v;
5432
+ persistConfig();
5433
+ const slot = todayHolidaySlot();
5434
+ if (v && slot !== null) loadSlot(slot).then(() => applyActive());
5435
+ applyActive();
5436
+ },
5092
5437
  exportTheme: () => {
5093
5438
  const images = {};
5094
- for (const rule of cfg.rules) {
5095
- const url = imageOf(rule.slot);
5096
- if (url !== null) images[rule.slot] = url;
5439
+ const slots = cfg.rules.map((r) => r.slot);
5440
+ for (const slot of slots) {
5441
+ const url = imageOf(slot);
5442
+ if (url !== null) images[slot] = url;
5097
5443
  }
5098
5444
  const payload = {
5099
5445
  version: 3,
@@ -5118,15 +5464,15 @@ window.__ModuleLoader__.load({
5118
5464
  adoptConfig(d.config);
5119
5465
  const incoming = d.images ?? {};
5120
5466
  const keep = /* @__PURE__ */ new Set();
5121
- for (const rule of cfg.rules) {
5122
- keep.add(rule.slot);
5123
- const raw = incoming[rule.slot];
5467
+ for (const slot of cfg.rules.map((r) => r.slot)) {
5468
+ keep.add(slot);
5469
+ const raw = incoming[slot];
5124
5470
  if (typeof raw === "string" && /^data:image\//.test(raw)) {
5125
- setImage(rule.slot, raw);
5126
- writeImage(rule.slot, raw);
5471
+ setImage(slot, raw);
5472
+ writeImage(slot, raw);
5127
5473
  } else {
5128
- setImage(rule.slot, null);
5129
- deleteImage(rule.slot);
5474
+ setImage(slot, null);
5475
+ deleteImage(slot);
5130
5476
  }
5131
5477
  }
5132
5478
  for (const slot of Object.keys(incoming)) if (!keep.has(slot) && /^[A-Za-z0-9_-]{1,32}$/.test(slot)) deleteImage(slot);