dsh-background-by-model 0.5.4 → 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: "上移",
@@ -77,21 +75,16 @@ window.__ModuleLoader__.load({
77
75
  ruleImageRemove: "移除图片",
78
76
  ruleEmptyHint: "这条规则还没有图片,不会被匹配到,也不会作为兜底",
79
77
  ruleColor: "主题色",
80
- ruleColorNone: "系统主题",
78
+ ruleColorNone: "跟随系统主题",
81
79
  ruleColorNoneHint: "未设颜色时跟随系统主题:界面用宿主的配色,壁纸与各部位的透明度、模糊照旧生效",
82
80
  ruleColorPick: "自定义颜色",
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",
@@ -184,21 +175,16 @@ window.__ModuleLoader__.load({
184
175
  ruleImageRemove: "Remove image",
185
176
  ruleEmptyHint: "This rule has no image — it can neither match nor serve as the fallback",
186
177
  ruleColor: "Theme color",
187
- ruleColorNone: "System theme",
178
+ ruleColorNone: "Follow system theme",
188
179
  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",
189
180
  ruleColorPick: "Custom color",
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
  }
@@ -1100,7 +1355,6 @@ window.__ModuleLoader__.load({
1100
1355
  .dab-orb-wrap{position:relative;width:118px;height:118px;flex:none}
1101
1356
  .dab-orb{position:absolute;inset:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,rgba(255,255,255,.5),rgba(255,255,255,0) 44%),var(--c,#888);box-shadow:0 16px 36px -10px var(--c-soft,transparent),inset 0 -10px 20px rgba(0,0,0,.16);animation:dab-orb-in .7s cubic-bezier(.22,1,.36,1) both}
1102
1357
  .dab-orb-ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 30%,var(--c,#888) 46%,transparent 62%,transparent 76%,var(--c,#888) 90%,transparent 100%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 3.5px),#000 calc(100% - 2.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 3.5px),#000 calc(100% - 2.5px));animation:dab-spin 7s linear infinite;opacity:.9}
1103
- .dab-hex-caption{font-size:11px;color:var(--dsw-alias-label-tertiary);letter-spacing:.04em}
1104
1358
  .dab-hex{font-family:var(--dab-mono);font-size:24px;font-weight:600;letter-spacing:.02em;line-height:1.2;margin-top:2px}
1105
1359
  .dab-hsl-row{display:flex;gap:16px;margin-top:7px;font-family:var(--dab-mono);font-size:11px;color:var(--dsw-alias-label-tertiary)}
1106
1360
  .dab-hsl-row b{font-weight:600;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
@@ -1129,8 +1383,12 @@ window.__ModuleLoader__.load({
1129
1383
  .dab-urlinput{flex:1;min-width:180px;height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12.5px;outline:none;transition:border-color .2s}
1130
1384
  .dab-urlinput::placeholder{color:var(--dsw-alias-label-quaternary)}
1131
1385
  .dab-urlinput:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent)}
1132
- .dab-swatch-lg{height:38px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);box-shadow:inset 0 0 14px rgba(0,0,0,.1);transition:transform .3s ease}
1133
- .dab-swatch-lg:hover{transform:scale(1.02)}
1386
+ /* Prints the current hex on the color itself. Without the value this was an
1387
+ * empty bordered rectangle sitting in the same column as the H/S/L number
1388
+ * fields, which made it read as one more — blank — text input; nothing is ever
1389
+ * typed here, so nothing may look typable. The hover scale went with that
1390
+ * reading: it advertised an interaction this field does not have. */
1391
+ .dab-swatch-lg{display:flex;align-items:center;justify-content:center;height:38px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);box-shadow:inset 0 0 14px rgba(0,0,0,.1);font-family:var(--dab-mono);font-size:12.5px;font-weight:600;letter-spacing:.06em;user-select:all}
1134
1392
 
1135
1393
  /* ── buttons & chips ─────────────────────────────────────────────────────── */
1136
1394
  /* Text/icon painted ON --dsw-alias-brand-primary reads
@@ -1297,6 +1555,11 @@ window.__ModuleLoader__.load({
1297
1555
  .dab-check-where code{font-family:var(--dab-mono);font-size:10.5px;overflow-wrap:anywhere}
1298
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}
1299
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
+
1300
1563
  /* ── keyframes ───────────────────────────────────────────────────────────── */
1301
1564
  @keyframes dab-fade-in{from{opacity:0}to{opacity:1}}
1302
1565
  @keyframes dab-page-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@@ -3334,6 +3597,10 @@ window.__ModuleLoader__.load({
3334
3597
  function clamp(v, min, max) {
3335
3598
  return Math.min(max, Math.max(min, v));
3336
3599
  }
3600
+ /** '#rrggbb' for a 0-255 RGB triple, in the caller's case. */
3601
+ function toHex$2(rgb) {
3602
+ return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
3603
+ }
3337
3604
  /**
3338
3605
  * A single numeric field that edits one color channel. Keeps its own text
3339
3606
  * while focused so typing never gets clobbered by the parent re-rendering the
@@ -3376,15 +3643,22 @@ window.__ModuleLoader__.load({
3376
3643
  }
3377
3644
  /**
3378
3645
  * Precise color entry next to the wheel: a HSL/RGB segmented toggle plus three
3379
- * numeric channel fields and a live swatch. The wheel is HSV end-to-end, so
3380
- * this panel converts at the boundary — HSL fields map straight onto the
3381
- * stored HSL, RGB fields round-trip through rgbToHsl — and both emit HSV via
3382
- * the same onChange the wheel uses, keeping one canonical color.
3646
+ * numeric channel fields and a live preview field that prints its own hex. The
3647
+ * wheel is HSV end-to-end, so this panel converts at the boundary — HSL fields
3648
+ * map straight onto the stored HSL, RGB fields round-trip through rgbToHsl —
3649
+ * and both emit HSV via the same onChange the wheel uses, keeping one canonical
3650
+ * color.
3383
3651
  */
3384
3652
  function ColorInputs({ hue, sat, lit, onChange }) {
3385
3653
  const [mode, setMode] = (0, react.useState)("hsl");
3386
3654
  const [h, s, l] = hsvToHsl(hue, sat, lit);
3387
3655
  const [r, g, b] = hslToRgb(h, s, l);
3656
+ const hex = toHex$2([
3657
+ r,
3658
+ g,
3659
+ b
3660
+ ]).toUpperCase();
3661
+ const ink = (.2126 * r + .7152 * g + .0722 * b) / 255 > .55 ? "rgba(0,0,0,.78)" : "rgba(255,255,255,.92)";
3388
3662
  const setHsl = (nh, ns, nl) => onChange(...hslToHsv(nh, ns, nl));
3389
3663
  const setRgb = (nr, ng, nb) => {
3390
3664
  const [nh, ns, nl] = rgbToHsl(nr, ng, nb);
@@ -3475,7 +3749,11 @@ window.__ModuleLoader__.load({
3475
3749
  }),
3476
3750
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3477
3751
  className: "dab-swatch-lg",
3478
- style: { background: `hsl(${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)` }
3752
+ style: {
3753
+ background: `hsl(${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`,
3754
+ color: ink
3755
+ },
3756
+ children: hex
3479
3757
  })
3480
3758
  ]
3481
3759
  });
@@ -4377,17 +4655,14 @@ window.__ModuleLoader__.load({
4377
4655
  sat: wheel[1],
4378
4656
  lit: wheel[2],
4379
4657
  onChange: onColor
4380
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4658
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4381
4659
  className: "dab-inputs",
4382
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4383
- className: "dab-hex-caption",
4384
- children: toHex(hslToRgb(h, s, l)).toUpperCase()
4385
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColorInputs, {
4660
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColorInputs, {
4386
4661
  hue: wheel[0],
4387
4662
  sat: wheel[1],
4388
4663
  lit: wheel[2],
4389
4664
  onChange: onColor
4390
- })]
4665
+ })
4391
4666
  })]
4392
4667
  }),
