dsh-background-by-model 0.9.2 → 0.10.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
@@ -330,6 +330,28 @@ window.__ModuleLoader__.load({
330
330
  function defaultTransition() {
331
331
  return { ...DEFAULT_TRANSITION };
332
332
  }
333
+ /**
334
+ * One image entry for a place that CREATES a picture rather than reads one back
335
+ * (a fresh holiday, and every upload in the panel).
336
+ *
337
+ * The two effects whose neutral value is also the shipped default are written
338
+ * explicitly (`opacity`, `blur`), even though `normalizeImage` would default them:
339
+ * the read-time lifts key off the ABSENCE of a key, so an entry created without one
340
+ * would come back as "written before this field existed" and inherit whatever the
341
+ * rule happened to carry. `brightness` is the one field whose absence is meaningful
342
+ * (see `BgImage.brightness`), so it is left out — "untouched" is exactly what a new
343
+ * picture is.
344
+ */
345
+ function defaultImage(slot, color = null, bgMode = "fit") {
346
+ return {
347
+ slot,
348
+ bgMode,
349
+ bgState: { ...DEFAULT_BG_STATE },
350
+ color,
351
+ opacity: 1,
352
+ blur: 0
353
+ };
354
+ }
333
355
  /** Every slot a rule owns, in order. The one thing the render and store layers
334
356
  * ask of the image list, so a rule's slots are never enumerated ad hoc. */
335
357
  function ruleSlots(rule) {
@@ -399,18 +421,10 @@ window.__ModuleLoader__.load({
399
421
  const color = hexToHsl(def.color);
400
422
  return {
401
423
  id: def.id,
402
- images: [{
403
- slot: def.slot,
404
- bgMode: "fill",
405
- bgState: { ...DEFAULT_BG_STATE },
406
- color
407
- }],
424
+ images: [defaultImage(def.slot, color, "fill")],
408
425
  match: "",
409
426
  enabled: true,
410
427
  color,
411
- wallpaperOpacity: 1,
412
- brightness: 100,
413
- blur: 0,
414
428
  rotate: defaultRotation()
415
429
  };
416
430
  }
@@ -434,9 +448,6 @@ window.__ModuleLoader__.load({
434
448
  match: "",
435
449
  enabled: true,
436
450
  color: null,
437
- wallpaperOpacity: 1,
438
- brightness: 100,
439
- blur: 0,
440
451
  rotate: defaultRotation()
441
452
  };
442
453
  }
@@ -547,28 +558,41 @@ window.__ModuleLoader__.load({
547
558
  durationMs: clamp$1(t.durationMs, 0, TRANSITION_MAX_MS, fallback)
548
559
  };
549
560
  }
561
+ /** The inherit block for a caller that has no rule-level values at all. */
562
+ const DEFAULT_IMAGE_INHERIT = {
563
+ color: null,
564
+ opacity: 1,
565
+ brightness: void 0,
566
+ blur: 0
567
+ };
550
568
  /**
551
569
  * Coerce one persisted image entry, or null when it lacks a usable slot.
552
570
  *
553
- * `inherit` (the RULE's colour) is applied only to an entry with no `color` KEY at
554
- * all — the shape written before per-image colours, where the rule's colour was the
555
- * only one there was. The key's PRESENCE is the whole distinction: `color: null` is
556
- * a deliberate "follow the system theme" and must not be re-inherited on every load.
571
+ * `inherit` is the rule-level block this entry may take its values from, and each
572
+ * field is lifted ONLY when the entry has no key of its own — that absence is the
573
+ * whole signal. `color: null` is a deliberate "follow the system theme" and must
574
+ * not be re-inherited on every load; `brightness` keeps its key absent when neither
575
+ * side ever named one, so an upgrade never writes a default nobody chose.
557
576
  *
558
577
  * `inheritMode` is the same one-shot lift for the layout mode. It is the caller's to
559
578
  * resolve because the callers answer differently: a user rule passes the mode its
560
579
  * config carried at the RULE level (schema 5), a holiday passes its own `fill`. An
561
580
  * entry that HAS a `bgMode` key keeps it, even when it equals the default.
562
581
  */
563
- function normalizeImage(raw, inherit = null, inheritMode = "fit") {
582
+ function normalizeImage(raw, inherit = DEFAULT_IMAGE_INHERIT, inheritMode = "fit") {
564
583
  const i = raw ?? {};
565
584
  if (typeof i.slot !== "string" || !SLOT_RE$1.test(i.slot)) return null;
566
- return {
585
+ const image = {
567
586
  slot: i.slot,
568
587
  bgMode: BG_MODES$1.includes(i.bgMode) ? i.bgMode : inheritMode,
569
588
  bgState: normalizeBgState(i.bgState),
570
- color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit
589
+ color: hasOwn(i, "color") ? normalizeHsl(i.color) : inherit.color,
590
+ opacity: clamp01(i.opacity, inherit.opacity),
591
+ blur: clamp$1(i.blur, 0, 60, inherit.blur)
571
592
  };
593
+ const brightness = i.brightness === void 0 ? inherit.brightness : clamp$1(i.brightness, 0, 200, 100);
594
+ if (brightness !== void 0) image.brightness = brightness;
595
+ return image;
572
596
  }
573
597
  /**
574
598
  * Coerce one persisted rule, or null when it names no image ANYWHERE.
@@ -593,38 +617,45 @@ window.__ModuleLoader__.load({
593
617
  if (id === null) return null;
594
618
  const color = normalizeHsl(r.color);
595
619
  const mode = BG_MODES$1.includes(r.bgMode) ? r.bgMode : "fit";
620
+ const inherit = {
621
+ color,
622
+ opacity: clamp01(r.wallpaperOpacity, 1),
623
+ brightness: r.brightness === void 0 ? void 0 : clamp$1(r.brightness, 0, 200, 100),
624
+ blur: clamp$1(r.blur, 0, 60, 0)
625
+ };
596
626
  const images = [];
597
627
  const seen = /* @__PURE__ */ new Set();
598
628
  const hadList = Array.isArray(r.images);
599
629
  if (hadList) for (const entry of r.images) {
600
- const image = normalizeImage(entry, color, mode);
630
+ const image = normalizeImage(entry, inherit, mode);
601
631
  if (image === null || seen.has(image.slot)) continue;
602
632
  seen.add(image.slot);
603
633
  images.push(image);
604
634
  }
605
635
  const legacySlot = typeof r.slot === "string" && SLOT_RE$1.test(r.slot) ? r.slot : null;
606
- if (images.length === 0 && legacySlot !== null) images.push({
607
- slot: legacySlot,
608
- bgMode: mode,
609
- bgState: normalizeBgState(r.bgState),
610
- color
611
- });
636
+ if (images.length === 0 && legacySlot !== null) {
637
+ const image = {
638
+ slot: legacySlot,
639
+ bgMode: mode,
640
+ bgState: normalizeBgState(r.bgState),
641
+ color,
642
+ opacity: inherit.opacity,
643
+ blur: inherit.blur
644
+ };
645
+ if (inherit.brightness !== void 0) image.brightness = inherit.brightness;
646
+ images.push(image);
647
+ }
612
648
  if (images.length === 0 && !hadList) return null;
613
649
  const rotate = normalizeRotation(r.rotate);
614
650
  if (images.length < 2) rotate.enabled = false;
615
- const brightness = r.brightness === void 0 ? void 0 : clamp$1(r.brightness, 0, 200, 100);
616
- const rule = {
651
+ return {
617
652
  id,
618
653
  images,
619
654
  match: typeof r.match === "string" ? r.match : "",
620
655
  enabled: r.enabled !== false,
621
656
  color,
622
- wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
623
- blur: clamp$1(r.blur, 0, 60, 0),
624
657
  rotate
625
658
  };
626
- if (brightness !== void 0) rule.brightness = brightness;
627
- return rule;
628
659
  }
629
660
  /**
630
661
  * Coerce one persisted holiday entry.
@@ -641,17 +672,29 @@ window.__ModuleLoader__.load({
641
672
  return {
642
673
  ...base,
643
674
  enabled: r.enabled !== false,
644
- wallpaperOpacity: clamp01(r.wallpaperOpacity, base.wallpaperOpacity),
645
- brightness: clamp$1(r.brightness, 0, 200, base.brightness ?? 100),
646
- blur: clamp$1(r.blur, 0, 60, base.blur),
647
- images: base.images.map((image) => ({
648
- slot: image.slot,
649
- bgMode: image.bgMode,
650
- bgState: normalizeBgState(r.images?.[0]?.bgState ?? r.bgState),
651
- color: image.color
652
- }))
675
+ images: base.images.map((image) => {
676
+ const stored = r.images?.[0];
677
+ const merged = {
678
+ slot: image.slot,
679
+ bgMode: image.bgMode,
680
+ bgState: normalizeBgState(stored?.bgState ?? r.bgState),
681
+ color: image.color,
682
+ opacity: clamp01(stored?.opacity ?? legacyWallpaperOpacity(r.wallpaperOpacity), image.opacity),
683
+ blur: clamp$1(stored?.blur ?? r.blur, 0, 60, image.blur)
684
+ };
685
+ const brightness = stored?.brightness === void 0 ? r.brightness === void 0 ? void 0 : clamp$1(r.brightness, 0, 200, 100) : clamp$1(stored.brightness, 0, 200, 100);
686
+ if (brightness !== void 0) merged.brightness = brightness;
687
+ return merged;
688
+ })
653
689
  };
654
690
  }
691
+ /**
692
+ * The rule-level opacity a schema-9 holiday entry carried, or `undefined` so the
693
+ * caller can tell "absent" from "set to 1" and leave `clamp01` its default.
694
+ */
695
+ function legacyWallpaperOpacity(raw) {
696
+ return typeof raw === "number" && isFinite(raw) ? raw : void 0;
697
+ }
655
698
  /** Coerce the whole holiday block; unknown ids are dropped, missing ones defaulted. */
656
699
  function normalizeHolidays(raw) {
657
700
  const r = raw ?? {};
@@ -892,24 +935,34 @@ window.__ModuleLoader__.load({
892
935
  function rBgMode() {
893
936
  return activeImage()?.bgMode ?? "fit";
894
937
  }
938
+ /**
939
+ * Opacity of the wallpaper layer, 0..1 (per image, see BgImage).
940
+ *
941
+ * Read from the image being painted, not from the rule: one rule's rotation can go
942
+ * through a full-bleed photo and a faint decorative plate, and a rule-level answer
943
+ * would be wrong for one of them. A rule with no image paints nothing at all, so
944
+ * there is no layer for the value to describe.
945
+ */
895
946
  function rWop() {
896
- return clamp01(activeRule()?.wallpaperOpacity, 1);
947
+ return clamp01(activeImage()?.opacity, 1);
897
948
  }
898
949
  /**
899
950
  * Brightness of the wallpaper layer, in percent (100 = the picture exactly as the
900
951
  * file has it).
901
952
  *
902
- * Read from the ACTIVE rule like the opacity and blur beside it, and defaulted the
903
- * same way: a rule whose `brightness` key is absent — every config written before
904
- * the setting existed — is left alone, so an upgrade changes nothing on screen.
905
- * The default and the ceiling live in ../schema, so the render layer holds no
906
- * numbers of its own (see ./wallpaper's `wpFilter`).
953
+ * Read from the CURRENT image, like the opacity and blur beside it, and defaulted
954
+ * the same way: an image whose `brightness` key is absent — every config written
955
+ * before the setting existed, and every picture whose rule never named one — is
956
+ * left alone, so an upgrade changes nothing on screen. The default and the ceiling
957
+ * live in ../schema, so the render layer holds no numbers of its own (see
958
+ * ./wallpaper's `wpFilter`).
907
959
  */
908
960
  function rBrightness() {
909
- return clamp$1(activeRule()?.brightness, 0, 200, 100);
961
+ return clamp$1(activeImage()?.brightness, 0, 200, 100);
910
962
  }
963
+ /** Blur of the CURRENT image, in px (0 = the picture as its file has it). */
911
964
  function rBl() {
912
- return clamp$1(activeRule()?.blur, 0, 60, 0);
965
+ return clamp$1(activeImage()?.blur, 0, 60, 0);
913
966
  }
914
967
  /** Framing of the CURRENT image (per image, see BgImage). */
915
968
  function rBgState() {
@@ -1064,7 +1117,7 @@ window.__ModuleLoader__.load({
1064
1117
  const d = data;
1065
1118
  const slots = Array.isArray(d.slots) ? d.slots.filter((s) => typeof s === "string") : [];
1066
1119
  const schema = typeof d.schema === "number" && isFinite(d.schema) ? d.schema : null;
1067
- hostShape = typeof schema === "number" && schema >= 9 ? "current" : "legacy";
1120
+ hostShape = typeof schema === "number" && schema >= 10 ? "current" : "legacy";
1068
1121
  if (hostShape === "legacy") holdWrite();
1069
1122
  return {
1070
1123
  config: d.config,
@@ -3197,13 +3250,25 @@ window.__ModuleLoader__.load({
3197
3250
  /** Index of the layer fading in right now, if any. */
3198
3251
  let pending = null;
3199
3252
  let fadeTimer = null;
3253
+ /** The wallpaper's steady opacity — the ACTIVE picture's, as last committed.
3254
+ *
3255
+ * Mirrored rather than read back off the style string, because a repaint that
3256
+ * runs in the middle of a fade (a theme re-registration, a panel re-render) has
3257
+ * to re-write the value the fade is HEADING for; reading `rWop()` there would
3258
+ * snap the whole layer to the incoming picture's alpha and cut the fade short.
3259
+ */
3260
+ let wpAlpha = 1;
3200
3261
  function createLayer() {
3201
3262
  const el = document.createElement("div");
3202
3263
  el.dataset.dabWpLayer = "";
3203
3264
  el.style.cssText = "position:absolute;inset:0;opacity:0;background-repeat:no-repeat;background-position:center;";
3204
3265
  return {
3205
3266
  el,
3206
- url: null
3267
+ url: null,
3268
+ posX: null,
3269
+ posY: null,
3270
+ brightness: 100,
3271
+ blur: 0
3207
3272
  };
3208
3273
  }
3209
3274
  function ensureWpContainer() {
@@ -3221,6 +3286,7 @@ window.__ModuleLoader__.load({
3221
3286
  layers = [a, b];
3222
3287
  front = 0;
3223
3288
  pending = null;
3289
+ wpAlpha = 1;
3224
3290
  document.body.prepend(wpEl);
3225
3291
  }
3226
3292
  /** Drop the whole wallpaper layer (no rule carries an image). */
@@ -3232,6 +3298,7 @@ window.__ModuleLoader__.load({
3232
3298
  pending = null;
3233
3299
  front = 0;
3234
3300
  layers = null;
3301
+ wpAlpha = 1;
3235
3302
  wpEl?.remove();
3236
3303
  wpEl = null;
3237
3304
  }
@@ -3239,9 +3306,14 @@ window.__ModuleLoader__.load({
3239
3306
  function clearLayer(l) {
3240
3307
  if (l.url === null) return;
3241
3308
  l.url = null;
3309
+ l.posX = null;
3310
+ l.posY = null;
3311
+ l.brightness = 100;
3312
+ l.blur = 0;
3242
3313
  l.el.style.backgroundImage = "";
3243
3314
  l.el.style.backgroundSize = "";
3244
3315
  l.el.style.backgroundPosition = "";
3316
+ l.el.style.inset = "0px";
3245
3317
  l.el.style.transform = "none";
3246
3318
  }
3247
3319
  /** Write the two properties a switch animates. Both are always written, so a
@@ -3269,6 +3341,34 @@ window.__ModuleLoader__.load({
3269
3341
  img.onerror = () => cb(0, 0);
3270
3342
  img.src = url;
3271
3343
  }
3344
+ /** How far a layer's box is grown on every side to hide a blurred edge.
3345
+ *
3346
+ * `filter: blur(σ)` convolves the layer's own rendering with a Gaussian of
3347
+ * standard deviation σ and treats everything outside the box as transparent, so
3348
+ * the outermost pixels come out half opaque and the image fades inward over
3349
+ * ~2σ — composited over the page behind it, that half-transparent rim is the
3350
+ * black border the blur slider paints, and it grows thicker with the slider.
3351
+ * Growing the box by 2σ puts every visible pixel at least 2σ inside the edge,
3352
+ * where ~2% of the page still shows through the outermost row: gone to the eye.
3353
+ * Two sigma rather than three is the trade for the `cover` modes, which pay for
3354
+ * the growth by framing slightly tighter — a fifth of the image at most, at the
3355
+ * slider's maximum, where the detail a wider frame would reveal is blurred out.
3356
+ */
3357
+ function wpBlurPad(blur) {
3358
+ return blur > 0 ? Math.ceil(blur * 2) + 2 : 0;
3359
+ }
3360
+ /** Grow a layer's box by `pad` on every side while keeping its image on the same
3361
+ * pixels. The background positioning area starts at the border box, so a box
3362
+ * whose origin moved by -pad needs a numeric placement moved by +pad; `center`
3363
+ * (and the box-relative `cover` / `100% 100%` sizes) sit in the middle of a
3364
+ * symmetrically grown box and need no compensation. The container clips the
3365
+ * growth, so nothing about the visible layout changes: it exists only to give
3366
+ * the blur pixels to sample beyond the viewport. */
3367
+ function writeLayerBox(l, pad) {
3368
+ const { posX, posY } = l;
3369
+ l.el.style.inset = `${-pad}px`;
3370
+ l.el.style.backgroundPosition = posX === null || posY === null ? "center" : `${posX + pad}px ${posY + pad}px`;
3371
+ }
3272
3372
  /** Paint one layer's image and its placement. The placement always follows the
3273
3373
  * ACTIVE rule: the incoming layer is painted right after a switch, and a
3274
3374
  * repaint of the same URL (framing edit, viewport resize) must use the rule's
@@ -3280,6 +3380,8 @@ window.__ModuleLoader__.load({
3280
3380
  const mode = rBgMode();
3281
3381
  const next = `url("${url}")`;
3282
3382
  if (el.style.backgroundImage !== next) el.style.backgroundImage = next;
3383
+ l.posX = null;
3384
+ l.posY = null;
3283
3385
  if (mode === "fit") {
3284
3386
  el.style.backgroundRepeat = "no-repeat";
3285
3387
  if (bg.iw > 0) {
@@ -3287,35 +3389,31 @@ window.__ModuleLoader__.load({
3287
3389
  const w = bg.iw * fit * bg.zoom;
3288
3390
  const h = bg.ih * fit * bg.zoom;
3289
3391
  el.style.backgroundSize = `${w}px ${h}px`;
3290
- el.style.backgroundPosition = `${bg.x * window.innerWidth - w / 2}px ${bg.y * window.innerHeight - h / 2}px`;
3291
- } else {
3292
- el.style.backgroundSize = "contain";
3293
- el.style.backgroundPosition = "center";
3294
- }
3392
+ l.posX = bg.x * window.innerWidth - w / 2;
3393
+ l.posY = bg.y * window.innerHeight - h / 2;
3394
+ } else el.style.backgroundSize = "contain";
3295
3395
  } else if (mode === "fill") {
3296
3396
  el.style.backgroundRepeat = "no-repeat";
3297
3397
  el.style.backgroundSize = "cover";
3298
- el.style.backgroundPosition = "center";
3299
3398
  } else if (mode === "stretch") {
3300
3399
  el.style.backgroundRepeat = "no-repeat";
3301
3400
  el.style.backgroundSize = "100% 100%";
3302
- el.style.backgroundPosition = "center";
3303
3401
  } else if (mode === "tile") {
3304
3402
  el.style.backgroundRepeat = "repeat";
3305
3403
  el.style.backgroundSize = "auto";
3306
- el.style.backgroundPosition = "0px 0px";
3404
+ l.posX = 0;
3405
+ l.posY = 0;
3307
3406
  } else {
3308
3407
  el.style.backgroundRepeat = "no-repeat";
3309
3408
  el.style.backgroundSize = "contain";
3310
- el.style.backgroundPosition = "center";
3311
3409
  imageNatSize(url, (w, h) => {
3312
3410
  if (l.url !== url || el.style.backgroundImage !== next || rBgMode() !== "center") return;
3313
- if (w > 0 && h > 0) {
3314
- el.style.backgroundSize = `${w}px ${h}px`;
3315
- el.style.backgroundPosition = "center";
3316
- }
3411
+ if (w > 0 && h > 0) el.style.backgroundSize = `${w}px ${h}px`;
3317
3412
  });
3318
3413
  }
3414
+ l.brightness = rBrightness();
3415
+ l.blur = rBl();
3416
+ applyLayerEffects(l);
3319
3417
  }
3320
3418
  /** Whether there is anything to animate FROM: a wallpaper on screen that is
3321
3419
  * actually visible. The duration question belongs to `rFadeMs`, and the
@@ -3339,6 +3437,10 @@ window.__ModuleLoader__.load({
3339
3437
  }
3340
3438
  pending = null;
3341
3439
  front = idx;
3440
+ if (wpEl !== null) {
3441
+ wpEl.style.transition = "none";
3442
+ wpEl.style.opacity = String(wpAlpha);
3443
+ }
3342
3444
  const el = layers[idx].el;
3343
3445
  el.style.opacity = "1";
3344
3446
  el.style.transform = "none";
@@ -3385,12 +3487,14 @@ window.__ModuleLoader__.load({
3385
3487
  if (pending !== null) {
3386
3488
  if (ls[pending].url === url) {
3387
3489
  paintLayer(ls[pending], url);
3490
+ wpAlpha = rWop();
3388
3491
  applyWpEffects();
3389
3492
  return;
3390
3493
  }
3391
3494
  if (ls[front].url === url) {
3392
3495
  cancelFade();
3393
3496
  paintLayer(ls[front], url);
3497
+ wpAlpha = rWop();
3394
3498
  applyWpEffects();
3395
3499
  return;
3396
3500
  }
@@ -3398,6 +3502,7 @@ window.__ModuleLoader__.load({
3398
3502
  }
3399
3503
  if (ls[front].url === url) {
3400
3504
  paintLayer(ls[front], url);
3505
+ wpAlpha = rWop();
3401
3506
  applyWpEffects();
3402
3507
  return;
3403
3508
  }
@@ -3418,7 +3523,11 @@ window.__ModuleLoader__.load({
3418
3523
  applyLayerStyle(el, plan.from);
3419
3524
  el.style.zIndex = "1";
3420
3525
  from.el.style.zIndex = "0";
3526
+ const toAlpha = rWop();
3527
+ const fadeAlpha = plan.animate && Math.abs(toAlpha - wpAlpha) > .001;
3528
+ if (fadeAlpha) wpEl.style.transition = "none";
3421
3529
  paintLayer(target, url);
3530
+ if (!fadeAlpha) wpAlpha = toAlpha;
3422
3531
  applyWpEffects();
3423
3532
  if (!plan.animate) {
3424
3533
  applyLayerStyle(el, plan.to);
@@ -3431,15 +3540,24 @@ window.__ModuleLoader__.load({
3431
3540
  if (pending !== idx || layers === null) return;
3432
3541
  el.style.transition = plan.transition;
3433
3542
  applyLayerStyle(el, plan.to);
3543
+ if (fadeAlpha) {
3544
+ wpEl.style.transition = plan.transition;
3545
+ wpEl.style.opacity = String(toAlpha);
3546
+ wpAlpha = toAlpha;
3547
+ }
3434
3548
  });
3435
3549
  fadeTimer = window.setTimeout(() => finishFade(idx), plan.durationMs + 160);
3436
3550
  }
3437
3551
  /**
3438
- * The `filter` a wallpaper layer carries, from the active rule's brightness and
3439
- * blur.
3552
+ * The `filter` ONE LAYER carries, from the brightness and blur of the picture
3553
+ * painted on IT.
3554
+ *
3555
+ * Both are per image (see BgImage): one rule's rotation can run a daylight photo
3556
+ * and a night shot, and a rule-level answer forced the second one to live at the
3557
+ * first one's exposure.
3440
3558
  *
3441
3559
  * ONE declaration, because the two are one CSS property: assigning `filter` twice
3442
- * would leave whichever ran last, so a rule that is both brightened and blurred
3560
+ * would leave whichever ran last, so an image that is both brightened and blurred
3443
3561
  * would silently lose one of them. Brightness is written first and blur second for
3444
3562
  * a reason that is visible: blurring a dimmed picture softens the highlights the
3445
3563
  * dimming just brought down, while dimming an already-blurred one simply darkens
@@ -3449,21 +3567,34 @@ window.__ModuleLoader__.load({
3449
3567
  * The neutral brightness is LEFT OUT rather than written as `brightness(100%)`.
3450
3568
  * A layer with neither effect gets `''`, which is what an untouched wallpaper has
3451
3569
  * always carried, and keeps the layer off the compositor's filter path entirely.
3570
+ *
3571
+ * The numbers are the LAYER's, not live reads: during a switch the two layers
3572
+ * hold two different photos, and a live read made the outgoing one jump to the
3573
+ * incoming one's exposure at the first frame of every fade.
3452
3574
  */
3453
- function wpFilter() {
3575
+ function wpFilter(brightness, blur) {
3454
3576
  const parts = [];
3455
- const brightness = rBrightness();
3456
3577
  if (brightness !== 100) parts.push(`brightness(${brightness}%)`);
3457
- const blur = rBl();
3458
3578
  if (blur > 0) parts.push(`blur(${blur}px)`);
3459
3579
  return parts.join(" ");
3460
3580
  }
3461
3581
  function applyWpEffects() {
3462
3582
  if (wpEl === null || layers === null) return;
3463
- wpEl.style.opacity = String(rWop());
3464
- const filter = wpFilter();
3465
- layers[0].el.style.filter = filter;
3466
- layers[1].el.style.filter = filter;
3583
+ wpEl.style.opacity = String(wpAlpha);
3584
+ applyLayerEffects(layers[0]);
3585
+ applyLayerEffects(layers[1]);
3586
+ }
3587
+ /** Write what a layer takes from ITS picture: the filter, and the box growth that
3588
+ * keeps a blurred edge from drawing a dark rim.
3589
+ *
3590
+ * ONE function for both, because the two are one visible decision — the same
3591
+ * blur that makes `filter` non-empty is what makes the pad non-zero — and
3592
+ * because BOTH update paths (a full repaint and a drag frame) write it, so a
3593
+ * blur dragged to its top cannot leave the rim behind until the slider is
3594
+ * released. */
3595
+ function applyLayerEffects(l) {
3596
+ l.el.style.filter = wpFilter(l.brightness, l.blur);
3597
+ writeLayerBox(l, wpBlurPad(l.blur));
3467
3598
  }
3468
3599
  /** Repaint the wallpaper layer, the token palette and every interface part from
3469
3600
  * the ACTIVE rule. This is the single entry point a rule switch goes through. */
@@ -3512,6 +3643,38 @@ window.__ModuleLoader__.load({
3512
3643
  tableFixStyleEl = null;
3513
3644
  stopWatchingParts();
3514
3645
  }
3646
+ /** Live wallpaper-opacity updates during slider drag (no full re-apply).
3647
+ *
3648
+ * The container is the one holder of this value (see `applyWpEffects`), so the
3649
+ * mirror and the style are written together — a later repaint then re-asserts
3650
+ * what the drag ended on rather than the value it started from. */
3651
+ function setWpOpacity(v) {
3652
+ wpAlpha = v;
3653
+ if (wpEl) wpEl.style.opacity = String(v);
3654
+ }
3655
+ /**
3656
+ * Live wallpaper-filter updates during a slide, with no full re-apply.
3657
+ *
3658
+ * Deliberately parameterless: the filter composes TWO settings (brightness and
3659
+ * blur), so a function trusting one caller's value would write half of it and
3660
+ * drop the other. Both are read back from the store instead (./state), which is
3661
+ * the same store the drag has already written to before calling this.
3662
+ *
3663
+ * Written onto the layer PAINTING that picture rather than onto both: during a
3664
+ * switch the other layer is still showing the previous photo, and giving it this
3665
+ * one's brightness and blur is exactly the snap this snapshot exists to prevent.
3666
+ */
3667
+ function setWpFilter() {
3668
+ if (layers === null) return;
3669
+ const url = rWp();
3670
+ if (url === null) return;
3671
+ for (const l of layers) {
3672
+ if (l.url !== url) continue;
3673
+ l.brightness = rBrightness();
3674
+ l.blur = rBl();
3675
+ applyLayerEffects(l);
3676
+ }
3677
+ }
3515
3678
  //#endregion
3516
3679
  //#region src/client/cadence.ts
3517
3680
  /**
@@ -4526,7 +4689,7 @@ window.__ModuleLoader__.load({
4526
4689
  if (url !== null) images[slot] = url;
4527
4690
  }
4528
4691
  return {
4529
- version: 9,
4692
+ version: 10,
4530
4693
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4531
4694
  config: cfg,
4532
4695
  images
@@ -4565,7 +4728,7 @@ window.__ModuleLoader__.load({
4565
4728
  * "replace everything with this" literally true.
4566
4729
  */
4567
4730
  async function applyThemeFile(host, data, wipeFirst) {
4568
- if (typeof data.version === "number" && data.version > 9) return false;
4731
+ if (typeof data.version === "number" && data.version > 10) return false;
4569
4732
  if (typeof data.config !== "object" || data.config === null) return false;
4570
4733
  if (wipeFirst) {
4571
4734
  await resetStore();
@@ -4753,6 +4916,7 @@ window.__ModuleLoader__.load({
4753
4916
  ruleBrightness: "图片亮度",
4754
4917
  ruleBlur: "图片模糊",
4755
4918
  ruleEffectPick: "调节项",
4919
+ ruleEffectNoImage: "添加图片后可分别设置每张图的透明度、亮度与模糊",
4756
4920
  rotTitle: "多图轮换",
4757
4921
  rotEnable: "轮换这组图",
4758
4922
  rotNeedTwo: "至少两张图才能轮换",
@@ -4936,6 +5100,7 @@ window.__ModuleLoader__.load({
4936
5100
  ruleBrightness: "Image brightness",
4937
5101
  ruleBlur: "Image blur",
4938
5102
  ruleEffectPick: "Effect",
5103
+ ruleEffectNoImage: "Add an image to give each one its own opacity, brightness and blur",
4939
5104
  rotTitle: "Image rotation",
4940
5105
  rotEnable: "Rotate these images",
4941
5106
  rotNeedTwo: "At least two images are needed to rotate",
@@ -5222,37 +5387,80 @@ window.__ModuleLoader__.load({
5222
5387
  //#region src/client/components/LiveSlider.tsx
5223
5388
  /**
5224
5389
  * Zero-lag slider: the thumb and value label update through DOM refs while
5225
- * dragging (onInput) so the caller can mutate the live UI directly without a
5226
- * React re-render; onChange commits the settled value. Double-click resets to
5227
- * the canonical default. The track fill is a gradient driven by the --pct
5228
- * custom property, updated imperatively alongside the thumb.
5390
+ * dragging, so the caller can preview the change without a React re-render; the
5391
+ * settled value is committed ONCE, when the gesture ends.
5392
+ *
5393
+ * The split is the whole point and it is not an optimisation detail. A range
5394
+ * input fires `input` on every frame of a drag, and React's `change` handler for
5395
+ * one is wired to that same event — so a slider that commits from `onChange`
5396
+ * commits dozens of times a second, and every commit is a full repaint (skin,
5397
+ * palette, wallpaper layer). That is why dragging a wallpaper effect cost a whole
5398
+ * `applyActive()` per frame while the cheap live writers beside it were never
5399
+ * reached.
5400
+ *
5401
+ * The gesture's end is therefore read from the events that actually end one: the
5402
+ * native `change` event, a pointer released over the track, and a blur. No timer
5403
+ * is armed — nothing here may compete with the plugin's one scheduler.
5229
5404
  */
5230
5405
  function LiveSlider({ min, max, step, def, fmt, label, ariaLabel, onInput, onChange }) {
5231
5406
  const inputRef = (0, react.useRef)(null);
5232
5407
  const valRef = (0, react.useRef)(null);
5408
+ /**
5409
+ * The value the current gesture has reached but has not committed yet.
5410
+ *
5411
+ * Non-null is also the answer to "is a gesture in flight?", which the `def` sync
5412
+ * below has to ask: the model watch re-emits on its own beat, and the render that
5413
+ * causes would otherwise snap the knob back to the committed value mid-drag.
5414
+ */
5415
+ const pending = (0, react.useRef)(null);
5416
+ /** `onChange` is read through a ref so the native listener cannot go stale. */
5417
+ const commitRef = (0, react.useRef)(onChange);
5418
+ commitRef.current = onChange;
5233
5419
  const paint = (el, v) => {
5234
5420
  el.style.setProperty("--pct", String((v - min) / (max - min) * 100));
5235
5421
  };
5236
- (0, react.useEffect)(() => {
5422
+ /** Move the knob and the label without calling anybody back. */
5423
+ const show = (v) => {
5237
5424
  if (inputRef.current) {
5238
- inputRef.current.value = String(def);
5239
- paint(inputRef.current, def);
5425
+ inputRef.current.value = String(v);
5426
+ paint(inputRef.current, v);
5240
5427
  }
5241
- if (valRef.current) valRef.current.textContent = fmt(def);
5428
+ if (valRef.current) valRef.current.textContent = fmt(v);
5429
+ };
5430
+ const commit = () => {
5431
+ const v = pending.current;
5432
+ if (v === null) return;
5433
+ pending.current = null;
5434
+ commitRef.current(v);
5435
+ };
5436
+ /** One frame of a gesture: the caller's cheap path, then remember the value. */
5437
+ const live = (v) => {
5438
+ show(v);
5439
+ pending.current = v;
5440
+ onInput?.(v);
5441
+ };
5442
+ (0, react.useEffect)(() => {
5443
+ if (pending.current !== null) return;
5444
+ show(def);
5242
5445
  }, [
5243
5446
  def,
5244
5447
  fmt,
5245
5448
  min,
5246
5449
  max
5247
5450
  ]);
5248
- const apply = (v) => {
5249
- if (inputRef.current) {
5250
- inputRef.current.value = String(v);
5251
- paint(inputRef.current, v);
5252
- }
5253
- if (valRef.current) valRef.current.textContent = fmt(v);
5254
- onInput?.(v);
5255
- onChange(v);
5451
+ (0, react.useEffect)(() => {
5452
+ const el = inputRef.current;
5453
+ if (el === null) return;
5454
+ const onEnd = () => commit();
5455
+ el.addEventListener("change", onEnd);
5456
+ return () => {
5457
+ el.removeEventListener("change", onEnd);
5458
+ commit();
5459
+ };
5460
+ }, []);
5461
+ const reset = () => {
5462
+ live(def);
5463
+ commit();
5256
5464
  };
5257
5465
  const name = label ?? ariaLabel;
5258
5466
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -5277,15 +5485,10 @@ window.__ModuleLoader__.load({
5277
5485
  defaultValue: def,
5278
5486
  "aria-label": name,
5279
5487
  title: name !== void 0 ? `${name} · ${fmt(def)}` : fmt(def),
5280
- onDoubleClick: () => apply(def),
5281
- onInput: (e) => {
5282
- const el = e.target;
5283
- const v = Number(el.value);
5284
- paint(el, v);
5285
- onInput?.(v);
5286
- if (valRef.current) valRef.current.textContent = fmt(v);
5287
- },
5288
- onChange: (e) => onChange(Number(e.target.value))
5488
+ onDoubleClick: reset,
5489
+ onInput: (e) => live(Number(e.target.value)),
5490
+ onPointerUp: commit,
5491
+ onBlur: commit
5289
5492
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5290
5493
  ref: valRef,
5291
5494
  className: "dab-slider-val",
@@ -6547,49 +6750,92 @@ window.__ModuleLoader__.load({
6547
6750
  });
6548
6751
  }
6549
6752
  /**
6550
- * The rule's three wallpaper effects on ONE row: the menu names the target and a
6551
- * single track edits it.
6753
+ * The selected image's three paint effects on ONE row: the menu names the target
6754
+ * and a single track edits it.
6755
+ *
6756
+ * Per IMAGE, like the layout mode, the framing and the colour above it. The move
6757
+ * is the same argument: a rotation through a daylight photo and a night shot does
6758
+ * not want one exposure, and a rule-level value forced one of them to live at the
6759
+ * other's compromise. A rule with no picture has nothing to expose, so the row is
6760
+ * DISABLED (not hidden) there — the same way the layout chips behave.
6552
6761
  *
6553
6762
  * The three ranges are not interchangeable — opacity and brightness are
6554
6763
  * percentages (brightness ceilings at the shared `WALLPAPER_BRIGHTNESS_MAX`),
6555
6764
  * blur is pixels — so changing the menu swaps the whole track: limits, formatter
6556
6765
  * and writer together. It never reinterprets one track under another name. The
6557
- * `key` remounts the input so `defaultValue` starts at the new field's current
6558
- * value; LiveSlider's drag-live path then works exactly as it did when the three
6559
- * tracks were stacked.
6766
+ * `key` remounts the input so `defaultValue` starts at the new target's value.
6767
+ *
6768
+ * A drag has TWO writers, and which one runs is the whole reason this row is not
6769
+ * a one-liner. `onInput` runs on every frame and must cost a wallpaper-layer
6770
+ * property write (./wallpaper's `setWpOpacity` / `setWpFilter`) — the same cheap
6771
+ * path the Interface page uses. `onChange` runs once, when the gesture ends, and
6772
+ * is the one that goes through `setImageEffects`: that is a full repaint (skin,
6773
+ * palette, wallpaper), and paying it per frame made a drag stall on a big image.
6560
6774
  */
6561
- function WallpaperEffectRow({ p, rule, field, onField }) {
6775
+ function WallpaperEffectRow({ p, rule, image, painted, field, onField, active }) {
6562
6776
  const { t } = p;
6777
+ /**
6778
+ * One frame of a drag: write the image in place, then touch only the layer
6779
+ * property that field owns.
6780
+ *
6781
+ * Gated on `active && painted`: the wallpaper layer paints the ACTIVE rule's
6782
+ * CURRENT image, so previewing any other one would write this card's numbers onto
6783
+ * a different picture's pixels — the visible lie the per-image split makes
6784
+ * possible and this gate prevents. The commit still lands, addressed by slot.
6785
+ *
6786
+ * Writing in place rather than through `setImageEffects` is deliberate and narrow:
6787
+ * that one bumps `rulesRev` and closes with a repaint, which is exactly the
6788
+ * per-frame cost being removed. Nothing is lost — the gesture's `onChange` hands
6789
+ * the settled value over, and that path clamps and saves.
6790
+ */
6791
+ const preview = (patch, paint) => {
6792
+ if (!active || !painted || image === void 0) return;
6793
+ Object.assign(image, patch);
6794
+ paint();
6795
+ };
6796
+ const slot = image?.slot;
6563
6797
  const spec = field === "opacity" ? {
6564
6798
  label: t("ruleOpacity"),
6565
6799
  min: 0,
6566
6800
  max: 100,
6567
6801
  step: 1,
6568
- def: Math.round(rule.wallpaperOpacity * 100),
6802
+ def: Math.round((image?.opacity ?? 1) * 100),
6569
6803
  fmt: (v) => `${v}%`,
6570
- onChange: (v) => p.setRule(rule.id, { wallpaperOpacity: v / 100 })
6804
+ preview: (v) => preview({ opacity: v / 100 }, () => setWpOpacity(v / 100)),
6805
+ onChange: (v) => {
6806
+ if (slot !== void 0) p.setImageEffects(rule.id, slot, { opacity: v / 100 });
6807
+ }
6571
6808
  } : field === "brightness" ? {
6572
6809
  label: t("ruleBrightness"),
6573
6810
  min: 0,
6574
6811
  max: 200,
6575
6812
  step: 1,
6576
- def: rule.brightness ?? 100,
6813
+ def: image?.brightness ?? 100,
6577
6814
  fmt: (v) => `${v}%`,
6578
- onChange: (v) => p.setRule(rule.id, { brightness: v })
6815
+ preview: (v) => preview({ brightness: v }, setWpFilter),
6816
+ onChange: (v) => {
6817
+ if (slot !== void 0) p.setImageEffects(rule.id, slot, { brightness: v });
6818
+ }
6579
6819
  } : {
6580
6820
  label: t("ruleBlur"),
6581
6821
  min: 0,
6582
6822
  max: 60,
6583
6823
  step: 1,
6584
- def: rule.blur,
6824
+ def: image?.blur ?? 0,
6585
6825
  fmt: (v) => `${v}px`,
6586
- onChange: (v) => p.setRule(rule.id, { blur: v })
6826
+ preview: (v) => preview({ blur: v }, setWpFilter),
6827
+ onChange: (v) => {
6828
+ if (slot !== void 0) p.setImageEffects(rule.id, slot, { blur: v });
6829
+ }
6587
6830
  };
6831
+ const disabled = image === void 0;
6588
6832
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6589
6833
  className: "dab-wp-row",
6590
6834
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
6591
6835
  className: "dab-select",
6592
6836
  value: field,
6837
+ disabled,
6838
+ title: disabled ? t("ruleEffectNoImage") : void 0,
6593
6839
  "aria-label": t("ruleEffectPick"),
6594
6840
  onChange: (e) => onField(e.target.value),
6595
6841
  children: [
@@ -6613,8 +6859,9 @@ window.__ModuleLoader__.load({
6613
6859
  step: spec.step,
6614
6860
  def: spec.def,
6615
6861
  fmt: spec.fmt,
6862
+ onInput: spec.preview,
6616
6863
  onChange: spec.onChange
6617
- }, field)]
6864
+ }, `${field}:${slot ?? ""}`)]
6618
6865
  });
6619
6866
  }
6620
6867
  function RuleCard({ p, rule, index, total, active, liveIndex, notify, sel, onSel, field, onField, onRemove }) {
@@ -6642,6 +6889,15 @@ window.__ModuleLoader__.load({
6642
6889
  const selIdx = rule.images.length === 0 ? 0 : Math.min(sel, rule.images.length - 1);
6643
6890
  const image = rule.images[selIdx];
6644
6891
  const url = image === void 0 ? null : p.imageOf(image.slot);
6892
+ /**
6893
+ * Whether the image being EDITED is also the one on screen.
6894
+ *
6895
+ * The wallpaper layer paints the active rule's CURRENT image, so a drag preview
6896
+ * may only write a layer property when the two coincide — otherwise the card's
6897
+ * numbers would land on another picture's pixels. This is the per-image split's
6898
+ * own version of the `active` gate beside it.
6899
+ */
6900
+ const painted = active && selIdx === liveIndex;
6645
6901
  const ownColor = image === void 0 ? rule.color : image.color;
6646
6902
  const setColor = (color) => {
6647
6903
  if (image === void 0) p.setRule(rule.id, { color });
@@ -7215,14 +7471,21 @@ window.__ModuleLoader__.load({
7215
7471
  })
7216
7472
  ]
7217
7473
  })] }),
7218
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7474
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7219
7475
  style: { marginTop: 16 },
7220
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WallpaperEffectRow, {
7476
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(WallpaperEffectRow, {
7221
7477
  p,
7222
7478
  rule,
7479
+ image,
7480
+ painted,
7223
7481
  field,
7224
- onField
7225
- })
7482
+ onField,
7483
+ active
7484
+ }), image === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7485
+ className: "dab-hint",
7486
+ style: { marginTop: 8 },
7487
+ children: t("ruleEffectNoImage")
7488
+ }) : null]
7226
7489
  }),
7227
7490
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7228
7491
  style: { marginTop: 14 },
@@ -8550,13 +8813,7 @@ window.__ModuleLoader__.load({
8550
8813
  const added = [];
8551
8814
  for (const dataUrl of dataUrls) {
8552
8815
  const slot = nextSlot();
8553
- const image = {
8554
- slot,
8555
- bgMode: "fit",
8556
- bgState: { ...DEFAULT_BG_STATE },
8557
- color: null
8558
- };
8559
- rule.images.push(image);
8816
+ rule.images.push(defaultImage(slot));
8560
8817
  added.push(slot);
8561
8818
  setImage(slot, dataUrl);
8562
8819
  writeImage(slot, dataUrl);
@@ -8632,6 +8889,28 @@ window.__ModuleLoader__.load({
8632
8889
  saveConfig();
8633
8890
  repaintIfMoved(id, winner);
8634
8891
  },
8892
+ /**
8893
+ * Store ONE image's three paint effects. Addressed by slot like the mode and
8894
+ * the framing: how a picture is exposed belongs to that picture, and a rule's
8895
+ * rotation can run a daylight photo next to a night shot.
8896
+ *
8897
+ * `normalizeRuleInPlace` is the clamp, so a drag can never write a value the
8898
+ * next load would trim; the layer's own preview path (`setWpOpacity` /
8899
+ * `setWpFilter`) has already painted the value for the frames of the gesture.
8900
+ */
8901
+ setImageEffects: (id, slot, patch) => {
8902
+ const rule = ruleById(id);
8903
+ const image = rule?.images.find((i) => i.slot === slot);
8904
+ if (rule === null || image === void 0) return;
8905
+ const winner = winnerId();
8906
+ if (patch.opacity !== void 0) image.opacity = patch.opacity;
8907
+ if (patch.brightness !== void 0) image.brightness = patch.brightness;
8908
+ if (patch.blur !== void 0) image.blur = patch.blur;
8909
+ normalizeRuleInPlace(rule);
8910
+ rulesRev++;
8911
+ saveConfig();
8912
+ repaintIfMoved(id, winner);
8913
+ },
8635
8914
  setRuleRotation: (id, patch) => {
8636
8915
  const rule = ruleById(id);
8637
8916
  if (rule === null) return;
@@ -8684,12 +8963,7 @@ window.__ModuleLoader__.load({
8684
8963
  const res = await fetchImageUrl(slot, url);
8685
8964
  if (!res.ok) return res;
8686
8965
  const winner = winnerId();
8687
- rule.images.push({
8688
- slot,
8689
- bgMode: "fit",
8690
- bgState: { ...DEFAULT_BG_STATE },
8691
- color: null
8692
- });
8966
+ rule.images.push(defaultImage(slot));
8693
8967
  setImage(slot, res.dataUrl ?? null);
8694
8968
  rulesRev++;
8695
8969
  persistConfig();