4393
4668
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4408,11 +4683,11 @@ window.__ModuleLoader__.load({
4408
4683
  onClick: () => setPickerOpen(true),
4409
4684
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PipetteIcon, { size: 13 }), t("eyedropper")]
4410
4685
  }),
4411
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4412
4687
  type: "button",
4413
4688
  className: "dab-btn dab-btn-ghost",
4414
4689
  onClick: () => p.setRule(rule.id, { color: null }),
4415
- children: t("ruleColorNone")
4690
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SunIcon, { size: 13 }), t("ruleColorNone")]
4416
4691
  })
4417
4692
  ]
4418
4693
  }),
@@ -4508,6 +4783,8 @@ window.__ModuleLoader__.load({
4508
4783
  function ProfilePage({ p, notify }) {
4509
4784
  const { t, exportTheme, importTheme } = p;
4510
4785
  const importRef = (0, react.useRef)(null);
4786
+ p.useStore((s) => s.rev + s.rulesRev);
4787
+ const holidays = cfg.holidays;
4511
4788
  const onImport = async (file) => {
4512
4789
  try {
4513
4790
  const ok = await importTheme(file);
@@ -4535,11 +4812,31 @@ window.__ModuleLoader__.load({
4535
4812
  })
4536
4813
  ]
4537
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
+ }),
4538
4835
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4539
- className: "dab-profile-grid",
4836
+ className: "dab-profile-grid dab-rise",
4837
+ style: { "--d": 2 },
4540
4838
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
4541
- className: "dab-card dab-card-hover dab-rise",
4542
- style: { "--d": 1 },
4839
+ className: "dab-card dab-card-hover",
4543
4840
  children: [
4544
4841
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4545
4842
  className: "dab-profile-ico",
@@ -4564,8 +4861,7 @@ window.__ModuleLoader__.load({
4564
4861
  })
4565
4862
  ]
4566
4863
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
4567
- className: "dab-card dab-card-hover dab-rise",
4568
- style: { "--d": 2 },
4864
+ className: "dab-card dab-card-hover",
4569
4865
  children: [
4570
4866
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4571
4867
  className: "dab-profile-ico",
@@ -4814,7 +5110,11 @@ window.__ModuleLoader__.load({
4814
5110
  };
4815
5111
  /** Resolve the active rule for the current model and repaint everything. */
4816
5112
  const applyActive = () => {
4817
- 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);
4818
5118
  setActive(rule === null ? null : rule.id, matched);
4819
5119
  const color = rule === null ? null : rule.color;
4820
5120
  if (skinTimer !== null) window.clearTimeout(skinTimer);
@@ -4829,6 +5129,14 @@ window.__ModuleLoader__.load({
4829
5129
  applyWp();
4830
5130
  sync();
4831
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);
4832
5140
  syncHostScheme();
4833
5141
  applyWp();
4834
5142
  sync();
@@ -4858,18 +5166,63 @@ window.__ModuleLoader__.load({
4858
5166
  applyActive();
4859
5167
  }
4860
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;
4861
5217
  (async () => {
4862
5218
  const persisted = await loadPersisted();
4863
5219
  if (persisted !== null) {
4864
5220
  adoptConfig(persisted.config);
4865
- const slots = Array.from(/* @__PURE__ */ new Set([...persisted.slots, ...cfg.rules.map((r) => r.slot)]));
4866
- const priority = matchRule(cfg.rules, modelText).rule;
4867
- const first = priority === null ? null : priority.slot;
5221
+ const first = prioritySlot();
5222
+ const slots = allSlots(persisted.slots);
4868
5223
  const order = first === null ? slots : [first, ...slots.filter((s) => s !== first)];
4869
5224
  for (const slot of order) {
4870
- if (imageOf(slot) !== null) continue;
4871
- const url = await readImage(slot);
4872
- if (url !== null) setImage(slot, url);
5225
+ await loadSlot(slot);
4873
5226
  if (slot === first) applyActive();
4874
5227
  }
4875
5228
  }
@@ -5074,11 +5427,19 @@ window.__ModuleLoader__.load({
5074
5427
  saveConfig();
5075
5428
  sync();
5076
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
+ },
5077
5437
  exportTheme: () => {
5078
5438
  const images = {};
5079
- for (const rule of cfg.rules) {
5080
- const url = imageOf(rule.slot);
5081
- 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;
5082
5443
  }
5083
5444
  const payload = {
5084
5445
  version: 3,
@@ -5103,15 +5464,15 @@ window.__ModuleLoader__.load({
5103
5464
  adoptConfig(d.config);
5104
5465
  const incoming = d.images ?? {};
5105
5466
  const keep = /* @__PURE__ */ new Set();
5106
- for (const rule of cfg.rules) {
5107
- keep.add(rule.slot);
5108
- 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];
5109
5470
  if (typeof raw === "string" && /^data:image\//.test(raw)) {
5110
- setImage(rule.slot, raw);
5111
- writeImage(rule.slot, raw);
5471
+ setImage(slot, raw);
5472
+ writeImage(slot, raw);
5112
5473
  } else {
5113
- setImage(rule.slot, null);
5114
- deleteImage(rule.slot);
5474
+ setImage(slot, null);
5475
+ deleteImage(slot);
5115
5476
  }
5116
5477
  }
5117
5478
  for (const slot of Object.keys(incoming)) if (!keep.has(slot) && /^[A-Za-z0-9_-]{1,32}$/.test(slot)) deleteImage(slot);